energy meters: Linux RAPL and NVML on real hardware; an NVML counter past its board is Implausible #129

Merged
dcharlot merged 10 commits from energy/meters-on-real-linux-hardware into main 2026-10-01 18:33:48 -04:00
Owner

The ten commits sit directly on main, which now holds #110, #114–#117 and #120–#124. Before this they sat on a merge of those branches, and the forge couldn't compute a clean merge base for that history. The tree is byte-identical to main merged with the earlier head, and that tree was compiled in all 9 dependent workspaces and tested in jcp-rs (369), eoc-rs (717), sandbox-rs, joule-ui-rs (72) and proof-rs (139).

Until now, the Linux RAPL and NVML paths in all five energy meters had only been tested against fixture sysfs trees. This PR runs them on real hardware. jetson-hub has a 13th-gen Intel i9 with powercap zones package-0, core, psys and an mmio package, plus an RTX 4050 Laptop GPU (driver 595.84). Each test checks the meter against an oracle the test reads for itself.

RAPL: correct everywhere

Each crate gets a tests/rapl_hardware.rs: eoc-meter, sandbox-meter, sandbox-exec-microvm, jcp-energy, jouleclaw-energy and proof-cascade. Each test asserts:

  • the meter's liveness status agrees with the oracle's own sustained-advance verdict;
  • a CPU burn's figure lies inside the oracle's package-only bracket (top-level package-N zones read just before and just after);
  • the figure is clearly below package + psys and below package + core;
  • the grade is HwShunt (or Measured) only when live.

Example from jcp-energy: the meter read 7,664,287 µJ against a bracket of 7,647,990–7,664,287 µJ. In the same window psys moved 61 mJ and the core sub-zone 6.35 J; neither was added.

Mutations caught: psys summed as a package, the core sub-zone summed, the mmio zone summed, and energy_uj read as mJ. One limit remains: under a package at its power limit (load 11), proof's busy-minus-idle figure can read 0 and miss a core-zone double count.

NVML: was wrong in all four crates, fixed

The RTX 4050 Laptop GPU's total-energy counter advances steadily, so every crate called it live and graded it as hardware. It is not the board's energy:

  • polled at the probes' 100–150 ms ticks, it runs at 44–372 W while the board draws 3.5–14 W against a 65 W maximum limit;
  • left unpolled, it reads close to the draw.

Independent check, reading NVML directly through ctypes:

Counter Board draw
Unpolled, 5 s 12.3 W 10.2 W
Polled every 100 ms 72.2 W 10.2–10.3 W
Unpolled again 5.2 W 1.75 W

Rule (sandbox-meter, jcp-energy, jouleclaw-energy, eoc-meter): each probe reads the board's power draw at every sample and its maximum power limit once. A counter that advances is Implausible, and never used, if any of these holds:

  • it ran faster than 1.5× the board's maximum limit;
  • it ran faster than twice the board's mean draw plus 5 W (NVML's stated draw accuracy);
  • a draw reading itself exceeded 1.5× the limit. The board reports about 590 W for a second after it wakes.

jouleclaw then falls back to its power-draw integral, graded ModelBased. The others decline the rung. Each crate has a tests/nvml_hardware.rs that failed on jetson-hub before its fix (crate said Live, oracle said Implausible) and passes after.

eoc-meter: detect() ranked NVML ahead of RAPL. eoc grades any advancing counter Measured, and it has no coverage field. So a GPU-only figure became the whole cascade's measurement. NVML is no longer a detect() rung, and NvmlCounter is no longer a JouleCounter: it is read with read_gpu_microjoules(), so no consumer can grade it as the cascade's counter.

Also:

  • jcp-energy's fresh-process pin now expects only the probes the ladder reached. On a host with live RAPL, NVML is never asked.
  • jouleclaw's NVML doc said the counter updates every 50 ms; it updates every 100 ms.

Verified

  • On jetson-hub: every crate's lib tests plus its hardware tests, with NVML features on. I re-ran jcp-energy independently from this branch's tree: RAPL Live / HwShunt / Whole inside the bracket, NVML Implausible (75.9 W counter against a 13.3 W board), 32 lib tests.

  • On macOS:

    Crate Tests passed
    jcp-energy (--features nvml) 87
    eoc-meter (--features cuda) 81
    sandbox-meter 131
    proof-cascade 102

    eoc's workspace check passes as well.

  • In CI: Docker masks /sys/devices/virtual/powercap (default masked paths, unprivileged), and the container has no GPU. So in CI these tests take their "unavailable, and the oracle agrees" branch. The live branch runs on a host that exposes the counters, such as jetson-hub itself.

🤖 Generated with Claude Code

The ten commits sit directly on `main`, which now holds #110, #114–#117 and #120–#124. Before this they sat on a merge of those branches, and the forge couldn't compute a clean merge base for that history. The tree is byte-identical to `main` merged with the earlier head, and that tree was compiled in all 9 dependent workspaces and tested in jcp-rs (369), eoc-rs (717), sandbox-rs, joule-ui-rs (72) and proof-rs (139). Until now, the Linux RAPL and NVML paths in all five energy meters had only been tested against fixture sysfs trees. This PR runs them on real hardware. jetson-hub has a 13th-gen Intel i9 with powercap zones `package-0`, `core`, `psys` and an mmio package, plus an RTX 4050 Laptop GPU (driver 595.84). Each test checks the meter against an oracle the test reads for itself. ## RAPL: correct everywhere Each crate gets a `tests/rapl_hardware.rs`: eoc-meter, sandbox-meter, sandbox-exec-microvm, jcp-energy, jouleclaw-energy and proof-cascade. Each test asserts: - the meter's liveness status agrees with the oracle's own sustained-advance verdict; - a CPU burn's figure lies inside the oracle's package-only bracket (top-level `package-N` zones read just before and just after); - the figure is clearly below package + psys and below package + core; - the grade is `HwShunt` (or `Measured`) only when live. Example from jcp-energy: the meter read 7,664,287 µJ against a bracket of 7,647,990–7,664,287 µJ. In the same window psys moved 61 mJ and the core sub-zone 6.35 J; neither was added. Mutations caught: psys summed as a package, the core sub-zone summed, the mmio zone summed, and `energy_uj` read as mJ. One limit remains: under a package at its power limit (load 11), proof's busy-minus-idle figure can read 0 and miss a core-zone double count. ## NVML: was wrong in all four crates, fixed The RTX 4050 Laptop GPU's total-energy counter advances steadily, so every crate called it live and graded it as hardware. It is not the board's energy: - polled at the probes' 100–150 ms ticks, it runs at 44–372 W while the board draws 3.5–14 W against a 65 W maximum limit; - left unpolled, it reads close to the draw. Independent check, reading NVML directly through `ctypes`: | | Counter | Board draw | |---|---|---| | Unpolled, 5 s | 12.3 W | 10.2 W | | Polled every 100 ms | 72.2 W | 10.2–10.3 W | | Unpolled again | 5.2 W | 1.75 W | **Rule (sandbox-meter, jcp-energy, jouleclaw-energy, eoc-meter):** each probe reads the board's power draw at every sample and its maximum power limit once. A counter that advances is `Implausible`, and never used, if any of these holds: - it ran faster than 1.5× the board's maximum limit; - it ran faster than twice the board's mean draw plus 5 W (NVML's stated draw accuracy); - a draw reading itself exceeded 1.5× the limit. The board reports about 590 W for a second after it wakes. jouleclaw then falls back to its power-draw integral, graded `ModelBased`. The others decline the rung. Each crate has a `tests/nvml_hardware.rs` that failed on jetson-hub before its fix (crate said Live, oracle said Implausible) and passes after. **eoc-meter:** `detect()` ranked NVML ahead of RAPL. eoc grades any advancing counter `Measured`, and it has no coverage field. So a GPU-only figure became the whole cascade's measurement. NVML is no longer a `detect()` rung, and `NvmlCounter` is no longer a `JouleCounter`: it is read with `read_gpu_microjoules()`, so no consumer can grade it as the cascade's counter. **Also:** - jcp-energy's fresh-process pin now expects only the probes the ladder reached. On a host with live RAPL, NVML is never asked. - jouleclaw's NVML doc said the counter updates every 50 ms; it updates every 100 ms. ## Verified - **On jetson-hub:** every crate's lib tests plus its hardware tests, with NVML features on. I re-ran jcp-energy independently from this branch's tree: RAPL Live / `HwShunt` / `Whole` inside the bracket, NVML Implausible (75.9 W counter against a 13.3 W board), 32 lib tests. - **On macOS:** | Crate | Tests passed | |---|---| | jcp-energy (`--features nvml`) | 87 | | eoc-meter (`--features cuda`) | 81 | | sandbox-meter | 131 | | proof-cascade | 102 | eoc's workspace check passes as well. - **In CI:** Docker masks `/sys/devices/virtual/powercap` (default masked paths, unprivileged), and the container has no GPU. So in CI these tests take their "unavailable, and the oracle agrees" branch. The live branch runs on a host that exposes the counters, such as jetson-hub itself. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jcp-energy: never select IOReport when its CPU rail is frozen
All checks were successful
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m51s
standards workspaces build + test / aac-rs (pull_request) Successful in 42s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m20s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 39s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 30s
standards workspaces build + test / map-rs (pull_request) Successful in 30s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m9s
standards workspaces build + test / mesh-rs (pull_request) Successful in 27s
standards workspaces build + test / openpay (pull_request) Successful in 1m33s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m27s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m39s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m39s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m33s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m24s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 3m1s
dfec5d44b0
This crate's contract, in its own module doc: "measured when the platform
exposes it, estimated when it doesn't, and the provenance always says which ...
A reading never silently upgrades its own provenance." The IOReport rung broke
that on current macOS, and the CI job meant to catch it could not.

What IOReport actually returns on macOS 27.0.1, measured by dumping the
channels rather than re-running the test:

- On an M5 Max, every CPU rail in the Energy Model group (`CPU Energy`, `PCPU`,
  `MCPU*`, `PACC_*`) holds the same cumulative value across 200 ms, 1 s, 3 s and
  ~4 minutes of full CPU load. `CPU Energy` read 73,176,481 mJ before and after.
  `GPU Energy` stays live.
- On an M3 Ultra, the same OS: all 645 Energy Model rails are frozen, CPU,
  DRAM and display included.
- Every channel is IOReport format 1 (Simple), so the accessor is right, and
  subscribing to the Energy Model group alone instead of all channels does not
  unfreeze anything. No channel in any group exposes live CPU energy to an
  unprivileged reader; "CPU Stats" residency moves, but that is activity.

So `detect()` selected IOReport the moment a subscription opened, and
`measure()` accepted any `a > b` on the summed rails as the task's energy. On
the M5 Max the GPU rail makes `a > b` true: GPU energy was reported as the energy
of CPU work, as a whole-task ModelBased figure. On the M3 Ultra nothing moves,
so it fell to time×power under the same tag, indistinguishable from a reading.

The CI job `macOS energy backend (jcp-energy)` flaked for the same reason. Its
test asserted the summed total advanced across a 200 ms CPU burn — satisfied by
the GPU, not the CPU. It passed when something happened to draw on screen and
failed when nothing did, which is why it failed overnight and "healed" by day.
That flake has been blocking unrelated PRs.

The fix enforces the contract where the rung is chosen. `detect()` selects
IOReport only when `ioreport_status()` is `Live`: the CPU rail itself — `CPU
Energy`, or `DIE_<n>_CPU Energy` on multi-die parts — must advance while this
thread burns CPU, polled every ~25 ms for up to one second, probed once per
process and cached. Liveness is decided on the raw integer before unit
conversion, and never on the summed total, because the live GPU rail is exactly
what corrupts the total. Otherwise the rung is declined and the time×power model
estimates, as the contract requires. `IoReportStatus` (`Unavailable`,
`CpuRailAbsent`, `CpuRailFrozen`, `Live`) is exposed so a caller or receipt can
say why an Apple Silicon host is being estimated. The change is additive:
`mod ioreport` is private and nothing outside the crate matches on `Meter`.

Tests. The flaky test is replaced by three that assert against an independent
oracle: it reads the CPU rail's ABSOLUTE cumulative value with no delta
samples, matches names with `ends_with("CPU Energy")` rather than the
implementation's die-prefix stripping, and burns for three times the probe's
budget. There is no early return that passes without asserting — a host with no
IOReport must report `Unavailable`, one with no CPU rail `CpuRailAbsent`.

- `the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail` — the property that
  matters. It logs what it observed so a CI log shows which branch was asserted.
- `the_liveness_probe_never_reports_a_frozen_rail_as_live` — the dangerous
  direction, asserted on its own.
- `a_selected_ioreport_meter_meters_cpu_work` — when the rung IS selected, a CPU
  burn meters as non-zero energy.

Observed on both machines: `status=CpuRailFrozen oracle=CpuRailMoved(false)
selected=false`, 10/10 passing — on the M3 Ultra via the `DIE_0_`/`DIE_1_` rails,
which confirms the multi-die path. The CI job becomes deterministic: green while
the meter correctly declines a frozen rail, red if it ever selects one.

Falsified: a probe that always says Live fails the test (the oracle saw the rail
hold); reverting `detect()` to select IOReport whenever it opens — main's
behaviour — fails it with "IOReport selected although its CPU rail did not
move", so main fails this test today.

jcp-cli, jcp-conformance and jcp-intercept (every dependant of this crate) pass,
as does the conformance suite's C9 energy-honesty check; clippy is clean on
jcp-energy. jcp-gateway fails on main as well and does not depend on this crate
— it meters through sandbox-meter's probe, which reads the same frozen rail.

Not done here, deliberately:
- A per-span check for a rail that freezes after `detect()` needs "CPU Stats"
  residency as a witness; the platform-level freeze observed here is caught at
  selection.
- Reporting the live GPU rail as an explicitly partial reading, instead of
  declining, would need a coverage field on the intercept/1 wire — a format
  change, so a separate decision.
- The same defect in jouleclaw-energy, eoc-meter, proof-cascade and
  sandbox-meter, and in the callers that seal a zero delta as a measurement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family's energy contract: "measured when the platform exposes it, estimated
when it doesn't, and the provenance always says which ... A reading never
silently upgrades its own provenance." proof-cascade's meter broke it on current
macOS, and `proof --measure` then blamed the machine for what the platform did.

Ground truth, macOS 27.0.1 (measured for PR #110 by dumping the channels): on an
M5 Max the IOReport Energy Model CPU rails do not advance for an unprivileged
reader while `GPU Energy` does; on an M3 Ultra every Energy Model rail is frozen.
Re-observed through this crate: the probe added below matched `CPU Energy` on the
M5 Max and `DIE_0_CPU Energy` / `DIE_1_CPU Energy` on the M3 Ultra, with a raw
delta of 0 on every sample.

The defect. `hardware_meter_available()` was true as soon as an IOReport
subscription opened, and `power_over()` summed every Energy Model rail,
returning `None` only when the SUM was zero. Instrumenting `bracket()` on main
showed what that meant:
- M5 Max: idle_w=0.345 W, busy_w=0.281 W with every spare core burning (then
  0.315/0.291 and 0.308/0.295 on two more runs): the GPU rail alone. The
  marginal clamps to Some(0) and the CLI printed "signal stayed inside the idle
  noise (a loaded/throttled box)". A run in which the GPU rail drew more in the
  busy window than at idle would print that difference as the proof's energy,
  tagged HwShunt, "a real hardware reading".
- M3 Ultra: idle_power=None (every rail frozen, sum 0), and the CLI printed the
  same "loaded/throttled box" misdiagnosis.
Its two tests could not see it: one returned early without IOReport and
asserted only inside `if let Some(w)`, the other asserted only an upper bound
inside `if let Some(uj)`, so every None passed silently, on the M3 Ultra always.

The fix, with jcp-energy's semantics (PR #110), copied in so the crate stays
self-contained. `macos_ioreport::probe_cpu_rail` opens a subscription and spins
~25 ms on the calling thread before each re-sample, for up to
`CPU_RAIL_LIVENESS_BUDGET` (1 s), deciding on the raw integer of the Energy
Model channel named `CPU Energy` after stripping a `DIE_<n>_` prefix, never on
the summed total that the live GPU rail corrupts: Live, Frozen or Absent.
`ioreport_status()` probes once per process and caches the answer as the public
`IoReportStatus` {Unavailable, CpuRailAbsent, CpuRailFrozen, Live}, so callers
can say why. `available()`, and so `hardware_meter_available()`, is true only
when Live; `power_over()` and `measure_marginal_uj()` return None otherwise,
before any window runs. None keeps meaning "no hardware interface", so callers
keep their ModelBased figure, and a live reading keeps its existing grade.

proof-cli prints the status as the reason ("IOReport opens, but its CPU energy
rail is frozen (it did not advance while this process burned CPU), so it cannot
meter CPU work. The figures above stay ModelBased"), keeps the idle-noise
message for a live interface whose busy window did not clear idle, and drops
the stale `macmon` references. The sealed receipt is unchanged: it is built
from the first CascadeResult, never from the --measure figure.

One measurement the reference does not state: sampling every channel is slow.
One IOReportCreateSamples call took 0.4-0.8 s on the M5 Max, mostly waiting (75
ms of 0.81 s on-CPU, timed on the thread clock), so the 1 s budget buys two or
three samples over a 1.1-1.9 s window and the probe's spin is a small share of
it. That cannot produce a false Live, since only the rail's own integer moving
can, so it errs only toward declining. The docs say so, and the test oracle
burns on a helper thread instead.

Tests. The two silent tests are replaced and three oracle-based ones added.
Every branch asserts something:
- the_ioreport_meter_is_never_available_on_a_frozen_cpu_rail, against an
  independent oracle: its own subscription, each CPU rail's ABSOLUTE value (no
  deltas), names matched by lowercase ends_with("cpu energy") in any group (a
  superset of the probe's rails), and a helper thread burning CPU for the whole
  3x-budget window (3.54 s of user CPU for this test alone). It logs
  "ioreport liveness: status=.. oracle=.. selected=..", asserts a frozen rail is
  never selected and that power_over and measure_marginal_uj return None there,
  and on every branch that selection equals status == Live.
- the_liveness_probe_never_reports_a_frozen_rail_as_live: a fresh probe and the
  oracle, the implication evaluated on every host, not only one claiming Live.
- a_selected_ioreport_meter_meters_cpu_work: when selected, status and a fresh
  probe are Live and a multi-core burst meters Some(>0); when not, the status
  is not Live and the meter returns None.
- power_over_declines_unless_the_cpu_rail_is_live and meter_is_total_or_nothing
  replace the silent pair: a declined interface must give None, a usable one a
  figure. ioreport_status_is_unavailable_off_macos covers other platforms.
- proof-cli tests/measure.rs runs `proof --measure`: when it declines, the
  output gives the reason (the frozen rail when that is the status) and holds no
  "HwShunt" and no idle-noise blame; when it meters, the host's meter must be
  available.

Observed with 16/16 proof-cascade lib tests passing on both chips, macOS 27.0.1:
"ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
selected=false" on the M5 Max and on the M3 Ultra, and the CLI prints the
frozen-rail reason on both.

Falsified on the M5 Max, each restored with git checkout:
- F1, probe always Live: the frozen-rail test fails with "IOReport meter
  available although its CPU rail did not move under 3s of CPU burn — it would
  bracket GPU-only energy, or zero, as the energy of CPU work" (status=Live
  oracle=CpuRailMoved(false) selected=true). The probe test fails, and so does
  the selected-meter test, with "a live CPU rail metered a multi-core CPU burst
  as Some(0)": main's bracket on GPU-only rails.
- F2, main's ungated available() and power_over(): the frozen-rail test fails
  with the same message (status=CpuRailFrozen oracle=CpuRailMoved(false)
  selected=true), so main fails it today.
- F3, main's proof-cli over this meter: tests/measure.rs fails with "neither a
  declined meter nor a measurement in the output".

The proof-rs workspace passes, as does the proof-conformance suite (10/10);
joule-code-release and joulecontract-proof pass. joule-code-remediate's
verified_fix_end_to_end_ships_with_receipt fails with "assertion failed:
report.total_uj() > 0", identically on unmodified origin/main; it meters through
sandbox-meter, not this crate. clippy adds no warnings (two pre-existing ones
remain). Off macOS, `cargo check --tests` passes for x86_64-unknown-linux-gnu;
aarch64 Linux and Windows could not be checked here, because blake3's C build
needs a target sysroot or MSVC.

Not done here, deliberately:
- The grade. The module header calls this the HwShunt backend and proof-cli tags
  a live figure `Energy::measured` (HwShunt), while the same header says a macOS
  Energy Model reading is ModelBased. Which grade IOReport earns is a
  family-wide decision.
- Reporting the live GPU rail as an explicitly partial reading.
- A rail that freezes after the cached probe; that needs a residency witness.
- measure_marginal_uj's doc promises None "inside the idle noise", but the code
  returns Some(0) when busy <= idle (observed above), and no test covers noise.
- power_over divides by the sleep `d` while its sample-to-sample window also
  holds a 0.4-0.8 s IOReportCreateSamples call, overstating a live reading.
- The Linux RAPL path: its "world-readable" comment predates the 2020 root-only
  change, and wrapping_sub over the sum of per-zone counters turns one zone
  wrapping into an absurd figure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The crate's contract, in its probe module doc: "A probe can degrade but it
can never lie: if the hardware path fails mid-span, the reading comes back
`Estimator`, not a zero dressed as a measurement." On current macOS the
IOReport probe broke that on every Apple Silicon host.

Ground truth, measured 2026-09-29 by dumping the Energy Model channels while
burning CPU, not by re-running tests:

- M5 Max, macOS 27.0.1: 364 channels. Of the three that `class_for` maps,
  `CPU Energy` (mJ) held at 480,083,160 and `ANE0` held across 3 s of burn;
  `GPU Energy` (nJ) advanced 682,543,073. Nothing else moved.
- M3 Ultra, macOS 27.0.1: 645 channels, none moved, including
  `DIE_0_CPU Energy`, `DIE_1_CPU Energy`, `ANE0_0`, `ANE0_1`, `GPU Energy`.

The defect. `IoReportProbe::new()` accepted the probe once a subscription
opened and one snapshot was non-empty, and `Span::stop` sealed every class
delta, zero included, as `measured_uj: Some(delta)` under `HwShunt`. main's
own `joule-meter 1`, busy on all cores, sealed and verified:

    M5 Max:    Cpu: 0 µJ [HwShunt]  Npu: 0 µJ [HwShunt]  Gpu: 226790 µJ [HwShunt]
    M3 Ultra:  Cpu: 0 µJ [HwShunt]  Npu: 0 µJ [HwShunt]  Gpu: 0 µJ [HwShunt]

On the M5 Max the display's GPU energy is passed off as the energy of CPU
work; on the M3 Ultra, three measured zeros. The same figure flows on:
`sample_uj()` sums the classes, and `run_attributed` apportioned a 0 J
"measurement" with `attributed = true`.

That is why dependants fail on main on these machines. This crate's
`attributed_run_captures_a_busy_child` fails at attribute.rs:297 ("a busy
window costs SoC energy"). joule-code-remediate's
`verified_fix_end_to_end_ships_with_receipt` fails with
`report.total_uj() > 0`: it apportions the frozen rail's zero. jcp-gateway's
`granted_tool_is_allowed_metered_and_debited` (lib.rs:585, `measured_uj > 0`)
and `budget_exhaustion_denies_further_calls_and_the_log_verifies` (lib.rs:638,
"the budget eventually exhausts") assert, under `metering_is_hardware()`, that
a span's summed total moved. That holds only while the GPU rail happens to
tick: 40/40 green on the M5 Max this morning with the display compositing,
red when it idles, and 3/3 and 2/3 red on the M3 Ultra.

The fix applies the contract in the three places the decision is made.

1. Selection. `IoReportProbe::new()` declines unless `ioreport_status()` is
   `Live`. The CPU rail itself (`CPU Energy`, or `DIE_<n>_CPU Energy` on
   multi-die parts, matched after stripping the die prefix) must advance
   while the probing thread burns CPU. It is sampled every ~25 ms for up to
   `CPU_RAIL_LIVENESS_BUDGET` (1 s), probed once per process and cached in a
   OnceLock. The probe reads the same absolute samples `snapshot()` reads, on
   the probe's own subscription, so it tests the path a span will use. It
   decides per rail on the raw integer, before unit conversion, paired by
   position and name. It never decides on a summed total, which the live GPU
   rail advances by itself. Otherwise `new()` returns `ProbeError::Unavailable`
   naming the frozen `CPU Energy` rail, and `PowerMeter` falls back to its
   estimator: wall × TDP, `Estimator`, no `measured_uj`. A probe whose snapshot
   carries no CPU class is refused too, because GPU and ANE alone would be the
   same defect.
2. The span. In a span this process ran in, a CPU-class delta of 0 is a
   counter that held, not a reading. The whole span now returns the
   estimator record, exactly as when every probe vanished. That covers a rail
   that freezes after the per-process probe, and RAPL: a package counter that
   did not advance is no measurement either. A probe set with no CPU class
   (NVML alone) is not subject to it. I checked for tests that would break:
   none expects a short span to be `HwShunt`. jcp-gateway's hardware-only
   asserts need only a positive figure, which an estimate is. Whether the
   Linux runners' containers can read RAPL was not verified here.
3. Attribution. `run_attributed` sets `attributed` only when the window's
   CPU-class record is an `HwShunt` reading that advanced, on top of the
   existing rusage and busy-tick conditions. An estimate is carried as
   context and never apportioned as a reading.

`IoReportStatus` (`Unavailable`, `CpuRailAbsent`, `CpuRailFrozen`, `Live`,
with a Display), `ioreport_status()` and `CPU_RAIL_LIVENESS_BUDGET` are
public and re-exported at the crate root, so a caller or CLI can say why an
Apple Silicon host is estimated. `joule-meter` now does. Its "machine-wide
counters; subtract idle" note is printed only for a measured span, because it
is false for an estimate. Grade names are untouched: a live IOReport reading
is still `HwShunt`.

Tests. Every IOReport test asserts against an independent oracle. The
implementation reads absolute samples on its own subscription and strips a
die prefix. The oracle opens its OWN subscription, reads DELTA samples with
`IOReportCreateSamplesDelta` (the implementation never calls it), matches
`ends_with("CPU Energy")`, and burns for three times the probe's budget.
No branch passes without asserting.

- `the_ioreport_probe_is_never_selected_on_a_frozen_cpu_rail` checks the
  property and prints "ioreport liveness: status=.. oracle=.. selected=..".
- `the_liveness_probe_never_reports_a_frozen_rail_as_live` checks the
  dangerous direction, and checks every other status claim against the
  oracle.
- `a_selected_ioreport_probe_meters_cpu_work`: when selected, a CPU burn
  meters an `HwShunt` CPU record > 0. When declined, the status is not Live,
  the span is the estimator's, and `sample_uj()` is None.
- `a_cpu_rail_is_the_rail_itself_on_one_die_or_many` and
  `liveness_is_decided_per_cpu_rail_on_the_raw_integer` cover the matcher
  and the pairing, using the dumps' names and values.
- Scripted-probe span tests replay the dumps and are deterministic on every
  platform: `a_span_whose_cpu_counter_held_is_estimated_even_though_the_gpu_advanced`
  (M5 Max), `a_span_where_every_counter_held_is_estimated` (M3 Ultra),
  `a_held_cpu_counter_estimates_the_whole_composite_span` (RAPL held, NVML
  moved), `a_span_whose_cpu_counter_advanced_is_measured`, and
  `a_probe_set_without_a_cpu_class_is_not_subject_to_the_cpu_rule`.
- `detect_never_fails_and_records_validate` used to assert `total_uj() > 0`,
  which the GPU rail satisfied. It now asserts the CPU record or the
  estimator record, and on Apple Silicon that the probe exists exactly when
  the status is Live.
- `attributed_run_captures_a_busy_child` used to assert `soc_cpu_uj > 0` and
  fails on main. It now asserts per status: Live means attributed; otherwise
  not hardware, not attributed, and an estimator figure > 0. Two scripted
  tests pin the rule itself: `a_window_whose_cpu_counter_held_is_never_attributed`,
  and `a_window_whose_cpu_counter_advanced_is_attributed` for the positive
  path, which no macOS 27 host reaches for real. Their child now runs
  2–3 s, bounded by the clock and an iteration cap. The machine tick
  counters behind `busy_cpu_secs` update in bursts up to ~990 ms apart on
  both chips. A ~0.3 s child saw `busy_cpu_secs = 0.0` on the M3 Ultra and
  would have declined for the wrong reason.

Observed with the same test binary on both chips, 5 runs each, 31/31 every
time:

    M5 Max:   ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false
    M3 Ultra: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false

Falsified. Each mutation was restored with git checkout, leaving a clean tree:
- F1, the probe always says Live: the frozen-rail test fails ("the oracle saw
  the CPU rail hold under 3s of CPU burn", left: Live, right: CpuRailFrozen),
  and so do the never-Live, metering and attribution tests.
- F2, main's ungated `new()`: it fails with "IOReport probe selected although
  its CPU rail did not move under 3s of CPU burn — every span would report
  GPU-only energy, or zero, as a measurement of CPU work", and 3 others fail.
- F3, the per-span rule removed: the three held-counter span tests fail. The
  M5 Max one reproduces main's seal exactly: Cpu Some(0), Npu Some(0),
  Gpu Some(682543), all HwShunt.
- F4, main's attribution rule: `a_window_whose_cpu_counter_held_is_never_attributed`
  fails because the estimator's 27.8 J is attributed as a reading.

Dependants, run on main and on this branch: sandbox-attest, -runtime,
-exec-wasm, -exec-microvm, -conformance (and its suite: all classes, no
skips), -proof, -proof-wasm, -mcp, -build, -cli, -jcp, jcp-gateway,
jcp-action-receipt, joule-ui-interaction and joule-ui-serve pass on the
branch. joule-code-remediate passed 15 of 16 branch runs. The failure was
`verified_fix_end_to_end_ships_with_receipt` with the assertion it fails
with on every main run, `report.total_uj() > 0`. On the branch that figure
is estimate × share, and the share is 0 when no machine tick burst lands in
any of its cargo windows. jcp-gateway passes because its hardware-only
asserts are now skipped. Both relabel the estimate this crate now returns:
see below. Clippy is clean on this crate (--tests), and so is
x86_64-unknown-linux-gnu.

Not done here, deliberately:
- Consumers that relabel the estimate this crate now returns: jcp-gateway
  debits and seals `Energy::measured(measurement.total_uj())` (lib.rs:255-259)
  and labels its eval by `meter.is_hardware()` (lib.rs:228, 430). Its
  hardware-only asserts are now skipped on Apple Silicon.
  joule-code-remediate seals an estimate-apportioned figure as `ModelBased`
  (lib.rs:656), and its end-to-end test passes here only because
  `total_uj()` is now an estimate > 0. jcp-action-receipt
  (examples/end_to_end.rs:139), sandbox-proof (lib.rs:1321) and joule-ui
  label by `is_hardware()` rather than by the span. They are for the next
  change, on this branch.
- A GPU or ANE zero beside a CPU counter that advanced, and an NVML-only
  set's zero GPU delta, are still sealed `HwShunt` 0 (probe/mod.rs:340).
- Reporting the live GPU rail as an explicitly partial reading instead of
  declining the probe is a receipt and wire decision.
- `class_for`'s substring match (ioreport.rs:101-111) sums every
  `DIE_n_CPU Energy`, which is right for an Ultra. It would also sum a
  sub-rail named "…CPU Energy", and it takes any channel containing "ANE".
  On these two dumps only the top-level rails match; other chips need a
  dump.
- `sample_uj()` sums every class, so its before/after users cannot apply the
  held-CPU rule. `Attribution` has no provenance for `soc_cpu_uj`. Both are
  API changes.
- `busy_cpu_secs` inherits the ~1 s tick bursts, so windows under ~1 s are
  declined or skewed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every energy backend in this family owes one contract: measured when the
platform exposes it, estimated when it doesn't, and the provenance always says
which; a reading never silently upgrades its own provenance. eoc-meter's
IOReport counter broke it on current macOS, and eoc sealed the result into its
receipts as measured.

Ground truth, read from the channels on both machines (macOS 27.0.1), not
inferred from test outcomes:

- M5 Max: of 364 Energy Model channels, only `GPU Energy` moved across a 3 s
  single-thread CPU burn (+851,622,003 nJ). `CPU Energy` held at 541,893,638 mJ
  (absolute) and no CPU sub-rail moved.
- M3 Ultra: of 645 channels, none moved in 3 s; over 12 s only `GPU Energy`
  did (+14,809,813 nJ). `DIE_0_CPU Energy` / `DIE_1_CPU Energy` held at
  9,494,336 / 10,212,656 mJ.
- The CPU rails are not frozen for good; they are refreshed now and then. A
  25-minute watch, one sample a second, saw the M5 Max's `CPU Energy` hold at
  541,893,638 mJ throughout (PR #110 records 73,176,481 mJ for an M5 Max
  earlier today; this one has been up since 00:23). The M3 Ultra's rails held
  from at least 10:47 to 11:09, then both dies stepped within one sample,
  +1,344,869 and +1,391,637 mJ, and held again.

The defect, as eoc showed it on main (860ead8c):

- `MacosIoReportCounter::try_detect()` returned the counter whenever a
  subscription opened, so `detect()` chose it on every Apple Silicon host. Its
  reading sums the Energy Model rails, so on the M5 Max it advanced only
  through the GPU rail.
- `eoc query "totally novel question"` on the M5 Max printed
  `cost : 50136686 µJ (measured)`: the neural stage's 50 J estimate plus a
  136,686 µJ GPU-only delta, which `attach_cascade_cost` tags Measured because
  it is positive. The receipt seals that tag. Four runs of four did this
  (+136,686, +533,511, +272,220 and +231,665 µJ).
- `eoc meter` printed `counter: ioreport` and `idle : 307124 µJ over 1.0 s`
  on the M5 Max, `idle : 10279 µJ over 1.0 s` on the M3 Ultra: GPU energy,
  presented as the machine's idle draw.
- The test meant to guard this, `ioreport_advances_after_cpu_burn`, asserted
  only that the summed total advanced across a 200 ms burn. The GPU rail
  satisfied it on the M5 Max; on the M3 Ultra it failed (`energy must advance:
  before=0 after=0`); where IOReport can't open it returned early and passed.

The fix. `try_detect()` now returns the counter only when `ioreport_status()`
is `Live`, and `Err(NoCounter)` otherwise, so `detect()` falls through. The
probe is jcp-energy's (PR #110, dfec5d44), copied in to keep the crate
self-contained: burn CPU on the probing thread, re-sample after every ~25 ms of
it for up to a second, and decide on the raw integers of `CPU Energy` or
`DIE_<n>_CPU Energy`, never on a summed total, because the live GPU rail is
what corrupts the total. Probed once per process and cached. `IoReportStatus`
(`Unavailable`, `CpuRailAbsent`, `CpuRailFrozen`, `Live`) and
`ioreport_status()` are public so a caller can say why a Mac is not metered by
IOReport. `Err(NoCounter)` is reused rather than adding an `eoc_core::Error`
variant: the reason lives in the status, and the error enum is another crate's
public API.

Two deliberate differences from the reference, both measured:

- The probe samples its own subscription to the Energy Model group, not every
  channel. Sampling every channel took ~781 ms per call on the M5 Max and
  ~144 ms on the M3 Ultra, the group ~6 ms. On every channel a one-second probe
  took 2 samples and spent 3% of its window spinning on the M5 Max (12% on the
  M3 Ultra); on the group it takes 31 samples and spins 77% (75%). "Poll every
  ~25 ms while this thread spins" holds only on the group. Both subscriptions
  read the same absolute CPU values. The counter's own subscription is
  unchanged, and the test oracle reads that one.
- A rail counts as live only when one CPU channel steps at two separate
  samples, not one. A live rail sampled every ~30 ms under load steps many
  times a second; a refresh steps once. On a single step, a refresh landing
  in the probe's second would select the counter for the life of the process,
  and every later refresh (~1.3 kJ per die, above) would land whole in
  whichever caller's interval straddled it, tagged Measured. A rail too slow
  to step twice in a second is declined: estimated, never over-claimed.

What runs instead, end to end. `detect()` now falls through to
`MacosPowermetricsCounter`, every read of which is `Err(NoCounter)`, or to
`StubCounter`, which reads 0. Neither can produce a positive delta, so
`attach_cascade_cost` keeps each stage's own source and no consumer here
(eoc-cascade, eoc-local, eoc-streaming) can relabel the fallback as a reading;
eoc-cascade is unchanged. `eoc query` now prints `cost : 50000000 µJ
(estimated)` on both machines. `eoc meter` printed a failed read as `0 µJ over
1.0 s (0.000 W)`; it now prints the IOReport status and `not metered (counter
read failed: no joule counter available)`.

Tests. The aggregate test is replaced by three that assert against an
independent oracle. The oracle samples the counter's all-channel subscription,
reads the CPU rails' ABSOLUTE values (no deltas), matches names with
`ends_with("CPU Energy")` rather than the implementation's group filter and
die-prefix stripping, and burns CPU on its thread for three times the probe's
budget between one read before and one after. Every branch asserts. The
oracle is not blind: the same absolute reads show `GPU Energy` moving on the
M5 Max.

- `the_ioreport_counter_is_never_selected_on_a_frozen_cpu_rail`: the property.
  Logs `ioreport liveness: status=.. oracle=.. selected=..`.
- `the_liveness_probe_never_reports_a_frozen_rail_as_live`: the dangerous
  direction, on a fresh uncached probe, which must also agree with the oracle
  on what exists.
- `a_selected_ioreport_counter_meters_cpu_work`: when the counter is available
  its CPU rail is live and a 200 ms burn meters non-zero; when it is not, the
  status is not `Live`.
- `a_cpu_rail_that_steps_once_is_not_live` and
  `a_cpu_rail_that_keeps_stepping_is_live`: the step rule, fed the M3 Ultra's
  observed refresh. A refresh cannot be scheduled, so no hardware test can
  catch a single-step rule on demand.
- eoc: `a_counter_that_did_not_read_is_never_printed_as_a_figure`.

Observed on both machines: `status=CpuRailFrozen oracle=CpuRailMoved(false)
selected=false` and `probe=Some(Frozen)`, 9/9 passing; on the M3 Ultra through
the `DIE_0_`/`DIE_1_` rails.

Falsified, each reverted with git checkout:

- F1, a probe that always says Live: the frozen-rail test fails on both
  machines ("IOReport selected (status=Live) although its CPU rail did not
  move under 3s of CPU burn ..."), and so does the probe test. The third test
  fails too on the M3 Ultra (before=0 after=0) but passes on the M5 Max, where
  the GPU rail moves the summed counter: the defect itself.
- F2, main's ungated `try_detect()`: the frozen-rail test fails on both
  ("IOReport selected (status=CpuRailFrozen) ..."), and so does the third.
- F3, liveness decided on every Energy Model rail, as a total would: fails on
  the M5 Max, where the GPU rail steps inside the probe window; passes on the
  M3 Ultra, where no rail stepped twice in that second.
- F4, main's `eoc meter` rendering: the CLI test fails on "0 µJ over 1.0 s
  (0.000 W)".
- F5, the reference's single-step rule: `a_cpu_rail_that_steps_once_is_not_live`
  fails ("one refresh counted as a live rail").

Every crate that depends on eoc-meter or eoc-cascade passes: eoc, eoc-agent,
eoc-carbon, eoc-cascade, eoc-distributed, eoc-local, eoc-observability,
eoc-route-learned, eoc-spec-decode, eoc-streaming, and eoc-wasm (no tests).
Clippy adds nothing on eoc-meter or eoc; eoc-meter's two existing warnings
remain. The probe costs about a second of one thread's CPU, once per process,
on Apple Silicon; `eoc query` on the M5 Max still got faster (1.11-1.24 s,
against 1.39-1.43 s on main, which paid for two all-channel reads per query).

Not done here, deliberately:

- `counts_once` admits CPU sub-rails beside `CPU Energy` (`PACC_0`..`PACC_5`,
  `MCPM*`, `PCPM*` on the M5 Max; `DIE_n_PACC*_CPM*`, `DIE_n_EACC_CPM*` on the
  M3 Ultra): a double count if those rails go live.
- The counter's own reads sample every channel (~781 ms a read on the M5 Max)
  although its sum uses only the Energy Model group.
- The powermetrics rung's docs say it degrades to `StubCounter`. It never does;
  every read errors.
- With a live counter, `attach_cascade_cost` tags a stage's estimate plus the
  measured overhead as one Measured total.
- `JouleCost::zero()` is Measured, so a cache hit claims a measured zero; the
  streaming meter tags a zero delta from any non-stub counter Measured;
  `sci_from_eval_report` tags every report Measured.
- A rail that stops tracking after selection: the probe runs once a process.
- Reporting the live GPU rail as an explicitly partial reading, and the grade
  IOReport readings carry: separate decisions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This crate's contract, in its own docs: every reading carries a Provenance
"declaring how the value was obtained", and "implementations that overstate
their honesty are non-conformant". The family contract (jcp-energy):
"measured when the platform exposes it, estimated when it doesn't, and the
provenance always says which ... A reading never silently upgrades its own
provenance." The IOReport counter broke both on current macOS.

Ground truth, measured today by reading the channels, not by re-running tests:

- M5 Max, macOS 27.0.1: `CPU Energy` (the only channel in any group whose name
  ends in "CPU Energy") held 480,083,160 mJ across a 3 s CPU burn while
  `GPU Energy` advanced ~892 mJ. M3 Ultra, same OS: `DIE_0_CPU Energy`,
  `DIE_1_CPU Energy` and `GPU Energy` all held. All are format 1 (Simple).
- The CPU rail is not permanently frozen; it is batch-updated, tens of
  minutes apart. Watched for 25 minutes (10:51-11:16) through an
  Energy-Model-only subscription sampled every 100 ms and an all-channel one
  every 2 s: on the M3 Ultra both dies' CPU rails changed exactly once, in
  the same sample, at 11:09:05 in both subscriptions (+1,344,869 and
  +1,391,637 mJ); on the M5 Max the CPU rail did not change in 13,614
  samples while `GPU Energy` changed in 13,125 of them. Yet the M5 Max rail
  read 541,893,638 mJ by 10:47, up from 480,083,160 at ~10:30.
- A live rail looks nothing like that: the M5 Max GPU rail changed in 166 of
  176 samples ~28 ms apart.
- One IOReportCreateSamples over the counter's all-channel subscription costs
  ~0.35-0.7 s on the M5 Max (11,860 channels), ~0.135 s on the M3 Ultra
  (14,335 channels).

The defect. `AppleIoReportCounter::new()` was available whenever a
subscription opened, `detect_meter()` put it first (since b11af6ce), and
`read()` summed every Energy Model rail, skipped non-positive deltas, and
labelled the total SocTotal / ModelBased. On the M5 Max a CPU burn was
metered as GPU energy — main's `measures_energy_after_cpu_burn` passes there
only through the GPU rail. On the M3 Ultra nothing moves and the same test
fails on main: "expected measured energy to advance after a CPU burn:
before=0 after=0"; a consumer flooring that zero to the meter's resolution
sealed it as a ModelBased reading.

The fix enforces the contract where the counter is opened.
`AppleIoReportCounter::new()` (so `Default`, `detect_meter()` and every direct
caller) returns an available counter only when `ioreport_status()` is `Live`.
The status is probed once per process and cached (OnceLock): open a
subscription, burn CPU on the calling thread, re-sample after every 25 ms of
burn for the 1 s budget, and decide on the raw integer of each CPU rail
(`CPU Energy`, or `DIE_<n>_CPU Energy`) per consecutive sample interval —
never on the summed total, because the live GPU rail is exactly what
corrupts the total. Otherwise the counter is declined: `is_available()` is
false, `status()` says why, and every `read()` returns
`Err(EnergyError::NoCounter(SocTotal))` — never a number. `detect_meter()`
then falls through to `apple::AppleSiliconMeter`, whose readings say
`Estimator` end to end (CompositeMeter passes the reading through; nothing
in this crate re-tags it). The probe costs ~1.9 s once per process on the
M5 Max. New public API, all additive: `IoReportStatus` { Unavailable,
CpuRailAbsent, CpuRailFrozen, Live }, `jouleclaw_energy::ioreport_status()`
(compiled on every target; `Unavailable` without the backend, no probe),
`AppleIoReportCounter::status()`, `apple_ioreport::CPU_RAIL_LIVENESS_BUDGET`.

One deliberate difference from the jcp-energy reference (dfec5d44): a single
advance does not make a rail live. The same CPU rail must advance in at least
two separate sample intervals (`RailWatch`). Because the rail is
batch-updated, a probe whose window straddles an update sees one large jump —
tens of minutes of the whole machine's CPU energy — and the reference's
"advanced at all" rule calls that live and selects the counter for the rest
of the process. That happened here: at ~10:46, while a falsification run
re-probed without the cache, fresh probes under the reference rule reported
the M5 Max rail Live and the oracle, a moment later, saw it hold. Counting per
rail keeps two dies' separate jumps from adding up. A live rail passes in its
first two intervals; the probe samples past the budget until it has two, up
to five budgets, so one slow sample cannot make a live rail look frozen.

Docs made false by the change are corrected in the files touched: the module
and `new()` / `is_available()` docs; the `detect_meter()` comment that said
the counter is unavailable only "if IOReport can't open"; the `apple`
estimator's doc, which said IOReport was not yet wired and told a maintainer
to re-tag the estimator ModelBased once it was — it is now the path that runs
on macOS 27, so that instruction would relabel an estimate as a reading; and
the `energy_probe` example, which printed "--- measured ---" over whatever it
picked and now prints why IOReport was declined and "--- estimated ---" for
an Estimator. No grade changes.

Tests. `measures_energy_after_cpu_burn` — which asserted only that the summed
total advanced, and returned early, passing, when IOReport was unavailable —
is replaced. The new tests assert against an independent oracle: it opens its
own subscription, reads each CPU rail's ABSOLUTE value (no delta samples),
matches names with `ends_with("CPU Energy")` in any group, burns for three
times the probe budget, and returns NoIoReport / NoCpuRail /
CpuRailMoved(bool), true only if a rail's value changed at two or more
separate reads, so one batch jump landing in both windows cannot vouch for a
false Live. No test returns early without asserting.

- the_ioreport_counter_is_never_used_on_a_frozen_cpu_rail: neither available
  nor selected by detect_meter when the oracle saw the rail hold; logs
  "ioreport liveness: status=.. oracle=.. selected=.. available=..".
- the_liveness_probe_never_reports_a_frozen_rail_as_live: runs the probe
  uncached on a fresh subscription beside the cached status; asserts on every
  outcome, not only Live.
- a_used_ioreport_counter_meters_cpu_work: when available, its own CPU rail is
  live and 200 ms of CPU meters non-zero ModelBased energy; when not, the
  status is not Live.
- a_declined_counter_yields_no_reading_and_the_fallback_says_estimator.
- the_liveness_status_is_probed_once_and_cached.
- Rule tests with no hardware: a_single_jump_is_a_batch_update_not_a_live_rail,
  one_jump_on_each_die_is_not_a_live_rail, a_rail_that_keeps_advancing_is_live,
  no_cpu_rail_is_absent_and_no_interval_is_no_verdict,
  the_probe_samples_until_live_or_long_enough_to_tell,
  only_the_cpu_energy_aggregate_is_a_cpu_rail; and, where the backend is not
  built, without_the_ioreport_backend_the_status_is_unavailable.

Observed with this commit, one test binary run on both machines:
  M5 Max:   ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
            selected=false available=false (oracle: changes per CPU rail [0])
            test result: ok. 39 passed; 0 failed
  M3 Ultra: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
            selected=false available=false (oracle: changes per CPU rail
            [0, 0], the DIE_0_/DIE_1_ path)
            test result: ok. 39 passed; 0 failed
With `--features ioreport` 39 lib tests (main: 29); without it 24 (main: 23).

Falsified on the M5 Max, each mutation applied to this commit and restored
with git checkout:
- F1, the probe always says Live: "the oracle did not see the CPU rail keep
  moving under 3s of CPU burn, yet status=Live" (and the probe test).
- F2, main's ungated new(): "IOReport counter used although its CPU rail did
  not keep moving under 3s of CPU burn — it would report GPU-only energy, or
  zero, as the energy of CPU work (available=true, selected=true)".
- F3, no per-process cache: "a cached status and a declined counter took
  6.389182s: the liveness probe ran again".
- F4, liveness decided on every Energy Model rail, as the total sees it: the
  GPU rail makes the M5 Max probe say Live; both liveness tests fail.
- F5, the reference's single-advance rule: four rule tests fail, e.g.
  one_jump_on_each_die_is_not_a_live_rail "left: Some(Live) right:
  Some(Frozen)".
- F6, advances counted across rails, not per rail:
  one_jump_on_each_die_is_not_a_live_rail fails.

Dependants, per workspace with -p: mesh-meter and mesh-core with
hw-meter-apple (2 and 3 passed), jouledesk-github (4), l3-jouleclaw-binding
(2; 4 ignored, they need a model), jouleclaw-harness-econ (11),
jouleclaw-loader-gguf with fabric-meter (357 passed, 14 ignored) — all pass.
clippy -p jouleclaw-energy --no-deps --tests, with and without ioreport: no
new warnings (the duplicated #[link] attribute, sum_energy_uj's b"..\0",
ledger.rs's constant asserts and meter.rs's vec-init-then-push are on main).
cargo check --tests --examples for x86_64- and aarch64-unknown-linux-gnu,
with and without ioreport: clean.

Not done here, deliberately:
- The consumers (jouledesk-github, mesh-meter, l3-jouleclaw, loader-gguf's
  fabric_dispatch / fabric_calibrate / mpp_energy, harness-econ) are handled
  next, on this branch. On macOS 27 they now get no IOReport counter.
- Reporting the live GPU rail as an explicitly partial reading instead of
  declining, and which grade IOReport readings carry, are David's decisions.
- A per-read check for a rail that stops moving after selection needs a
  witness (CPU Stats residency); the platform-level freeze is caught here.
- `counts_once` would sum `PACC_<n>` sub-rails (no "cpu" in the name) on top
  of `CPU Energy`, a latent double count once the rail is live; fixing it
  needs a channel dump from a host whose CPU rail is live.
- The counter subscribes to all ~12-14k channels, so every read() costs one
  0.35-0.7 s sample on the M5 Max; an Energy-Model-only subscription samples
  in ~8 ms. Worth doing, but it changes what every read costs.
- jcp-energy (the reference) and the sibling backends still accept a single
  advance as live.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The contract, stated in jcp-energy and sandbox-meter alike: measured when the
platform exposes it, estimated when it doesn't, "and the provenance always
says which". The parent commit makes sandbox-meter keep it. On macOS 27.0.1
no Apple Silicon host checked exposes a live CPU energy counter, so
PowerMeter declines IOReport and every span is the estimator's wall × TDP
record. On a live host, a span whose CPU counter held is estimated too. The
meter's consumers then threw that provenance away.

Ground truth: the consumers' own output at the parent commit. The same
binaries ran on an M5 Max and an M3 Ultra, both on macOS 27.0.1; the M5 Max
comes first.

- jcp-gateway printed "metering=estimator", then "allowed, 1541728 µJ
  measured", and sealed energy_provenance "HwShunt" in the receipt. It
  debited Energy::measured(total_uj()) whatever the span was. On the M3
  Ultra: "999293 µJ measured", sealed HwShunt.
- joule-ui-serve sealed every interaction as Estimator with estimated_uj 0,
  for 24-56 ms of work. Its sampler summed raw counters under the Meter
  trait's default HwShunt. It returned None once no hardware probe was left,
  and run_interaction turns None into 0 J.
- joule-fix printed "energy per verified fix: 1.446 J (attributed)" for an
  estimate apportioned by CPU share, and sealed it ModelBased in the JCR-1
  meter envelope (M3 Ultra: 7.377 J, ModelBased). The share is 0 whenever
  the machine tick counters miss every window, so the figure can be 0 J. The
  parent commit gave that as the likely cause of verified_fix_end_to_end's
  1-in-16 failure.
- sandbox-proof said "ESTIMATED" here only because it asked is_hardware().
  On a live host it would say "measured" for a span the meter estimated.

The defect. Each consumer labelled a span by the meter instead of by the
span's own records: is_hardware(), a trait default, or a hard-coded
"ModelBased" or "measured". Or it turned a meter with no reading into 0 J.

The fix. Every consumer derives the label from the Measurement it got: the
weakest of its records, because a sum is only as good as its weakest part.

- jcp-gateway. energy_of() builds the ledger entry with jcp-schema's own
  constructors:
    - Energy::estimator for the estimator's record;
    - Energy::measured for a hardware reading that has a CPU record;
    - Energy::measured_partial for a hardware reading without one (a GPU
      counter alone, NVML without RAPL);
    - Energy::modeled for ModelBased.
  The figure is unchanged, so a runaway is still debited. Only what the
  receipt says about it changes, through fields it already had:
  energy_provenance, and energy_coverage when Partial. The flow monitor's
  scan is sealed with its own span's provenance (flow_energy_provenance)
  instead of is_hardware()'s. The binary's summary line reads the grade
  back from the receipt it just sealed ("[Estimator]") instead of printing
  "measured". The docs and the crate description said "metered on real
  hardware" and "the MEASURED joules"; both are corrected. jcp-gateway now
  depends on sandbox-schema directly, for the CPU class; the crate was
  already in the lock graph.
- joule-code-remediate. An EnergyTally grades the run by its cargo windows.
  The run is ModelBased only when every window was attributed. main needed
  only one, and never reset the flag between runs. An attributed run's
  receipts carry the attributed figure, anchored as before. Any other run's
  receipts carry the unapportioned total over the windows, as an estimate:
    - the JCR-1 meter envelope seals it with the Estimator grade;
    - the JouleContract receipt seals it as Unaccounted;
    - total_uj() and uj_per_verified_fix() return it.
  An apportioned estimate is no better than an estimate, and apportioning can
  put 0 J on the receipt for work that ran. joule-fix prints "estimated, not
  attributed" and "(estimated)" to match.
- joule-ui-serve. SpanMeter replaces the summing sampler. Every sample closes
  the span the previous sample opened, adds its figure to a running counter,
  and opens the next. provenance() returns that span's own grade, which
  run_interaction asks for right after its second sample. An estimated span
  is sealed as Estimator with the estimator's figure, not 0 J, and a hardware
  span as HwShunt. The Meter trait is untouched. The binary's doc and its
  Cargo.toml said every receipt's HwShunt was true; corrected.
- sandbox-proof. prove_local says "measured" only when every record of the
  span is a hardware reading (energy_word). The records were, and still are,
  sealed verbatim into the receipt, the attestation and the proof cell.
- jcp-action-receipt. The end_to_end example sealed energy_provenance from
  is_hardware(); it now takes the span's.

Checked and left alone:
- sandbox-jcp debits a declared ceiling as Energy::estimator and never reads
  a PowerMeter.
- joule-ui-interaction's library labels nothing as a hardware reading that
  its meter did not declare one. Its jcp_metered example wraps whatever figure
  it is charged in Energy::measured, but only in a JCP runtime decision that
  it neither seals nor prints. See below.

After the fix, with the same binaries on both chips:
- jcp-gateway prints "allowed, 2309714 µJ [Estimator]" and seals Estimator
  (M3 Ultra: 973555 µJ, Estimator).
- joule-ui-serve seals 482410 and 1121739 µJ as Estimator for intensities 3
  and 7 (M3 Ultra: 481236 and 1121485 µJ).
- joule-fix prints "44.352 J estimated, not attributed" and seals Estimator
  and Unaccounted (M3 Ultra: 20.723 J).
- sandbox-proof still says ESTIMATED.

Tests. The same test binaries ran 5/5 green on each chip. On the M3 Ultra,
joule-code-remediate's the_shipped_formal_template_parses_and_validates
cannot find formal.example.json, whose path is fixed at compile time on the
dev machine, so the five runs there skip that one test. That host also has no
z3, so the two existing formal-proof tests return early there.
- jcp-gateway a_metered_span_enters_the_ledger_as_what_it_is: every span
  shape maps to its constructor, and the debit is the span's own figure.
- jcp-gateway a_calls_receipt_says_how_its_joules_were_obtained: end to end,
  the sealed energy_provenance is Estimator when the meter has no hardware
  source ("gateway call: hardware=false sealed=Estimator cost_uj=30621").
  Otherwise it is a reading or an estimate. cost_uj > 0 either way.
- jcp-gateway granted_tool_is_allowed_metered_and_debited and
  budget_exhaustion_denies_further_calls_and_the_log_verifies asserted only
  under metering_is_hardware(), so on these hosts they passed without
  asserting. They now assert on every host: an estimate is debited, and the
  budget exhausts.
- The exfiltration test now also checks flow_energy_provenance.
- joule-code-remediate
  an_unattributed_run_is_sealed_as_an_estimate_never_an_apportioned_zero and
  a_run_is_attributed_only_when_every_window_was pin the tally.
- joule-code-remediate verified_fix_end_to_end_ships_with_receipt now also
  checks that joules_spent equals total_uj(), the JouleContract class, and
  the grade inside the sealed meter envelope. It also checks that nothing is
  attributed without a live CPU rail.
- joule-ui-serve each_served_interaction_carries_its_own_spans_provenance
  replays a HwShunt span, then an Estimator span, through the real Host.
- joule-ui-serve the_served_meter_never_seals_zero_or_an_estimate_as_a_reading
  runs the served meter on this host.
- sandbox-proof a_local_proof_says_estimated_for_an_estimated_span.

Falsified. Each mutation was restored with git checkout, leaving a clean tree.
- energy_of returns Energy::measured(total), main's rule: both new gateway
  tests fail. The end-to-end one fails with left "HwShunt", right
  "Estimator".
- eval_prov is sealed as HwShunt, which is what main sealed here: the
  exfiltration test fails with "an estimated scan sealed as a reading".
  Reverting eval_prov to is_hardware()'s answer is not caught on these hosts,
  because with no hardware source both say Estimator.
- energy_of returns Energy::measured(0), as main did on the M3 Ultra: four
  tests fail, including the two formerly hardware-only ones ("a real span
  costs joules, measured or estimated", "the budget eventually exhausts").
- figure_uj returns the apportioned sum: three joule-code tests fail. The
  unit tests see 10000000 against 60000000; end to end, 1.373789 J sealed
  against 65.634219 J.
- The meter envelope is always graded ModelBased: three tests fail. End to
  end: "an estimate sealed in the meter envelope as ModelBased".
- main's any-window attribution: the mixed-window test fails.
- main's total_uj, the apportioned figure: the end-to-end test fails
  (45.799836 against 0.776724).
- SpanMeter without its provenance() override, so the trait's HwShunt
  applies: both joule-ui-serve tests fail.
- served_meter returns the parent commit's HardwareMeter: the end-to-end test
  fails with "~24 ms of work sealed at 0 J: Energy { measured_uj: None,
  estimated_uj: 0, provenance: Estimator }".
- energy_word always says "measured": the sandbox-proof test fails.
Not separately tested: the per-run reset of the tally. It is observable only
with attributed windows, which need a live CPU rail.

Clippy --no-deps --all-targets is clean on every changed crate. It is also
clean for x86_64-unknown-linux-gnu, except that sandbox-proof could not be
checked for that target: zstd-sys's build script needs a Linux C cross
compiler, which is not installed here. One pre-existing warning, in
jcp-action-receipt's ar-verify.rs, is untouched.

Not done here, deliberately:
- The joule-ui Meter trait: its default provenance is HwShunt, and its
  provenance is per meter, not per sample. SpanMeter relies on
  run_interaction asking for provenance right after its second sample.
  run_interaction also turns a None sample into Estimator(0), so an
  Authorizer charges 0 for work that ran. Authorizer::charge carries no
  provenance. Both joule-ui-interaction examples still use the summing
  sampler. These are public API and SPEC §5/§6: David's call.
- Wire keys named measured_uj now carry estimates: jcp-gateway's _meta and
  Relayed::Allowed, and joule-ui-serve's POST /action response. The sealed
  provenance beside each key is right.
- A GPU counter alone (NVML without RAPL): the gateway seals it Partial but
  still debits only the GPU figure, so CPU work goes undebited there. Its two
  budget tests would fail on such a host, correctly. joule-ui has no coverage
  field. Not observed: no such host here.
- JouleContract §4.2 says an Unaccounted figure seals its method reference
  (energy_anchor). The harness leaves it None, as it always has.
- proof-cascade's joules: FormalOutcome, "(real z3, ModelBased)" in joule-fix,
  and formal_fix's "µJ measured". Another change owns that meter.
- joule-code-remediate's Cargo.toml description still says hardware-measured.
  This commit does not touch that file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family liveness rule says the probe re-samples the CPU energy rail after
every ~25 ms of CPU burn on the probing thread. In this crate that was not
true. The probe (b9d7e945) sampled the counter's own subscription, which holds
every IOReport channel, and one IOReportCreateSamples over every channel costs
0.12-0.8 s on the M5 Max (11,885 channels; 0.4-0.8 s yesterday, 117-144 ms
today) and ~0.15 s on the M3 Ultra (14,342). So the "1 s of burn, polled every
25 ms" probe spent much of its second waiting inside IOReport, compared the
rail a handful of times, and overran its budget by up to a sample. It still
declined correctly on both machines; it just was not the probe it said it was.

The fix. The probe now opens its own subscription to the Energy Model group
(IOReportCopyChannelsInGroup("Energy Model") then IOReportCreateSubscription),
samples that, and drops it. The CPU rails in it are the same channels the
counter reads, at the same absolute values. If the group cannot be subscribed,
the probe opens the counter's all-channel subscription only to tell
Unavailable (IOReport does not open) from CpuRailAbsent (no such group), as
eoc-meter does (c6378629). The counter's own read path is unchanged: it still
subscribes to every channel and filters the group when it sums. The rule
itself (RailWatch: the same rail advancing in two separate intervals) is
unchanged.

Measured with this commit, by the new test, from what the probe itself sampled:
  M5 Max:   364 channels per sample, 0 outside the group (all-channel: 11,885);
            one sample 5.9 ms (all-channel 117 ms); 33 intervals in 1.08 s.
  M3 Ultra: 645 channels per sample, 0 outside the group (all-channel: 14,342);
            one sample 8.6 ms (all-channel 146 ms); 32 intervals in 1.10 s.
The uncached probe (open + 1 s of burn) took 1.10 s on the M5 Max and 1.13 s on
the M3 Ultra; on the all-channel subscription it took 1.32 s on the M5 Max
this morning, 1.9 s when the core commit measured it.

To test what the probe really sampled rather than a helper beside it, the
sampling loop now returns a ProbeRun: the RailWatch, the channels per sample,
and how many were outside the Energy Model group. host_status caches
probe_status, which is probe(budget).0.

Tests.
- the_liveness_probe_subscribes_to_the_energy_model_group_only (new): runs the
  same probe host_status runs, and asserts every channel it sampled was in the
  Energy Model group, that it sampled fewer channels than one all-channel
  sample holds, and that the group's CPU rails are exactly those in the
  counter's subscription. Where the group cannot be subscribed, the status
  must say CpuRailAbsent or Unavailable, matching whether IOReport opens. It
  asserts on channels, not timings; the timings are printed.
- the_liveness_probe_never_reports_a_frozen_rail_as_live now runs the real
  uncached probe_status instead of a probe on the all-channel subscription.
40 passed on both machines, both "status=CpuRailFrozen
oracle=CpuRailMoved(false) selected=false available=false".

Falsified, each mutation applied to this commit and restored with git checkout:
- FP1, the probe back on the counter's all-channel subscription (probe()
  calling open()): "the probe sampled 11521 channels outside the Energy Model
  group (11885 per sample)"; it managed 6 intervals in 1.45 s.
- FP2, the group subscription built from IOReportCopyAllChannels: the same
  failure, 11521 of 11885.
- FP3, the family rule's threshold set to 1 (LIVE_RAIL_ADVANCES = 1): four rule
  tests fail, e.g. a_single_jump_is_a_batch_update_not_a_live_rail
  ("assertion failed: !w.is_live()") and a_rail_that_keeps_advancing_is_live
  ("one advance is not yet live").

Not done here, deliberately:
- The counter's own read() still samples every channel (0.1-0.8 s per read on
  the M5 Max). It is declined on both machines, so nothing pays that today;
  changing what a live counter's read costs is a separate change.
- The oracle in the tests still samples every channel, deliberately: it shares
  no subscription with the probe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family's energy contract: "measured when the platform exposes it,
estimated when it doesn't, and the provenance always says which ... A reading
never silently upgrades its own provenance." a5c77d8d stopped this meter from
bracketing a frozen CPU rail, but its probe had two defects of its own.

- It called the rail Live on ONE advance. On macOS 27.0.1 the Energy Model CPU
  rails are not permanently frozen for an unprivileged reader: they are
  refreshed in rare batches. Both of an M3 Ultra's `DIE_<n>_CPU Energy` rails
  held for at least 22 minutes, then stepped +1,344,869 and +1,391,637 mJ in a
  single 100 ms sample and held again; an M5 Max's `CPU Energy` went from
  480,083,160 to 541,893,638 mJ between ~10:30 and 10:47 but did not change in
  13,614 samples over the next 25 minutes. A probe whose window straddles a
  refresh sees one jump, says Live, and caches that for the process; from then
  on `bracket()` would hand each later ~1.3 kJ refresh, whole, to whichever
  idle or busy window straddled it, tagged HwShunt. This false Live was seen
  at ~10:46 on the M5 Max by the jouleclaw-energy work.
- It sampled an all-channel subscription (~11,900 channels on the M5 Max), so
  "spins ~25 ms before each re-sample" was not what happened: at the 0.4-0.8 s
  a5c77d8d measured per sample, the 1 s budget bought two or three samples,
  each compared with the first.

The fix brings the probe onto the family liveness rule, the one
jouleclaw-energy (b9d7e945) and eoc-meter (c6378629) implement, copied in so
the crate stays self-contained:

1. The probe samples its OWN subscription to the "Energy Model" group only
   (IOReportCopyChannelsInGroup, then IOReportCreateSubscription). If the
   group cannot be subscribed, opening the all-channel subscription tells
   "no such group" (CpuRailAbsent) from "no IOReport" (Unavailable).
   `power_over()`'s own all-channel read path is unchanged.
2. Liveness is decided per CPU rail (the name, after stripping `DIE_<n>_`,
   equals "CPU Energy" case-insensitively), on the raw integer between
   CONSECUTIVE samples, never on a summed total: Live means the same rail
   advanced in at least two separate sample intervals.
3. It spins 25 ms on the calling thread before each sample, until Live, or
   until the 1 s budget has elapsed and at least two intervals were observed,
   with a hard cap of five budgets.
4. Live, Frozen, Absent or Unavailable (no sample could be taken), cached once
   per process as before; anything but Live declines the rung.
5. The rule is a pure struct, `liveness::RailWatch`, in a module compiled on
   every platform, so proof-rs's Linux CI job runs its tests too. The IOReport
   FFI only feeds it.

The public API is unchanged: IoReportStatus and its variants,
ioreport_status(), CPU_RAIL_LIVENESS_BUDGET, hardware_meter_available() and
measure_marginal_uj(). Docs made false by the change are corrected: the module
header, IoReportStatus::CpuRailFrozen / Live, CPU_RAIL_LIVENESS_BUDGET ("buys
two or three samples, each compared with the first"),
hardware_meter_available's probe cost, and probe_cpu_rail. proof-cli keeps
its messages; the frozen-rail reason's parenthetical now says "it did not keep
advancing" instead of "it did not advance", since a rail that stepped once is
also frozen. tests/measure.rs is unchanged.

Measured tonight (01:24-01:29) with a standalone timer: one
IOReportCreateSamples on the Energy Model group (364 channels on the M5 Max,
645 on the M3 Ultra) takes 5.1-5.4 ms median on both chips, all on-CPU. One on
every channel took ~106-111 ms on the M5 Max (75 ms on-CPU) and ~133 ms on the
M3 Ultra, well under a5c77d8d's 0.4-0.8 s figure, so that cost varies. The
docs give 0.1-0.8 s. The probe now takes 1.09-1.14 s for a Frozen verdict,
over 29-32 intervals of ~35 ms, both chips; under a full-core burst from a
concurrent test it saw 16.

Tests. Seven rule tests, no hardware: a_single_jump_is_a_batch_refresh_not_a_
live_rail, one_jump_on_each_die_is_not_a_live_rail,
both_dies_stepping_together_once_is_not_a_live_rail (the M3 Ultra's observed
refresh), two_advances_of_the_same_rail_are_live,
no_interval_is_no_verdict_and_no_cpu_rail_is_absent,
the_probe_samples_until_live_or_long_enough_to_tell (budget, two-interval
floor, five-budget cap, one advance does not stop it) and
only_the_cpu_energy_aggregate_is_a_cpu_rail. The hardware oracle keeps its own
all-channel subscription, ABSOLUTE values and the helper thread that burns
CPU for the whole 3x-budget window. It now matches names with
ends_with("CPU Energy"), as the family rule says, where it used a lowercase
match, and reports CpuRailMoved(true) only when one rail's absolute value
changed at two or more separate reads. The old oracle took any single change.
It logs "ioreport oracle: N reads in T, changes per CPU rail [..]". The probe
test now checks every outcome against the oracle, not only Live, requires a
Frozen verdict to rest on at least two intervals unless the cap cut it short,
and logs probe_took and intervals.

Observed, proof-cascade lib tests, macOS 27.0.1:
  M5 Max:   ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
            selected=false
            ioreport probe: probe=Some(Frozen) status=CpuRailFrozen
            oracle=CpuRailMoved(false) probe_took=1.093259666s intervals=31
            test result: ok. 23 passed; 0 failed
  M3 Ultra: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
            selected=false (oracle: changes per CPU rail [0, 0])
            ioreport probe: probe=Some(Frozen) status=CpuRailFrozen
            oracle=CpuRailMoved(false) probe_took=1.137413416s intervals=32
            test result: ok. 23 passed; 0 failed
(16 before this commit.)

Falsified on the M5 Max. Each mutation was applied to this commit, then
restored with git checkout, with a clean tree after each:
- F-rule, LIVE_RAIL_ADVANCES = 1: five of the seven rule tests fail, e.g.
  "one refresh counted as a live rail", "one advance is not yet live", "one
  interval, past budget: keep going", and one_jump_on_each_die "left:
  Some(Live) right: Some(Frozen)".
- F1, the probe always says Live: the frozen-rail test fails with "IOReport
  meter available although its CPU rail did not keep moving under 3s of CPU
  burn — it would bracket GPU-only energy, zero, or a refresh as the energy of
  CPU work" (status=Live oracle=CpuRailMoved(false) selected=true). The probe
  test and the selected-meter test also fail ("a live CPU rail metered a
  multi-core CPU burst as Some(0)").
- F2, main's ungated available(): the frozen-rail test fails with the same
  message (status=CpuRailFrozen oracle=CpuRailMoved(false) selected=true), and
  so does the selected-meter test ("selected on a CPU rail that is not live").
- F-sum, liveness decided on the summed Energy Model total: the live GPU rail
  makes the probe say Live after 2 intervals in 167 ms. The frozen-rail test
  fails with the message above (status=Live selected=true), and so do the
  probe and selected-meter tests.

The proof-rs workspace passes on the M5 Max (z3 present), as does the
proof-conformance binary (10/10, "all checks passed"). `proof --measure`
declines with the frozen-rail reason on both chips, with no "HwShunt" and no
"idle noise" in its output. The run takes 1.24 s (M5 Max) and 1.17 s (M3
Ultra). Dependants: joule-code-release (6) and joulecontract-proof (6) pass on
both. joule-code-remediate's verified_fix_end_to_end_ships_with_receipt fails
with "assertion failed: report.total_uj() > 0" (7 passed, 1 failed on the M5
Max), the same as on a5c77d8d run from a git archive. On the M3 Ultra it is 6
passed, 2 failed: that test and the formal-template test described below. It meters through sandbox-meter; no
dependant calls proof_cascade::meter. On the M3 Ultra the shipped binaries of
proof-rs's lib/soundness tests pass. measure.rs, proof-conformance's pack.rs
and remediate's formal-template test bake this checkout's absolute paths into
the binary (CARGO_BIN_EXE_*, CARGO_MANIFEST_DIR), so they cannot run on
another host. There, measure.rs's assertions were checked by hand against
`proof --measure`. clippy --no-deps --tests on proof-cascade and proof-cli
adds no warnings (the duplicated #[link] attribute and the b"..\0" literal are
on a5c77d8d). clippy for x86_64-unknown-linux-gnu is clean, including the
liveness module off macOS.

Not done here, deliberately:
- The group-only probe is shown by the logged interval count, not asserted.
  A count threshold would flake under load (16 intervals under a concurrent
  full-core burst).
- power_over() still reads an all-channel subscription and divides by the
  sleep, not the sample-to-sample window. The grade, the partial GPU reading,
  a rail that stops tracking after the cached probe, and the RAPL notes all
  stand as a5c77d8d listed them.
- proof/README.md still says the meter reads IOReport "via `macmon`", which
  has been stale since a5c77d8d.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: IOReport is live only when one CPU rail keeps advancing
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m37s
standards workspaces build + test / aac-rs (pull_request) Successful in 35s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m18s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 40s
standards workspaces build + test / map-rs (pull_request) Successful in 44s
standards workspaces build + test / mesh-rs (pull_request) Successful in 29s
standards workspaces build + test / openpay (pull_request) Successful in 3m11s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m19s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 1m49s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 1m55s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 1m48s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m59s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m48s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 1m54s
2518f0d70a
The liveness gate added in dfec5d44 enforces the contract at selection, but
it had three defects. Each was measured on the M5 Max and the M3 Ultra (both
macOS 27.0.1).

1. One advance counted as Live. For an unprivileged reader the CPU rails are
   not permanently frozen: they are refreshed in rare batches. On the M3 Ultra
   both dies held for at least 22 minutes, then stepped +1,344,869 and
   +1,391,637 mJ in a single 100 ms sample. On the M5 Max the rail went from
   480,083,160 to 541,893,638 mJ in about 17 minutes, then did not change
   across 13,614 samples in a 25-minute watch. A probe whose window straddles
   a refresh sees one jump and calls the rail Live; this happened on the M5
   Max at ~10:46 on 2026-09-29. The rung then stays selected for the life of
   the process, and each later ~1.3 kJ refresh lands whole in whichever
   metered span straddles it, tagged ModelBased. Both machines have been
   refreshed again since: today the M5 Max rail reads 1,308,503,606 mJ and the
   M3 Ultra's read 21,170,718 / 23,300,102 mJ. Under load they still do not
   move.

2. The probe sampled the counter's all-channel subscription. Its doc said
   "re-sampling every ~25 ms", but one IOReportCreateSamples over every
   channel took 130-160 ms on the M5 Max today (0.4-0.8 s on 2026-09-29) and
   ~135 ms on the M3 Ultra. Instrumented: the probe took 7 samples in ~1.09 s
   on the M5 Max and burned CPU for only 175 ms of that; on the M3 Ultra, 6
   samples and 150 ms. It also compared every sample with the first one
   instead of with the previous one.

3. The oracle burned CPU on its sampling thread, between samples, so most of
   its "three times the budget" window was not under load. It burned for
   0.90-0.95 s of ~3.1 s on the M5 Max and 0.80 s on the M3 Ultra with
   today's ~130 ms samples; with 0.4-0.8 s samples that falls to ~250 ms. It
   also returned CpuRailMoved(true) on any change in the absolute value, and
   a single refresh is a change.

The fix ports the family liveness rule: RailWatch from jouleclaw-energy
(b9d7e945) and the group-only probe subscription from eoc-meter (c6378629).
The code is copied in, so the crate gains no dependency.

- The probe opens its own subscription to the "Energy Model" group only
  (IOReportCopyChannelsInGroup, then IOReportCreateSubscription). One sample
  costs 6-12 ms on the M5 Max and ~6.6 ms on the M3 Ultra, and it returns the
  same absolute CPU values as the all-channel subscription. The probe now
  burns 25 ms, samples, and repeats, which gives 29-31 intervals in its
  one-second budget on both machines. The counter's own read path, over all
  channels, is unchanged.
- Liveness is decided per CPU rail: a channel whose name, after any DIE_<n>_
  prefix is stripped, equals "CPU Energy" (case-insensitive). The test is the
  raw integer delta between consecutive samples, never a summed total. Live
  requires the same rail to advance in at least two separate intervals
  (LIVE_RAIL_ADVANCES). One jump is Frozen, and so is one jump on each die in
  different intervals.
- The probe keeps sampling until the rail is Live, or until the budget has
  elapsed and at least two intervals were observed. It never runs past five
  budgets.
- The outcomes are unchanged: Live, CpuRailFrozen, CpuRailAbsent and
  Unavailable. Unavailable now also covers a subscription that yields no
  sample. The result is still cached once per process in a OnceLock, and any
  outcome except Live declines the rung. If the Energy Model group cannot be
  subscribed, opening the all-channel subscription tells CpuRailAbsent from
  Unavailable.
- The rule itself is RailWatch, a pure struct with no FFI.

The public API is unchanged: IoReportStatus and its variants, ioreport_status(),
CPU_RAIL_LIVENESS_BUDGET (1 s), Meter, and the grades. One item is gone:
IoReport::probe_cpu_rail, a public method on an unnameable type that dfec5d44
added and nothing outside the crate used. The probe is now a free function
that takes its own subscription.

Docs made false by the change are corrected:
- the crate doc's IOReport rung, which now says what Live means and mentions
  the refresh;
- the probe doc ("every ~25 ms" was only achievable on the group
  subscription);
- the four IoReportStatus variant docs;
- CPU_RAIL_LIVENESS_BUDGET, which now describes the intervals and the
  five-budget cap instead of "updates within a few hundred ms";
- the cost described in the ioreport_status() doc;
- the detect() comment.

Tests. There are now 17 lib tests on macOS, up from 10, all in
mod ioreport::tests except the new off-macOS one.

- Rule tests, which need no hardware:
  - a_single_jump_is_a_batch_refresh_not_a_live_rail, including the M3 Ultra
    refresh with both dies in one interval
  - one_jump_on_each_die_in_different_intervals_is_not_a_live_rail
  - two_advances_of_the_same_rail_are_live
  - no_interval_is_no_verdict_and_no_cpu_rail_is_absent
  - the_probe_samples_until_live_or_long_enough_to_tell, including the
    five-budget cap
  - only_the_cpu_energy_aggregate_is_a_cpu_rail
- The oracle opens its own all-channel subscription and reads absolute
  values, matching names with ends_with("CPU Energy") in any group. A helper
  thread burns CPU for the whole 3 s window while the test thread reads. It
  reports CpuRailMoved(true) only if one rail's value changed at two or more
  separate reads, using its own literal threshold rather than the crate's
  constant. It also counts changes in GPU Energy, which shows its reads are
  fresh: 16-21 of 16-21 reads on the M5 Max, 17-18 of 19-20 on the M3 Ultra.
- the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail keeps its name. On
  every oracle branch it asserts the selection first, then the status. It
  prints one "ioreport liveness: status=..." line that includes the oracle's
  reads and its changes per rail.
- the_liveness_probe_never_reports_a_frozen_rail_as_live now runs the probe
  itself, uncached, on a fresh group subscription, beside the cached status.
  It asserts on every oracle outcome, not only on Live, and prints how long
  the probe took and how many intervals it saw.
- the_liveness_status_is_probed_once_and_cached is new.
- a_selected_ioreport_meter_meters_cpu_work now reads the counter directly,
  before and after a 200 ms burn. It used to go through Meter::measure, whose
  time x power fallback made e.uj() > 0 true whatever the counter did.
- ioreport_is_unavailable_off_macos is new and runs only off macOS.

Observed with the same test binary on both machines, three runs each:
  M5 Max:   ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
            selected=false (oracle: 21 reads over 3s with a helper thread
            burning CPU; changes per CPU rail [0], per GPU rail [21])
            probe: took=1.10-1.11s intervals=29-31
            test result: ok. 17 passed; 0 failed
  M3 Ultra: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
            selected=false (oracle: 20 reads over 3s with a helper thread
            burning CPU; changes per CPU rail [0, 0], per GPU rail [17]),
            which exercises the DIE_0_/DIE_1_ path
            probe: took=1.08-1.09s intervals=31
            test result: ok. 17 passed; 0 failed

Falsified on the M5 Max. Each mutation was reverted with git checkout.
- F-rule, LIVE_RAIL_ADVANCES = 1: four rule tests fail. For example, "one
  jump counted as a live rail: RailWatch { intervals: 5, rails: [("CPU
  Energy", 1)] }", "one jump per die counted as a live rail", "one interval
  cannot show two advances" and "one advance is not yet live".
- F1, probe_status always returns Live: the frozen-rail test fails with
  "IOReport selected (status=Live) although its CPU rail did not keep moving
  under 3s of CPU burn — it would report GPU-only energy, zero, or a batch
  refresh as the energy of CPU work". The probe test and
  a_selected_ioreport_meter_meters_cpu_work fail too, the latter with
  "IOReport selected but a fresh probe says Some(Frozen)".
- F2, detect() ungated as on main: the frozen-rail test fails with "IOReport
  selected (status=CpuRailFrozen) although ...".
- F-sum, liveness decided on the summed Energy Model total: through the GPU
  rail the probe says Live after 2-3 intervals (0.16-0.18 s). The frozen-rail
  test and the probe test fail in both of two runs. The same happens on the
  M3 Ultra, where GPU Energy changed at 16-17 of 18 reads today. A variant
  that treats every Energy Model channel as a rail fails the same way.

Dependants on the M5 Max all pass: jcp-cli (0 tests), jcp-conformance (1 lib
test and 5 pack tests) and jcp-intercept (8). So does the conformance suite:
"C9 energy provenance honesty" passes and it ends "all checks passed". On the
M3 Ultra the jcp-conformance lib, jcp-cli and jcp-intercept test binaries and
the jcp-conformance suite also pass (exit 0). The pack test was not run there:
it reads the pack from its compile-time path and does not touch this crate.
clippy -p jcp-energy --no-deps, lib and --tests, for aarch64-apple-darwin and
x86_64-unknown-linux-gnu: no warnings.

Not done here, deliberately:
- The counter's own reads still sample every channel, 130 ms to 0.8 s a read
  on the M5 Max. A group-only subscription returns the same values in
  ~6-12 ms, but switching changes what every read costs, so it belongs in a
  separate change.
- counts_once admits CPU sub-rails (PACC_*, MCPM*, ...) beside CPU Energy.
  That is a double count once the rail is live.
- A rail that stops tracking after selection is not caught: the probe runs
  once per process.
- Reporting the live GPU rail as an explicitly partial reading, and deciding
  which grade IOReport readings carry, are separate decisions. Both need a
  wire-format change.
- No live CPU rail exists on these hosts, so every Live-branch assertion has
  been exercised only by mutation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family contract: measured when the platform exposes it, estimated when
it doesn't, "and the provenance always says which ... A reading never
silently upgrades its own provenance." 3b6a7c67 applied it to this crate's
IOReport probe, but its liveness probe called a CPU rail live on a single
advance, and on current macOS a single advance is exactly what a frozen rail
produces now and then.

Ground truth, measured 2026-09-29 on an M5 Max and an M3 Ultra (both macOS
27.0.1) by reading the channels, not by re-running tests:

- For an unprivileged reader the Energy Model CPU rails (`CPU Energy`;
  `DIE_<n>_CPU Energy` on the Ultra) do not advance under CPU load over
  seconds to minutes, but they are not frozen for good: they are refreshed
  in rare batches. The M3 Ultra's two dies held for at least 22 minutes, then
  stepped +1,344,869 and +1,391,637 mJ within one 100 ms sample, and held
  again. The M5 Max's rail read 480,083,160 mJ at ~10:30 and 541,893,638 mJ
  by 10:47, then did not change in 13,614 samples over a 25-minute watch.
  Cadence and trigger are unknown.
- `GPU Energy` is live on the M5 Max (a change about every 28 ms) and moves
  only sporadically on the M3 Ultra.
- One IOReportCreateSamples over the Energy Model group takes ~6 ms and
  returns the same absolute values as one over all channels.

The defect. `probe_cpu_rail` compared every sample with the first one and
returned Live on the first `a > b` of any CPU rail. A probe window that
straddles a batch refresh sees one large step and calls it live: that
happened with the same rule at ~10:46 on the M5 Max, in the jouleclaw-energy
work. The probe's answer is cached for the process, so the IOReport probe is
then used for its whole life, and each later ~1.3 kJ refresh lands whole in
whichever span straddles it. `Span::stop`'s rule (a CPU delta of 0 means the
span is estimated) cannot catch that: the refresh is not 0, so it is sealed
`HwShunt`. The hardware oracle had the same blind spot: it counted any change
at all, as a delta from its first sample, so one refresh landing in both
windows would have vouched for the false Live.

The fix ports the family liveness rule (jouleclaw-energy b9d7e945's
RailWatch, eoc-meter c6378629's LIVE_AFTER_STEPS), with no new dependency:

- `RailWatch`, a pure struct, is the rule. Per CPU rail (channel name, after
  stripping `DIE_<n>_`, equal to `CPU Energy` ignoring case), keyed by
  position and name, it counts the intervals in which that rail's raw
  integer rose between consecutive samples. It is never applied to a summed
  total. LIVE means one rail advanced in at least `LIVE_RAIL_ADVANCES` (2)
  separate intervals. Two dies stepping once each, or two same-named channels
  stepping once each, are not live.
- `wants_more` keeps the probe sampling, with ~25 ms of spin on the calling
  thread between samples, until Live, or until `CPU_RAIL_LIVENESS_BUDGET`
  (1 s) has passed with at least two intervals seen. Hard cap: five budgets.
- `verdict`: Live, Frozen (a CPU rail exists, none advanced twice), Absent
  (no CPU rail), or none (not one interval could be sampled). None now maps
  to `Unavailable`. It used to report Absent when the first sample failed.
- `rail_advances` pairs consecutive samples by position AND name. If the
  channel list changes between samples, no rail advanced in that interval
  (the safe side).
- The probe already sampled its own Energy Model subscription
  (IOReportCopyChannelsInGroup, then IOReportCreateSubscription), so the
  "polled every ~25 ms" claim was true here. It is now pinned by a test. The
  counter's read path is the same group-only subscription, unchanged.

Still cached once per process (OnceLock). Anything but Live declines the
rung, as before. The public API (`IoReportStatus`, `ioreport_status()`,
`CPU_RAIL_LIVENESS_BUDGET`) and the grade names are unchanged. Docs that the
rule made false are corrected: the status variants and their Display text
("did not advance" is now "did not keep advancing ... (it held, or stepped
once)"; Unavailable now also covers "or sampled"), the budget and
`ioreport_status` docs, `PowerMeter::detect`'s "up to the budget" (the probe
can sample past it, never past five budgets), the module doc, and one
test-constant comment in attribute.rs.

Span::stop: no plausibility bound was added for a CPU delta too large for
its wall time. There is no evidence to set one. No host here has a Live
probe, so there is no measured live-rail power ceiling to bound against. The
only straddled refreshes observed were on rails the new rule now declines.
Picking a wattage would be inventing a threshold. Under this rule a Live
verdict needs the same rail to step twice within about a second. A rail that
behaves like that and then falls back to rare batches was never observed. If
it is, it needs a per-read witness, not a guessed bound.

Tests. The rule tests need no hardware:
- a_single_jump_is_a_batch_refresh_not_a_live_rail (the M5 Max's
  480,083,160 to 541,893,638 mJ step, fed through rail_advances);
- one_jump_on_each_die_is_not_a_live_rail (the observed same-sample step on
  both dies, and one step per die in different intervals);
- two_channels_that_share_a_name_are_two_rails;
- two_advances_of_the_same_rail_are_live (adjacent or not, either die,
  through the raw-integer pairing too);
- no_interval_gives_no_verdict_and_no_cpu_rail_is_absent;
- the_probe_samples_until_live_or_long_enough_to_tell (including the
  five-budget cap with zero and with one interval);
- an_interval_is_read_per_cpu_rail_on_the_raw_integer, which replaces the
  test for the old single-comparison helper.

The hardware oracle now opens its OWN subscription to EVERY channel (the
probe never does). It reads ABSOLUTE values (no delta samples, no deltas from
a first sample) of every channel whose name ends_with("CPU Energy") in any
group. A HELPER thread burns CPU for the whole 3 s window (3x the budget), so
the window stays loaded even while the reading thread blocks in an
all-channel sample. The oracle returns CpuRailMoved(true) only if one rail's
absolute value changed at two or more separate reads, a literal threshold of
its own. It is not blind: pointed at `GPU Energy` on the M5 Max, it counted
16 changes in 16 reads and 17 in 17.

- the_ioreport_probe_is_never_selected_on_a_frozen_cpu_rail prints the CI
  line "ioreport liveness: status=.. oracle=.. selected=.. available=..".
- the_liveness_probe_never_reports_a_frozen_rail_as_live now also runs the
  probe uncached on a fresh subscription, beside the cached status. It
  checks both against the oracle, checks that Frozen rests on at least two
  intervals and that the probe respects its cap, and prints the probe's
  cost.
- the_probe_samples_the_energy_model_group_alone (new): every channel in the
  probe's sample belongs to the Energy Model group, and a CPU rail is among
  them unless the status says Absent.
- a_selected_ioreport_probe_meters_cpu_work is adapted to the new rule.

Observed with the same test binaries on both chips, 38/38 in every run (6 on
the M5 Max, 5 on the M3 Ultra; the crate had 31 tests):
  M5 Max:   ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false
            ioreport oracle: 16-20 reads over 3s under a helper-thread burn, changes per CPU rail [0]
  M3 Ultra: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false
            ioreport oracle: 17 reads over 3s under a helper-thread burn, changes per CPU rail [0, 0]
Probe cost, uncached: M5 Max 1.096-1.125 s for 30-33 intervals over 364
channels, one group sample 5.96-10.49 ms. M3 Ultra 1.103-1.153 s for 31-33
intervals over 645 channels, one group sample 5.71-7.32 ms. That is ~34 ms
per interval: 25 ms of spin plus the sample.

Falsified. Each mutation was applied to this change and restored with git
checkout, leaving a clean tree:
- F-rule, LIVE_RAIL_ADVANCES = 1: five rule tests fail, e.g.
  a_single_jump_is_a_batch_refresh_not_a_live_rail "one refresh counted as a
  live rail: RailWatch { intervals: 4, rails: [(0, "CPU Energy", 1)] }";
  one_jump_on_each_die_is_not_a_live_rail "left: Some(Live) right:
  Some(Frozen)"; two_channels_that_share_a_name_are_two_rails (same);
  two_advances_of_the_same_rail_are_live "one advance is not yet live";
  the_probe_samples_until_live_or_long_enough_to_tell "one interval is not
  enough to tell".
- F1, the probe always says Live: on both chips the frozen-rail test fails,
  "the oracle did not see the CPU rail keep moving under 3s of CPU burn
  left: Live right: CpuRailFrozen" (status=Live selected=true). So do the
  probe test ("the cached status said Live, the oracle did not see the CPU
  rail keep moving"), the metering test and the attribution test.
- F2, IoReportProbe::new() ungated: on both chips the frozen-rail test fails,
  "IOReport probe selected although its CPU rail did not keep moving under 3s
  of CPU burn — every span would report GPU-only energy, zero, or a stale
  refresh as a measurement of CPU work" (status=CpuRailFrozen selected=true),
  as do detect_never_fails_and_records_validate, the metering test and the
  attribution test.
- F-sum, liveness decided on the summed raw total of all Energy Model
  channels: the M5 Max probe says Live after 2 intervals (167-185 ms), and
  the frozen-rail and probe tests fail ("the uncached probe said Live, the
  oracle did not see the CPU rail keep moving"). It failed the same way on
  the M3 Ultra too, Live after 10 intervals.

Dependants, run on the M5 Max. sandbox-attest (49), -build (7), -conformance
(3), -exec-microvm (12), -exec-wasm (13), -jcp (4), -mcp (3 + roundtrip 2),
-proof (16), -proof-wasm (4), -runtime (13) pass. So do jcp-gateway (12),
jcp-action-receipt (17 + conformance 3), joule-code-remediate (10),
joule-ui-interaction (6) and joule-ui-serve (4 + 2).

The same binaries on the M3 Ultra: jcp-gateway 12, jcp-action-receipt 17,
joule-ui-interaction 6, joule-ui-serve 4 + 2, sandbox-proof 16, sandbox-attest
49, -build 7, -exec-microvm 12, -jcp 4, -mcp 3, -proof-wasm 4 and -runtime 13
pass. joule-code-remediate passes 9 of 10 (9 with that one skipped),
sandbox-conformance 2 of 3 and sandbox-exec-wasm 12 of 13. Eight tests in
five places fail there, for a reason that is not this change: each reads a file, or spawns a
binary, at a path fixed at compile time on the dev machine, and that
worktree does not exist on the M3 Ultra ("No such file or directory"):
- jcp-action-receipt tests/conformance (3, "conformance dir must exist next
  to the spec");
- joule-code-remediate the_shipped_formal_template_parses_and_validates
  ("template present");
- sandbox-mcp tests/roundtrip (2, "spawn server");
- sandbox-conformance committed_vector_files_verify ("read
  content_addressing.json");
- sandbox-exec-wasm wasmtime_version_matches_the_lockfile ("workspace
  Cargo.lock").

clippy -p sandbox-meter --no-deps --all-targets is clean on
aarch64-apple-darwin and x86_64-unknown-linux-gnu. On x86_64-apple-darwin
its one warning (unused_mut on `probes` in `PowerMeter::detect`) is in a
line this change does not touch. rustdoc's two warnings (contention.rs:14,
probe/mod.rs:6) are likewise untouched lines.

Not done here, deliberately:
- `IoReportProbe::open()` never releases the `desired` channel dictionary
  when IOReportCreateSubscription returns a separate `subbed` one: one
  CFDictionary per open. The leak is older than this change and small.
- A rail that stops tracking after the per-process probe said Live still
  needs a per-read witness (CPU Stats residency, say). A plausibility bound
  in Span::stop needs a live host to measure against (see above).
- Reporting the live GPU rail as an explicitly partial reading, instead of
  declining the rung, is a receipt and wire decision.
- The other IOReport backends in the family are separate crates and
  separate branches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family contract: measured when the platform exposes it, estimated when it
doesn't, and the provenance always says which; a reading never silently
upgrades its own provenance. jouledesk-github broke it in the ledger and in
every reply it posts.

The defect, on origin/main (54e89b56) and still after b9d7e945:
- do_work read jouleclaw-energy's detect_meter() around the work and floored
  the delta to the meter's resolution, `(after - before).max(1000)`, falling
  back to that 1000 µJ when a read failed. A counter that read nothing, which is
  what the IOReport CPU rail does on macOS 27.0.1, came out as 1000 µJ of work.
- seal() committed that figure through Teammate::tag, which seals every task's
  efficiency/1 as Energy::measured: HwShunt, a hardware reading, whatever
  metered it.
- The GitHub and Slack replies said "measured via `<meter>`" regardless.
Since b9d7e945 declines IOReport on both of our Macs, the meter that runs is
the load-average estimator (apple-silicon), and the three together sealed an
estimate as a hardware reading and posted "measured via `apple-silicon`,
provenance `estimator`".

The fix.
- jouledesk: Teammate::tag_with_provenance(kind, intent, provenance) seals the
  task's energy at how it was obtained, using the schema's own constructors
  (Energy::measured / modeled / estimator). Teammate::tag is unchanged: it
  still seals HwShunt, and the signed conformance vector (examples/vector.rs,
  tests/pack.rs) is built through it. EnergyProvenance re-exports
  jcp_schema::Provenance so a binding need not depend on jcp-schema.
- jouledesk-github seals the meter's figure at the meter's provenance, and the
  replies say "estimated by `<meter>`" for an estimate and "measured via" only
  for a reading, naming the meter and its provenance either way.
- The floor is gone. The figure is the delta as read. And no zero becomes a
  figure: the delta counts only if both reads succeeded, agree about
  provenance, the counter did not go backwards, the window was at least the
  active meter's own min_window_ns (the estimator declares 100 ms, IOReport
  10 ms), and it is non-zero, since no work runs on zero energy. Otherwise the
  work is not sealed and the reply says "not metered — `<meter>`: <why>; no
  energy figure was sealed". The same gate refuses a zero in
  jouleclaw-loader-gguf's tuner (next commit).

Why the window gate: with the floor removed, the no-model fallback (a
deterministic extract that takes microseconds) sealed "0.0 mJ (0 µJ) —
estimated by `apple-silicon`" on the M5 Max. The estimator integrates at 1 ms
steps, so such a window reads 0, or one whole millisecond of its watts when it
straddles a tick. Neither is a figure for the work, and the estimator says so
itself through its 100 ms minimum window.

What a reply says now. A real mention with the default model (gemma-4-E2B,
--dry-run, M5 Max):
  - **energy:** 134774.9 mJ (134774859 µJ) — estimated by `apple-silicon`,
    provenance `estimator`
  - **decision:** completed · ledger verified ✓
The no-model fallback (M5 Max; the M3 Ultra says "7 µs"):
  - **energy:** not metered — `apple-silicon`: the work took 71 µs, shorter
    than the 100 ms window it can resolve; no energy figure was sealed
It used to seal 1000 µJ as HwShunt and post "measured via".

Tests (jouledesk 18 + pack 4, jouledesk-github 12; all pass on the M5 Max; the
jouledesk-github binary also on the M3 Ultra, 12 passed):
- a_tags_energy_is_sealed_at_the_provenance_it_was_obtained_at (jouledesk):
  each provenance, read back through the verified efficiency/1 chain; plain
  tag still seals HwShunt.
- a_figure_is_the_delta_as_read_never_floored: 250 µJ stays 250.
- a_window_without_two_good_readings_has_no_figure: a failed read, a counter
  going backwards, readings that disagree about provenance.
- a_window_the_meter_cannot_resolve_is_never_a_zero: a 200 µs window, with or
  without a step in it, and a zero delta over a second.
- an_estimate_is_sealed_and_posted_as_an_estimate,
  a_reading_is_sealed_at_its_own_provenance,
  work_with_no_figure_is_neither_sealed_nor_given_one, and Slack's
  slack_posts_an_estimate_as_an_estimate: what is sealed, read back from the
  verified chain, and what each reply says.
- On this host, not skippable: a_mention_answered_too_fast_to_meter_is_posted_
  as_unmetered and the Slack equivalent run the whole handle() path and assert
  the reply names IOReport exactly when ioreport_status() is Live;
  on_this_host_work_the_meter_can_resolve_is_sealed_at_its_provenance burns CPU
  for the real meter's min window + 50 ms and asserts the figure is non-zero and
  sealed at that meter's provenance. Both machines:
  "ioreport=CpuRailFrozen sealed Estimator 6000000 µJ ... estimated by
  `apple-silicon`" (M3 Ultra: 1492895 µJ).
The old handles_a_mention_and_seals_measured_energy asserted
`measured_uj >= 1000` and "measured via": it held only because of the floor.

Falsified, each mutation applied to this commit and restored with git checkout:
- FJ1, the floor back (`uj.max(1_000)`): a_figure_is_the_delta_as_read_never_
  floored fails.
- FJ2, no min-window gate: three tests fail; the fallback's window then reads
  "it registered no energy across 11 µs of work", so the zero gate still holds
  it back.
- FJ3, a zero delta accepted as a figure: a_window_the_meter_cannot_resolve_is_
  never_a_zero fails.
- FJ4, seal through plain tag (every figure HwShunt): three tests fail; the
  on-host one sealed "HwShunt 5379036 µJ" for apple-silicon's estimate.
- FJ5, an estimate posted "measured via": three tests fail, GitHub and Slack.
- FJ6, tag_with_provenance sealing Energy::measured for an estimator:
  a_tags_energy_is_sealed_at_the_provenance_it_was_obtained_at fails.

clippy --no-deps --tests on jouledesk and jouledesk-github: nothing new (the
unused libwebrtc patch warning is the workspace's). jcp-efficiency is a new
dev-dependency of jouledesk-github, already in the graph through jouledesk.

Not done here, deliberately:
- Work::measured_uj and Handled::measured_uj are public Rust fields that now
  carry an estimate when provenance says `estimator` (documented). The sealed
  efficiency/1 is right (measured_uj: None for an estimate); renaming the Rust
  fields is an API question.
- Teammate::tag still seals a skill's declared cost as a HwShunt reading. The
  signed jouledesk vector is built through it, so changing it means
  regenerating a signed vector.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family contract: measured when the platform exposes it, estimated when it
doesn't, and the provenance always says which. This crate's energy paths
price work as marginal draw, `ΔE_busy − P_idle·Δt`, on the IOReport counter,
and three of them turned a counter that measured nothing into a figure that is
then treated as measured.

The defect (b9d7e945, unchanged since origin/main 54e89b56):
- fabric_dispatch::tune_shape_live read the counter with `.unwrap_or(0)` /
  `.unwrap_or(e0)`, clamped the marginal with `.max(0.0)` and truncated the
  per-op figure to u64. On a CPU rail that does not advance (macOS 27.0.1, both
  our Macs) every backend came out at 0 µJ per op, joules_minimal picked one,
  and TuningProfile::record kept it: a recorded 0 beats every later
  measurement, so the shape was pinned to that backend for the process.
- examples/fabric_calibrate wrote ~/.jouleclaw/fabric-calibration.json, which
  jouleclaw-fabric loads as Measured placement costs, with idle
  `(e1 - e0) / t` (0 on a frozen rail) and each member's marginal clamped to
  `.max(0.0)`: zeros written as measurements.
- examples/mpp_energy overlaid its matmul2d figure into the same file with an
  unclamped marginal (negative on a frozen rail), printed a CPU/GPU ratio of
  0/0 = NaN, and ignored whether its CPU matmul ran at all.
b9d7e945 now declines the counter on those hosts, so today these paths stop
at `is_available()`. That covers today's hosts. A counter that is available but
does not see a window would still have written zeros.

The fix.
- New module marginal_energy, pure arithmetic over raw readings, which the
  three paths share: idle_draw_uw refuses an idle window that did not advance
  (an SoC draws power doing nothing, so a counter that holds is not metering),
  marginal_uj refuses a busy window at or below idle and is never clamped to
  zero, per_op_uj refuses a per-op cost that would round to 0, energy_ratio is
  None unless both figures are positive and finite, and refusal() renders what
  an example prints before it exits non-zero.
- tune_shape_live: a failed read, an idle baseline that did not advance, or
  either backend's window drawing nothing above idle voids the tune (None,
  nothing recorded), through the new tune_measured: an unmeasured backend
  neither wins nor loses against a measured one.
- TuningProfile::record (and so record_global and tune_and_record) never
  records a zero-joule cost; tune_and_record returns None for one.
- fabric_calibrate and mpp_energy refuse to write, exit 1, and print the file,
  the reason and jouleclaw_energy::ioreport_status() when the counter is
  unavailable, the idle baseline did not advance, or any marginal is <= 0. They
  refuse before any write, so no partial file. mpp_energy's ratio is n/a unless
  both runs were measured, and its CPU run now asserts the matmul ran, as its
  GPU run already did (a refused shape would meter an empty loop, and that run
  is one of the refusal gates).
- energy_autotune (example) says why a shape was not tuned, with the status.

Observed, M5 Max and M3 Ultra (macOS 27.0.1), examples run with HOME set to a
fresh directory and JOULECLAW_FABRIC_CALIBRATION unset:
  refusing to write <tmp>/.jouleclaw/fabric-calibration.json: the energy
  counter is not available on this host (apple-ioreport declined)
  IOReport status: CpuRailFrozen; nothing was written (its figures are loaded
  as measurements)
exit 1 for fabric_calibrate and mpp_energy on both, no file under the temp
HOME. The real ~/.jouleclaw/fabric-calibration.json kept its 2026-06-11
11:02:14 mtime and SHA-256 (792b00e6...) on the M5 Max, and its mtime on the
M3 Ultra. energy_autotune: "not tuned: the meter produced no figure (IOReport
CpuRailFrozen)", global profile empty.

Tests. Default features, what CI's jouleclaw-rs job runs: 294 lib tests pass
(the marginal_energy and energy_autotune tests below are not feature-gated).
With `--features "rayon fabric-meter"`: 302 pass on the M5 Max and, from the
same binary, on the M3 Ultra.
- a_counter_that_holds_at_idle_has_no_idle_baseline, a_busy_window_at_or_below_
  idle_is_refused_never_clamped_to_zero, a_per_op_cost_never_rounds_to_zero,
  the_cpu_gpu_ratio_is_undefined_unless_both_are_measured,
  a_refusal_names_the_file_the_reason_and_the_ioreport_status, a_refusal_says_why.
- a_zero_joule_cost_is_never_recorded (incl. through record_global),
  tune_and_record_records_nothing_when_the_winner_drew_zero_joules,
  an_unmeasured_candidate_voids_the_tune.
- a_live_tune_records_only_what_the_meter_measured (fabric-meter, on this
  host): calls tune_shape_live itself and asserts it records exactly what it
  returns, never a zero, and None where ioreport_status() is not Live. Both
  machines: "ioreport=CpuRailFrozen tuned=None recorded=None".

Falsified, each mutation applied to this commit and restored with git checkout:
- FL1, record accepts a zero: "the meter did not see the work: nothing to record".
- FL2, a held idle counter as a 0 W baseline: "left: Ok(0.0) right:
  Err(IdleDidNotAdvance { secs: 3.0 })".
- FL3, the marginal clamped to zero again: "left: Ok(0.0) right:
  Err(NoMarginal { marginal_uj: 0.0 })".
- FL4, the raw ratio: "left: Some(NaN) right: None".
- FL5, an unmeasured backend taken as 0 µJ: "left: Some(Cost { backend: Cpu,
  uj: 0, ns: 0 }) right: None".
- FL6, tune_shape_live returning and recording a zero cost when the counter is
  declined: the host test fails, "a live tune records exactly what it
  returns", tuned=Some(Cost { uj: 0 .. }) recorded=None (the record gate
  dropped it; the test caught the returned zero).
- FL7, the refusal without the IOReport status, and FL8, a per-op cost
  rounding to 0 accepted: their tests fail.

clippy --no-deps --tests on the crate (with and without fabric-meter, plus the
three energy examples): no warning in the lines this commit adds. The crate
does not pass clippy on its own: two deny-level lints elsewhere
(clippy::erasing_op at gemma4.rs:562 and :582, clippy::approx_constant in a lib
test) stop it before the tests and examples, so they were checked with those
two allowed. The pre-existing warnings in the touched files (mpp_energy's
double_parens, fabric_calibrate's chunks_exact_mut, fabric_dispatch's
too_many_arguments) are unchanged, and mpp_energy's unused-Result warning is
gone. `cargo check --tests --examples` for aarch64-unknown-linux-gnu (CI's
jouleclaw-rs target, with +fp16): clean.

Not done here, deliberately:
- jouleclaw-fabric loads every calibration row as Measured, and the file
  carries no date, OS or provenance. A file written on a live counter is still
  loaded at that grade on a host where the counter is now declined.
- TuningProfile and the autotune/1 receipt carry no provenance field, so a
  sealed tuning profile cannot say how its joules were obtained. Both are wire
  or format changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family contract: measured when the platform exposes it, estimated when it
doesn't, and the provenance always says which. The three examples that meter a
real local model's decode with jouleclaw-energy's detect_meter() broke it in
their output, and turned a failed read into a figure.

The defect (b9d7e945, unchanged since origin/main 54e89b56):
- local_model_econ, swebench_local_coder and swebench_local_suite read the meter
  with `.unwrap_or(0)` before the decode and `.unwrap_or(e0)` after. A failed
  first read billed the dispatch the meter's whole cumulative value; a failed
  second read billed it zero.
- local_model_econ printed "{J} J measured" per dispatch and "(Estimator,
  energy-only $)" for the average; swebench_local_coder printed "{J} J
  measured"; swebench_local_suite said "Energy is real inference measured on
  this machine". Those labels were fixed strings, not the meter's provenance.
  Since b9d7e945 the meter on both of our Macs is the load-average estimator,
  so every "measured" is an estimate called a measurement. Where IOReport's
  CPU rail is live, the hard-coded "Estimator" would understate a reading.
- The docs: Cargo.toml said the `ioreport` dev-feature "selects the
  Apple-Silicon load-derived energy estimator" and that the IOReport meter
  "lives in a separate repo's sandbox-meter"; the examples said energy "is the
  honest Provenance::Estimator figure". It is IOReport where its CPU rail is
  live, else the estimator.

The fix.
- Two small, documented functions in the library, so the rule is tested where
  CI runs (tests inside examples/ never run under `cargo test --workspace`):
  window_joules(before, after) gives the delta and the readings' provenance,
  or why there is no figure: a failed read, readings that disagree about
  provenance, a counter that went backwards, or a zero (no decode runs on zero
  energy). joules_obtained(provenance) is "measured" for a reading and
  "estimated" for an estimate. The crate already depended on jouleclaw-energy
  for Provenance; no dependency is added.
- The three examples meter every dispatch through window_joules. They stop
  with the reason if the meter put no figure on a dispatch, since the report
  exists to print that figure. They print each dispatch's joules with
  joules_obtained, the meter that ran and its provenance. local_model_econ's
  average states the dispatches' own provenance.
- The false doc strings are corrected: Cargo.toml, the three examples' module
  docs, and swebench_local_suite's closing line.

Observed, M5 Max, `cargo run --release -p jouleclaw-harness-econ --example
local_model_econ` with its real Nemotron-3-Nano-4B snapshot:
  dispatch 1: 6 tok in 0.84s (7.1 tok/s), 33.720 J estimated by apple-silicon (estimator)
  dispatch 2: 6 tok in 0.98s (6.1 tok/s), 39.320 J estimated by apple-silicon (estimator)
  dispatch 3: 6 tok in 0.85s (7.1 tok/s), 33.840 J estimated by apple-silicon (estimator)
  avg real-model dispatch  : 35.627 J  (Estimator, energy-only $)
The report's own energy_provenance ("(Estimator)") is derived from the tier,
not the meter, and is unchanged.

Tests: 14 lib tests pass (M5 Max), 3 new, none feature- or host-gated:
a_window_is_the_delta_at_its_readings_provenance,
a_window_without_two_good_readings_is_never_zero (a failed read on either side,
backwards, disagreeing provenance, a zero), an_estimate_is_never_reported_as_
measured. The WIP version of this change had put the label test inside
examples/local_model_econ.rs, where `cargo test --workspace` compiles it but
never runs it.

Falsified, each mutation applied to this commit and restored with git checkout:
- FH1, a failed first read taken as a 0 µJ reading (the old unwrap_or(0)):
  a_window_without_two_good_readings_is_never_zero fails.
- FH2, a zero window accepted as a figure: the same test fails.
- FH3, an estimate called "measured": an_estimate_is_never_reported_as_measured
  fails.

clippy --no-deps --tests --examples: no warning in the lines this commit
touches; swebench_local_suite's unread `text` field and empty-format-literal
warnings are pre-existing. `cargo check --tests --examples` for
aarch64-unknown-linux-gnu (+fp16, CI's target): clean.

Not done here, deliberately:
- EconReport::energy_provenance comes from the tier (skill/cache hits are
  HwShunt, model tiers Estimator), not from any meter. A skill hit's joules are
  a constant (SKILL_JOULES), so a report whose rows are all skill hits says
  HwShunt for a figure nothing measured. That is not an IOReport consumer; it
  needs its own change.
- meter_probe (example) prints the meter and provenance it gets and is not
  wrong; it does not print ioreport_status().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family contract: measured when the platform exposes it, estimated when it
doesn't, and the provenance always says which. mesh-meter's HwMeter bridges
jouleclaw-energy's detect_meter() into Mesh's Meter/Session, and seals each
span's Energy into the mesh/1 receipt.

What was asked: confirm that on a host where b9d7e945 declines the IOReport
counter, the bridge takes the no-hardware path and not a zero ModelBased
reading. It does, as long as the composite reads. detect_meter() puts the Apple
load estimator behind the declined counter, and every span says Estimator (on
both Macs: "meter=apple-silicon energy=Energy { measured_uj: None,
estimated_uj: 2000000, provenance: Estimator }").

What the check found. The bridge turned any failed read into `Sample { uj: 0,
provenance: Estimator }`, and a Session's delta is `end - start` at the end
sample's provenance:
- An unavailable counter with nothing behind it (CompositeMeter over the
  IOReport counter alone, declined) reads Err every time. Every span came out
  `estimated_uj: 0`: a zero, billed as an estimate.
- A span whose opening read failed and whose closing read succeeded was billed
  the counter's whole cumulative value at the counter's provenance (with a
  RAPL-sized history, 1,000,000,001,000 µJ tagged ModelBased in the test).
- A span whose closing read failed was billed 0.

The fix. When a read of the backend fails, the bridge takes this crate's
no-hardware path from then on: EstimatorMeter, tagged Estimator, continuing
from the backend's last good reading, so a span across the failure is billed
an estimate of its own length. It never bills zero, never the counter's
history, and never runs backwards. The fallback is sticky: once a read has
failed, the backend is not trusted again in this meter. Mixing its readings
back in would bill whichever span straddled the switch the difference between
two unrelated counters. `active_name()`/`name()` report "estimator" once it
has fallen back. The module and HwMeter docs now say the bridge can resolve to
an estimator (on Apple Silicon whose CPU rail does not advance, it does).

mesh-core, the only consumer (its demo): `--features hw-meter-apple` (and
-rapl, which the README documents, and -nvml) built the backend but did not
turn on mesh-core's own `hw-meter` feature, which is what the demo checks, so
the demo ran Mesh's 15 W EstimatorMeter and never the family meter. Each
platform feature now implies hw-meter, and a compile_error! stops a build
where one is on without it. `cargo run -p mesh-core --example demo --features
hw-meter-apple` now prints "meter: apple-silicon" and seals
"energy_provenance": "Estimator" (it printed "meter: estimator" before).

Tests (mesh-meter: 2 without features, 8 with hw-meter, 10 with hw-meter-apple,
M5 Max; the hw-meter-apple binary also on the M3 Ultra, 10 passed; mesh-core
3/3 with hw-meter-apple):
- an_unavailable_counter_takes_the_no_hardware_path_never_a_modelbased_zero: a
  backend that is not available and whose read would claim a ModelBased zero.
- the_real_ioreport_counter_when_declined_takes_the_no_hardware_path
  (hw-meter-apple, macOS): the real AppleIoReportCounter bridged alone;
  available exactly when ioreport_status() is Live, and otherwise an
  Estimator span, no measured figure, non-zero. Both machines: "ioreport=
  CpuRailFrozen available=false meter=estimator energy=Energy { measured_uj:
  None, estimated_uj: 305126 .. }" (M3 Ultra: 450329).
- on_this_host_the_bridge_is_as_honest_as_the_family_meter (hw-meter-apple):
  detect() on this host; IOReport only when Live, else Estimator.
- a_read_that_fails_mid_span_is_estimated_never_zero,
  a_span_that_opens_on_a_failed_read_is_not_billed_the_counters_history,
  a_backend_that_never_reads_is_estimated,
  once_a_read_fails_the_bridge_estimates_and_never_runs_backwards,
  a_backend_that_reads_passes_through_unchanged: a scripted backend.
These run only with hw-meter; CI's mesh-rs job builds without features.

Falsified, each mutation applied to this commit and restored with git checkout:
- FM1, main's snapshot (a failed read is `uj: 0`, Estimator): 6 tests fail,
  among them the real declined counter ("meter=none .. estimated_uj: 0") and
  the history case ("estimated_uj: 1000000001000, provenance: ModelBased").
- FM2, the backend trusted again after a failure: 2 fail, e.g. "the backend is
  not trusted again" (left: ModelBased).
- FM3, hw-meter-apple no longer implying hw-meter: mesh-core does not compile
  ("a hw-meter-* feature must enable mesh-core's `hw-meter` feature").

clippy --no-deps --tests --examples on mesh-meter and mesh-core, with no
features, hw-meter, hw-meter-apple and hw-meter-rapl: clean.

Not done here, deliberately:
- The mesh-rs CI job runs `cargo test --workspace` with no features, so none
  of the hw tests above run in CI. Adding a feature leg means editing
  .github/workflows.
- mesh-core's module doc still says Session makes the figure "measured, not
  self-reported"; with the default EstimatorMeter it is an estimate, as the
  receipt's provenance says. That predates this branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family contract: measured when the platform exposes it, estimated when it
doesn't, and the provenance always says which. The L3 binding's `run` binary
meters the host across a model inference and puts the figure on a Joule Code
receipt.

The defect (b9d7e945, unchanged since origin/main 54e89b56):
- It printed every counter figure that was not HwShunt as "(<provenance>,
  host-metered)". Since b9d7e945 declines IOReport on both of our Macs, the
  counter it gets is the load-average estimator, so the printout read
  "(Estimator, host-metered)": an estimate presented as metered.
- The comment said the estimator runs "only if IOReport will not open". It now
  also runs when IOReport opens but its CPU rail does not advance, which is the
  case on both machines, and nothing said which.
- The delta was `a.saturating_sub(b)` with the after-reading's provenance: a
  counter going backwards gave a 0, and so did one that registered nothing.
  A before/after pair with different provenance was labelled with the second.

The fix.
- receipt_energy() takes the counter's delta at its provenance only if both
  reads succeeded, agree about provenance, and the delta is positive (no
  inference runs on zero energy). Otherwise the receipt carries the token-count
  estimate, which is what it already did without a counter. HwShunt carries
  measured_uj; every other provenance carries none, as the receipt schema
  requires.
- The printout adds "meter : <name> (IOReport: <ioreport_status()>)", and the
  energy line names how the figure was obtained: "measured by <meter>" (HwShunt),
  "host-metered by <meter> (energy model)" (ModelBased), "estimated by <meter>
  from host load" (Estimator), or "estimated from token counts".
- host_counter returns the meter as an EnergyMeter so the printout can name it,
  and its comment says when IOReport is used.

Observed, M5 Max, a real model (`JOULE_CODE_L3_ENGINE=llama`, gemma-4-E2B q4_k_m):
  meter        : apple-silicon (IOReport: CpuRailFrozen)
  tokens       : in=280 out=255
  energy       : 180.091 J  (Estimator, estimated by apple-silicon from host load)

Tests (the `run` bin, 4; the lib's 2 and the 4 model-gated ignored tests are
unchanged). CI's joule-code-conformance job runs `cargo test` here on its Linux
runner, where there is no counter:
- a_counter_reading_keeps_its_provenance, an_estimate_is_never_printed_as_metered.
- without_two_readings_the_receipt_is_the_token_estimate: missing reads, a
  counter going backwards, one that registered nothing (a held ModelBased rail
  included), readings that disagree about provenance.
- on_this_host_the_estimator_fallback_runs_unless_ioreport_is_live: IOReport
  only where Live; on Apple Silicon otherwise the estimator meters a 20 ms burn
  (non-zero, Estimator, "estimated by apple-silicon from host load"); with no
  counter (Linux) the receipt is exactly the token estimate. M5 Max and, from
  the same binary, M3 Ultra: "ioreport=CpuRailFrozen meter=apple-silicon
  energy=Energy { measured_uj: None, estimated_uj: 670069, provenance:
  Estimator }" (M3 Ultra: 198232).

Falsified, each mutation applied to this commit and restored with git checkout:
- FX1, an estimate printed "host-metered by <meter>": 2 tests fail ("left:
  host-metered by apple-silicon").
- FX2, a zero delta taken as the figure: fails on "Some((400, Estimator)) →
  Some((400, Estimator))".
- FX3, disagreeing provenance ignored: fails on "Some((100, ModelBased)) →
  Some((400, Estimator))".
- FX4, IOReport used although declined: "IOReport is used only when its CPU
  rail is live".

clippy --no-deps --tests --bins: nothing in src/bin/run.rs (the lib's one
warning predates this). `cargo check --tests --bins --target
x86_64-unknown-linux-gnu`: clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family's energy contract: "measured when the platform exposes it,
estimated when it doesn't, and the provenance always says which ... A reading
never silently upgrades its own provenance." 5d01888f put this meter's probe on
the family liveness rule. An adversarial review of it found two medium defects
and several low ones. All of them are fixed here, and the family test design is
applied.

The two medium defects:
- The rule keyed rails by channel name and counted every same-named entry in
  an interval. So two channels with the same name that stepped together in one
  batch refresh counted as one rail advancing twice, which gave Live. The M3
  Ultra's Energy Model group already lists 264 of its 645 names twice (e.g.
  ECPUDTL00..22, once per die, with no DIE_ prefix). Its CPU aggregates are
  unique today, so neither measured host reached this.
- The half of the rule that says "between CONSECUTIVE samples" lived only in
  the FFI loop, and no test pinned it. With `prev = cur` removed, so every
  sample was compared with the FIRST one, all 23 tests still passed. That is
  the a5c77d8d shape: one batch refresh in the window then reads as an advance
  in every later interval, and the rail looks Live.

What holds now:
- D1, a pure sample layer. `RailWatch::observe` takes each whole sample as
  (channel name, ABSOLUTE raw integer) pairs. It keeps the previous sample
  itself and compares each CPU rail with it. A rail is keyed by (name, ordinal
  among the same-named channels in that sample), so it advances at most once
  per interval. The FFI sampler (`energy_model_sample`) only extracts the pairs
  from one IOReportCreateSamples call. The probe no longer uses
  IOReportCreateSamplesDelta.
- D2, a generic loop. `liveness::watch(sample, tick, now, budget)` is the
  probe loop. The real probe passes the FFI sampler, a 25 ms spin and Instant.
  A failed sample is not an interval: the next good one is compared with the
  last good one, and failures keep sampling up to the five-budget cap. It is
  tested with scripted samples and a scripted clock:
  (a) the M5 Max refresh [480083160, 480083160, 541893638, ...], with a GPU rail
      that moves every sample, is Frozen after 40 intervals;
  (b) the M3 Ultra's two dies stepping together once, among unprefixed per-die
      sub-rails listed twice, is Frozen;
  (c) two "CPU Energy" channels stepping together once is Frozen;
  (d) one rail advancing every sample is Live after exactly 2 intervals and 3
      samples, and the loop stops there;
  (e) failing samples run to exactly 5 budgets (201 samples), with no verdict,
      which maps to Unavailable;
  (f) past the budget it keeps sampling with 1 interval and stops with 2
      (Frozen at 1.6 s, 5 samples on a 400 ms clock);
  (g) one step on each die, in different intervals, is Frozen.
- D3. The verdict-to-status mapping is a pure fn, `status_from`, with a test.
- D4. The once-per-process cache is pinned: STATUS_PROBE_RUNS counts the runs
  behind ioreport_status(). A test calls it several times, and also
  hardware_meter_available(), available(), power_over() and
  measure_marginal_uj(). It asserts that exactly one probe ran and that a warm
  call returns within one tick.
- D5, cadence. The cached probe keeps its interval count. The frozen-rail test
  prints "ioreport liveness: status=.. probe_intervals=N", and when the status
  is CpuRailFrozen it asserts N >= 10. The fresh-probe test asserts the same
  floor for Frozen, and for Absent when the group opened. The group-only probe
  sees 26-32 intervals. The all-channel one gives 2-3 on the M5 Max tonight
  and 5-6 on the M3 Ultra.
- D6, the declined path. This meter's honest fallback is no figure at all. A
  test burns 80 ms of real CPU per call through measure_marginal_uj on a
  declined host and asserts three things: the result is None (not Some(0)),
  the work closure never ran (no bracket), and the call returned in under
  80 ms. proof-cli's test asserts the sealed receipt stays ModelBased with a
  non-zero figure.

Also fixed:
- RAPL wrap. rapl_power_over took wrapping_sub over the SUM of the per-zone
  counters, but each zone wraps at its own max_energy_range_uj. One zone
  wrapping therefore gave ~1.8e19 uJ, which proof-cli would have printed as
  HwShunt. The pure `rapl::delta_uj` now sums per-zone deltas, each taken
  modulo that zone's range. It gives no figure if a zone that wrapped has an
  unreadable range, or if the zone sets differ. Tests cover no wrap, a wrap in
  one zone, and an unreadable range with and without a wrap.
- A worker leak in bracket(). It returned at `busy_power()?` before stopping
  its workers, so a busy window with no reading left every core spinning for
  the rest of the process. The RAPL change adds such a None, so the workers are
  now stopped and joined first. `bracket_with` takes the power readers, and a
  test pins that the workers stop.
- measure_marginal_uj's doc said None when "the signal stays inside the idle
  noise", but the code returns Some(0) there. The doc now says so, and that it
  is not a measured zero. The behaviour is unchanged.
- Unavailable. Its doc and the CLI's reason now cover a subscription that
  opens but can never be sampled. They no longer blame Rosetta alone.
- proof-cli prints the decision it made ("--measure: hardware meter
  available=.. · ioreport_status=.."). tests/measure.rs asserts against that
  line instead of running a second probe, which could disagree on a
  borderline rail.
- strip_die_prefix now requires at least one digit and matches DIE_ in any
  case.
- The oracle's doc now says it shares the CFString helper, not only the FFI.

Claims in pushed messages and docs that were not true:
- 5d01888f says "Live means the same rail advanced in at least two separate
  sample intervals" and "The IOReport FFI only feeds it". With same-named
  channels, "the same rail" was one name. The FFI also did the
  consecutive-sample pairing, the part no test reached.
- 5d01888f says the rule is "the one jouleclaw-energy (b9d7e945) and
  eoc-meter (c6378629) implement". Its shape matched jouleclaw-energy's, which
  has the same duplicate-name defect. eoc-meter keys by channel index and
  computes steps inside its pure struct.
- 5d01888f deferred asserting the group-only probe because "a count threshold
  would flake under load". A floor of 10 is asserted here. The green runs saw
  26-32, and 15-16 under concurrent load.
- The test comments said "so a CI log says/shows". No CI job on this branch
  runs proof-cascade's macOS tests: proof-rs builds on the linux runner, where
  the IOReport code is cfg'd out. They now say "a log of a macOS run".
- a5c77d8d says it "drops the stale `macmon` references", but proof/README.md
  still said "IOReport via `macmon`". The README now describes the direct C
  API, used only while the CPU rail is live.

Observed, macOS 27.0.1. proof-cascade lib went from 23 to 36 tests:
  M5 Max, 3 runs: ioreport liveness: status=CpuRailFrozen
            oracle=CpuRailMoved(false) selected=false probe_intervals=29/31/30
            ioreport probe: probe=Some(Frozen) ... probe_took=1.097s/1.094s/
            1.102s intervals=28/31/29
            declined path: available=false ioreport_status=CpuRailFrozen
            figure=None work_runs=0 took=250ns
            test result: ok. 36 passed; 0 failed (each run)
  M3 Ultra: ioreport liveness: status=CpuRailFrozen
            oracle=CpuRailMoved(false) selected=false probe_intervals=32
            ioreport probe: probe=Some(Frozen) ... probe_took=1.10663325s
            intervals=32
            test result: ok. 36 passed; 0 failed
The whole proof-rs workspace passes on the M5 Max (z3 present), and so does
the proof-conformance binary ("all checks passed"). `proof --measure` declines
on both chips with "available=false · ioreport_status=CpuRailFrozen" and the
frozen-rail reason. Its receipt stays "provenance ModelBased" (167 uj on the M5
Max, 345 uj on the M3 Ultra, where z3 is absent). clippy --no-deps --tests
shows the same 2 warnings as 5d01888f and none new, and is clean for
x86_64-unknown-linux-gnu.

Dependants: joulecontract-proof (6) and joule-code-release (6) pass on both
chips. joule-code-remediate's verified_fix_end_to_end_ships_with_receipt
fails with "assertion failed: report.total_uj() > 0". It fails identically
with 5d01888f's meter.rs checked out; it meters through sandbox-meter. On the
M3 Ultra, the formal-template test also fails, from a baked
CARGO_MANIFEST_DIR path.

Falsified on the M5 Max. Each mutation was applied, the tests run, then
restored with git checkout, with a clean tree after each:
- Survivors of the last review. Skip the OnceLock (M4): "the liveness probe
  ran more than once in this process left: 25 right: 1". Compare with the
  first sample (M7): 6 rule tests fail, e.g. the M5 Max refresh "Run {
  verdict: Some(Live), intervals: 3 ..}". Probe on the all-channel
  subscription (M8): "Frozen after only 2 interval(s) in 1.367s" and "the
  cached probe called the rail frozen after 3 intervals". On the M3 Ultra, M8
  gives 5 and 6 intervals and fails the same two tests.
- Key rails by name alone, or count every same-named entry: (c) fails, Live.
- Sum advances across rails: (b), (c) and (g) fail.
- One advance is live: 9 tests fail.
- Stop at the budget with under 2 intervals: (e), (f) and the stopping-rule
  test fail.
- Don't stop when Live: (d) fails, 40 intervals.
- Give up on a failed sample: (e) and (f) fail.
- Treat every channel as a CPU rail: the rule tests fail, and on hardware
  "probe=Some(Live) ... oracle=CpuRailMoved(false)".
- Swap Frozen/Absent in status_from: the mapping test and the frozen-rail
  test fail.
- A declined meter returns Some(0), or runs a bracket before declining: D6
  fails.
- bracket() returns early without stopping its workers: "workers kept running
  after the bracket returned".
- RAPL wrapping_sub with no modulo, or an unreadable range taken as u64::MAX:
  the rapl tests fail, e.g. "left: Some(18446743811566228266) right:
  Some(5500)".
- proof-cli meters regardless, or prints the wrong decision: measure.rs fails
  with "the CLI's output disagrees with the decision it printed".

Left for David:
- The CLI tags a live IOReport figure Energy::measured, i.e. HwShunt, while
  the module header says a macOS Energy Model reading is ModelBased. The
  comment now states this instead of endorsing it.
- No macOS CI job runs this crate's tests yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The family contract: measured when the platform exposes it, estimated when it
doesn't, and the provenance always says which; a reading never silently
upgrades its own provenance. On macOS 27.0.1 the IOReport CPU rails are
refreshed in rare batches, so this crate's liveness probe declines the
counter unless one CPU rail steps in two separate intervals. The rule was
right. What an independent verification of 1a477bc8 found is that the parts
around it could be broken without any test noticing.

What was wrong:
- Only the pure RailWatch was tested. The loop that pairs samples, where the
  original defect lived, and the FFI mapping that feeds it were not. Two
  mutations passed all 40 tests on both Macs: the real probe loop breaking
  after its first interval (probe_took fell to 138 ms), and every CPU rail
  collapsed into one synthetic "any rail advanced" rail at the FFI mapping. On
  a multi-die host whose dies refresh in different intervals, the second
  reports Live, which is the over-claim the rule exists to prevent. The
  family's shared mutation, comparing every sample with the first one, would
  have passed as well, because nothing tested the pairing.
- RailWatch keyed rails by name. The M3 Ultra's Energy Model group has 645
  channels and 264 duplicated names. The CPU aggregates are unique there
  today, but two same-named rails stepping in one refresh would have counted
  as two advances of one rail.
- The test oracle ran its CPU burn on the calling thread, in 50 ms slices
  between blocking all-channel reads of 0.1-0.8 s each. The window was under
  load 13-18% of the time on the M5 Max and 27% on the M3 Ultra.

Two claims were false. Its doc said it "burns CPU for `budget`, reading as it
goes". b9d7e945's message said the oracle "burns for three times the probe
budget". Both are true now, of a helper thread. b9d7e945 also said "Counting
per rail keeps two dies' separate jumps from adding up". That held only for
rails with different names.

The fix follows the family test design.
- D1, the sample layer. RailWatch::observe takes a whole sample as (channel
  name, absolute raw integer) and keeps the CPU rails: `CPU Energy`,
  optionally `DIE_<n>_`-prefixed. It keys each rail by (name, ordinal among
  same-named CPU rails in that sample) and compares it with the PREVIOUS
  sample. A rail can advance at most once per interval, and only by going up.
  The FFI side, sample_energy_model, takes one plain IOReportCreateSamples
  (absolute values; the probe no longer uses delta samples) and extracts
  (name, raw) for every Energy Model channel. It decides nothing. A sample
  with no channel array is a failed sample, not a sample with no CPU rail.
- D2, the loop. It is generic: watch(sample, tick, now, budget). It takes a
  baseline, then ticks and samples again while RailWatch::wants_more, and
  skips failed samples; the next good sample is compared with the last good
  one. The real probe passes the FFI sampler, a 25 ms spin and Instant.
  Behaviour change: a failed first sample used to end the probe at once with
  no verdict. Failed samples are now retried up to the five-budget cap, so a
  host whose Energy Model group subscribes but never samples pays up to 5 s,
  once per process, before Unavailable.
- D3: status_of maps a verdict to an IoReportStatus.
- D4: the real probe counts its runs in a per-thread counter (PROBE_RUNS).
  Tests that probe on purpose, in parallel, do not disturb it.
- The oracle's burn runs on a helper thread. It starts before the oracle's
  subscription opens and stops after its last read. The calling thread only
  reads, back to back. The oracle still has its own all-channel subscription,
  absolute values, ends_with("CPU Energy") in any group, and its own ">= 2
  changes" threshold.

Tests (50 with --features ioreport, was 40; 24 without, unchanged):
- Scripted, no hardware, through watch() with a scripted sampler and clock:
  (a) the M5 Max refresh 480,083,160 -> 541,893,638 mJ, beside a GPU rail
  moving every sample, is Frozen; (b) the M3 Ultra's two dies stepping in one
  sample, beside duplicated ECPUDTL00 names, is Frozen; (c) two channels
  named `CPU Energy` stepping together once is Frozen; (d) a rail stepping
  every sample is Live after exactly 2 intervals, with sampling stopped at 3
  samples and 2 ticks; (e) samples that always fail run to the 5-budget cap
  with no verdict (Unavailable), and a failed baseline followed by good
  samples still gets a verdict; (f) at 600 ms a sample, the probe keeps going
  past the budget with one interval and stops at two, Frozen; at 3 s a sample
  it stops at the cap with one; (g) DIE_0 and DIE_1 stepping once each, in
  different intervals, is Frozen.
- Rule tests now feed samples. New: two_same_named_rails_stepping_once_are_
  not_a_live_rail, only_a_cpu_rail_going_up_is_an_advance, and
  each_verdict_maps_to_its_status (D3).
- D4, the_liveness_status_is_probed_once_and_cached: three
  ioreport_status() calls, new(), default() and detect_meter() ran the real
  probe at most once on the test's thread. It replaces a timing check.
- D5, the_liveness_probe_never_reports_a_frozen_rail_as_live: wherever the
  probe could sample and found no live rail, it ran its whole budget and
  compared at least 10 intervals. It prints the count.
- the_liveness_probe_subscribes_to_the_energy_model_group_only also asserts:
  - the rails the rule watched, as (name, ordinal), are exactly the group's
    CPU rails, with the test's own name matching;
  - unless the verdict is Live, at least 2 intervals and the whole budget.
- D6: a_declined_counter_yields_no_reading_and_the_fallback_says_estimator
  now also runs 150 ms of CPU work through detect_meter(). The figure must
  be above one quantum of the meter, with Estimator provenance on both
  readings.

Observed. M5 Max (macOS 27.0.1), three runs, 50 passed each:
  ioreport probe subscription: status=CpuRailFrozen after 30 intervals in
    1.096744083s; 364 channels per sample, 0 outside the Energy Model group
    (all-channel subscription: 11885); one sample 18.000625ms (all-channel
    449.545167ms); rails watched [("CPU Energy", 0)]
  ioreport oracle: 8 reads over a 3s window, CPU burned on a helper thread
    for 3.085035917s, changes per CPU rail [0] -> CpuRailMoved(false)
  ioreport probe: probed=CpuRailFrozen cached=CpuRailFrozen
    oracle=CpuRailMoved(false) intervals=Some(30) probe_took=1.087961791s
  ioreport declined (CpuRailFrozen): 150.009083ms of CPU work metered by
    `apple-silicon` as 6000000 µJ, provenance estimator then estimator
  ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
    selected=false available=false
Intervals were 28-30 across the three runs. The oracle got 3-8 reads, since
an all-channel sample took 0.45-1.07 s at the time.
M3 Ultra (same test binary, run by scp, then deleted), 50 passed:
  ... after 32 intervals in 1.100072167s; 645 channels per sample, 0
    outside ...; rails watched [("DIE_0_CPU Energy", 0), ("DIE_1_CPU
    Energy", 0)]
  ioreport oracle: 17 reads over a 3s window, CPU burned on a helper thread
    for 3.031725917s, changes per CPU rail [0, 0] -> CpuRailMoved(false)
  ioreport declined (CpuRailFrozen): 150.007459ms of CPU work metered by
    `apple-silicon` as 1452465 µJ, provenance estimator then estimator
In every run the cache test saw 0 probe runs on its own thread, because
another test's thread filled the cache. It pins "at most once"; the D4
mutation below shows 6.

Falsified on the final code, each mutation applied, run, and restored with
git checkout, with a clean tree after:
- Compare every sample with the first (prev.clone() + get_or_insert). 7
  fail, e.g. "one refresh counted as a live rail: RailWatch { intervals: 5,
  ... rails: [(("CPU Energy", 0), 4)] }".
- The round-2 survivor M7, the loop stopping after its first interval. 5
  fail, including both hardware probe tests: "the probe decided
  CpuRailFrozen on 1 intervals in ...: it did not re-sample the Energy Model
  group every ~25ms".
- The real probe's clock running 10x fast. 2 fail: "the probe decided
  CpuRailFrozen on 3 intervals in 225.471167ms".
- The real probe given a tenth of its budget. The same 2 fail.
- The round-2 survivor M8, the FFI collapsing the CPU rails into a synthetic
  "total". 3 fail on both Macs: "the probe's rule did not watch the group's
  CPU rails one by one" and "the probe found no CPU rail, the oracle found
  one".
- The same collapse into one rail named `CPU Energy`. It fails on the M3
  Ultra ("rails watched [("CPU Energy", 0)]" / "did not watch the group's
  CPU rails one by one"). It passes on the M5 Max, where the group has one
  CPU rail, so the collapse changes nothing there.
- The rule keying every CPU rail as one. 5 fail, (b), (c) and (g) among them.
- Keying by name alone (ordinal 0). (c) and the same-named rule test fail
  ("left: Some(Live) right: Some(Frozen)").
- The GPU rail counted as a CPU rail. 8 fail.
- A failed sample ending the probe. (e) fails: "gave up at 37ms after 2
  samples, before the 5s cap".
- Stopping at the budget with fewer than 2 intervals. 3 fail, (f) among them.
- Frozen mapped to Live. 4 fail, D3 among them.
- No per-process cache. D4 fails: "the real liveness probe ran 6 times: the
  status is not cached".
- PROBE_TICK at 250 ms. D5 fails: "the probe decided CpuRailFrozen on 4
  intervals", and (a).
- The probe on the all-channel subscription. The group test fails ("11521
  channels outside the Energy Model group") and so does D5 ("on 2 intervals
  in 1.569546042s").
- The estimator apportioning whole seconds only. D6 fails: "150.009209ms of
  CPU work metered as 0 µJ by `apple-silicon`: a zero or a floor, not a
  figure".
One mutation I wrote was wrong and proved nothing. It moved the real probe's
clock a budget ahead, but watch() takes its own origin from that clock, so a
constant offset cancels. It passed, and it was replaced by the 10x clock.

clippy --no-deps --tests, with and without ioreport: nothing new. The
collapsible-if my first draft added is fixed. What remains was there before:
the duplicated #[link], sum_energy_uj's b"..\0", ledger.rs's constant asserts
and meter.rs's vec-init-then-push. cargo check --tests --examples for
x86_64- and aarch64-unknown-linux-gnu, with and without ioreport: clean.

Dependants, re-run against this commit on the M5 Max:
- jouleclaw-loader-gguf --features "rayon fabric-meter": 302 passed, 2
  ignored ("tune_shape_live on this host: ioreport=CpuRailFrozen tuned=None
  recorded=None").
- jouleclaw-harness-econ: 14 passed.
- mesh-meter --features hw-meter-apple: 10 passed. mesh-core
  --features hw-meter-apple: 3 passed.
- l3-jouleclaw: 2 + 4 passed ("estimated by apple-silicon from host load").
- jouledesk + jouledesk-github: 18 + 4 + 12 passed.
- The energy_probe example prints "ioreport   : declined (CpuRailFrozen)"
  and "--- estimated ---".
~/.jouleclaw/fabric-calibration.json is untouched: Jun 11 11:02, sha256
792b00e6...b488.

Not done here:
- No CI job runs this crate with --features ioreport. The workflows are
  outside this branch; see the report.
- The counter's own read() still samples every channel. It is declined on
  both Macs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
226a70cb reported "jouledesk-github 12" passing on the M5 Max. That held on
some runs only. Two defects in that commit's own change:

1. A test race on a process-global. The two on-host handle() tests, GitHub
   and Slack, each ran set_var("JOULEDESK_MODEL", "/nonexistent.gguf"), then
   handle(), then remove_var, and cargo runs them in parallel. When one
   removed the variable before the other's run_model read it, run_model fell
   back to DEFAULT_MODEL. This Mac has that model file, and llama-completion is
   on its PATH, so a unit test ran gemma-4-E2B for 2-3 s. The work was then
   metered and sealed, and the test's "not sealed" assertion failed. The
   same binary at e4b20074, run 20 times here: pass=15 fail=5, e.g.
     left: ("completed", true, 70845000)
     right: ("not sealed (no energy figure)", false, 0)
   and on the Slack side
     left: ("completed", true)
     right: ("not sealed (no energy figure)", false)
   The race was on main too, but main's loose assertions passed either way.
   This machine is also the macos CI runner for jouledesk-rs. The M3 Ultra has
   no model file, so it never showed there.

2. The reply for work with no energy figure still said "_Decided under a
   signed grant, ..._" and "ledger UNVERIFIED ✗"; Slack said "_Signed grant
   · ..._". seal_work returns before any Teammate, grant or ledger exists, so
   no grant decided anything and there was no ledger to be unverified.

The fix.
- The work path takes the model explicitly. do_work_with(question, model)
  and handle_with(event, handle, model), plus slack::handle_with(event,
  model), are pub(crate). run_model and model_label take the path they are
  given. The public do_work, handle and slack::handle keep their signatures.
  They read JOULEDESK_MODEL, else DEFAULT_MODEL, once at the entry point and
  pass it down. The model that runs is now also the model the reply names;
  before, run_model and model_label each read the variable separately.
- Under cfg(test), configured_model() panics. Every unit test passes a model
  path, NO_MODEL = "/nonexistent.gguf", so none can read the process-global
  or reach the default model and spawn llama-completion. No test calls
  set_var or remove_var any more.
- A reply for work with no figure now says it was not decided under a grant
  and has no ledger entry:
    _Worked on real hardware; not metered, so no grant decided it and nothing
    was sealed._
    - **energy:** not metered — `apple-silicon`: the work took 121 µs,
      shorter than the 100 ms window it can resolve; no energy figure was
      sealed
    - **decision:** not sealed (no energy figure) · no ledger entry
  In Slack:
    _Worked on real hardware · not metered · no grant decision · nothing
    sealed_ … • *decision:* not sealed (no energy figure) · no ledger entry
  A sealed reply is unchanged. From a real --dry-run with the default model
  on the M5 Max:
    _Decided under a signed grant, worked on real hardware, sealed to a
    ledger anyone can verify._
    - **energy:** 99956.5 mJ (99956487 µJ) — estimated by `apple-silicon`,
      provenance `estimator`
    - **decision:** completed · ledger verified ✓

Tests. assert_unmetered_reply: no "signed grant", no "UNVERIFIED" or "ledger
verified", and "no ledger entry". It is applied to the composed GitHub and
Slack replies and to both on-host handle() paths. Those paths also assert the
reply names `deterministic-extract`. The sealed-reply tests now assert the
grant clause and "ledger verified ✓".

After, on the M5 Max:
- The same test binary 20 times with JOULEDESK_MODEL unset: pass=20 fail=0,
  every run 12 passed in 1.22-1.27 s.
- 5 more times with JOULEDESK_MODEL set to the real gemma model: pass=5
  fail=0, 1.22-1.24 s. No run spent seconds in a model.
M3 Ultra: 12 passed. jouledesk: 18 + 4 passed. clippy --no-deps --tests on
jouledesk and jouledesk-github: nothing new; the unused libwebrtc patch
warning is the workspace's.

Falsified, each mutation applied, run, and restored with git checkout, with a
clean tree after:
- handle_with ignores its model and calls configured_model():
  a_mention_answered_too_fast_to_meter_is_posted_as_unmetered fails with "a
  unit test reached the configured model (/Volumes/.../gemma-4-E2B-q4_k_m.gguf
  unless JOULEDESK_MODEL); pass a model path".
- The GitHub unmetered reply says "Decided under a signed grant" and "ledger
  {verify}" again: 2 tests fail ("no grant decided this work: ...").
- The same in Slack ("Signed grant · ..."): 2 tests fail.

Not changed: Work::measured_uj and Handled::measured_uj keep their names,
although they carry an estimate when provenance says `estimator`. Renaming
them is an API decision.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
c6378629 gated the IOReport counter on a CPU-rail liveness probe, but no one
had checked it against the family rule, and the part of the probe where this
family's defect first appeared was never tested. Auditing it against the rule
found these gaps:

- The probe loop stopped at the one-second budget however few intervals it
  had seen. The rule is to keep sampling until the rail is Live, or until the
  budget has elapsed AND at least two intervals were seen, capped at five
  budgets. The loop ran inline in the FFI function, so its pairing,
  termination and failed-sample handling could not be tested at all.
- A failed first sample, or a probe that got no interval, was reported as
  CpuRailAbsent ("no CPU rail"). The rule reports Unavailable: nothing was
  learned.
- The oracle read the rail twice, before and after a burn on its own thread,
  and called one change "moved". So a single batch refresh inside its window
  counted as a moving rail. It opened its subscription through the
  implementation's MacosIoReportCounter::open() and converted names with the
  implementation's cfstring_to_string. So c6378629's doc claim that it "shares
  nothing with the liveness probe but the raw FFI" was false, and so was the
  message's "the same absolute reads show GPU Energy moving": the oracle never
  read GPU Energy.
- Nothing pinned the once-per-process cache, the probe's cadence on the
  Energy Model group, the verdict-to-status mapping, the Live-only gate, or
  what a declined host meters.
- open_energy_model() leaked the subscribed-channel dictionary when
  IOReportCreateSubscription returned null. The counter's own open() did
  too, before this branch.

What holds now:

- The rule is pure (mod rail_liveness). It is compiled on every platform
  under test, so the Linux CI leg runs it too. The FFI only takes a sample
  and lists each Energy Model channel as (name, absolute raw integer).
  RailWatch::observe keys each CPU rail (the name after stripping DIE_<n>_ is
  "CPU Energy", case-insensitive) by (channel name, ordinal among
  same-named channels in that sample). It compares each usable sample with
  the previous usable one, never with the first. So a rail advances at most
  once per interval, and dies are never summed. The M3 Ultra lists 264 of its
  645 Energy Model names twice. Two same-named rails stepping together are
  one advance each, not two advances of one rail.
- watch(sample, tick, now, budget) is the probe loop. It is generic over the
  sampler, the tick and the clock. The real probe passes the IOReport
  sampler, 25 ms of CPU spin on the calling thread, and Instant. Live means
  the same rail advanced in >= 2 intervals. The loop keeps sampling until
  Live, or until the budget has elapsed with >= 2 intervals, capped at five
  budgets. No interval at all is no verdict, which maps to Unavailable.
- status_of() and admits_counter() are pure. try_detect() admits the counter
  only on Live.
- PROBE_RUNS counts the real probe behind ioreport_status().
- The oracle opens its own all-channel subscription with raw calls and
  converts names with its own helper. It reads absolute values for
  ends_with("CPU Energy") and ends_with("GPU Energy") over and over for 3 s,
  while a helper thread burns CPU for the whole window. It says the rail
  moved only if one rail changed at >= 2 reads, and it must get >= 3 reads
  before it may say Frozen.
- The powermetrics docs said the rung degrades to StubCounter. It never does:
  every read errors. The docs now say so, and a test pins it.

Tests added:

- Scripted samples and a scripted clock:
  - (a) the M5 Max refresh 480,083,160 -> 541,893,638 is Frozen: exactly 40
    intervals and 41 samples, while GPU Energy moves at every sample;
  - (b) the M3 Ultra's two dies stepping +1,344,869 / +1,391,637 in one
    sample is Frozen;
  - (c) same-named rails are counted apart;
  - (d) a rail stepping every sample is Live after exactly 2 intervals and
    3 samples;
  - (e) failed samples sample to the cap (201 samples) and give no verdict,
    which is Unavailable;
  - (f) past the budget the probe stops at once with two intervals, and keeps
    sampling with fewer;
  - (g) one step on each die, in different intervals, is Frozen.
  The verdict-to-status mapping, the Live-only gate and the name rule each
  have a test.
- Hardware tests:
  - the_liveness_probe_runs_once_per_process: exactly one run after repeated
    ioreport_status(), detect() and try_detect() calls;
  - the fresh-probe test requires >= 10 intervals, and the probe thread on
    CPU for >= 50% of the probe, whenever the verdict is not Live;
  - on a declined host, detect()'s fallback measures 100 ms of real CPU work
    as no figure (both reads error, or it is the stub).
- In the eoc binary, work_on_an_unmetered_host_keeps_its_estimate runs the
  CLI's own cascade around a backend that burns 100 ms. On this host it must
  come back as the backend's 50,000,000 µJ estimate, tagged estimated.
  (async-trait becomes a dev-dependency of eoc for that backend.) `eoc meter`
  now says Unavailable can also mean "yielded no samples".

Observed on the M5 Max, 3 runs of 3, macOS 27.0.1:

  ioreport probe: probed=Some(Some(Frozen)) cached=CpuRailFrozen
  took=1.11s on-cpu=95-98% intervals=30-31
  ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
  selected=false (oracle: 9-12 reads, CPU rail changes [0], GPU rail 8-11)
  eoc-meter 19 passed, eoc 2 passed

Observed on the M3 Ultra (10.10.10.2), same binaries by sha256:
intervals=32, on-cpu=100%, CPU rail changes [0, 0] on DIE_0/DIE_1, GPU rail
changed at 17 and 20 of 21 oracle reads. eoc-meter 19 passed, eoc 2 passed. On both machines,
`eoc query` prints `cost : 50000000 µJ (estimated)`, and `eoc meter` prints
the declined status and "not metered".

Falsified, each restored with git checkout (clean status every time). Each
of these fails at least one test:
- comparing every sample with the first, in RailWatch and in the loop;
- one advance counting as Live;
- advances summed across dies;
- rails keyed by name alone;
- GPU Energy counted as a CPU rail;
- no DIE_ stripping;
- c6378629's stop-at-budget loop;
- no five-budget cap;
- None mapped to Live or to CpuRailAbsent;
- the gate admitting CpuRailAbsent;
- Absent reported for rails that did not advance;
- a coarse 1e9 threshold;
- no OnceLock (the probe ran 14 times);
- an all-channel probe (2 intervals in 1.86 s);
- a probe that sleeps (26% on CPU);
- a powermetrics fallback reading Ok(0) or a time-based count;
- main's ungated try_detect();
- a probe that always says Live.
One mutation survives: the counter's own read returning Ok(0). On a frozen
host the counter is never constructed, so only a Live host runs that path.

Leak check: 150 probes plus 150 all-channel opens under leaks --atExit gave
"0 leaks for 0 total leaked bytes". A control with CFRelease(sample)
removed gave 100,080 leaks.

The whole eoc workspace passes: 594 passed, 0 failed, 3 ignored. That covers
every dependant c6378629 ran. Clippy adds nothing: the two eoc-meter
warnings are on lines that predate this branch.

Not done here: counts_once still admits CPU sub-rails; the counter's reads
still sample every channel; and a rail that freezes after selection is not
re-probed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: the liveness rule pairs consecutive samples, and tests pin each step
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m1s
standards workspaces build + test / aac-rs (pull_request) Successful in 38s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 41s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 39s
standards workspaces build + test / map-rs (pull_request) Successful in 33s
standards workspaces build + test / mesh-rs (pull_request) Successful in 27s
standards workspaces build + test / openpay (pull_request) Successful in 1m42s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
94e7cd3f24
This commit corrects dfec5d44 and 2518f0d7 where they were wrong, and closes
the gaps the round-2 review of 2518f0d7 found. The part of the probe where
the original defect lived could not be tested: putting that defect back
passed every test.

Where the two earlier commits were wrong:

- dfec5d44 says that on the M3 Ultra "all 645 Energy Model rails are frozen,
  CPU, DRAM and display included", and that there "nothing moves". Both are
  wrong. GPU Energy moves on the M3 Ultra, sporadically: 2518f0d7's oracle saw
  it change at 17-18 of 19-20 reads, the review at 18 of 19, and this
  commit's runs at 16-19 of 20. The CPU rails are not frozen for good either:
  they are refreshed in rare batches, as 2518f0d7 already said.
- dfec5d44 deleted cumulative_advances_after_cpu_burn. The comment on the
  macOS job in .github/workflows/standards-build.yml still says that test
  catches read_uj() returning Some(0), the symptom seen on 2026-09-19.
  Neither commit said so, and from dfec5d44 until this commit nothing caught
  that mutation. The job also runs without --nocapture, so a passing log
  never shows the "ioreport liveness:" line. The workflow belongs to PR #102
  and is not edited here.
- 2518f0d7 fixed the probe comparing every sample with the first one, but
  nothing tested the fix: with the defect put back, all 17 tests passed. The
  pairing lived in the FFI loop, and the rule's tests took precomputed
  booleans.
- 2518f0d7's cache test timed a second ioreport_status() against 200 ms. A
  re-probe on a live rail takes 145-155 ms, so on such a host it could not
  catch a missing cache.
- 2518f0d7's oracle doc says it "shares nothing with the liveness probe but
  the raw FFI declarations". It used the crate's cfstring_to_string. 2518f0d7
  also says the oracle's GPU Energy count "shows its reads are fresh"; that
  holds only while the GPU draws (the review saw 7 changes in 16-20 reads).
- 2518f0d7 gives 130-160 ms for one all-channel sample on the M5 Max "today".
  That depends on load. At today's load averages of 17-27 on 18 cores the
  oracle got 2-11 all-channel reads in 3 s, and the review measured ~425 ms,
  so the 0.4-0.8 s figure from 2026-09-29 comes back under load.

What changes:

- The FFI only reads. A probe sample is one plain sample of the Energy Model
  group: (channel name, absolute raw integer) for every channel. RailWatch,
  which is pure, picks out the CPU rails, keys each by (name, ordinal among
  same-named channels in that sample) because the M3 Ultra's group repeats
  264 of its 645 names, compares each sample with the one before it, and
  counts at most one advance per rail per interval. A failed sample is
  skipped; the next is compared with the last usable one.
- The probe loop is watch(sample, tick, elapsed, budget), generic over the
  sampler, the tick and the clock. The real probe passes the FFI sampler, a
  25 ms CPU spin and Instant; the tests pass scripted samples and a scripted
  clock.
- Behaviour change: a first sample that fails no longer ends the probe. Like
  any failed sample it keeps the probe sampling, up to the five-budget cap;
  with no two usable samples the status is Unavailable.
- status_of(verdict) is a pure mapping to IoReportStatus. detect() calls a
  private detect_with(status), so the gate can be driven with every status.
  A private counter, PROBE_RUNS, counts runs of the real probe. The public
  API is unchanged.
- IoReport::open no longer leaks the subscribed-channels dictionary when the
  subscription comes back null. The path is implausible, but since 2518f0d7
  probe_status reaches it.
- The oracle converts CFStrings itself, so its doc is now true.

Tests: 26 lib tests on macOS, up from 17.

- Scripted samples through watch() with a scripted clock:
  - the M5 Max refresh [480083160, 480083160, 541893638, ...] beside a rising
    GPU rail: Frozen, the step counted once, after the whole budget;
  - the M3 Ultra two-die refresh in one sample, among duplicated ECPUDTL00
    names: Frozen;
  - two same-named "CPU Energy" channels stepping together once: Frozen;
  - one rail rising by one raw unit at every sample: Live after exactly two
    intervals, three samples and two ticks;
  - every sample failing: sampling continues to the five-budget cap, no
    verdict, Unavailable;
  - past the budget: stops with Frozen once there are two intervals, samples
    again with only one;
  - DIE_0 and DIE_1 each stepping once, in different intervals: Frozen;
  - advances in non-consecutive intervals, and on one die only: Live;
  - failed samples between usable ones: the pairing goes on.
- each_verdict_reports_its_status covers all four verdicts.
  only_a_live_status_selects_the_ioreport_rung drives detect_with with every
  status: Live selects IOReport wherever the counter opens; the others give
  TimePower at the default 15 W.
- the_liveness_status_is_probed_once_and_cached counts instead of timing:
  after several status and detect() calls, PROBE_RUNS must equal 1.
- the_liveness_probe_never_reports_a_frozen_rail_as_live also pins cadence
  and burn. When a fresh probe's verdict is not Live it must have seen at
  least 10 intervals (group-only: ~30; all-channel: 2-8). The probing thread
  must use at least half a 25 ms tick of CPU per interval
  (CLOCK_THREAD_CPUTIME_ID): burning, 23-34 ms; sleeping, 6-10 ms. Per
  interval, not against the wall clock: in one run under load averages above
  20, the probe (unmutated there) spent only 36% of its wall time on CPU,
  because each IOReport sample waited longer while the burn still got its
  CPU.
- a_selected_ioreport_meter_meters_cpu_work, in its declined branch, meters
  100 ms of real CPU work with the detected meter and asserts uj() > 0, at
  least the time x power figure at the default 15 W, measured_uj None,
  provenance ModelBased and check_provenance().
- the_counter_advances_when_a_rail_it_sums_advanced is new. The counter takes
  a baseline; the oracle's own subscription reads GPU Energy (Energy Model
  group, absolute) until one channel rises by at least 10^6 raw units or 3 s
  pass; the counter reads again. If the GPU rose inside the counter's window
  the counter must have advanced. With the GPU idle only after >= base is
  asserted, and the printed line says which branch ran.
- the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail keeps its name
  and its "ioreport liveness: status=" line.

Falsified on the M5 Max against this tree. Each mutation was a temporary
edit, restored with git checkout; git status was clean after each. Every
mutation the review listed as surviving now fails:
- M6, compare each sample with the first (dfec5d44's defect): 6 tests fail,
  e.g. "a batch refresh read as live: [("CPU Energy#0", 2)]".
- M7, the probe on the all-channel subscription: "the probe saw 2 intervals
  in 1.689598583s: it is not re-sampling the Energy Model group after every
  25ms tick".
- M8, the detect() fallback TimePower { milliwatts: 0 }: "the declined meter
  metered 100ms of CPU work as 0 µJ" and "status Unavailable gave
  TimePower { milliwatts: 0 }".
- M9, the probe sleeping instead of burning: "the probing thread used
  207.32975ms of CPU over 27 intervals, 7.678879ms each, under half of each
  25ms tick".
- M10, no verdict mapped to Live: 2 tests fail, "left: Live, right:
  Unavailable".
- M11, the gate selecting on CpuRailAbsent: "status CpuRailAbsent gave
  IoReport(IoReport)".
- M13, the advance threshold raised to 1e9 raw units: 8 tests fail.
- M14, read_uj() returning Some(0): "GPU Energy rose 18851614 raw inside the
  counter's window, but the counter read 0 -> 0 µJ".
Also caught, on an intermediate tree with the same rule and loop: keying
rails by name alone, no OnceLock (the probe ran 13 times), summing advances
across rails, counting GPU Energy as a CPU rail, stopping at the budget with
one interval, LIVE_RAIL_ADVANCES = 1, and Absent for rails that held.

Observed on this tree:
  M5 Max, 3 runs, load averages 23-24: ioreport liveness:
    status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle:
    7-9 reads over 3s; changes per CPU rail [0], per GPU rail [7-9]); probe
    1.00-1.04 s, 16-27 intervals, 31.4 ms thread CPU per interval; declined
    meter 100 ms -> ~1,500,010 µJ ModelBased, measured_uj None;
    test result: ok. 26 passed; 0 failed.
  M3 Ultra, the same test binary (sha256 55d69f84...): status=CpuRailFrozen
    oracle=CpuRailMoved(false) selected=false (20 reads; CPU rails [0, 0],
    GPU rail [17]); probe 1.03 s, 28 intervals, 33.7 ms per interval;
    test result: ok. 26 passed; 0 failed.

Dependants on the M5 Max: jcp-cli (0 tests), jcp-conformance (1 lib, 5
pack) and jcp-intercept (8) pass, and the suite prints "✓ C9 energy
provenance honesty" and "all checks passed". On the M3 Ultra the jcp-cli,
jcp-conformance lib and jcp-intercept test binaries and the suite pass too.
Under M8, C9 and the consumers still pass (jcp-cli prints "ModelBased
backend → 0 µJ probe"); only this crate's tests catch it. clippy -p
jcp-energy --no-deps, lib and --tests, is clean on aarch64-apple-darwin,
x86_64-apple-darwin and x86_64-unknown-linux-gnu.

Caveats:
- M14 is caught only when a GPU Energy channel rises within 3 s. It did in
  every run today on both machines; on an idle headless host nothing can
  tell a constant reader from a true zero.
- The two new hardware floors depend on load, the interval floor most:
  12 and 16 intervals were seen at load averages of 20-27 on the 18-core
  M5 Max, which also hosts the macOS CI runner.
- Keying by ordinal assumes IOReport lists same-named channels in the same
  order in every sample of one subscription.
- The tests were not run on Linux (no docker); only clippy and check for
  x86_64-unknown-linux-gnu.
- The workflow comment and --nocapture are left to PR #102; C9 is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
StreamMeter::start marked a stream measured as soon as one counter read
succeeded, as long as the counter was not named "stub". So any other counter
that read but did not move had its zero sealed as JouleSource::Measured. That
includes a frozen rail, a placeholder, or a counter with no resolution over
the span. The flag was also set before any checkpoint, so snapshot() reported
a measured 0 µJ for a stream that had not been metered at all. c6378629
listed this as deferred.

A second defect sat in the same function. When the start read failed, the
baseline was 0, so the first successful checkpoint reported the counter's
whole since-boot total as the stream's energy.

What holds now. The baseline is an Option. A checkpoint computes a delta only
when there is a baseline and a reading now. The account is `measured` only
when the counter advanced over the span (now > start). Without a baseline or
a reading, the account stays as it was. StreamAccount's fields and their types
are unchanged. The `measured` doc now says what the flag means.

Tests: a non-stub counter that reads a constant is not a measurement (before
and after a checkpoint). A counter that advances by 450 µJ is
JouleCost::measured(450). A failed start read followed by a reading of
9,000,000 µJ measures nothing. Falsified, each restored with git checkout:
- the old `name() != "stub"` rule fails two tests;
- `>=` in place of `>` fails three;
- a 0 baseline after a failed start fails with `left: (9000000, true)`;
- marking the stream measured at start fails with "measured before any
  checkpoint".

eoc-streaming passes: 20 unit tests plus its integration tests. The whole eoc
workspace passes: 594 passed, 0 failed, 3 ignored.

Not done: an unmetered stream still reports `0 µJ (estimated)`, because
StreamAccount has no way to say "not metered". Saying so honestly would
change a public type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The parent commit's rule is sound, but a round-2 adversarial review showed that
nothing tested the loop that fed it samples. That loop is where the original
defect lived: it compared every sample with the first one. Deleting
`prev = cur;` put that defect back, and all 38 tests still passed on the M5 Max
and on the M3 Ultra. Only a batch refresh landing inside the 1 s probe window
could have caught it. Four more mutations also survived:
- the once-per-process cache removed;
- a probe with no verdict reported as CpuRailAbsent instead of Unavailable;
- the probe sleeping between samples instead of burning CPU;
- joule-code-remediate's per-run tally reset deleted.

The same review found two more problems in this branch. First,
examples/meter_cmd.rs printed "marginal: 0.000 J (work − idle, the workload's
honest share)" on every estimated host. Work and idle there are both
wall × TDP over the same wall time, so the difference is 0 by construction.
Second, IoReportProbe::open() leaked a CFDictionary, despite a module doc that
said every Copy/Create was released.

The rule, apart from the FFI (probe/liveness.rs). The module is compiled on
every platform under test, so the rule's tests run where `ioreport` compiles
out, including the Linux runner that runs this workspace in CI. It has four
parts:

- RailWatch consumes each sample whole: every CPU rail's (channel name, raw
  integer), in channel order. It compares each sample with the previous one
  itself. The first sample is the baseline. A rail is keyed by its name and its
  ordinal among the same-named channels in that sample, and it counts at most
  one advance per interval. A channel list that changed between two samples
  still counts as no advance.
- watch(sample, tick, now, budget) is the probe loop, generic over its sampler,
  tick and clock. The real probe passes the FFI sampler, a 25 ms CPU spin and
  Instant. The FFI side now only extracts (name, raw) pairs.
- status_of maps a verdict to IoReportStatus: None to Unavailable, Absent to
  CpuRailAbsent, Frozen to CpuRailFrozen, Live to Live.
- spin is the tick.

One behaviour change, by design: a sample that cannot be read is skipped, not
fatal. Before, a failed first sample returned at once. Now a host whose
IOReport subscription opens but never yields a sample keeps sampling to the
cap of 5 budgets, then reports Unavailable, once per process.

The leak. When IOReportCreateSubscription returns a separate `subbed`
dictionary, open() now releases the `desired` one it asked for, and the
module doc is true again. I measured it with `leaks --atExit` on a
test-only loop of open()+sample+drop (the loop was not committed):
- without the fix: N=1 gave 2812 leaks, 176272 bytes; N=200 gave 562400
  leaks, 35254400 bytes, which is 200 root leaks of one ~172 KB
  NSMutableDictionary each;
- with the fix: 0 leaks at N=0, N=1 and N=200.
So the leak is one dictionary per open, not one per process as the review
reported. In practice that is one or two opens per process: one for the
probe, and one for new() when the rail is Live.

Tests. The probe loop runs on scripted samples and a scripted clock, and its
scripted sampler fails instead of hanging if the loop never stops:
- the M5 Max refresh 480,083,160 -> 541,893,638 mJ, then held, is Frozen with
  one advance;
- the M3 Ultra's two dies stepping together once is Frozen;
- two same-named `CPU Energy` channels stepping together once is Frozen;
- a rail that steps every sample is Live after exactly 2 intervals, and
  the call order is sample, tick, sample, tick, sample;
- samples that never read go on to the 5-budget cap and give no verdict,
  which is Unavailable;
- past the budget, two intervals stop the loop and one does not;
- DIE_0 stepping once and DIE_1 stepping once, in different intervals, is
  Frozen.

The pure RailWatch tests now feed whole samples, and a_verdict_maps_to_its_status
pins the mapping. The hardware tests gain four checks:
- the_liveness_probe_runs_once_per_process: a test-only counter of real probe
  runs is exactly 1 after repeated ioreport_status(), IoReportProbe::new()
  and PowerMeter::detect() calls.
- the_probe_tick_burns_cpu_on_the_calling_thread: at least a quarter of the
  tick in thread CPU time. A sleep uses ~10 µs.
- The uncached-probe test now asserts cadence. A Frozen or Absent verdict
  must rest on at least 10 intervals; a subscription to every channel gets
  2 to 8. A None verdict must have run to the cap.
- The same test checks that the probe burns. It subtracts the CPU cost of
  as many samples taken back to back from the probe's thread CPU, and the
  rest must cover a quarter of the ticks. The bound is a quarter, not all,
  because this machine ran at load average 19-24 on 18 cores from other
  sessions during these runs. In one run the probe got 49% of a core, which
  a "CPU >= half the wall time" bound would have failed.

The declined path meters 120 ms of real CPU work as one Estimator record
equal to wall × TDP, with no measured figure and a non-zero total.

meter_cmd. It now mirrors joule-meter:
- a span that is not fully measured prints its estimate with its provenance
  and no work − idle marginal;
- it says "estimated" when no record is measured, and "partly estimated"
  only when some are;
- the idle baseline runs only for a measured span;
- a measured work figure that does not exceed idle prints "not resolved"
  instead of a floored 0.
On this host it prints "estimate: 17.165 J (estimated) — wall-clock × TDP,
not a reading; no work − idle marginal".

joule-code-remediate. a_second_run_seals_only_its_own_cargo_windows runs
run_once twice on one Harness. It adds a 1 GJ stale window to the tally
between the runs, and asserts that run 2's figure, its receipt and the tally
hold only run 2's windows. The reset's comment gave only the attribution
reason; it now also says a run would seal the previous run's figure, on
every host.

joule-ui-interaction examples. Only the labels changed; the Meter trait, the
Authorizer API and each example's charge path are untouched. Each line is now
labelled from the sealed record, not from the meter:
- a 0 µJ figure for work that ran reads "no figure", never a measurement;
- the totals say "measured" only when every record is a HwShunt reading that
  moved;
- jcp_metered reports the µJ actually debited, and says the budget was never
  spent when nothing was denied;
- on Apple Silicon the header names the IOReport status.
On this host real_meter prints "3 of 3 interaction(s) have no figure (0 µJ
for work that ran): not a measurement". Before, it printed "0 µJ total".
jcp_metered prints "6 had no figure, 0 µJ for work that ran; 0 of the
15000000 µJ budget was debited; 0 denied by the real JCP runtime, as the
budget was never spent". Before, it printed "6 allowed (real joules, debited
from a real grant)".

Doc corrections:
- the all-channel sample cost is 0.13–0.8 s on the M5 Max, not 0.4–0.8 s;
- a group sample costs ~6–12 ms;
- IoReportStatus::Unavailable now says "fewer than two samples could be read
  before the cap";
- the "Cached: the probe runs once per process" comment asserted only
  equality; it now points at the counting test.

Claims in earlier commits of this branch that were false:
- 3d9eb150: "Not separately tested: the per-run reset of the tally. It is
  observable only with attributed windows, which need a live CPU rail."
  Wrong. soc_uj carries over on any host: with the reset deleted, run 2
  sealed 1,000,000,045,883,686 µJ. It is tested now.
- 3d9eb150: jcp_metered "wraps whatever figure it is charged in
  Energy::measured, but only in a JCP runtime decision that it neither seals
  nor prints." It did print "(real joules, debited from a real grant)" while
  debiting 0 µJ. The label is corrected; the Energy::measured wrap is
  unchanged (see below).
- 3b6a7c67 and 67f821e6, module doc: "Every Copy/Create is released." It was
  not, until this commit.

Runs. The same lib test binary ran on both machines:
- M5 Max, 3 of 3: "test result: ok. 48 passed; 0 failed". The probe took
  1.004, 1.030 and 1.012 s over 30, 30 and 20 intervals, and burned 724,
  615 and 333 ms against bounds of 188, 188 and 125 ms. It printed "ioreport
  liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false
  available=false".
- M3 Ultra (macOS 27.0.1), 1 of 1: 48 passed. The probe took 1.010 s over 32
  intervals, with rails [("DIE_0_CPU Energy", 0, 0), ("DIE_1_CPU Energy", 0,
  0)] and 793 ms burned. The binary was deleted afterwards.

Consumers on the M5 Max, all exit 0:
- jcp-gateway 12, with "gateway call: hardware=false sealed=Estimator";
- jcp-action-receipt 17, plus conformance 3;
- joule-ui-interaction 6;
- joule-ui-serve 4, plus 2 in its bin;
- joule-code-remediate 11 (on the M3 Ultra, 10 with the compile-time-path
  template test skipped as before);
- every other sandbox-rs crate (cargo test --workspace), with the
  conformance suite reporting "All classes passed with no skips."
clippy --no-deps --all-targets is clean on sandbox-meter, joule-code-remediate
and joule-ui-interaction, and on sandbox-meter for x86_64-unknown-linux-gnu. For
x86_64-apple-darwin the only warning is the pre-existing unused_mut in
detect(). Neither Mac has Rosetta, so the x86_64 build compiles but was not
run.

Falsified. Each mutation was applied to this exact tree and the lib tests
run on the M5 Max; each was then restored with git checkout, leaving git
status clean:
- M1, RailWatch keeps the first sample as `prev` (the original defect): 7
  failed, e.g. "left: Some(Live) right: Some(Frozen)" for the M5 Max refresh.
- M1-loop, the loop re-feeds the baseline before each sample: 4 failed.
- M5, no OnceLock: "the real probe ran more than once in this process
  left: 23 right: 1".
- M5b, new() probes afresh: "left: 10 right: 1".
- M8, None mapped to CpuRailAbsent: 2 failed ("left: CpuRailAbsent right:
  Unavailable").
- M9a, spin sleeps: "a 25ms tick used 10.458µs"; the probe check failed too.
- M9b, the probe's tick sleeps: "24.094709ms burned" failed the bound; on the
  M3 Ultra "the probe used 163.3475ms of CPU over 25 intervals, 164.845625ms
  of it sampling".
- A failed sample breaks the loop: 2 failed ("gave up at 43ms, before the
  cap").
- No tick: 4 failed.
- An all-channel subscription: "a CpuRailFrozen verdict on 2 intervals in
  1.157435166s", and the group-only test failed too.
- C3, joule-code-remediate reset deleted: "run 2 sealed an earlier run's
  windows: 1000000045883686 µJ".
The mutations caught in round 2 are still caught:
- threshold 1: 11 failed;
- name-only key: 2 failed;
- no die strip: 1 failed on the M5 Max, and 2 on the M3 Ultra ("status=
  CpuRailAbsent");
- deciding on the GPU rail: 5 failed;
- stopping at the budget with fewer than 2 intervals: 3 failed;
- the declined meter's sample_uj returning Some(0): 1 failed;
- summing advances across rails: 5 failed;
- no cap: 2 failed;
- gating new() only on Unavailable: 4 failed;
- a verdict from zero intervals: 2 failed.

Left for David. None of these is changed here:
- jcp-gateway's `_meta` key measured_uj carries an estimate on every declined
  host, and only the sealed receipt says Estimator. That is a wire decision.
- The joule-ui Meter trait still defaults to HwShunt, its provenance is per
  meter, not per sample, and a None sample becomes Estimator(0).
- Authorizer::charge carries no provenance, so jcp_metered still wraps its
  figure in Energy::measured.
- CI: this workspace runs only on the Linux runner. The liveness rule's
  tests now run there; the IOReport hardware tests and the "ioreport
  liveness:" line need a macOS job, which the workflow owner decides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The round-3 review of 3b827e2a found the hardware liveness tests flaky on
the M5 Max that also runs the macOS CI. With three test processes at once
and a load average of 97-172, 8 of 12 runs failed on "the probe's thread
was on CPU for only 33% ... 50%", and the oracle once completed 2 reads in
its fixed 3 s window. Both assertions measured wall time, which host load
stretches. A sleeping probe (26-27% on CPU) sat close to a loaded burning
one (33-57%).

The probe now burns thread CPU, not wall time. Each tick spins until this
thread's CPU clock (clock_gettime(CLOCK_THREAD_CPUTIME_ID)) has advanced
25 ms. A descheduled thread's CPU clock stands still, so load stretches a
tick in wall time but never shortens its CPU. A wall cap of 500 ms bounds a
tick on a host that gives the thread under 5% of a core. The spin is a pure
function, spin_for_cpu, tested with scripted clocks, and the real tick is
still injected into the generic loop.

The probe records each sample call: its wall duration, and the thread's
CPU clock before and after. The tests read that record instead of timing
the probe from outside, and no hardware test asserts a wall-clock ratio.

- the_probe_burns_its_tick_of_thread_cpu_between_samples: the CPU consumed
  between one sample call and the next must lie between 12.5 and 50 ms,
  against a literal 25 ms in the test. Only the 500 ms wall cap could make a
  tick fall short, and the message says so. The upper bound catches a slower
  cadence: round 3's surviving PROBE_TICK = 100 ms now fails. The recorded
  tick CPU is also checked against the test's own reading of the thread
  clock over the whole probe.
- the_probe_samples_only_the_energy_model_group: the median wall duration
  of the probe's own sample calls must be under 50 ms. The Energy Model
  group takes ~6-12 ms and every channel 130-800 ms. This replaces the
  ">= 10 intervals" and ">= 50% on CPU" assertions.
- the_probe_sampler_reads_each_cpu_rail_as_the_oracle_does: for three
  samples, the oracle reads each CPU rail just before and just after the
  sample the probe's sampler takes. A rail that held must read EQUAL, and
  a rail that moved must read between the two oracle reads. The sampler
  must also list exactly the oracle's CPU rails. This catches a sampler
  that returns a constant 0 or another rail's value.

The oracle now reads until its window has passed AND it has 3 reads, the
fewest in which a rail can change twice, capped at five windows. Fewer
than 3 reads is a test failure, never a Frozen verdict. A failed first
read no longer reports "no IOReport".

Round-3 survivors:
- F21: the counter's own read path now has a test on the hosts we have.
  the_counter_advances_when_a_rail_it_sums_advanced builds the counter past
  the gate on purpose. It takes a baseline, then reads GPU Energy (which
  the counter sums) independently in absolute values, with the oracle's own
  unit table, until it has risen by 1 µJ or 3 s pass, then reads the
  counter again. If the GPU rose, the counter must have advanced. If not,
  the counter is printed, not asserted, and the line says so.
- probe_status mapped "no Energy Model subscription" inline, and a
  mutation to Live survived. It now goes through the pure
  status_from_probe(). Following the family rule, a group that cannot be
  subscribed is Unavailable, never CpuRailAbsent: CpuRailAbsent now means
  the group was sampled and held no CPU rail. The IoReportStatus docs and
  the `eoc meter` line for Unavailable say so. No variant changed.

The pure rules each have their own test now: a decrease is not an advance;
a rail absent from the previous sample only sets its baseline; no
subscription or no sample is Unavailable; no verdict is Unavailable. The
once-per-process pin releases six threads together, and each asks for the
status, try_detect() and detect(). The process-wide PROBE_RUNS must read
exactly 1. The test that prints "ioreport liveness: status=" keeps its name
and line.

On this M5 Max, unloaded, 28 passed: per-tick thread CPU 25.01-25.03 ms,
median sample 11.8 ms, CPU Energy read identically by the sampler and the
oracle, GPU Energy +10.6 mJ in 257 ms with the counter at 0 -> 53,238 µJ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
counts_once, the filter on what the counter sums, admitted CPU rails whose
names do not say "CPU" beside the `CPU Energy` aggregate they add up to.
Round 3 carried this as latent. It is now confirmed from one absolute
sample of the Energy Model group on each chip:

- M5 Max: `CPU Energy` = `MCPU0` + `MCPU1` + `PCPU` (1,863,990,848 vs
  1,863,990,847 mJ). `PCPU` = `PACC_0..5` + `PCPU0..5_SRAM` + `PCPM` +
  `PCPM_SRAM`, and `MCPUn` = its cores + their SRAM + `MCPMn` + `MCPMn_SRAM`.
  counts_once admitted `PACC_*`, `MCPM*`, `PCPM` and their `_SRAM` rails.
- M3 Ultra: `DIE_n_CPU Energy` = its `EACC_CPU` + `PACC0_CPU` + `PACC1_CPU`,
  each the sum of its cores, their SRAM and its `_CPM` + `_CPM_SRAM` rails
  (all to within 1-8 mJ). counts_once admitted `DIE_n_EACC_CPM`,
  `DIE_n_PACC*_CPM` and their `_SRAM` rails.

On a host whose CPU rail is live, the counter would have summed that CPU
energy twice and sealed the result as measured. Neither chip is live on
macOS 27.0.1, so no reading so far was affected.

counts_once now also drops per-cluster accumulators (`PACC*`, `EACC*`,
`MACC*`) and CPU power managers (`*CPM*`). The aggregates, and the
domains that are no aggregate's summand (DRAM, AMCC, DCS, display, ISP,
AVE, MSR, FAB, PCIe), are still summed. counts_once_drops_subrails now
pins this on the real channel names of both chips.

Not changed: neither chip has an `ANE Energy` aggregate, and counts_once
drops `ANE0` (and `ANE0_0`/`ANE0_1`) as summands, so ANE energy is never
summed. Counting it needs a rule that looks at the whole sample, and that
is left for later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jouleclaw-energy: the liveness probe burns its own CPU time, and its tests hold on a loaded host
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m27s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 57s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m9s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m10s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m12s
standards workspaces build + test / aac-rs (pull_request) Successful in 40s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 42s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 37s
standards workspaces build + test / map-rs (pull_request) Successful in 31s
standards workspaces build + test / mesh-rs (pull_request) Successful in 26s
standards workspaces build + test / openpay (pull_request) Successful in 2m41s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
7d7e766b4a
The hardware tests for the CPU-rail liveness probe run in CI on the macOS runner,
which is this M5 Max, and it is often heavily loaded: load averages of 24-104 have
been seen. Round 3 left tests that only held on a quiet host, a cache that was
pinned per thread rather than per process, and rail values nobody checked. This
commit fixes those.

The probe's tick
- The tick now burns 25 ms of the probing thread's own CPU time, measured on
  CLOCK_THREAD_CPUTIME_ID. It gives up after 500 ms of wall time.
- The old tick spun on the wall clock. On a loaded host the scheduler gave it
  only part of each 25 ms. With 36 extra spinners on this Mac, that old tick
  left intervals with only 8.6-11.8 ms of thread CPU.
- The tick is still passed into watch(), so the scripted tests keep their
  scripted ticks.

What the hardware tests assert now
- The probe burns CPU. In every interval it counts, the probing thread must use
  at least half a tick (12.5 ms) of CPU time. Only the 500 ms wall cap could
  break that, and the assertion message says so. The probe records its thread
  CPU at each sample for this check.
- The probe subscribes to the Energy Model group only. The median wall time of
  the probe's own sample calls must be under 50 ms. A group sample takes about
  6-12 ms and an all-channel sample 130-800 ms. The probe records each call's
  duration, and the test prints the median.
- The interval-count assertions are gone: ">= 10 intervals" and ">= 2 intervals
  plus the full budget". What stays is "a frozen or absent verdict took at
  least the 1 s budget". It is a lower bound, and load can only make the probe
  slower. No hardware test asserts a ratio of CPU time to wall time.
- New test: the_probe_sampler_reads_each_cpu_rail_at_its_own_value. It checks
  the values the sampler reads, against an oracle on the oracle's own
  all-channel subscription.
  - The oracle reads every Energy Model CPU rail by name, once before and once
    after one probe sample.
  - If the oracle saw a rail hold, the probe's value must equal it. If the rail
    moved, the probe's value must lie between the two reads.
  - Before this, a sampler that returned constant 0, or gave each CPU rail the
    sum of all CPU rails, passed every test.

The oracle
- It keeps reading past its 3 s window, up to 4 windows, until it has at least
  2 reads after its baseline.
- With fewer reads it fails the test, with the read count and the reason. It
  never returns CpuRailMoved(false) on 0 or 1 reads.
- The "ioreport liveness:" line now ends with oracle_reads=N. The test that
  prints it keeps its name.
- Under load the oracle needed the extension: one run got 2 reads in 3.76 s.

The pure rule: each case has its own test
- A rail that goes down between two samples (a reset or wrap) has not advanced.
- A rail missing from the previous sample only sets its baseline.
- No verdict maps to Unavailable.
- A probe that cannot subscribe to the Energy Model group, or whose subscription
  never returns a sample, maps to Unavailable through status_of_run(). It is
  never Live and never CpuRailAbsent.

That last rule changes behaviour. A host where the group cannot be subscribed
but the all-channel subscription opens used to report CpuRailAbsent. It now
reports Unavailable, because the probe saw nothing. IoReportStatus's
documentation says this. The enum and its variants are unchanged.

The once-per-process cache
- A process-wide AtomicUsize, HOST_PROBE_RUNS (built only for tests), counts the
  runs of the real host probe. The thread-local counter is removed.
- the_liveness_status_is_probed_once_and_cached now starts 6 threads through a
  barrier. Each calls ioreport_status() twice, builds two counters and calls
  detect_meter. The test asserts the host probe ran exactly once in the process.
- This corrects 073fb7d8, which said the cache was pinned "once per process".
  Its thread-local counter only pinned "once on this thread": a per-thread
  cache passed all 50 tests.

Evidence
- M5 Max (macOS 27.0.1), clean test binary, 10 runs under 36 background
  spinners (2 x hw.ncpu). Load average went from 34.8 to 142.8 during the runs.
  The spinners were killed afterwards, and none was left.
  - Result: 10/10, "test result: ok. 55 passed".
  - Every run: "ioreport liveness: status=CpuRailFrozen
    oracle=CpuRailMoved(false) selected=false available=false oracle_reads=3..7".
  - Group probe: 5-10 intervals, median sample cost 7.16-10.24 ms.
  - Least thread CPU in any interval: 31.72-32.39 ms.
  - Oracle: 2-8 reads after the baseline.
- M3 Ultra (27.0.1), the same binary: 55 passed.
  - Median sample cost 6.75 ms over 33 calls.
  - Rails DIE_0_CPU Energy and DIE_1_CPU Energy.
  - Sampler values equal to the oracle's: 28237793 and 30845042.
  - Oracle: 16 reads.

Mutations. Each was a temporary edit, restored with git checkout, and git
status was clean afterwards.
- A decrease counts as an advance: 2 tests fail, "a fall counted as an advance".
- A rail new in a sample counts as an advance: 1 fails, "a new rail counted as
  an advance".
- No group subscription maps to Live: 1 fails.
- Per-thread cache: 1 fails, "the host liveness probe ran 13 times in this
  process". With no cache at all, 41 times.
- The sampler returns constant 0: 1 fails, "held at 1863990848 ... but the
  probe's sampler read 0".
- Each CPU rail gets the sum of all CPU rails:
  - On the M3 Ultra it fails, "("DIE_0_CPU Energy", 0) held at 28237793 ... but
    the probe's sampler read 59082835".
  - On the M5 Max it is an equivalent mutant, because that group has one CPU
    rail. Summing every channel named *CPU* into it does fail there: "read
    5567863491".
- The tick sleeps: fails, "interval 0 of 29 burned only 6.086583ms". A no-op
  tick fails the same way: "interval 0 of 114 ... 8.550958ms".
- The probe on the all-channel subscription: fails, "median sample call took
  538.447416ms".
- The oracle concludes after 1 read: 2 tests fail with the read-count message.
- The mutations whose old assertions were removed are still caught:
  - watch() stopping after 1 interval fails 4 tests.
  - A 10x-fast probe clock fails, "decided CpuRailFrozen after 211.462541ms,
    before its 1s budget".
  - PROBE_TICK = 250 ms fails the scripted test (a).
- The round-3 wall-clock tick passes on a quiet host. Under the same load it
  fails 3/3 on the new CPU pin.

Other checks
- No features: 24 passed.
- Linux x86_64 and aarch64, with and without ioreport: cargo check passes.
- Clippy: no new warnings.
- Consumers:
  - jouledesk-github: 20/20.
  - jouledesk: 18 + 4.
  - loader-gguf with rayon and fabric-meter: 302 passed. Runs with only
    fabric-meter hit a tier_store temp-file race (NotFound) twice in 12 runs,
    in a file this branch does not touch; the 10 other runs passed.
  - harness-econ: 14.
  - mesh-meter with hw-meter-apple: 10. mesh-core: 3.
  - l3-jouleclaw: 2 + 4.

Not done here
- The counter's own read() still samples every channel. It is declined on both
  Macs today.
- No CI job runs these tests: .github/workflows is off-limits to this branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
proof-cascade: the probe burns thread CPU time, and tests pin how it samples
All checks were successful
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m18s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 3m16s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / aac-rs (pull_request) Successful in 2m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m7s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m45s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 51s
standards workspaces build + test / map-rs (pull_request) Successful in 43s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m34s
standards workspaces build + test / mesh-rs (pull_request) Successful in 44s
standards workspaces build + test / arl-rs (pull_request) Successful in 12m29s
standards workspaces build + test / openpay (pull_request) Successful in 5m46s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 12m23s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 5m40s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 5m8s
standards workspaces build + test / sense-rs (pull_request) Successful in 3m37s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 3m16s
79375a1c48
The re-verification of bedef9b0 left four mutations alive. All four are in
how the probe samples, not in what it decides:
- an advance was any change (`now != before`), so a decrease counted;
- a rail missing from the previous sample counted as an advance;
- spin_tick slept instead of burning;
- the probe passed a no-op tick.
It also found two docs that claimed more coverage than the tests had.
two_advances_of_the_same_rail_are_live claimed to cover decreases and new
rails. MIN_PROBE_INTERVALS claimed to pin the ~25 ms tick. This commit makes
each of those fail a test, and applies the family's round-4 test design
(R1-R8).

R1, the tick. A tick now burns 25 ms of the probing thread's own CPU time,
read with clock_gettime(CLOCK_THREAD_CPUTIME_ID), with a cap of 500 ms of wall
time. Before, it spun for 25 ms of wall time, which gives the thread only a
fraction of that in CPU on a loaded host. The tick is still passed into the
generic loop, so the scripted tests keep their scripted ticks.

The probe's record. probe() now returns a ProbeRun: the verdict, the interval
count, whether the Energy Model subscription opened, and for every FFI sample
call, its wall time and the thread CPU clock just before and after the call.
The once-per-process cache keeps this record, so a test can check the run that
actually made the decision. Two pins read it; neither is a wall-clock ratio:
- R2, the probe burns CPU. In every interval, the thread CPU time between
  sample calls is at least PROBE_TICK/2. Host load cannot shorten a CPU-time
  burn. Only the 500 ms cap can, and the assertion message says so. A
  separate test checks that one tick burns at least PROBE_TICK.
- R3, the probe samples the group alone. The median wall time of the probe's
  own sample calls is under 50 ms (the group takes ~6-12 ms, every channel
  130-800 ms). This replaces the floor of at least 10 intervals. Under load
  the probe saw as few as 4 intervals (load average 92), which the old floor
  would have failed.
Both hardware tests check R2 and R3, one on the cached probe and one on a
fresh probe, and print the median.

R4, the FFI value pin. A new test places one probe sample between two
independent all-channel oracle reads, three times over. It matches each CPU
rail by group, name and ordinal. Where the oracle saw the rail hold, the
probe's value must be equal to it. Where the rail moved, the value must lie
between the two reads. On both hosts every rail held and matched exactly: M5
Max `CPU Energy` 1,863,990,848; M3 Ultra `DIE_0_` 28,237,793 and `DIE_1_`
30,845,042.

R5, oracle evidence. The oracle's window is extended, up to 3x, only while it
has fewer than 2 reads. If it still has fewer than 2 at the end, the test
fails. It never reports CpuRailMoved(false) from 0 or 1 reads.

R6, the pure rules, one test each:
- a decrease between consecutive samples is not an advance;
- a rail that is new in a sample only sets its baseline (a new name, or a
  second channel with the same name);
- an Energy Model subscription that cannot be opened, or never yields a
  sample, gives no verdict, which maps to Unavailable, never to Live or
  Absent. This goes through the new pure `liveness::probe_with`;
- no verdict is Unavailable (`no_verdict_is_unavailable`).
One behaviour changes. A host where IOReport opens but the Energy Model group
cannot be subscribed used to report CpuRailAbsent; it now reports Unavailable.
Both decline. The enum variants are unchanged, and the docs and proof-cli's
reason strings now say which status means what. The sampler's FFI code now
only reads each channel's (group, name, integer). Which channels are kept is
decided by the pure `energy_model_sample_from`, and a test checks that each
channel keeps its own value.

R7, the cache. STATUS_PROBE_RUNS was already a process-wide static
AtomicUsize. The cache test now releases 8 threads together through a Barrier.
Each asks for the status and selects the meter: hardware_meter_available and
available, and on a declined host also measure_marginal_uj and power_over.
The test asserts that the probe ran exactly once.

R8. the_ioreport_meter_is_never_available_on_a_frozen_cpu_rail still prints
"ioreport liveness: status=...". The line now also carries median_sample,
min_interval_cpu and oracle_reads.

Flake removals. The M5 Max is also the macOS CI runner and is often heavily
loaded, so three timing assertions are gone:
- the warm-cache `< 25 ms` check (the probe counter is the pin);
- the declined path's `took < 80 ms` check (the work-call count is the pin,
  and the time is still printed);
- the fixed 30 ms sleep in a_busy_window_with_no_reading_stops_its_workers
  (the test now waits until the workers have run).

Falsified. Each mutation was applied, run, and restored with git checkout,
with git status clean afterwards. Each one failed a test:
- a decrease counts (`now != before`): "a decrease counted as an advance";
- a new rail counts: "a rail's first appearance counted as an advance";
- no group subscription maps to Live, or to Absent: "left: (Some(Live), 0)" /
  "(Some(Absent), 0)";
- status_from(None) is Live: 4 tests fail, including no_verdict_is_unavailable;
- a per-thread cache: "probe runs in this process: 14"; skipping the
  OnceLock: 54;
- the sampler returns constant 0: "held at 1863990848 across the oracle's
  reads, yet the probe's sampler read 0";
- the sampler gives each CPU rail the sum of all CPU rails, applied in the
  pure assembly: caught on the M5 Max. Applied at the FFI boundary: an
  equivalent mutant on the M5 Max, which has one CPU rail (44/44 pass). It is
  caught on the M3 Ultra: "held at 28237793 ... read 59082835";
- the sampler reads the GPU rail: 4 tests fail;
- the tick sleeps: "one tick burned 10.75µs"; "interval 0 of 25 consumed
  27.25µs";
- the tick is a no-op: "interval 0 of 114 consumed 15.792µs";
- the probe uses the all-channel subscription: median sample calls of 528 ms
  and 293 ms on the M5 Max, and 174 ms and 163 ms on the M3 Ultra;
- the oracle is starved: "the oracle got only 0 read(s)".
The round-3 wall-clock spin, as an extra check, is caught only by the
one-tick test (24.70 ms < 25 ms), and narrowly.

Runs on this tree:
- M5 Max, lib tests 10x under 36 extra busy-loop processes (2 x 18 CPUs),
  load average 50-168: 10/10, 44 passed each. Median sample call 7.5-11.4 ms,
  minimum interval CPU 25.019 ms, 6-8 intervals. Every run printed
  "ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
  selected=false".
- M3 Ultra, once: 44 passed, median 8.7 ms, 29 intervals.
- The proof-rs workspace is green, and the proof-conformance binary prints
  "all checks passed". joulecontract-proof passes 6/6 and joule-code-release
  6/6.
- joule-code-remediate fails verified_fix_end_to_end_ships_with_receipt. It
  fails the same way with bedef9b0's meter.rs, so the failure predates this
  change; that crate meters through sandbox-meter, not this crate.
- clippy reports only the two pre-existing warnings. The x86_64 Linux build
  is compile-checked only (no Linux host).

Not changed, for David: which provenance grade a live IOReport reading earns
(the module header still says HwShunt), and adding this crate to a macOS CI job.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: the probe burns thread CPU, and its sampler answers to the oracle
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m2s
standards workspaces build + test / aac-rs (pull_request) Successful in 55s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 43s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 38s
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 2m4s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m54s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m20s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m5s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m41s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m6s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m31s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m47s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m36s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m26s
7105154289
Round 3 left three mutations alive and one assertion whose margin shrank
with load. This Mac is also the macOS CI runner, often at load averages of
24-104, so every pin on how the probe paces itself had to stop depending on
wall time. This commit closes all four.

What changes in the probe:

- A tick now burns thread CPU time. It spins until the thread's own clock,
  clock_gettime(CLOCK_THREAD_CPUTIME_ID), has advanced 25 ms, with a 500 ms
  wall-clock cap. Before, a tick spun 25 ms of wall time, and on a loaded host
  that is a few milliseconds of CPU. Under 36 extra spinners, the old tick got
  a median of 3.2 ms of CPU in 48 ms.
- The probe records the wall time of each of its own FFI sample calls, and
  the CPU and wall time of each tick. Tests read how it sampled and burned
  from that record, not by timing it from outside.
- With no Energy Model subscription, the status is now Unavailable, through
  the same pure status_of as a probe that saw no interval. Before, a failed
  group open beside an openable counter said CpuRailAbsent, which claims a
  group was read and had no CPU rail. probe_with() is generic over the opener,
  so that path now runs in a test.

Tests:

- Group-only subscription: the MEDIAN wall time of the probe's own sample
  calls is under 50 ms. Group samples measured 5.7-12.8 ms; all-channel ones
  142-714 ms. This replaces the >= 10-interval floor. No hardware test asserts
  a wall-clock ratio.
- Burning: thread CPU per interval is at least 12.5 ms by the tests' own
  clock (clock_gettime_nsec_np); only the 500 ms wall cap could cut it short,
  and the message says so. From the probe's record, every tick burned 25 ms of
  thread CPU or hit the cap, and a tick came before every re-sample.
- New: the_probe_sampler_reads_each_cpu_rail_as_the_oracle_does. The oracle
  reads each Energy Model CPU and GPU rail absolutely, on its own all-channel
  subscription, before and after each of two probe samples. A rail it saw hold
  must read EQUAL in the probe's sample; a rail that moved must read between
  the two. This catches a sampler that returns constant 0 (round 3's X3), sums
  rails, or reads the wrong channel.
- Pure-rule pins, one test each: a decrease is not an advance (X5); a rail
  missing from the previous sample only sets its baseline; no subscription,
  or no sample, is Unavailable (X4); no verdict is Unavailable.
- Oracle evidence: fewer than two reads after the first is now a failure
  (Oracle::TooFewReads), never CpuRailMoved(false). The oracle extends its 3 s
  window up to 5x to get the two reads.
- The cache pin is process-wide. Eight threads, released together by a
  barrier, ask for the status and build the meter. The static PROBE_RUNS
  counter must read exactly 1.
- The IOReport hardware tests now share one lock. Side by side, one test's
  all-channel reads slowed another's group samples: max sample calls reached
  132-279 ms, and 12 probes at once pushed the median to 112 ms. Serialised,
  the median is 6-13 ms. The suite takes ~12-16 s instead of ~5 s.
- `the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail` still prints
  "ioreport liveness: status=". It now shows how long the oracle actually read.

Evidence, on this exact tree:

- M5 Max, 10 runs of the lib tests with 36 extra spinners (2 x hw.ncpu), at
  1-minute load averages of 61-180: 31/31 in all 10. Median sample cost was
  7.0-12.8 ms, thread CPU per interval 32.3-33.6 ms, CPU per tick 25.00 ms, and
  the oracle took 5-17 reads. Every run printed status=CpuRailFrozen
  oracle=CpuRailMoved(false) selected=false.
- M3 Ultra, same binary: 31/31. DIE_0 and DIE_1 read equal to the oracle
  (28237793 and 30845042), and the median sample cost was 5.9 ms.
- Each mutation below was restored with git checkout, and each one fails a
  test: X3 constant-0 sampler, X4 no subscription -> Live, X5 decrease counts,
  a new rail counts, per-thread cache, sum of CPU-named rails, GPU value given
  as CPU, tick sleeps, tick is a no-op (both the burn call and the closure),
  tick burns wall time, probe on the all-channel subscription, no OnceLock,
  compare with the first sample, TimePower{0} when declined, None -> Live, and
  a coarse threshold. Summing the CPU Energy rails is the identity on the M5
  Max's single rail, so it cannot fail there. On the M3 Ultra it fails: "held
  at 28237793 ... but the probe's sampler read 59082835".
- jcp-cli, jcp-conformance (lib and pack) and jcp-intercept pass. So does the
  conformance suite, including "C9 energy provenance honesty", on both
  machines. clippy --no-deps is clean for aarch64-apple-darwin,
  x86_64-apple-darwin and x86_64-unknown-linux-gnu, with and without --tests.

Not done here, deliberately:

- C9 still meters `|| ()`, so a declined meter giving 0 passes it. That is a
  conformance-suite change.
- The standards-build.yml comment is stale, and its job lacks --nocapture.
  PR #102 owns that workflow.
- read_uj still samples every channel. counts_once admits CPU sub-rails that
  do not contain "cpu" (e.g. PACC_*) beside CPU Energy, a double count once a
  rail is live. The receipt carries no IoReportStatus (a wire decision).
  CPU_RAIL_LIVENESS_BUDGET is public on macOS only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The liveness probe's hardware tests run in CI on the macOS runner, which is
the M5 Max this was written on, at load averages of 24-104. The parent
commit's tick spun for 25 ms of WALL time, so on a loaded machine each tick
burned only the CPU the scheduler gave the thread. Its tests pinned the burn
and the group-only subscription with load-dependent bounds: burned CPU at
least a quarter of the ticks, and a Frozen verdict on at least 10 intervals.
With the tick below, the probe completes 11-13 intervals in its 1 s budget
under 2 x ncpu busy-loop spinners (29 on an idle machine), so that floor
would have sat one to three intervals from failing.

The tick (probe/liveness.rs):

- spin(cpu, wall) burns until the thread's CPU clock has advanced PROBE_TICK
  (25 ms), with a 500 ms wall cap per tick. The real probe passes
  CLOCK_THREAD_CPUTIME_ID (thread_cpu in ioreport.rs) and a monotonic
  clock. A loaded machine makes a tick longer, never lighter.
- Both clocks are injected, so the rule is tested with scripted ones: a full
  tick on a third of a core, and a thread that gets no CPU ending at the
  cap. The loop still takes an injected tick, and the scripted-loop tests
  are unchanged.

What the probe records as it runs (ProbeTrace, returned beside the
RailWatch):

- the wall time of every FFI sample call;
- per interval, the thread CPU its ticks burned, measured around the ticks,
  apart from the samples.

The hardware test the_liveness_probe_never_reports_a_frozen_rail_as_live
now asserts:

- every interval burned at least PROBE_TICK/2 (only the wall cap can cut a
  tick short, and the message says so);
- the median sample call took under 50 ms (a group sample is ~6-12 ms,
  one over every channel 0.13-0.8 s), and it prints that median.

The ">= 10 intervals" floor, the "burned >= a quarter of the ticks" bound
and the ">= 2 intervals for Frozen" check are gone. The cap check stays,
with generous slack (5 budgets + 5.5 s).

The sampler's values, against the oracle. The new test
the_probe_sampler_reads_each_cpu_rails_own_value brackets each of three
probe samples between two oracle reads on the oracle's own all-channel
subscription. It matches each rail by name and ordinal in the Energy Model
group:

- where the oracle saw the rail hold, the probe's value must equal it;
- where the rail moved, the value must lie between the two reads.

This catches a sampler that returns 0, a sum over rails, or another rail's
value. the_probe_samples_the_energy_model_group_alone also checks every
sampled name with is_cpu_rail, so the FFI filter is pinned on any host
(round-3 nit).

The oracle reads for its window, and on past it until it has two reads,
never past four windows. Fewer than two reads is a test failure with a
message, never CpuRailMoved(false). Its load thread runs until the oracle
stops reading.

Pure pins, each its own test:

- a decrease between consecutive samples is not an advance;
- a rail new in a sample only sets its baseline;
- probe_host is the open-watch-map path the real probe now goes through. A
  subscription that cannot open, or never yields a sample, goes through
  status_of to Unavailable, and is never watched;
- status_of(None) is Unavailable.

The once-per-process cache is pinned process-wide. Six threads, released by
one barrier, ask for the status and build the backend at once, and the
static PROBE_RUNS counter must read 1.

The test that prints "ioreport liveness: status=" keeps its name, and no
test was renamed.

Evidence, from one lib test binary of this tree (55 tests):

- M5 Max (macOS 27.0.1), 10 runs under 36 busy-loop spinners (2 x
  hw.ncpu), load averages 124-149 (other sessions included). 10/10
  passed. Every run printed:
  'ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
  selected=false available=false'
  - median sample cost: 6.93-9.01 ms;
  - intervals per probe: 11-13;
  - least burn in one interval: 25.0009-25.0015 ms;
  - oracle reads: 7-12.
  Afterwards all 36 spinner PIDs were confirmed gone.
- M3 Ultra (macOS 27.0.1), 1 run: 55 passed; median 8.28 ms over 30
  calls; 29 intervals; least burn 25.0003 ms; 17 oracle reads; both dies
  equal to the held oracle values.

Mutations, each restored with git checkout (git status clean after each).
Every one fails a test on the M5 Max except the one marked:

- a decrease counts as an advance: 2 failed;
- a rail new in a sample counts as an advance: 2 failed;
- no subscription maps to Live: 1 failed;
- per-thread cache: 1 failed ('left: 16 right: 1');
- sampler returns 0: 1 failed ('held at 1863990848 ...; the probe read 0');
- sampler reports the GPU rail as CPU Energy: 5 failed;
- FFI filter also keeps the GPU rail: 6 failed;
- spin sleeps: 4 failed ('a 25ms tick used 8.875µs');
- the real tick sleeps: 2 failed ('interval 0 of 26: the probe burned
  8.541µs');
- spin is a no-op: 4 failed;
- the real tick is a no-op: 2 failed;
- the tick counts wall time: 3 failed;
- all-channel subscription: 2 failed ('median sample call took
  481.672458ms');
- None maps to CpuRailAbsent: 4 failed;
- oracle cut to 1 ms: 2 failed ('the oracle got 1 read(s)');
- PROBE_TICK = 100 ms: 1 failed;
- sampler gives each CPU rail the sum of all CPU rails: EQUIVALENT on the
  M5 Max, which has one CPU rail (55 passed). On the M3 Ultra it fails:
  'DIE_0_CPU Energy #0 held at 28237793 ...; the probe read 59082835'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
joule-ui-interaction examples: each interaction is sealed with its own span's figure, never 0 J for work that ran
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Failing after 53s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m0s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / aac-rs (pull_request) Successful in 49s
standards workspaces build + test / joule-code-rs (pull_request) Failing after 46s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m21s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 1m18s
standards workspaces build + test / arl-rs (pull_request) Successful in 5m8s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m12s
standards workspaces build + test / map-rs (pull_request) Successful in 35s
standards workspaces build + test / mesh-rs (pull_request) Successful in 32s
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
20ad7bc386
The round-3 review found that real_meter sealed "0 µJ [Estimator]" for each
~150 ms interaction that ran. It sampled PowerMeter::sample_uj, a sum of
raw counters that is None wherever no hardware probe exists (every macOS
27.0.1 host checked), and run_interaction turns None into
Energy::estimator(0). The printed labels were already honest; the sealed
records were not. jcp_metered had the same defect, and for the same reason
it debited nothing, so its 15 J grant never bound.

No API change was needed. joule-ui-serve already seals served interactions
through a `Meter` over PowerMeter spans (its SpanMeter): each sample closes
the span the previous one opened, so two consecutive samples differ by
exactly one span's figure, and `provenance()` returns that span's own. The
same adapter now lives in examples/support/span_meter.rs, which both
examples include with #[path]. Each interaction is sealed as:

- `HwShunt` only when its span was a hardware reading whose CPU counter
  advanced;
- otherwise the meter's `Estimator` figure, wall-clock × TDP for its span.

jcp_metered also charges with the right provenance. Authorizer::charge
carries only a number, so the example's authorizer reads the provenance
from the span meter it shares with run_interaction: the span just closed,
whose figure it is being charged. It hands the JCP runtime
Energy::estimator (or modeled, or measured) accordingly. It no longer
wraps every figure in Energy::measured. The summary now reports the sum of
the sealed figures and, separately, what the grant was actually debited:
the runtime never debits past the budget.

Output on this M5 Max (CpuRailFrozen):

- real_meter:
  - 'render #1: allow · 3003067 µJ [Estimator, an estimate]' (and #2, #3);
  - 'sealed chain: 3 links verified, 9008244 µJ total — estimated
    (wall-clock × TDP over each interaction's span), not measured'.
- jcp_metered:
  - 'apply #1: allow · 3002215 µJ [Estimator, an estimate] · grant
    11997785 µJ left';
  - through 'apply #5: ... grant 0 µJ left';
  - 'apply #6: deny · not run';
  - '5 allowed; 15012116 µJ sealed, estimated ...; 15000000 of the
    15000000 µJ budget debited; 1 denied by the real JCP runtime once the
    budget was spent.'

Left for David, unchanged:

- Authorizer::charge carries no provenance;
- the Meter trait's provenance defaults to HwShunt;
- run_interaction seals a None sample as Estimator(0).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-meter: a tick that spins on the wrong clock fails its test, not hangs it
All checks were successful
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m21s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m10s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 4m21s
standards workspaces build + test / aac-rs (pull_request) Successful in 46s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 47s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 40s
standards workspaces build + test / map-rs (pull_request) Successful in 1m20s
standards workspaces build + test / mesh-rs (pull_request) Successful in 58s
standards workspaces build + test / openpay (pull_request) Successful in 3m9s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m0s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m48s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 5m34s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 4m45s
standards workspaces build + test / sense-rs (pull_request) Successful in 3m18s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m53s
0e18d6fe9b
In the_tick_spins_until_the_thread_cpu_clock_advanced, the scripted wall
clock advanced only when the spin read the CPU clock. So a spin_for_cpu
that measured its target on the wall clock never read the CPU clock, the
scripted time never moved, and the test hung until killed (900 s in the
mutation run) instead of failing.

Each scripted clock now advances on its own reads. The correct spin reads
each clock once per turn, so the expected values are unchanged. A spin that
targets wall time now stops at 28 ms of wall time and fails the assertion
that it burned 25 ms of CPU.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
joule-code-remediate: a fix is shipped only when it is committed
All checks were successful
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m27s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m54s
standards workspaces build + test / aac-rs (pull_request) Successful in 42s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m10s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 47s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 35s
standards workspaces build + test / map-rs (pull_request) Successful in 33s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m8s
standards workspaces build + test / mesh-rs (pull_request) Successful in 24s
standards workspaces build + test / openpay (pull_request) Successful in 1m46s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m30s
standards workspaces build + test / arl-rs (pull_request) Successful in 4m53s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 10m44s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m5s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 5m14s
standards workspaces build + test / sense-rs (pull_request) Successful in 7m49s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 6m41s
41d804348f
The ship step ran `git checkout -b`, `git add` and `git commit` and discarded
all three results. Two consequences, both pre-existing on main:

- A failed `commit` still reported `shipped: true` with a branch name while
  the verified fix sat uncommitted in the work tree. Any environment with no
  git identity does this: the CI container for joule-code-rs has none, which
  is how this surfaced. The two-run test added in d7adbe6c failed there with
  `Err(Dirty)` on its second run, because the first run's "shipped" fix was
  never committed. main's single-run tests never checked that a commit
  happened, so its CI has been green over the same silent failure (including
  the formal-obligation and L3-skill tests, which also ship).
- A failed `checkout -b` (the fix's branch already exists) let the commit
  land on whatever branch was checked out.

Now each step's result is checked. A failure returns the new
`HarnessError::Ship`, whose message says where the verified change was left
(uncommitted in the tree, or staged on the fix's branch), and nothing is
reported as shipped. No crate outside joule-code-remediate uses
HarnessError.

Tests:
- scratch_crate gives each scratch repo a repo-local identity, so the
  harness's own ship commit works in any environment.
- verified_fix_end_to_end_ships_with_receipt asserts that shipped means
  committed: HEAD is on the reported branch, one commit above the start,
  with a clean tree.
- a_fix_whose_commit_is_rejected_is_never_reported_shipped: a pre-commit
  hook that rejects makes run_once return Ship("git commit ..."), and HEAD
  does not move.
- a_fix_never_lands_on_the_current_branch_when_its_own_branch_exists:
  pre-creating joule-fix/<rev> makes run_once return Ship("git checkout -b
  ..."), with the current branch and HEAD unchanged.

Reproduced the CI failure locally by removing the git identity
(GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1, user.useConfigOnly=true):
before, a_second_run_seals_only_its_own_cargo_windows failed with
`Err(Dirty)` at the same line as in CI. After, all 13 tests pass both with
and without a git identity.

Falsified (each restored with git checkout):
- ignoring commit's result again fails the rejected-commit test;
- ignoring checkout -b's result again fails the existing-branch test;
- scratch repos without the repo-local identity, run with no identity, fail
  the four shipping tests with Err(Ship) rather than passing over an
  uncommitted fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Round 4 left one medium finding and four mutations that no test caught. This
commit closes all five. The hardware tests run in CI on this M5 Max, which is
often heavily loaded, so every new pin reads the probe's own record or a count.
None of them is a wall-clock ratio.

The once-per-process pin counts the real probe
- PROBE_RUNS (test builds only) is now incremented inside probe(), the function
  that opens the Energy Model subscription and samples it. Round 4 counted in
  probe_status, the wrapper that the cache called. So a counter constructor that
  called probe() directly, uncached, passed all 55 tests (O6).
- probe_status is gone; host_status caches probe() itself.
- Tests that run the probe on purpose, uncached, call the same probe() inside
  deliberately(). That is a test-only scope which marks one call, on one thread,
  as not the host's run. It exists only in test builds, so no production path
  can use it and still build.
- the_liveness_status_is_probed_once_and_cached still releases 6 threads
  through a barrier. Each thread calls ioreport_status() twice, builds two
  counters and calls detect_meter. The real probe must have run exactly once in
  the process. A constructor that probes uncached now fails it with "the real
  liveness probe ran 26 times".

The tick is pinned from the probe's own record
- probe_cpu_rail records each tick: the probing thread's CPU time, on its own
  CLOCK_THREAD_CPUTIME_ID, and the wall time. Every tick must show a whole
  PROBE_TICK (25 ms) of thread CPU or have hit the 500 ms wall cap, and there
  must be one tick before every re-sample.
- Round 4 pinned only the CPU used per interval, from the end of one sample to
  the end of the next. That includes the sample call's own 6-8 ms, so a tick
  that burned 25% or 30% of PROBE_TICK passed under load (O1, O1b). Both now
  fail at any load, for example: "tick 0 of 66 burned 6.251291ms ... a tick
  burns 25ms of thread CPU time".
- The record is checked against the tests' own thread clock, which is Mach
  thread_info, not clock_gettime. The thread must have used at least as much
  CPU as the probe says its ticks and its counted intervals used. A probe whose
  "thread CPU" clock was really the wall clock passes this only when the thread
  was almost never off the CPU. Under load it fails: "its ticks used
  804.630251ms ... the thread used only 566.975ms".

The no-subscription path goes through the pure mapping, and a test runs it
- probe() is now probe_with(open, probe_on), given the real opener and the real
  probe. probe_with maps "nothing opened" through status_of_run to Unavailable,
  the same as a probe that saw no interval.
- A test-only switch, NO_ENERGY_MODEL_GROUP, makes open_energy_model return None
  on one thread. a_probe_that_cannot_subscribe_to_the_group_is_unavailable runs
  the real probe() that way, on a host whose group opens, and asserts
  Unavailable with nothing probed. It also calls probe_with with an opener that
  opens nothing, and with a subscription that never returns a sample.
- Round 4's O2 (probe() maps a failed subscription straight to CpuRailAbsent)
  passed on both Macs. It now fails with "the real probe with no Energy Model
  subscription ... left: CpuRailAbsent". The same bypass returning Live fails
  too, placed in probe() or in probe_with.

The sampler is pinned to read afresh
- New test: the_probe_samples_afresh_across_its_window. While the real probe
  runs, an oracle thread reads the Energy Model group every 10 ms and records
  when each read ran. It uses its own subscription, opened with the raw FFI, not
  open_energy_model. The probe records each sample call's start, end and values
  (test builds only).
  - For every probe sample with an oracle read wholly before it and one wholly
    after it: a GPU Energy rail that held must read the oracle's value, and one
    that moved must read between the two oracle reads.
  - If the oracle saw a GPU Energy rail change between two reads strictly inside
    the probe's window, the probe's own samples must show it changing too.
  - Fewer than two oracle reads inside the window is no evidence, and fails.
- the_probe_sampler_reads_each_cpu_rail_at_its_own_value now holds GPU Energy
  to the same bracketing rule as the CPU rails, over two rounds on one
  subscription.
- Round 4's O3 (the sampler returns its first sample's values forever) passed
  on both Macs. It now fails. So do a sampler that is stale per probe run and
  one that is stale per subscription. Example: "probe sample 1: ("GPU Energy",
  0) moved 123056146417043 -> 123056172426143 across the oracle's reads either
  side of it, but the probe's sample read 123056142245337, outside them: not a
  fresh sample".
- What this test can show depends on the GPU:
  - The CPU rails are frozen on both Macs, so they never can.
  - On this M5 Max, GPU Energy changes every ~28 ms. It moved inside the window
    in every run here.
  - An M3 Ultra's GPU moves only sporadically: in 113 of 11,716 100 ms samples
    over 25 minutes one day, and in about a fifth of 30 ms intervals today. Today
    the M3 Ultra caught all three stale samplers.
  - In a window where the GPU does not move, the test prints "no GPU rail moved
    inside the window: this run cannot tell a fresh sampler from a stale one",
    and a stale sampler would pass that run.

Docs: the budget's doc no longer says "each still under a full tick of burn",
and the all-channel sample cost is now quoted as 0.1-1 s.

Evidence, on this exact tree
- M5 Max, macOS 27.0.1, 10 runs under 36 background spinners (2 x hw.ncpu),
  1-minute load average 23-57: 10/10 "test result: ok. 57 passed". All spinners
  were killed afterwards and none was left. Every run showed:
  - "ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
    selected=false available=false", with oracle_reads=8-13.
  - "the real probe ran 1 time(s)".
  - Probe: 7-12 intervals, median sample cost 6.98-7.79 ms, least tick
    25.0005 ms of thread CPU, least interval 31.34 ms.
  - Freshness: the oracle saw GPU Energy change 43-52 times inside the window,
    and the probe's own samples changed in every interval (6-13 changes).
- M5 Max, 5 more runs under 72 spinners (4 x hw.ncpu), load up to 75: 5/5.
- M3 Ultra (10.10.10.2), the same binary: 57 passed. DIE_0 and DIE_1 read equal
  to the oracle (35656018 and 38872869). GPU Energy moved 9 times inside the
  probe's window, and the probe's samples 9 times.
- 31 mutations. Each was applied, built, run, and restored with git checkout,
  with git status clean after each one.
  - These fail at least one test: baseline never moves; same-named rails
    counted under one key; a decrease, or a first appearance, counted as an
    advance; no subscription mapped to Live (in status_of_run, in probe_with, in
    probe); no subscription mapped to CpuRailAbsent (in probe_with, in probe);
    per-thread cache; no cache; constructor probing uncached; constant-0
    sampler; three stale samplers; tick sleeps; tick is a no-op (the burn call,
    or the whole tick); tick burns 30% or 25%; tick burns on a helper thread;
    all-channel subscription; liveness decided on the sum of every Energy Model
    rail, or on the GPU rail; advances summed over rails; a declined counter
    reading 0; the fallback metering 0.
  - Two pass on a quiet M5 Max, and each is caught where it differs. The first
    gives each CPU rail the sum of all CPU rails. It is equivalent on the M5
    Max's single rail, and on the M3 Ultra it fails: "DIE_0_CPU Energy held at
    35656018 ... but the probe's sampler read 74528887". The second makes the
    wall clock the probe's thread clock. It agrees with the thread clock on a
    quiet host, and fails 3/3 under 36 spinners.
- 24 no-feature tests pass. cargo check --tests --examples passes for
  aarch64-apple-darwin, x86_64-apple-darwin, aarch64-unknown-linux-gnu and
  x86_64-unknown-linux-gnu, with and without ioreport. clippy --no-deps shows
  only the warnings that were already there.

Not done here
- A path that re-implements probe(), by calling probe_with or probe_cpu_rail
  directly, is not counted.
- No CI job runs these ioreport tests; .github/workflows is outside this
  branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jouleclaw-loader-gguf: each test temp file gets a name no other test in the process can pick
All checks were successful
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m29s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m0s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m2s
standards workspaces build + test / aac-rs (pull_request) Successful in 38s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 38s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 33s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m47s
standards workspaces build + test / map-rs (pull_request) Successful in 30s
standards workspaces build + test / mesh-rs (pull_request) Successful in 28s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / openpay (pull_request) Successful in 1m30s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m10s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m7s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m29s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m0s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m16s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m33s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m16s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m14s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 1m55s
0c2a43bbd5
The tier_store tests sometimes failed with "tier_store.rs:738:50: called
`Result::unwrap()` on an `Err` value: Io(Os { code: 2, kind: NotFound, ... })".

write_synth_gguf named its file jouleclaw-tier-store-<pid>-<nanos>.gguf. On
macOS SystemTime ticks in microseconds, and the harness starts these tests
together on parallel threads, so two tests could pick the same name. They then
shared one file, and the first to finish deleted it before the other had read
it.

Each such helper now takes a number from its own process-wide AtomicU64 instead
of the time, giving <prefix>-<pid>-<seq>. The crate's mellum, falcon_h1 and
hybrid_lm tests already name their files this way.

Four more helpers used the same timestamp naming with no per-test tag, and
several tests call each of them at once, so they get the same fix:
- lib.rs prefetch_policy_tests::write_synth_gguf (8 tests)
- gpu_buffer.rs aligned_temp_mmap (5 tests; it truncates and maps the file)
- mmap_advise.rs tempfile_compat (2 tests)
- moe.rs build_synthetic_store_with_experts (2 tests)
None of the four failed in the runs below, but they had the same race.

Only #[cfg(test)] code changes.

Evidence, on this M5 Max:
- Before, on the parent tree (3b260e0a): the lib test binary built with
  --features fabric-meter, run 30 times, passed 27 and failed 3. All three
  failures were at tier_store.rs:738:50 with NotFound, in
  pin_hot_changes_tier_to_hot, demote_to_cold_returns_to_cold and
  out_of_bounds_errors.
- After, the same build and the same 30 runs: 30/30, 299 passed each time.
- `cargo test -p jouleclaw-loader-gguf --lib --features fabric-meter`, 12
  consecutive runs: 12/12, "test result: ok. 299 passed; 0 failed; 2 ignored".
- With --features "rayon fabric-meter": 3/3, 302 passed.
- clippy --no-deps --tests --features fabric-meter: no warning on or next to
  any changed line. It exits 101 on three error-level lints that were already
  there, in files this commit does not touch: ssm.rs:406 (approx_constant) and
  gemma4.rs:562 and :582 ("this operation will always return zero").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
proof-cascade: one counted production probe, pinned on what it was handed
All checks were successful
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m50s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m53s
standards workspaces build + test / aac-rs (pull_request) Successful in 38s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 40s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m20s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 34s
standards workspaces build + test / map-rs (pull_request) Successful in 30s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / mesh-rs (pull_request) Successful in 24s
standards workspaces build + test / openpay (pull_request) Successful in 1m32s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m11s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m28s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m39s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 5m9s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m58s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m29s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m50s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m37s
c085945c7a
The re-verification of 79375a1c left the decision rules pinned but the
production chain around them loose: a wall-clock pin that flaked whenever
another process sampled IOReport, a run counter that counted cache
initialisations, an unopened subscription that could be mapped straight to
Live inside probe() or the cache, and no pin on the values between the FFI
reader and the rule (a sum of the CPU rails there survived on the M3 Ultra).
Those were the dangerous survivors. The fail-safe ones were a wrong clock id,
a stale reader and a reader that drops rails. Each now fails a test.

The chain. `host_status()` is the one production composition: it increments
REAL_PROBE_RUNS, then returns the pure `status_of` of
`probe_with(EnergyModelSub::open, EnergyModelSub::read, spin_tick,
wall_clock, thread_cpu_time, budget)`, with no branch. It is the only
production caller of `probe_with`. `ioreport_status()` caches it in one
process-wide OnceLock. `hardware_meter_available()` is
`admits(ioreport_status())`, and `power_over` and `measure_marginal_uj`
select only through it. `admits` accepts Live alone. `status_of` reads the
run's `opened` flag: a subscription that did not open is Unavailable whatever
verdict the run carries. `macos_ioreport::probe`, `available`, `cached_probe`
and `status_from` are gone.

Once per process. The concurrent test moved to tests/ioreport_once.rs, so
it is its process's first use of the meter. Eight threads released through a
Barrier ask for the status, the selection and measure_marginal_uj. The
counter must read 1, and each thread's warm calls must burn under 10 ms of
its own CPU. power_over is private, so the lib test
the_gated_entry_points_read_the_cached_status pins it, and the other two,
the same way on the calling thread's CPU.

Fault injection. #[cfg(test)] hooks inside the real opener and the real
reader (`seam`, per thread) let unit tests run the real, uncached
`host_status()`. With the group denied, it gives Unavailable with no read,
no tick and no verdict. On scripted channel reads, with the real tick and
clocks, a single batch jump, a constant rail, and one step on each die in
different intervals each give CpuRailFrozen. A rail that advances at every
read gives Live after two intervals. Each scripted run must have been handed
exactly the script. Measured on the tests' own clock, it must also have
burned at least 12.5 ms of CPU per tick. For the cache,
tests/ioreport_unopenable.rs drives the whole chain in a process of its own
after `simulate_no_energy_model_group()`, a doc-hidden hook that can only
turn the meter off. Four threads see Unavailable, no selection and no
figure, the work never runs, and the probe runs once.

Group-only, structurally (F1). Every sample the probe read listed only
Energy Model channels, exactly as many as the group has: 364 of 11,886 on the
M5 Max, and 645 of 14,345 on the M3 Ultra. The median thread CPU of a read
is under 50 ms; measured, 6-9 ms. The wall-clock median is logged, never
asserted. No wall-clock bound or ratio remains in these tests.

What the rule was handed (F4). The rule records, for every sample, the
(name, ordinal) keys and raw integers of every CPU rail and every `GPU
Energy` channel it was handed. A test runs `host_status()` uncached between
two all-channel oracle reads:
- the keys in every sample equal the oracle's Energy Model keys;
- a rail that held was handed exactly the oracle's value;
- a rail that moved was handed a value within the two reads.
For freshness, an independent watcher reads the group on its own thread.
Every GPU Energy rise it saw strictly between two probe reads must appear as
two different GPU values in the record. The FFI pin also asserts the key
sets and brackets the GPU rail. A raw-reader test alternates the group and
the all-channel subscriptions, so a reader that is stale for the process
fails whether or not anything moves.

Clocks (F3). The tests bind their own clock_gettime(16). The production
thread clock stays still across a 100 ms sleep, and while a helper thread
burns 100 ms as this thread waits. It moves with this thread's own burn, and
never by more than the tests' clock. Each production tick burns at least
25 ms of thread CPU by the tests' clock, unless it ran into the 500 ms wall
cap, and never more CPU than wall time. The probe's wall clock is checked
against Instant around a sleep.

Counting CPU energy once, the rule from eoc-meter 7aa42418. counts_once,
now pure and in `liveness`, summed per-cluster accumulators and CPU power
managers beside the `CPU Energy` aggregate they add up to:
- on the M5 Max, `PACC_*`, `MCPM*`, `PCPM` and their `_SRAM` rails;
- on the M3 Ultra, `DIE_n_*_CPM` and their `_CPM_SRAM` rails.
It now drops `PACC*`, `EACC*`, `MACC*` and `*CPM*`. A test pins this on both
chips' real channel names. It only matters on a live host, so no reading here
was affected. Also, `open()` now releases `subscribed` when the subscription
is null.

Falsified. Each mutation was applied, run and restored with git checkout.
git status was clean after each. Every one failed a test:
- compare with the first sample: 10 lib tests, e.g. "one refresh counted as
  a live rail";
- a decrease counts as an advance: "a decrease counted as an advance";
- no subscription -> Live inside host_status: "an unopenable Energy Model
  group left: Live". The CpuRailAbsent variant fails the same test;
- no subscription -> Live inside the cache: the lib tests pass, and
  ioreport_unopenable fails ("no Energy Model group, yet Live");
- a single advance -> Live inside host_status's read closure: "one refresh
  counted as a live rail";
- the sum of the CPU rails in host_status's read closure:
  - scripted, on any host: "one step on each die added up to a live rail";
  - on the M3 Ultra also "("DIE_0_CPU Energy", 0) held at 38945708 across
    the probe, yet it handed the rule 81077035";
  - inside probe_with: 3 tests;
- a no-op tick in host_status: "interval 0 of 92 consumed 27.584µs", and
  "host_status() ticked 2 times, yet this thread burned only 14.167µs".
  spin_tick as a no-op: "tick 0 burned 333ns";
- a per-thread cache: "the liveness probe ran 8 times";
- hardware_meter_available() probing uncached:
  - "hardware_meter_available() burned 1.007352584s of this thread's CPU";
  - "ran 17 times";
  - ioreport_unopenable: 9;
- power_over probing uncached: "a declined power_over burned 730.585083ms";
- the reader returns constant 0: "held at 2588973125 ..., yet the probe read
  0";
- stale readers:
  - process-wide at the FFI: "listed 364 channels for the group and 364 for
    every channel";
  - process-wide in EnergyModelSub: the GPU bracket;
  - per probe in the closure: "GPU Energy rose ... yet both handed the rule
    the same GPU value", and the scripted tests;
- the thread clock as CLOCK_MONOTONIC: "advanced 101.41ms while this thread
  slept 100 ms". As process CPU: 598.246ms;
- admits() accepting CpuRailAbsent: "no CPU rail admitted the meter";
- the probe subscribing to every channel: "sample 0 listed 11886 channels,
  some outside the Energy Model group".

Runs on this tree:
- M5 Max: the lib tests 5x while a helper process sampled every IOReport
  channel in a tight loop. Sibling suites' IOReport readers were also running
  on the host, at load 16-23. 5/5 passed, 58 each; the median read was
  6.1-8.8 ms of thread CPU, and the minimum interval 25.02-25.03 ms of thread
  CPU. The helper was killed on exit, and none remain.
- M3 Ultra, once: 58 passed, with "status=CpuRailFrozen
  oracle=CpuRailMoved(false) selected=false probe_intervals=26". The
  once-per-process and unopenable tests also pass there.
- The proof-rs workspace is green, and the proof-conformance binary prints
  "all checks passed".
- clippy reports only the two warnings that are also on origin/main.
- x86_64 Linux is compile-checked only.

Not done, deliberately:
- The oracle's evidence floor, at least 2 reads of every channel within at
  most 3x its window, is the one hardware assertion still sensitive to
  IOReport contention. Under the helper plus the sibling readers it got 2,
  the edge. It fails loudly rather than passing without evidence.
- GPU freshness is asserted only when the watcher saw GPU Energy rise between
  two probe reads, and the log says how many. With the GPU and CPU rails
  both idle, a reader made stale between the test hook and the FFI call, in
  EnergyModelSub, would survive. That is fail-safe: it can only decline.
- power_over and measure_marginal_uj are pinned warm on a declined host only.
  On a live one they do real sampling work.
- Which provenance grade a live IOReport reading earns, and a macOS CI job
  for this crate, are left for David.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
sandbox-meter: the status the meter is gated on is the run the tests drive, and no hardware pin rests on wall time
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m48s
standards workspaces build + test / aac-rs (pull_request) Successful in 42s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 41s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 36s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m28s
standards workspaces build + test / map-rs (pull_request) Successful in 1m5s
standards workspaces build + test / mesh-rs (pull_request) Successful in 50s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m50s
standards workspaces build + test / openpay (pull_request) Successful in 1m31s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m20s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m15s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m12s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m35s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m33s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 4m5s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m24s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 4m6s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m58s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m54s
4b20ce2a50
Round-5 verification of 41d80434 (its sandbox/ tree is 20ad7bc3's) found
three kinds of problem in the IOReport liveness probe's tests.

- A flake on correct code. The R3 pin asserted that the probe's median
  sample call took under 50 ms of WALL time. When another process samples
  IOReport over every channel, a group-only sample call waits behind that
  process's 0.25-0.8 s samples. Beside one such reader, 3 of 6 unmodified
  copies failed (medians 85, 165 and 171 ms). The other backends' test
  oracles are such readers.
- Two dangerous-side survivors in production glue no test ran. The cached
  run was probe_status -> probe_host -> watch_cpu_rail, and it only ever ran
  against this host's real, frozen IOReport. Mapping a failed open straight
  to Live inside probe_status passed every test, and so did lifting a single
  advance (one batch refresh) to Live inside its closure.
- Fail-safe survivors: a failed open mapped to CpuRailAbsent inside
  probe_status, a no-op tick in the cached run, a sampler that returns a
  stale sample, and thread_cpu() reading CLOCK_MONOTONIC or
  CLOCK_PROCESS_CPUTIME_ID. The tick and its pins all read the same
  thread_cpu(), so a wrong clock passed them all.

What now holds.

One production chain, with no branch of its own. ioreport_status() caches
host_status() in a process-wide OnceLock, and nothing else in production
runs the probe. host_status bumps a process-wide AtomicUsize first, then
returns status_of(real_run().verdict()). real_run is liveness::probe_with
over the real opener (IoReportProbe::open, whose sampler is
IoReportProbe::read), the real tick (probe_tick), this thread's CPU clock and
a monotonic clock, for CPU_RAIL_LIVENESS_BUDGET. probe_with is generic over
all of those. A subscription that does not open is never sampled or ticked.
probe_status, probe_host, watch_cpu_rail, cpu_rail_raw and ProbeTrace are
gone. The counter is read through probe::ioreport_probe_runs()
(#[doc(hidden)]).

Selection only through the cached status and one pure gate. detect() is
detect_given(ioreport_status()). IoReportProbe::given(status) replaces
IoReportProbe::new(), which read the cache itself. Both ask
liveness::admits(status), true for Live alone. admits, given and
detect_given are each tested for all four statuses, with nothing probing.

The run records what it did. For every sample call: when it started and
ended on the run's clock, the thread CPU it used, how many channels the
sample held, whether all of them were in the Energy Model group, and each
GPU Energy rail's (name, raw). RailWatch keeps every CPU sample it was
handed (observed, which replaces prev). For every interval: the tick CPU,
measured apart from the samples.

Fault injection at the FFI leaf. A #[cfg(test)] thread-local script (mod
seam) sits inside IoReportProbe::open and IoReportProbe::read. It decides
whether the subscription opens and what each sample holds. Every other
thread, and every build that ships, reads the real IOReport. Tests call the
real, uncached host_status on the scripted thread:

- the open fails: Unavailable, one open, zero sample calls, no tick;
- one batch refresh (480,083,160 -> 541,893,638 mJ, then holding): Frozen;
- a rail that advances at every sample: Live after exactly three samples;
- a rail that holds: Frozen, watched for at least 0.9 s of the 1 s budget;
- two dies that each step once, in different intervals: Frozen;
- no CPU rail: CpuRailAbsent.

The script stamps every sample call on the tests' own clock. From those
stamps, each test checks that every tick burned at least 25 ms of this
thread's CPU (or ran into the 500 ms wall cap), and no more CPU than wall
time.

No wall-clock pin. the_liveness_probe_never_reports_a_frozen_rail_as_live
runs real_run itself, uncounted and uncached, and pins from its record:

- every sample held the Energy Model group alone, with as many channels as
  the all-channel oracle counts in that group (364 of 11,886 on the M5 Max,
  645 of 14,345 on the M3 Ultra);
- the median sample call used under 50 ms of this thread's CPU;
- every interval burned at least PROBE_TICK/2.

The median wall time is printed, not asserted.

The clock is pinned on the tests' own clock. The tests read
CLOCK_THREAD_CPUTIME_ID through their own literal (16, from macOS
<time.h>). thread_cpu() must not move over a 100 ms sleep, nor while
another thread burns 100 ms. It must track this thread's own 50 ms burn to
within 2 ms. probe_tick is checked five times with literal bounds.

Freshness. the_real_probe_hands_the_rule_each_cpu_rails_own_fresh_value
runs real_run while a helper thread reads the tests' own Energy Model
subscription over and over. Each sample is bracketed by a timeline read
that ended before it and one that started after it. It asserts:

- each CPU value the rule got, and each recorded GPU value, lies within its
  bracket, so it equals the bracket where the rail held;
- the CPU keys equal the Energy Model CPU rails the all-channel oracle lists;
- where the timeline saw GPU Energy rise between the run's first sample and
  its last, the run's own GPU values rose too.

Once per process, in a process of its own. The concurrent test moved to
tests/ioreport_probe_once_per_process.rs. Eight threads start behind a
barrier: half ask for the status first and half call PowerMeter::detect()
first, then each does the other. It asserts the real-run counter is 0
before and exactly 1 after (0 off Apple Silicon macOS).

class_for, checked against the channel names in 7aa42418 and a live dump
of the Energy Model group on both chips. Only `CPU Energy` (M5 Max) and
`DIE_0_CPU Energy` / `DIE_1_CPU Energy` (M3 Ultra) contain "CPU Energy". No
CPU sub-rail does (`MCPU*`, `PCPU*`, `PACC_*`, `*CPM*`, `DIE_n_EACC_CPU`,
`DIE_n_PACC*_CPU*`, `*DTL*`). So it is unchanged. Its test now covers every
name 7aa42418 lists, plus the domains that are no aggregate's summand.

No test was removed or renamed; the once-per-process test moved to its own
file under the same name. The frozen-rail test still prints
"ioreport liveness: status=".

Corrections to 41d80434's message:

- "The two-run test added in d7adbe6c": it was added in cee66773
  (`git log -S a_second_run_seals_only_its_own_cargo_windows`). d7adbe6c
  touches only sandbox-meter.
- "fail the four shipping tests": it fails five. Reproduced here with no git
  identity: "FAILED. 8 passed; 5 failed". The five are
  a_second_run_seals_only_its_own_cargo_windows,
  a_proven_formal_obligation_lets_the_fix_ship,
  verified_fix_end_to_end_ships_with_receipt,
  shipped_l3_fix_induces_a_skill_that_closes_the_recurrence_without_a_model
  and l3_fix_passes_the_same_gates, each Err(Ship("git commit on
  joule-fix/...: Author identity unknown ...")).

Evidence, on an M5 Max, macOS 27.0.1, unless noted.

- cargo test -p sandbox-meter: 73 lib tests and 1 integration test pass.
- 20 mutations, each applied, built, run on both test binaries, and restored
  with git checkout; the tree was clean after each. All 20 are caught.
  - Rule: comparing with the first sample (12 failures); a decrease counted
    as an advance (2); LIVE_RAIL_ADVANCES = 1 (19).
  - No subscription inside host_status: -> Live when the run did not open;
    -> Live when an open fails before the run; -> CpuRailAbsent.
  - A single advance lifted to Live: after the run in real_run; inside its
    sampler closure.
  - Ticks: a no-op tick passed by real_run; a no-op probe_tick.
  - A per-thread cache: caught by the integration test ("real probe
    runs=9").
  - Sampler: every value 0; stale per probe and process-wide, below the test
    seam (caught on hardware: "sample 0: GPU rail ... read 126274113687220;
    the timeline read 126274125371248 just before"); stale inside real_run's
    closure.
  - thread_cpu() on CLOCK_MONOTONIC ("advanced 101.266ms while this thread
    slept 100 ms") and on CLOCK_PROCESS_CPUTIME_ID.
  - admits() accepting CpuRailAbsent (3).
  - Span::stop's held-CPU rule removed (3).
  - run_attributed reverted to meter.is_hardware() (1).
- Contention: the lib tests 5x while a helper process sampled IOReport over
  every channel in a tight loop: 5/5 passed, with a median thread CPU per
  sample call of 7.4-8.3 ms. In the round-5 flake's own shape (3 copies at
  once beside that reader, 2 rounds), 6/6 passed. In one of them the median
  WALL time reached 58.7 ms, which the old pin would have failed, while the
  median thread CPU was 8.4 ms.
- M3 Ultra (macOS 27.0.1), the same binaries, 1x: "test result: ok. 73
  passed", "ioreport liveness: status=CpuRailFrozen
  oracle=CpuRailMoved(false) selected=false available=false", "30 samples,
  each 645 channels ... median thread CPU per sample call 7.621667ms", "29
  intervals, the least thread CPU burned in one 25.001125ms", and "8
  threads, status=CpuRailFrozen, real probe runs=1".
- Dependants pass:
  - jcp-gateway (12) and jcp-action-receipt (17 + 3);
  - joule-code-remediate, 13, with and without a git identity;
  - joule-ui-interaction (6) and joule-ui-serve (4 + 2);
  - sandbox-rs `cargo test --workspace`, 260 tests.
- clippy --all-targets --no-deps on sandbox-meter is clean.
- cargo check --tests passes for x86_64-unknown-linux-gnu, and for
  x86_64-apple-darwin with only the pre-existing unused_mut on
  `let mut probes`.

Not done, deliberately:

- CI does not run these hardware tests. standards-build runs sandbox-rs on
  the linux runner, where the integration test takes its non-macOS branch.
  The macOS runner reaches sandbox-meter only through joule-ui-rs. Moving
  the job is a workflow change and is out of scope here.
- The seam scripts open() and read(), the liveness path. snapshot(), the
  span path, still reads IOReport directly. In production it runs only
  after a Live status admitted the probe, which neither host here reaches.
- A per-rail sum inside the FFI read is invisible on a one-rail M5 Max. The
  scripted two-die tests pin the pure split, and only an M3 Ultra run
  covers the FFI.
- Still left for David, unchanged from earlier rounds: jcp-gateway `_meta`
  measured_uj, Authorizer::charge without provenance, the Meter trait's
  default HwShunt, the macOS CI job, and rust_string's 128-byte buffer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-meter: the tests drive the production liveness chain itself, and hold under load
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m2s
standards workspaces build + test / aac-rs (pull_request) Successful in 40s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 35s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 32s
standards workspaces build + test / map-rs (pull_request) Successful in 32s
standards workspaces build + test / mesh-rs (pull_request) Successful in 24s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m19s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m19s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m9s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m35s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m58s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m20s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m57s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
33f6d78c2c
The round-5 review of 0e18d6fe found eight gaps. Four were on the
dangerous side:

- The group-only pin (median wall time per sample call < 50 ms) failed
  spuriously on the loaded M5 Max that runs macOS CI: 2 of 23 load runs.
- The once-per-process count sat in the cache wrapper, so a constructor
  that probed uncached through probe() passed. An instrumented run made 21
  real probes while the test printed 1.
- Mapping "no Energy Model subscription" to Live or CpuRailAbsent inline in
  probe_status, around the pure mapping, survived on both chips.
- try_detect()'s gate rewritten inline to decline only CpuRailFrozen
  survived on both chips.
- A stale sampler survived, because only the frozen CPU rails were
  bracketed.
- A probe_tick that spun 25 ms of wall time passed unloaded.
- the_counter_advances_when_a_rail_it_sums_advanced asserted nothing when
  GPU Energy did not rise.
- CPU_RAIL_LIVENESS_BUDGET's doc called a second of wall time "a second of
  single-thread CPU work".

What holds now.

The production chain is thin, branch-free, and the one the tests run.
host_status() adds one to a process-wide AtomicUsize, then returns
status_of(probe_with(real opener, real tick, thread CPU clock, wall
clock, budget).verdict()). ioreport_status() caches it in a OnceLock and
is its only production caller. probe_with is the only place that composes
the opener, the sampler, the tick and the clocks. An opener that fails
gives a run with no verdict, and the pure status_of maps no verdict to
Unavailable. try_detect() is try_detect_given(ioreport_status()).
try_detect_given and detect() both gate through the pure admits_counter(),
which admits Live only. The real tick is one call of the pure spin_for_cpu
with this thread's CPU clock and the wall clock injected.

Fault injection sits inside the real leaves. In test builds, a script
installed on a thread stands in for IOReport in open_energy_model() and
State::group_sample(), and nowhere above them. The tests call the real
host_status(), uncached, under these scripts:

- the opener fails: Unavailable, with no sample taken and no tick run
- a single batch jump: CpuRailFrozen
- two advances: Live, at the third sample
- one step on each of two dies: CpuRailFrozen
- a constant rail: CpuRailFrozen, and every production tick burned 25 ms
  of thread CPU by the test's own clock

A status installed on a thread likewise stands in for the cached one, so
the real try_detect() and detect() run under all four statuses.

The pins. No pin bounds wall time from above, so host load cannot fail
one.

- Group-only, by structure. Every sample the production probe took has
  exactly as many channels as the Energy Model group in the oracle's
  all-channel read, and every one is in the group: 364 of 11,886 channels
  on the M5 Max, 645 of 14,345 on the M3 Ultra. Also, the median thread CPU
  of a sample call must be under 50 ms. It measured 6-9 ms; every channel
  cost 110-120 ms in round 5.
- The clock, against the tests' own CLOCK_THREAD_CPUTIME_ID literal (16).
  The implementation's clock keeps pace while this thread burns. It does
  not move across a 100 ms sleep, or while another thread burns 100 ms.
  Each production tick is at least 25 ms of thread CPU or 500 ms of wall
  time, and never more CPU than wall. This is checked once unloaded, and
  once beside 2 x ncpu busy threads, where at least one tick must have
  shared its core.
- Freshness. The production probe runs, uncached and outside
  host_status(), between two oracle reads of the Energy Model group's CPU
  and GPU energy rails. The run records the (key, raw) values it handed
  the rule, GPU Energy included. They must carry the oracle's keys, EQUAL
  a rail that held, and lie within a rail that moved. When the oracle saw GPU Energy change, the
  record must show it changing too.
- Once per process. tests/liveness_probe_once_per_process.rs runs in its
  own process. It releases 8 threads at a cold cache through status,
  construction and selection, in three orders. Two counts must each be
  exactly 1: runs of host_status(), and opens of the probe's sampler. The
  second count catches a probe run outside the cache that never touched
  host_status().
- Declines before anything opens. Given Unavailable, CpuRailAbsent or
  CpuRailFrozen, try_detect_given returns Err(NoCounter), and detect_with,
  try_detect() and detect() decline too. None of them attempts to open the
  counter's subscription, by a per-thread count inside its opener. Given
  Live, the attempt is counted. This test runs on every macOS host,
  whether or not IOReport opens there.
- The counter's value. The counter is built past the gate on purpose, and
  its delta over a window of at least 3 s must lie between two rises of the
  oracle's sum of the same summed rails: the rise inside the window, and
  the rise around it. The oracle rounds rail by rail, as the counter does.
  0 == 0 is asserted too; no branch skips.

The budget doc now says the second is wall time. A loaded host gives the
probe fewer intervals, which fails safe.

Evidence. eoc-meter: 37 lib tests + the once-per-process test pass.

- M5 Max, unloaded: per-tick thread CPU 25.02-25.03 ms. Median thread CPU
  per sample call 6.5 ms. `ioreport liveness: status=CpuRailFrozen
  oracle=CpuRailMoved(false) selected=false`. The counter read 136,442 uJ
  against a bracket of 126,920..162,549 uJ; GPU Energy was the only rail
  that moved.
- M5 Max, 5 runs with a helper process sampling every IOReport channel in
  a tight loop: 5/5 ok, 37 passed each.
- M5 Max, 5 runs beside 1 x ncpu busy spinners (load average 23-39): 5/5
  ok, 37 passed each. The probe saw 4-11 intervals.
- M3 Ultra (10.10.10.2, macOS 27.0.1), 1 run: 37 passed, and the
  once-per-process test passed with 1 run and 1 open. Both DIE_n_CPU Energy
  rails were read equal to the oracle. 12 of 17 production ticks shared
  their core with 64 busy threads, each still burning 25.00-25.01 ms of
  CPU.
- The whole eoc workspace: 613 passed, 0 failed, 3 ignored. The ignored
  three are pre-existing ignored doctests.
- clippy --no-deps on eoc-meter, all targets: the only two warnings are
  from 1bae1ea1 (a duplicated #[link] attribute, and a b"...\0" literal).
  The aarch64-linux cross type-check and clippy are clean.

Mutations. Each was a temporary edit, then git checkout; the tree was
clean after every one. All 24 were caught (test that failed in brackets):

- compare with the first sample [9 pure rule tests + host_status]
- decrease counts as an advance [a_decrease_is_not_an_advance]
- no subscription -> Live, and -> CpuRailAbsent, inside host_status
  [host_status_is_the_rule_on_the_production_composition]
- single advance -> Live, inside host_status and inside the production
  sampler closure [the same]
- no-op tick; tick spinning 25 ms of wall time; tick timed on the wall
  clock [the production-tick and probe-tick tests]
- per-thread cache [once-per-process: "ran 8 times"]
- try_detect probing uncached, through host_status and through
  production_probe [once-per-process: 9 runs / "sampler was opened 9
  times"; only_a_live_status_constructs_or_selects_the_counter]
- try_detect's gate inlined to decline only CpuRailFrozen, and
  try_detect_given likewise [a_declined_status_declines_before_anything_opens]
- try_detect_given opening before it declines [the same: "left: 4, right:
  0"]
- sampler constant 0, in the FFI extraction and in the production closure
  [sampler/freshness tests, host_status]
- stale sampler, in the production closure and cached per thread below
  the script hook [freshness: "every one of the 39 samples ... read
  [126274575439834]: its sampler is not sampling"]
- thread clock = CLOCK_MONOTONIC and = CLOCK_PROCESS_CPUTIME_ID
  [the_probe_thread_clock_is_this_threads_cpu_time, and others]
- counts_once re-admitting PACC_* [counts_once_drops_subrails]
- the counter's sum skipping GPU Energy, or counting every rail twice
  [the_counter_advances_when_a_rail_it_sums_advanced]

Not done here, deliberately:

- A sampler stale BELOW the script hook is caught only through GPU Energy
  moving during the 1 s probe. On a host whose GPU rail holds, the
  freshness line says "NOT asserted" for it. Then a stale value is also
  the true value, and the held-rail equality still asserts. Staleness at
  or above the hook is caught by the scripted tests on any host.
- The crate was not rustfmt-clean before this branch, and it is not
  reformatted here, so the diff stays reviewable.
- Linux: a cross type-check and clippy only, not a run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: the production probe runs under test, and no pin reads wall time
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m40s
standards workspaces build + test / aac-rs (pull_request) Successful in 35s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 34s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 32s
standards workspaces build + test / map-rs (pull_request) Successful in 29s
standards workspaces build + test / mesh-rs (pull_request) Successful in 24s
standards workspaces build + test / openpay (pull_request) Successful in 1m18s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m18s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m14s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m11s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m38s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m59s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m5s
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
a0daa3da62
Round 5's verifier found five problems. (1) The pin "median WALL time of the
probe's sample calls < 50 ms" failed on correct code while another process
sampled IOReport over all channels: a flake on this host, which is the macOS
CI runner. (2) The glue between the tested parts was itself untested.
probe_status() and the closure it handed probe_with() ran only against the
hardware, where the subscription always opens and the CPU rail never moves,
so a no-subscription -> Live branch, a single advance -> Live in that
closure, and a no-op tick all survived, and PROBE_RUNS counted calls to the
wrapper, not probe runs. (3) Nothing pinned which clock the probe reads as
thread CPU. (4) A sampler that repeated a stale sample passed on frozen
rails, where a stale value equals a fresh one. (5) Nothing pinned the values
the rule is handed.

What changes in the code:

- One production chain, and every link is thin. ioreport_status() caches
  host_status() in a process-wide OnceLock. host_status() has no branch: it
  counts itself in HOST_PROBE_RUNS, then returns status_of(probe_with(the
  real opener, burn_tick, HostClocks, the budget)). It is the only
  production caller of probe_with. Meter::detect() is
  detect_with(ioreport_status), which selects the rung only when the pure
  admits(status) does, and admits() takes only Live.
- probe_with() is generic over the opener, the sampler, the tick and the
  clocks, and returns a ProbeRun: the watch, and a record of every sample
  call (when it began, wall and thread CPU, and the keyed CPU and GPU Energy
  values handed to the rule) and of every tick (thread CPU and wall). The
  record is kept in every build; only tests read it.
- The FFI read only reads: (group, name, raw) per channel. group_sample()
  does the rest, and tests run it.
- Fault injection inside the real parts, in test builds only (#[cfg(test)],
  thread-local, undone by a guard): EnergyModelSubscription::open can be made
  to fail, and its FFI read can be passed through or replaced by a script. A
  tap keeps each ProbeRun. A dependant, or an integration test, links a
  library with none of this.
- counts_once, the counter's filter, drops PACC*, EACC*, MACC* and *CPM*
  (with their _SRAM rails) beside the CPU aggregates: eoc-meter's rule from
  7aa42418. On a live rail the counter would have counted that CPU energy
  twice.
- Two #[doc(hidden)] counters, ioreport_probe_runs() and
  ioreport_energy_model_opens(), so an integration test can count.

Tests (45: 44 in the library, 1 integration test):

- No pin reads wall time. Group-only subscription, by structure: every
  sample the real probe took carried exactly the Energy Model group's
  channels as the oracle counts them on its own all-channel subscription
  (364 of 11,886-12,038 on the M5 Max, 645 of 14,345 on the M3 Ultra), none
  from another group. By cost: the median THREAD CPU of the probe's FFI
  sample calls, by the tests' own clock, is under 50 ms. Measured: 5.8-6.2
  ms, with or without an all-channel sampler beside it.
- The real host_status(), uncached, under fault injection. Its opener
  failing: Unavailable, with no read, no tick and no CPU burned. A scripted
  batch refresh: Frozen, one advance. A scripted constant rail: Frozen, no
  advance. A rail rising at every read: Live, after three reads and two
  ticks. Two dies stepping once each, in different intervals: Frozen. In
  each, between every two reads the thread burned 25 ms of its CPU, or the
  tick ran into the 500 ms wall cap, by the tests' own clocks, and no tick
  shows more CPU than wall time.
- Clock identity, on the tests' own clock ids (their literals 16 and 12,
  read with clock_gettime_nsec_np). The probe's thread clock advances under
  5 ms across a 100 ms sleep, and under 5 ms while a helper thread burns 100
  ms and the process clock advances at least 100 ms. It tracks 30 ms of this
  thread's CPU to within 1 ms.
- Values handed to the rule. The real host_status() runs, uncached, while an
  oracle on another thread reads back to back. Rails only count up. So for
  each sample handed to the rule, a rail the oracle saw hold must have been
  handed the oracle's value exactly, and a rail that moved a value between
  the oracle reads either side. The CPU and GPU key sets must equal the
  oracle's. Freshness is forced: the probe's second read is held until the
  oracle sees a rail differ from the first read (or has looked twice, and
  3 s have passed). Every later read must then show the rise. In every run
  below, on both chips, the GPU rail gave that witness within 0.06-0.9 s, at
  the oracle's first or second read. Pure versions of the two-die case run
  through probe_with.
- Once per process, counted: eight threads, released by a barrier, ask for
  the status and build the meter. HOST_PROBE_RUNS and the Energy Model opens
  must both be 1. tests/ioreport_probed_once.rs does this in a process of its
  own. The macOS CI job runs only `--lib`, which skips that file, so a lib
  test does it too: the_liveness_probe_runs_once_in_a_fresh_process re-runs
  its own binary, filtered to itself, with a marker in the environment, and
  fails if that process fails or ran no test.
- admits() on all four statuses. detect_with() gives the time×power model on
  every status but Live, and the IOReport meter on Live wherever the counter
  opens.
- counts_once_drops_subrails pins the rule on the real channel names of both
  chips. Its summed lists are exactly what counts_once admits in a dump of
  each chip's Energy Model group taken for this commit (17 of 364 channels
  on the M5 Max, 29 of 645 on the M3 Ultra). In that dump, CPU Energy =
  MCPU0 + MCPU1 + PCPU to 1 mJ, and DIE_n_CPU Energy = EACC + PACC0 + PACC1
  to 1-2 mJ.
- No early return passes by default. Where a test finds no Energy Model
  subscription, or no counter, the oracle must find none either.
- Kept: every earlier pin, and the "ioreport liveness: status=" line.

Found while verifying: the freshness test could fail on correct code. Its
witness only waited 3 s. Under load one all-channel oracle read can take
longer, and a witness seen after the probe's second read had been released
was then asserted as a floor under it. It failed that way once, under an
unrelated mutation. The witness now counts only if it was seen before the
release, and the wait is counted in the oracle's reads.

Evidence, on this tree less one type alias (see the last item):

- Mutations. Each was a temporary edit, then the whole suite (library and
  integration tests), then git checkout; the tree was clean after each. All
  17 fail at least one test:
  - every sample compared with the first: 10 tests ("a batch refresh read
    as live: [("CPU Energy#0", 2)]");
  - a decrease counts as an advance: the pure decrease test;
  - no subscription -> Live inside host_status: host_status with its opener
    failing ("no Energy Model subscription");
  - a single advance -> Live in host_status's composition: 2 host_status
    tests; the same inside probe_with's production sample closure (it
    fabricates later advances): 4 tests;
  - a no-op tick passed by host_status: 5 tests ("tick 0 used 9.209µs of
    this thread's CPU in 9.291µs ... a tick must burn 25ms of thread CPU");
  - a per-thread cache: the lib fresh-process pin ("probe runs = 8") and
    tests/ioreport_probed_once.rs;
  - the sampler returns constant 0: both oracle tests ("CPU Energy#0 held at
    2588973125 ... the probe's sampler read 0");
  - a stale sampler, three ways: repeating the process's first sample (2
    tests), each subscription address's first sample (2), and each live
    subscription's first sample, so only later reads are stale (1, the
    forced witness: "GPU Energy#0 moved 126278986003314 -> 126278989490261
    ... probe read 1, but the probe handed the rule 126278982679214, outside
    them: a stale or made-up sample");
  - the thread clock read as CLOCK_MONOTONIC: 6 tests; as
    CLOCK_PROCESS_CPUTIME_ID: the clock-identity test;
  - admits() accepts CpuRailAbsent: 2 tests; detect() gates only on
    CpuRailFrozen: "status Unavailable gave IoReport(IoReport)";
  - the counter sums every Energy Model rail: counts_once_drops_subrails;
    the probe hands the rule the sum of every rail as its CPU rail: 10 tests,
    the hardware ones included.
- Contention. The lib test binary ran 5 times while another process sampled
  IOReport over all channels in a tight loop (384 samples, median 144-313 ms
  each), at 1-minute load averages of 10.6-12.1: 44/44 passed in all 5. The
  probe's median thread CPU per sample call was 5.99-6.22 ms, its median
  tick 25.06 ms of thread CPU, 30-31 intervals. Every run printed
  status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false, and every
  run asserted staleness. The helper was killed by a trap; none remains.
- M3 Ultra, macOS 27.0.1, the same binary once: 44/44. DIE_0 and DIE_1 read
  equal to the oracle (38945708 and 42131327 mJ), the median sample cost 5.8
  ms of thread CPU, staleness was asserted through the GPU rail, and the
  fresh process counted probe runs = 1.
- jcp-cli, jcp-conformance (lib and pack) and jcp-intercept pass. The
  conformance binary prints "all checks passed", C9 included. clippy
  -p jcp-energy --no-deps --tests is clean for aarch64-apple-darwin,
  x86_64-apple-darwin and x86_64-unknown-linux-gnu. The freshness test's new
  Witness struct drew one type_complexity warning. A type alias, added after
  the runs above, clears it; the suite passed again after it (44 + 1).

Not done here, deliberately:

- The macOS CI job's `cargo test --lib -p jcp-energy` still skips
  tests/ioreport_probed_once.rs; the lib pin covers the same ground there.
  The workflow is PR #102's.
- C9 still meters `|| ()`, so a declined meter giving 0 passes it. That is a
  conformance-suite change.
- read_uj still samples every channel. ANE energy is not summed on either
  chip: there is no `ANE Energy` aggregate, and counting its rails needs a
  whole-sample rule, which eoc-meter leaves for later too. The receipt
  carries no IoReportStatus (a wire decision). CPU_RAIL_LIVENESS_BUDGET is
  public on macOS only.
- The same defects in jouleclaw-energy, proof-cascade and sandbox-meter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jouleclaw-energy: one tested composition is the host status, and only Live opens a counter
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m41s
standards workspaces build + test / aac-rs (pull_request) Successful in 33s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 34s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 32s
standards workspaces build + test / map-rs (pull_request) Successful in 27s
standards workspaces build + test / mesh-rs (pull_request) Successful in 24s
standards workspaces build + test / openpay (pull_request) Successful in 1m20s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m44s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 58s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m18s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m12s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m7s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m34s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
c445d15028
Round 5 left two dangerous survivors. Tests only ever saw CpuRailFrozen, the
one status either Mac produces, so these two passed 57/57 on both Macs:
- OWN1: a counter that declined only a frozen rail.
- OWN4: a host cache that took "no subscription" for a live rail.
It also left four fail-safe survivors:
- a constructor that re-implemented the probe, which went uncounted;
- a zero or stale transform between the probe's record and the rule;
- a tick that always ran to its wall cap;
- a latent flake on a host whose rail is live.
In the sibling crates, a wall-time median pin also failed on correct code under
concurrent all-channel IOReport sampling. This commit closes all of these.

The production chain
- probe_host_status() is the one production status. It bumps REAL_RUNS, then
  returns status_of(probe_with(open_probe_sampler, probe_tick, <monotonic
  clock>, CPU_RAIL_LIVENESS_BUDGET)). It has no branch of its own.
- probe_with opens a sampler and watches it. If nothing opened, or no
  interval was observed, it returns None, which status_of maps to Unavailable.
- crate::ioreport_status() now holds the process-wide OnceLock. It is the only
  production caller of probe_host_status.
- AppleIoReportCounter::new() is new_given(ioreport_status()). new_given
  declines, before opening anything, unless admits(status) says yes. admits is
  a pure, exhaustive match that is true for Live alone.
- GROUP_OPENS counts every call of the real group opener. Only the probe opens
  the group. So a constructor that re-implements the probe around the cache
  shows up there, even though it bypasses REAL_RUNS. Both counters are read
  through #[doc(hidden)] ioreport_probe_runs() and ioreport_group_opens().

Every status is tested
- admits: Live admits; Unavailable, CpuRailAbsent and CpuRailFrozen do not.
- new_given with Unavailable, CpuRailAbsent or CpuRailFrozen:
  - opens no subscription of any kind and samples nothing;
  - is unavailable and names the status;
  - read() returns NoCounter.
- new_given(Live) opens the all-channel subscription once and probes nothing.
- OWN1 now fails: "a counter given Unavailable opened a subscription or
  sampled: Record { group_opens: 0, all_channel_opens: 1, ... }".
- The once-per-process pin now lives in its own integration test,
  tests/ioreport_probe_once_per_process.rs, so it runs in a process of its own.
  A barrier releases 9 threads:
  - a third ask for the status first;
  - a third build a counter first;
  - a third call detect_meter first.
  REAL_RUNS and GROUP_OPENS must both end at 1. Without the ioreport backend
  the test asserts that nothing probed.
- In the lib test process the uncached status now runs on purpose, so the
  unit test there only checks that every thread sees the one cached status.

Fault injection inside the production code
- A test-only, thread-local seam sits in four places:
  - inside the real group opener;
  - inside the all-channel opener;
  - inside the FFI leaf of the real sampler, sample_raw, now split from the
    pure energy_model_channels filter;
  - at the top of RailWatch::observe.
- A test can script whether the group opens and what each sample holds.
  Everything between the leaf and the rule is production code, including the
  real 25 ms thread-CPU tick and the real clock.
- The real, uncached probe_host_status runs under these scripts:
  - The group does not open: Unavailable, one open call, zero sample calls.
    OWN4 now fails: "the production status with no Energy Model subscription".
  - The M5 Max's own single refresh: CpuRailFrozen.
  - A rail advancing twice: Live, after exactly 3 sample calls.
  - A constant rail with one failed sample: CpuRailFrozen. The failed sample is
    kept from the rule, and the verdict comes only after the whole budget.
  - Two dies stepping in different intervals: CpuRailFrozen. The script also
    has same-named sub-rails and an out-of-group channel named "CPU Energy"
    that moves in every sample. Each die reaches the rule at its own value.
  - No CPU rail: CpuRailAbsent.
- Every scripted run also checks two things:
  - The rule was handed exactly the Energy Model channels of each sample that
    came back.
  - Each tick between two sample calls burned at least 25 ms and under 50 ms of
    this thread's CPU on the tests' clock, unless it hit the 500 ms wall cap.

What the rule is handed
- The record is now taken at the rule's input (observe), not beside the
  sampler. A zero or stale transform anywhere between the FFI leaf and the rule
  now shows up in it.
- The freshness test runs the real uncached composition while an oracle reads
  the group on its own subscription. It checks:
  - the rule's input equals what the sampler read;
  - its CPU and GPU rails are the oracle's;
  - each bracketed sample equals the oracle where the rail held, and lies
    between the oracle's reads where it moved;
  - a rail the oracle saw change inside the window also changes in what the
    rule was handed.
- On a host whose rail is live, the probe stops after two intervals (~60 ms),
  and one such window often holds too few oracle reads. So the test runs the
  composition up to 8 times, until one run has evidence and a moving rail.
  - No evidence in 8 runs fails.
  - Evidence but no moving rail passes, and the test prints that such runs
    cannot tell a fresh sampler from a stale one.

Cost and clocks, with no wall-clock bound
- The group-only pin is structural. Every channel of every sample the real
  probe took is in the Energy Model group. Each sample holds exactly as many
  channels as the oracle's own group subscription: 364 on the M5 Max, 645 on
  the M3 Ultra.
- The cost pin is the median THREAD CPU per sample call, under 50 ms on the
  tests' clock. The wall-time median is gone.
- No hardware test asserts a wall-clock bound or ratio. The one wall-clock
  check left is a lower bound in a scripted test: a Frozen verdict comes only
  after the whole budget.
- The tests read their own CLOCK_THREAD_CPUTIME_ID, the literal 16 from the
  SDK's <_time.h>. Against it, the production clock:
  - does not advance over a 200 ms sleep (10.5 µs);
  - does not advance while a helper thread burns 200 ms (57 µs);
  - advances by what the tests' clock saw over this thread's own burn
    (100.001 ms for 100.0008 ms).
- Each production tick burns at least 25 ms and under 50 ms of this thread's
  CPU (unless it hits its 500 ms cap), and no more CPU than wall time.
- The probe's own record of its ticks and sample costs is gone: ProbeRun,
  Tick, DELIBERATE_PROBE, NO_ENERGY_MODEL_GROUP and PROBE_RUNS. The tests now
  measure the ticks themselves, between sample calls.

counts_once
- Ported from eoc-meter 7aa42418. Beside the CPU aggregates, counts_once now
  also drops:
  - per-cluster accumulators (PACC*, EACC*, MACC*);
  - CPU power managers (*CPM*), with their _SRAM rails.
  It matches on the die-stripped name. The test pins the real channel names of
  both chips.

Evidence (this commit's tree; M5 Max, macOS 27.0.1, hw.ncpu=18)
- Tests with --features ioreport:
  - lib: "test result: ok. 66 passed; 0 failed" (57 before);
  - ioreport_probe_once_per_process: 1 passed, "real probe runs=1, Energy
    Model group opens=1";
  - vendor_energy_conformance: 1 passed.
- Tests without the feature: lib 24, integration 1, conformance 1.
- Every probe status line said "ioreport liveness: status=CpuRailFrozen
  oracle=CpuRailMoved(false) selected=false available=false".
- Load runs, on the lib test binary built from this tree (sha256 df3f59b0…):
  - 5 runs while a helper process sampled IOReport over all ~11,900 channels in
    a tight loop (~185 ms a sample): 5/5, each "66 passed". Median sample-call
    thread CPU 6.95-8.65 ms, median tick 25.071-25.079 ms.
  - 5 runs under 18 busy-loop spinners (1 x ncpu, load1 14.2 -> 27.3): 5/5.
    Median sample-call thread CPU 6.59-6.82 ms.
  - Every helper was killed; kill -0 and pgrep found 0 left.
  - A light group-only watcher of mine was also running during these runs.
- M3 Ultra (10.10.10.2, 27.0.1), the same binaries, run with --nocapture and
  then deleted:
  - lib "test result: ok. 66 passed; 0 failed", and the once test "real probe
    runs=1, Energy Model group opens=1".
  - 645 channels per sample, equal to the group; median 7.10 ms; DIE_0 and
    DIE_1 keyed apart.
  - GPU Energy changed 6 times inside the window and 7 times in what the rule
    was handed: fresh.
- Simulated live host (a temporary edit makes is_live true after two
  intervals; restored with git checkout), freshness and group-only tests only:
  - 10/10 unloaded, 10/10 under the all-channel helper.
  - Under the helper, the median thread CPU over the 3 sample calls was
    6.1-8.3 ms; round 5's wall-time median was 100-202 ms there.
  - Every ~57 ms window held 3-4 oracle reads.
  - Where no rail moved in any of the 8 windows, the test said it cannot tell
    fresh from stale: 4 of 10 unloaded, 6 of 10 under the helper. In this
    simulation the CPU rail is really frozen. On a genuinely live host the rail
    itself moves in nearly every interval.
- Mutations: 22, each an exact-string edit of this tree, run with
  cargo test --no-fail-fast (lib and integration), then restored with
  git checkout (clean after each). All 22 failed:
  - new_given declines only CpuRailFrozen: "a counter given Unavailable opened
    a subscription or sampled".
  - no subscription -> Live inside probe_host_status: "the production status
    with no Energy Model subscription".
  - a single advance -> Live inside the production closure: "one batch refresh
    counted as a live rail" and "two dies' separate steps ... made a live rail".
  - the rule compares with the first sample: 11 tests fail.
  - a decrease counts as an advance: "a fall counted as an advance".
  - a no-op tick: "production tick 0 burned 416ns ...".
  - a tick that always runs to its wall cap: "production tick 0 burned
    455.483167ms ... a tick stops once it has burned 25ms".
  - a per-thread cache: "the real probe ran 10 time(s) and asked for the Energy
    Model group 10 time(s)".
  - a constructor that probes uncached: "ran 19 time(s) and asked ... 19".
  - a constructor that re-implements the probe through probe_with: "ran 1
    time(s) and asked for the Energy Model group 19 time(s)".
  - a zero or stale transform in the production sampler closure: "the rule was
    not handed exactly the Energy Model channels of each sample".
  - a zero transform inside watch() before observe: 7 tests fail.
  - zeroing inside the rule after the record: 6 tests fail.
  - the sampler reads constant 0: "("CPU Energy", 0) held at 2643843378 across
    the oracle's reads either side of it, but the rule was handed 0".
  - the thread clock is CLOCK_MONOTONIC: "this thread slept 200ms, and the
    production thread clock advanced 210.015ms".
  - admits accepts CpuRailAbsent: "CpuRailAbsent admitted the counter".
  - counts_once re-admits PACC_*: "PACC_0, a summand of CPU Energy on an M5
    Max, is summed beside it".
  - extras:
    - CLOCK_PROCESS_CPUTIME_ID: "advanced 1.767591s".
    - the probe on the all-channel subscription: 9 lib tests and the
      integration test fail.
    - each CPU rail handed to the rule as the sum of all CPU rails: fails on
      this one-rail M5 Max through the two-die script.
    - a tick of 30%: "burned 7.50375ms".
- Consumers, all passing:
  - jouledesk-github: 10/10, 12 passed each.
  - jouleclaw-loader-gguf with fabric-meter: 299 passed, 2 ignored.
  - jouleclaw-harness-econ: 14.
  - mesh-meter with hw-meter-apple: 10.
  - l3-jouleclaw: 2 + 4, with 2 + 2 ignored.
  - energy_probe prints "ioreport   : declined (CpuRailFrozen)".
  - ~/.jouleclaw/fabric-calibration.json is unchanged (2026-06-11 11:02:14).
- cargo check --all-targets, with and without ioreport, on
  x86_64/aarch64-unknown-linux-gnu and x86_64/aarch64-apple-darwin: 0 warnings.
- clippy --no-deps --all-targets --features ioreport shows only warnings that
  were already there:
  - the duplicated link attribute, now apple_ioreport.rs:112;
  - the b"IOReportChannels\0" literal in sum_energy_uj, now :823;
  - ledger.rs:631-634.
- rustdoc --document-private-items shows only its three earlier warnings.

Found, not fixed here: one batch refresh is two advances, so the rule can say
Live on this frozen host
- During the first mutation pass, the per-thread-cache mutant ran about a dozen
  real probes in one lib test process. Two of them, on two test threads, said
  Live, and the oracle tests failed:
  - "probe said Live (probed=CpuRailFrozen, cached=Live), the oracle did not
    see the CPU rail keep moving";
  - "left: [CpuRailFrozen, ...] right: [Live, ...]".
- CPU Energy moved in one batch refresh, 2,588,973,125 -> 2,643,843,378,
  between 17:54 and 17:59:38. That mutant ran from 17:58:24 to 17:58:34.
- On this chip CPU Energy is the sum MCPU0 + MCPU1 + PCPU: 311,045,470 +
  313,039,186 + 2,019,758,721 = 2,643,843,377, against 2,643,843,378.
- A watcher sampled the Energy Model group every ~16 ms: 20 minutes with no
  refresh, then the next one caught one. The refresh landed as two steps of
  every cluster and of CPU Energy, 64 ms apart:
    t=1076.463s CPU Energy 2643843378 -> 2705607863 (+61764485) sample#56065
    t=1076.528s CPU Energy 2705607863 -> 2705611298 (+3435)     sample#56068
  (MCPU0 +9470641 then +788, MCPU1 +9747542 then +844, PCPU +42546301 then
  +1802.)
- The probe's intervals are ~31 ms: a 25 ms tick plus a ~6 ms sample. A probe
  whose window holds such a refresh sees the same rail advance in two distinct
  intervals, which is Live under the current rule.
- A probe lands on a refresh rarely: its window is 1 s, and the two refreshes
  seen here came about 30-34 minutes apart. When it does, production caches
  Live for the whole process. The counter would then sum batch-refreshed CPU rails, plus the GPU,
  tagged ModelBased: the over-claim this branch exists to stop.
- The same mutant on the final tree left the lib tests at 66/66, with no
  refresh during that run.
- The rule itself (the same rail advancing in two distinct intervals) is the
  cross-crate rule every sibling crate shares, so it is left unchanged here.
  One direction would demand what a live rail shows and a refresh cannot: a
  rail that advances in most intervals over a minimum count, for example at
  least 5 intervals with 80% of them advancing. The M5 Max GPU rail changed in
  166 of 176 samples ~28 ms apart. A refresh gives at most 2 or 3 advancing
  intervals in a row.

Not done here
- Changing the liveness rule, per the finding above. It needs one decision
  across jouleclaw-energy, eoc-meter, proof-cascade, sandbox-meter and
  jcp-energy.
- The counter's own read() still samples every channel. It is declined on both
  Macs.
- No CI job runs these ioreport tests: .github/workflows is off-limits to this
  branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-meter: a rail is live only if it keeps advancing, never on a two-step refresh
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m41s
standards workspaces build + test / aac-rs (pull_request) Successful in 33s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 37s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 39s
standards workspaces build + test / map-rs (pull_request) Successful in 36s
standards workspaces build + test / mesh-rs (pull_request) Successful in 26s
standards workspaces build + test / openpay (pull_request) Successful in 1m29s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m52s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m7s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m38s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m50s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m34s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m48s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m47s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m11s
65ff45ba75
The jouleclaw-energy verifier found the defect on 2026-09-30, on the M5 Max
(macOS 27.0.1). The liveness rule said Live when the same CPU rail advanced
in two separate sample intervals (LIVE_RAIL_ADVANCES = 2). That rule assumed
a batch refresh lands as one step. It does not always. A watcher sampling
the Energy Model group alone every ~16 ms saw `CPU Energy` step
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. MCPU0, MCPU1 and PCPU each stepped twice
too. The probe samples every ~31 ms, so a probe window that holds such a
refresh sees two advancing intervals and says Live, and the status is cached
for the process. That happened: two real probes said Live while the oracle
saw no sustained movement, and both coincided with a refresh. Refreshes come
~30 minutes apart, so this is rare, but it is exactly the failure the rule
exists to prevent. On this M5 Max `CPU Energy` has held at 2,705,611,298,
that refresh's second step, in every run for this commit that printed it.

The rule now asks for a sustained advance. A rail is live only if the same
rail (same key: name + ordinal) meets three bars after some interval:

- it advanced in at least MIN_LIVE_ADVANCES = 5 separate intervals;
- those were at least 80% of every interval observed so far
  (advancing * 5 >= intervals * 4);
- the samples observed so far span at least MIN_LIVE_WINDOW = 300 ms of
  wall time, measured from the first usable sample.

It is evaluated after each interval, and Live is declared the first time all
three hold. A burst of steps fails the window while it is young, and fails
the share once it is not. The rule needs each sample's wall time, so
RailWatch::observe now takes it: watch() reads its injected clock as each
sample returns, and tests script that clock.

The probe keeps sampling until the rail is Live, or until the budget (1 s)
has elapsed and at least 5 intervals were observed, and never past 5
budgets. Otherwise the verdict is Frozen, Absent or Unavailable, as before.
Nothing else changed: per-rail keys, consecutive-sample pairing, a decrease
or a first appearance is not an advance, one advance per rail per interval,
the group-only subscription, the thread-CPU tick, the pure status mapping,
the OnceLock cache, the scripted leaves, try_detect_given / admits_counter,
counts_once, and the "ioreport liveness: status=" line.

The cost: a live rail is declared after 300 ms at the least, not ~60 ms. A
frozen verdict still takes the ~1 s budget.

The test oracle had the same weakness. It said CpuRailMoved(true) when one
rail changed at two reads, so a refresh that coincided with a false Live
vouched for it. Now it is CpuRailMoved(true) only if one rail changed at
least 5 times, at no fewer than 80% of the read pairs it compared. It keeps
reading past its window until it has compared 5 pairs (6 reads), so a live
rail could meet the bar, and it stops at 5 windows. Too few pairs is still a
test failure, never a verdict. The per-read tally is now a pure OracleTally,
so the bar runs on scripted reads too.

The docs that described the old two-step rule are updated: the constants,
CpuRail, RailWatch, the macos_ioreport module comment,
IoReportStatus::{CpuRailFrozen, Live}, CPU_RAIL_LIVENESS_BUDGET and
ioreport_status.

Tests. The rule tests use a scripted 31 ms interval unless noted. (*) marks
the ones that must fail under the old ">= 2 advances" rule. That was
checked by temporarily restoring the old rule: 13 tests failed, every (*)
test among them. The F9 test failed on its two-step script: "a refresh in
two steps (5 samples) must be frozen", left: Live.

- (*) h_the_two_step_refresh_is_frozen: the observed +61,764,485 / +3,435
  refresh is Frozen at every position tried. The steps land in intervals
  (3,4), (1,2), (1,3), (3,5), (20,21) and (31,33), beside a moving GPU
  rail and sub-rails that step with it.
- (*) i_a_refresh_in_three_to_five_steps_at_the_start_is_frozen: 3, 4 or 5
  steps within 150 ms at the start (30 ms a sample) are Frozen. At five
  steps every interval so far has advanced; only the window stops it.
- (*) j_the_m3_ultra_two_steps_on_each_die_are_frozen: DIE_0 and DIE_1
  each step twice within 64 ms, together or one interval apart, at the
  start and mid-probe, amid same-named per-die channels. Frozen.
- d_a_rail_advancing_every_interval_is_live_at_five_advances_and_300_ms:
  not Live at 279 ms; Live at the 10th interval (310 ms), and the probe
  stops there. At 60 ms a sample it is Live at the 5th interval (exactly
  300 ms); at 59 ms, only at the 6th.
- k_a_live_rail_missing_one_interval_in_five_is_live_two_is_not_yet:
  missing 1 interval in every 5 is Live at 8 of 10. Missing 2 of the first
  5 (100 ms a sample) is not Live at 3 of 5 or at 7 of 9; it is Live at
  8 of 10. Missing 2 in every 5 is Frozen.
- l_a_live_rail_on_a_loaded_host_is_live_after_five_intervals: at 100 ms
  intervals, Live after exactly 5 intervals (500 ms).
- f_the_budget_stops_the_probe_only_once_five_intervals_are_seen: with
  fewer than 5 intervals the probe keeps sampling past the budget, and the
  5-budget cap stops it (at 2 s ticks: 3 intervals, all advancing, Frozen).
- sustained_needs_five_advances_80_percent_and_300_ms: each bar at its
  edge.
- probe_with_the_two_step_refresh_is_frozen and
  probe_with_a_sustained_advance_is_live: the same rule through
  probe_with's fakes. The sustained case is Live at 12 intervals, 300 ms.
- F9, the real host_status() on scripted IOReport leaves. The old "two
  advances -> Live at the third sample" script is now a sustained advance:
  Live after 6..=14 samples and >= 300 ms of real wall time. Also added:
  (*) the two-step refresh through host_status() gives CpuRailFrozen.
- the_oracle_does_not_count_a_refresh_as_movement: the two-step refresh,
  a single step and a five-step burst are no movement; 5 of 5, 16 of 20
  and every read are movement; 15 of 20 and 4 of 4 are not.
- Minimums follow the rule. A frozen verdict holds at least 5 ticks, and
  the budget comments say five intervals.

Mutations. Each was a temporary edit, then `git checkout`; the tree was
clean after every one. All four were caught (tests that failed):

- MIN_LIVE_ADVANCES = 2: f_..., l_..., sustained_...
- 80% share check removed: a_decrease_is_not_an_advance (a rail rising
  and falling in turn), i_..., k_..., sustained_...
- 300 ms window check removed: c_..., d_..., i_..., k_...,
  probe_with_a_sustained_advance_is_live, sustained_...,
  host_status_is_the_rule_on_the_production_composition
- oracle back to ">= 2 changes":
  the_oracle_does_not_count_a_refresh_as_movement

Evidence.

- M5 Max, unloaded: eoc-meter has 45 lib tests (37 before) and the
  once-per-process test, all passing.
  `ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
  selected=false (oracle: 7 reads over 3.062004833s with a helper thread
  burning CPU; changes per CPU rail [0], per GPU rail [5], of 6 compared
  pairs)`
- M5 Max, 3 runs of all eoc-meter tests beside a helper process sampling
  every IOReport channel in a tight loop: 3/3 ok, 45 + 1 passed each. The
  oracle read past its 3 s window to reach 5 compared pairs:
  `ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
  selected=false (oracle: 6 reads over 3.664727125s with a helper thread
  burning CPU; changes per CPU rail [0], per GPU rail [4], of 5 compared
  pairs)`, then 11 reads over 3.54 s, then 9 reads over 3.57 s. The
  fresh probe said Frozen every time (12-14 intervals over ~1 s).
- M3 Ultra (10.10.10.2, macOS 27.0.1), 1 run of the lib and
  once-per-process test binaries: 45 + 1 passed.
  `ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
  selected=false (oracle: 18 reads over 3.101584s with a helper thread
  burning CPU; changes per CPU rail [0, 0], per GPU rail [17], of 17
  compared pairs)`
- The whole eoc workspace: 621 passed, 0 failed, 3 ignored. The ignored
  three are the pre-existing ignored doctests.
- clippy --no-deps on eoc-meter, all targets: no new warnings. The only
  two are the pre-existing ones (a duplicated #[link] attribute, and a
  b"...\0" literal). The aarch64-linux cross clippy could not run here:
  blake3's C build finds no Linux sysroot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
sandbox-meter: a rail is live only if it keeps advancing, never on a two-step refresh
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m37s
standards workspaces build + test / aac-rs (pull_request) Successful in 32s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 33s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 30s
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 1m18s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m28s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m47s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m13s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m8s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m8s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m39s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m13s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m40s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m34s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m38s
d35c99affa
The CPU-rail liveness rule said Live once the same rail advanced in two
separate sample intervals. On 2026-09-30 the jouleclaw-energy verifier, on
the M5 Max under macOS 27.0.1, showed that two advances can be one batch
refresh. A group-only watcher sampling every ~16 ms saw `CPU Energy` go
2,643,843,378 -> 2,705,607,863 (+61,764,485) and then, 64 ms later,
-> 2,705,611,298 (+3,435); `MCPU0`, `MCPU1` and `PCPU` each stepped twice
with it. This probe samples about every 31 ms, so a probe window that holds
such a refresh sees two advancing intervals and says Live, cached for the
process. Two real probes did exactly that while the independent oracle saw no
sustained movement, coinciding with a refresh. Refreshes are ~30 minutes
apart, so this is rare, but it is the failure the rule exists to prevent: a
Live status selects IOReport for the life of the process, and every later
refresh lands whole in whichever span straddles it, sealed as a measurement.

The rule now asks whether one rail KEEPS advancing. Live iff the same rail
(name + ordinal) advanced in at least MIN_LIVE_ADVANCES = 5 separate
intervals, those advancing intervals are at least 80% of all intervals
observed so far (advancing * 5 >= intervals * 4), and the samples so far span
at least MIN_LIVE_WINDOW = 300 ms of wall time from the first sample. It is
evaluated after every interval and latches Live as soon as all three hold.
A refresh of fewer than five steps can never pass; one of five or more steps
inside 150 ms cannot pass at any steady sampling pace (five advances in
150 ms need intervals of 37.5 ms or less, and then 300 ms holds eight
intervals or more, of which the burst advanced in under two thirds). A live
rail at the probe's idle cadence proves itself at the tenth interval, ~310 ms.

The rule needs each interval's wall time, so RailWatch::observe takes the
wall-clock reading with each sample, and the loop (`watch`) passes its clock
read just after the sample returned; RailWatch records them in `at`.
LIVE_RAIL_ADVANCES (2) becomes MIN_LIVE_ADVANCES (5), with MIN_LIVE_WINDOW
and a pure `sustained(advances, intervals)` beside it. The loop keeps
sampling until Live, or until the 1 s budget has elapsed AND at least five
intervals were observed; the hard cap stays five budgets. Frozen, Absent and
Unavailable map exactly as before. Unchanged: per-rail keys, reading each
interval against the previous sample, a decrease or a first appearance not
being an advance, one advance per rail per interval, the group-only
subscription, the thread-CPU tick, the pure status mapping, the OnceLock
cache, the F9 fault seam, the Span::stop and run_attributed rules, and the
"ioreport liveness: status=" line.

The test oracle's "moved" verdict had the same hole: CpuRailMoved(true) on
two changes, so the refresh that fooled the probe could also vouch for it.
It now says moved only if one rail changed at five reads or more, at four
reads in five at least (oracle_saw_it_keep_moving, its own literals). It
reads for its whole window and past it until it has five reads, never past
four windows, and still fails the test on fewer. Under contention it did
extend: two runs took 5 reads over 3.64 s and 3.19 s of a 3 s window.

Tests added (scripted clock, ~31 ms an interval unless stated). Those marked
(*) fail under the old two-advance rule, as the old-rule run below shows.

- (*) the_observed_two_step_refresh_is_frozen: [a,a,a,a+61764485,
  a+61764485+3435], then flat for a 1 s budget, and the same with the steps
  two intervals apart: Frozen, never live at any interval.
- (*) a_refresh_in_three_to_five_steps_at_the_start_is_frozen: three, four
  and five steps in consecutive intervals inside 150 ms: Frozen.
- (*) an_m3_ultra_refresh_stepping_twice_per_die_is_frozen: DIE_0 and DIE_1
  each stepping twice within 64 ms, in step and apart: Frozen.
- a_rail_advancing_every_interval_is_live_not_before_five_advances_and_300_ms:
  live first at interval 10 at 31 ms, 5 at 60 ms, 5 at 75 ms (where 300 ms
  arrives at the fourth), and stays live.
- a_live_rail_missing_one_interval_in_five_is_live (80%, wherever the miss
  falls: interval 10 at 31 ms, 6 at 100 ms) and
  a_rail_missing_two_intervals_in_five_is_not_yet_live (60% never, and a
  rail that missed two of its first five is live only at the tenth, 8 of 10).
- a_loaded_host_with_a_live_rail_is_live_after_five_intervals: 100 ms
  intervals, live at the fifth (500 ms), not the third or fourth; the loop
  stops there with six samples and five ticks.
- two_rails_that_advance_in_turn_are_not_one_live_rail, for two dies and
  for twin channels, so per-rail keying still decides a verdict now that two
  single steps no longer could.
- Keep-sampling: the_probe_samples_until_live_or_long_enough_to_tell and
  the_probe_loop_needs_the_budget_and_five_intervals_to_stop (continues past
  the budget while fewer than five intervals, stops at five, stops at the
  cap with three); the loop hands the rule each sample with the clock just
  after it (the_probe_loop_stops_as_soon_as_a_rail_is_live pins `at`).
- the_oracle_says_moved_only_for_a_rail_that_changed_at_four_reads_in_five.

Real-host-composition (F9) tests, through host_status on a scripted
IOReport: the "two advances -> Live" script is now a rail advancing at every
sample (Live after 6..=14 samples, and a scripted real_run shows from its own
record that it stopped at the first interval the rule allows); new
the_production_status_of_the_observed_two_step_refresh_is_frozen
(CpuRailFrozen, watched >= 900 ms of the budget); the two-dies test now has
the dies advancing in turn for the whole budget, so a sampler that summed
the dies would be called Live; frozen-status tests expect five intervals.
The same in-turn change was made to the probe_with sum test.

Mutations, each a temporary edit restored with git checkout (tree clean
after each):
- the old rule (MIN_LIVE_ADVANCES = 2, no fraction, no window): 17 failed,
  including all three (*) tests and the F9 two-step refresh ("a two-step
  refresh was called Live").
- MIN_LIVE_ADVANCES = 2: 6 failed (e.g. the loaded host went live at the
  third interval, Some(3)).
- fraction check removed: 5 failed (five-step refresh live at interval 10;
  in-turn dies live; 60% rail live).
- window check removed: 7 failed (five-step refresh live at interval 5).
- oracle back to ">= 2 changes": 1 failed, the oracle verdict test.

Runs. sandbox-meter lib 83 passed, integration 1 passed, on this M5 Max
(macOS 27.0.1). Three runs of the lib and integration binaries beside an
all-channel IOReport reader (~337 ms a sample): 0 of 6 failed, each
"ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
selected=false available=false". The M3 Ultra (macOS 27.0.1), same binary:
83 passed, "ioreport probe: probed=CpuRailFrozen cached=CpuRailFrozen
oracle=CpuRailMoved(false) took=1.240480083s intervals=28
window=1.002887292s rails=[("DIE_0_CPU Energy", 0, 0), ("DIE_1_CPU Energy",
0, 0)]". Consumers: jcp-gateway + jcp-action-receipt 32 passed,
joule-code-remediate 13 passed, joule-ui-interaction + joule-ui-serve 12
passed, the rest of sandbox-rs 186 passed. clippy --no-deps on
sandbox-meter, all targets: no warnings on aarch64-apple-darwin or
x86_64-unknown-linux-gnu (x86_64-apple-darwin shows one pre-existing
unused-mut in detect_given, untouched here).

Not done here, deliberately:
- A refresh of five or more steps whose last interval is stalled well past
  its tick (a heavily loaded host) could still meet all three conditions;
  no such refresh has been seen, and the observed ones have two steps.
- The same rule in the four sibling meters is being changed separately.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
proof-cascade: a rail is live only if it keeps advancing, never on a two-step refresh
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m34s
standards workspaces build + test / aac-rs (pull_request) Successful in 31s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 34s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 31s
standards workspaces build + test / map-rs (pull_request) Successful in 26s
standards workspaces build + test / mesh-rs (pull_request) Successful in 24s
standards workspaces build + test / openpay (pull_request) Successful in 1m18s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m12s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m8s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m34s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m15s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m5s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 5m1s
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
906ed1a60f
The liveness rule said Live once the same CPU rail advanced in two separate
sample intervals. That was meant to stop a batch refresh (one step, then a
hold) from vouching for a frozen rail. But a refresh is not always one step.
On an M5 Max running macOS 27.0.1, the jouleclaw-energy verifier ran a
group-only watcher that sampled the Energy Model group every ~16 ms. It saw
`CPU Energy` go 2,643,843,378 -> 2,705,607,863 (+61,764,485), then 64 ms
later -> 2,705,611,298 (+3,435). `MCPU0`, `MCPU1` and `PCPU` each stepped
twice with it. The probe's intervals are ~31 ms, so a probe window that
holds such a refresh sees the rail advance in two intervals and says Live.
That answer is cached for the process. There, two real probes said Live
while the oracle saw no sustained movement, at the time of a refresh.
Refreshes come ~30 minutes apart, so this is rare. But it is exactly the
failure the rule exists to prevent: the meter is selected over a frozen
rail, and every later refresh lands whole in some bracket.

The rule, the same in all five crates that carry it. Live iff, after some
interval, the same CPU rail (name + ordinal):
- advanced in at least MIN_LIVE_ADVANCES = 5 separate intervals;
- advanced in at least 80% of all intervals seen so far
  (advances * 5 >= intervals * 4);
- was watched for at least MIN_LIVE_WINDOW = 300 ms of wall clock since
  the first sample.
The rule is checked after each interval. Live is declared, and the probe
stops, the first time all three hold. The pure rule now takes each sample's
wall-clock reading. The probe passes the read's `end`, and the tests script
the clock. Sampling goes on until Live, or until the 1 s budget has passed
and at least 5 intervals were seen. The hard cap stays at 5 budgets. On a
quiet M5 Max a live rail is now called live after ~10 intervals (~310 ms),
not 2.

Unchanged: per-rail keys; consecutive samples; a decrease or a first
appearance is not an advance; one advance per rail per interval; the
group-only subscription; the thread-CPU tick; the pure status mapping; the
OnceLock cache; the F9 fault hooks; proof-cli's messages; the RAPL per-zone
wrap code; and the "ioreport liveness: status=" log line.

The oracle. It called a rail moving after 2 changes, so a two-step refresh
in its window could vouch for a false Live. Now it calls a rail moving only
if the rail changed at 5 or more of its reads and at 80% or more of them.
Its count moves into an `OracleTally` that the tests feed scripted reads.
It still extends its window while it has fewer than 5 reads, but the cap is
now 8 budgets (was 3). Under contention it needed up to 6.8 s of the old
9 s cap to get 5 reads. Too few reads is still a failure, never a "not
moved".

Tests. Those marked (*) fail under the old rule.
- (*) The observed two-step refresh, with MCPU0/MCPU1/PCPU stepping beside
  it. Frozen after the whole budget: in the pure loop, in the composition,
  and in the real `host_status()` on scripted reads (CpuRailFrozen).
- (*) A refresh in 3, 4 or 5 steps within its first 150 ms, back to back or
  spread out: Frozen.
- (*) The M3 Ultra shape, where DIE_0 and DIE_1 each step twice within
  64 ms: Frozen.
- (*) Two advances of one rail over 300 ms: not live. Five advances in six
  intervals over 600 ms: live.
- A rail that advances every 31 ms interval is Live at the tenth (310 ms).
  It is not Live after 5 advances (155 ms) or 9 (279 ms), and it stays Live.
- A rail missing one interval in five is Live at exactly 80% (8 of 10). One
  missing two in five is never Live. One that missed two of its first five
  is not yet Live at intervals 7-9, where the count and window are already
  met, and is Live at the tenth.
- On a loaded host (100 ms intervals), Live comes at the fifth interval
  (500 ms), not the third (300 ms).
- The probe keeps sampling past the budget while it has fewer than 5
  intervals and stops at 5. It stops at the cap with 3.
- The oracle's bar, on scripted reads: a one- or two-step refresh is never
  "moved".
Scripts whose meaning changed were rewritten so they still catch what they
were written for:
- "Two advances -> Live" became a sustained-advance script. It must be Live
  after 5 or more intervals and 300 ms or more of the real clock, at the
  first interval where the tests' own reading of the record says so.
- The one-step-per-die scripts became two dies that advance in alternate
  intervals, so a sum of the rails advances every interval and fails.
- The decrease and first-appearance tests now come after four advances over
  400-500 ms.
- The record test pins which wall reading the rule gets.
`assert_verdict_matches_record` recomputes the new rule with the tests' own
literals, and checks that a Live run stopped at the first interval that met
it.

Mutations. Each was a temporary edit, restored with git checkout; the tree
was clean after each.
- The old rule (2 advances, no share check, no window): 21 lib tests fail,
  including every (*) test, e.g. "a two-step refresh counted as a live rail:
  Run { verdict: Some(Live), intervals: 4, ...".
- MIN_LIVE_ADVANCES = 2: 7 fail. For one, the loaded host is "Run { verdict:
  Some(Live), intervals: 3, samples: 4, took: 300ms".
- Share check removed: 6 fail, e.g. "a rail advancing in 60% of intervals
  counted as live" and "a refresh in steps [1, 2, 3, 4, 5] counted as live:
  Run { verdict: Some(Live), intervals: 10, ...".
- Window check removed: 8 fail, e.g. "a refresh in steps [1, 2, 3, 4, 5]
  counted as live: Run { verdict: Some(Live), intervals: 5, samples: 6,
  took: 150ms" and "Live after 127.469542ms of window".
- Oracle back to ">= 2 changes": "two steps in consecutive reads: the
  oracle called a refresh moving".

Runs.
- proof-cascade tests, 3 times, beside an all-channel IOReport sampler
  (150-570 ms a sample on this loaded host): 68 + 1 + 1 passed each time,
  e.g. "status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false
  probe_intervals=29 ... oracle_reads=5". The sampler was killed after each
  run.
- M3 Ultra (macOS 27.0.1): the lib test binary was built here, run once,
  then deleted. 68 passed, with "status=CpuRailFrozen
  oracle=CpuRailMoved(false) selected=false probe_intervals=27 ...
  oracle_reads=20".
- The proof-rs workspace is green, and proof-conformance prints "all checks
  passed". clippy --no-deps shows no new warnings; the two in proof-cascade
  were already there.

Not done here:
- The Live branch is tested only on scripted rails. Neither Mac exposes a
  live CPU rail to an unprivileged reader, so a real Live has not been
  seen.
- The other four crates that carry this rule get it in their own commits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: a rail is live only if it keeps advancing, never on a two-step refresh
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m37s
standards workspaces build + test / aac-rs (pull_request) Successful in 34s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 34s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 32s
standards workspaces build + test / map-rs (pull_request) Successful in 28s
standards workspaces build + test / mesh-rs (pull_request) Successful in 23s
standards workspaces build + test / openpay (pull_request) Successful in 1m18s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m7s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
3fd35db5f9
The CPU-rail liveness rule said Live once the same CPU rail had advanced in
two separate sample intervals. On 2026-09-30 the jouleclaw-energy verifier
found a batch refresh that passes it. On this M5 Max (macOS 27.0.1), a
group-only watcher sampling the Energy Model every ~16 ms saw a refresh
published in two steps:

    CPU Energy 2643843378 -> 2705607863 (+61764485)
    CPU Energy 2705607863 -> 2705611298 (+3435)      64 ms later

and MCPU0, MCPU1 and PCPU each stepped twice with it. The probe's intervals
are ~31 ms (a 25 ms thread-CPU tick and a ~6 ms sample), so a probe window
holding such a refresh sees two advancing intervals and says Live, and the
Live is cached for the process. Two real probes did say Live while the
oracle saw no sustained movement, coinciding with a refresh. Refreshes are
~30 minutes apart, so this is rare, but it is exactly the failure the rule
exists to prevent: the rung selected on a frozen rail, and every later
refresh billed whole to whichever metered span straddles it. The rail on
this host still holds at 2,705,611,298, the value that refresh left.

The rule is now, for one CPU rail (name + ordinal):
- it advanced in at least MIN_LIVE_ADVANCES = 5 distinct intervals;
- those are at least 80% of all the intervals observed so far
  (advancing * 5 >= intervals * 4);
- the window observed so far spans at least MIN_LIVE_WINDOW = 300 ms of
  wall time from the first usable sample.
It is checked after each interval, and the rail is Live from the first
interval at which all three hold. The probe keeps sampling until Live, or
until the 1 s budget has elapsed and at least five intervals were
observed, capped at five budgets. A live rail is now called live about
300 ms in (12 intervals on an idle host) rather than ~60 ms.

The rule needs each interval's wall time, so watch() reads the clock after
every sample and hands that reading to RailWatch::observe; the tests script
it. Everything else stands: per-rail keys, consecutive samples, a fall or a
first appearance is no advance, one advance per rail per interval, the
group-only subscription, the thread-CPU tick, the pure status mapping, the
OnceLock, the test seams, and the "ioreport liveness: status=" line.

The oracle gets the same spirit. CpuRailMoved(true) now needs one rail to
have changed at 5 or more reads making up at least 80% of its reads. Its
all-channel reads are 0.13 s or more apart, so even a refresh spread over
150 ms shows as three changes at most, and a refresh coinciding with the
probe can no longer vouch for the Live it caused. It extends its window, up
to 5x, until it has five reads after the first, and still fails as
TooFewReads when it cannot. Its loop is now oracle_watch_with, over a read
function and a clock, so its verdict is tested on scripts.

Tests, on a scripted clock at 31 ms an interval unless stated; (*) fail
under the two-advance rule:
- (*) the observed two-step refresh, with the steps in consecutive
  intervals, 62 ms apart, and on a host at 160 ms an interval: Frozen;
- (*) a refresh in 3, 4 or 5 steps within 150 ms at the start of the
  window, and in 5 steps mid-window: Frozen;
- (*) the M3 Ultra shape, both dies stepping twice within 64 ms: Frozen;
- a rail advancing in every interval: Live at the 10th (310 ms), not at the
  5th (155 ms), on either die;
- missing 1 interval in 5 (exactly 80%): Live; missing 2 in 5: never; on a
  loaded host, 2 early misses: not at 5 of 7, 6 of 8 or 7 of 9, Live at 8
  of 10;
- a loaded host at 100 ms an interval: Live at the 5th (500 ms), not at the
  3rd, where the window is already met;
- keep-sampling: at 600 ms an interval the probe goes past the budget to
  five intervals (where a live rail is proven Live, at 3 s); at 1.5 s it
  stops at the 5 s cap with four;
- through probe_with: a sustained advance is Live at 10 intervals, and
  (*) the two-step refresh is Frozen;
- through host_status itself (the real opener, burn tick and clocks, with
  scripted FFI reads): (*) the two-step refresh is CpuRailFrozen; a
  sustained advance is Live with 5+ advances over a 300+ ms window by both
  the probe's clock and the tests' own, stopping as soon as it is live;
- the two-die summing check now has the dies advance in turn (each in half
  the intervals, summed in all): two one-step dies, summed, are only two
  advances, which no longer trips the rule;
- the oracle on scripts: the two-step refresh is CpuRailMoved(false) (it
  was true), a three-step refresh false, a change at every read true at the
  5th, 4 in 5 true, 3 in 5 false; slow reads are extended to five, reads
  too slow for that are TooFewReads.

Mutations, each a temporary edit restored with git checkout, the tree clean
after each:
- MIN_LIVE_ADVANCES = 2: 4 tests fail (the two-step refresh on a loaded
  host, loaded-host Live before the 5th interval, keep-sampling, the
  stopping rule);
- the 80% check removed: 4 fail (the 80% test, the 5-step refresh, two dies
  in turn through probe_with and through host_status);
- the 300 ms window removed: 6 fail (Live at the 10th, the 80% test, the
  5-step refresh at the start, the sustained advance through probe_with and
  through host_status, the stopping rule);
- the oracle's bar back to >= 2 changes: both oracle tests fail. The whole
  old oracle (2 changes, 2 reads) fails with "a two-step refresh vouched for
  a moving rail: OracleRun { verdict: CpuRailMoved(true), reads: 4, ...,
  cpu_changes: [2] ...";
- the whole old rule (2 advances, no share, no window): 15 fail, every (*)
  test among them.

Runs. The crate's tests pass (51 lib, 1 integration). Three runs under a
helper process sampling IOReport over all channels back to back (~250-280
ms a sample, load average 14-16), 51/51 each:

    ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 6 reads over 3.857275292s of a 3s window with a helper thread burning CPU; changes per CPU rail [0], per GPU rail [4])
    ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 7 reads over 3.051596791s of a 3s window with a helper thread burning CPU; changes per CPU rail [0], per GPU rail [6])
    ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 9 reads over 3.133878417s of a 3s window with a helper thread burning CPU; changes per CPU rail [0], per GPU rail [5])

On the M3 Ultra (macOS 27.0.1), the same binary, 51/51:

    ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 21 reads over 3.033503042s of a 3s window with a helper thread burning CPU; changes per CPU rail [0, 0], per GPU rail [19])

jcp-cli (it builds; it has no tests), jcp-conformance (with the conformance
binary: all checks passed, C9 energy provenance honesty among them) and
jcp-intercept pass. Clippy --no-deps and rustdoc over private items with
-D warnings are clean on jcp-energy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jouleclaw-energy: a rail is live only if it keeps advancing, never on a two-step refresh
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m38s
standards workspaces build + test / aac-rs (pull_request) Successful in 32s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 33s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 29s
standards workspaces build + test / map-rs (pull_request) Successful in 27s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m20s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m28s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m1s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m52s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m12s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m8s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / arl-rs (pull_request) Successful in 5m32s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 5m24s
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
962d34a766
The CPU-rail liveness rule said Live once one CPU rail had advanced in two
separate sample intervals. A macOS 27 batch refresh can meet that on its own.
On this M5 Max (macOS 27.0.1, 2026-09-30) a watcher sampling the Energy Model
group alone every ~16 ms saw one refresh land in two steps:

    CPU Energy  2643843378 -> 2705607863  (+61764485)
    CPU Energy  2705607863 -> 2705611298  (+3435)      64 ms later

with MCPU0, MCPU1 and PCPU each stepping twice alongside. The probe's
intervals are ~31 ms (a 25 ms CPU tick and a ~6 ms group sample), so a probe
window that holds such a refresh sees two advancing intervals and says Live,
and that answer is cached for the process. It was seen to happen: under the
per-thread-cache mutant, two real probes said Live while the oracle saw no
sustained movement, at the moment of a refresh.

The rule is now the family rule for the five crates that probe this rail
(this commit changes this crate only):

- Live iff one CPU rail (same key: name + ordinal) advanced in at least
  MIN_LIVE_ADVANCES = 5 distinct intervals, those are at least four in five
  of all the intervals observed so far (advancing * 5 >= intervals * 4), and
  the samples so far span at least MIN_LIVE_WINDOW = 300 ms of wall-clock
  time from the first. It is evaluated after every interval, and is Live as
  soon as all three hold.
- The probe keeps sampling until Live, or until the 1 s budget has elapsed
  and at least 5 intervals were observed; hard cap 5 budgets.
- The rule is handed each sample's wall-clock time: `observe(sample, at)`,
  where the loop passes its clock's reading as each sample comes back, and
  the tests script it.

To be Live, a refresh would have to step in eight of the probe's first ten
~31 ms intervals, which is what a live rail does and a refresh does not. A
live rail (the M5 Max GPU rail changed in 13,125 of 13,614 samples 100 ms
apart, 96%) is Live after ten unloaded intervals (310 ms), or five 100 ms
intervals on a loaded host (500 ms). Everything else is unchanged: per-rail
keys, consecutive samples, a decrease or a first appearance is no advance,
one advance per rail per interval, the group-only subscription, the
thread-CPU tick, the pure verdict-to-status mapping, the OnceLock cache,
REAL_RUNS and GROUP_OPENS, the F9 seam (which also records the time handed
with each sample now), new_given/admits, counts_once, the consumers, and the
"ioreport liveness: status=" line. The docs that described the two-interval
rule (module doc, RailWatch, IoReportStatus, ioreport_status, detect_meter,
the Apple estimator) describe this one.

The test oracle is held to the same bar. It said CpuRailMoved(true) at two
changes, so a refresh landing in its window could vouch for a false Live.
Now it says so only if one rail changed at five or more of its reads and at
four in five or more of them, over its whole window. It reads on past its
3 s window until it has five reads (capped at four windows), and a CPU rail
read fewer times still fails the calling test as no evidence. The freshness
test's "cannot tell fresh from stale" disclosure is now bounded: a run whose
probe said Live must show the oracle seeing a CPU rail change inside the
probe's window (Live means the rule was handed one advancing in five or more
intervals over 300 ms or more), so the disclosure can only cover runs that
were not Live, and a stale sampler can never make one Live.

Tests. Those marked (*) fail under the old two-advance rule.
- (*) the_observed_two_step_refresh_is_frozen: the refresh above, with its
  steps in consecutive intervals and 64 ms apart.
- (*) a_refresh_in_three_to_five_quick_steps_is_frozen: 3, 4 and 5 steps in
  the first 150 ms, one per interval.
- (*) an_m3_ultra_two_die_two_step_refresh_is_frozen: DIE_0 and DIE_1 each
  stepping twice within 64 ms. A modelled shape: the M3 Ultra has been seen
  to refresh in one step per die.
- (*) the_production_status_of_a_two_step_refresh_is_frozen: the same
  refresh through the real uncached host status function (F9 seam).
- a_rail_advancing_every_interval_is_live_not_before_five_advances_and_300_ms:
  Live at interval 10 unloaded (310 ms) and 5 loaded (500 ms); not at 9
  (279 ms) or at 3 (300 ms).
- a_rail_missing_one_interval_in_five_is_live (Live at exactly 8 of 10) and
  a_rail_missing_two_intervals_in_five_is_not_yet_live.
- the_window_runs_from_the_first_sample_on_the_clock_handed_in, and
  two_rails_advancing_in_turn_are_not_one_live_rail.
- The watch-loop tests at 31 and 100 ms intervals and past the budget: the
  probe stops at five intervals, keeps going with fewer, stops at the cap.
- the_production_status_of_a_rail_that_keeps_advancing_is_live replaces "two
  advances -> Live after exactly 3 sample calls": Live after 6 to 13 sample
  calls, each sample's time handed to the rule inside the tests' own clock
  bracket, at least 300 ms watched, and not Live one sample earlier.
- the_oracle_says_moved_only_for_a_rail_that_kept_changing, and
  the_oracle_reads_on_past_its_window_until_a_live_rail_could_meet_its_bar.
The lib tests go from 66 to 76.

Mutations, each a temporary edit restored with git checkout (the tree clean
after each), lib tests with --features ioreport:
- the old rule (two advances, nothing else): 13 fail, all four (*) among them
- MIN_LIVE_ADVANCES = 2: 6 fail
- the four-in-five check removed: 3 fail
- the 300 ms window check removed: 6 fail
- the oracle back to ">= 2 changes": 2 fail
- also caught: the window measured from the clock's origin (3 fail), the
  loop handing the rule a zero time (3), ">" for ">=" in the share (2).

Runs. jouleclaw-energy lib and integration tests with and without
--features ioreport (76 + 1 + 1, and 24 + 1 + 1). Three times beside an
all-channel IOReport sampler looping in another process, all green:

  ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false oracle_reads=7
  ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false oracle_reads=5
  ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false oracle_reads=5

The oracle extended itself to five reads in up to 5.5 s where its 3 s window
gave fewer. On the M3 Ultra (macOS 27.0.1), 76 passed:

  ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false oracle_reads=16

The scripted sustained rail went Live after 13 sample calls there and in
every M5 Max run (rule window 302-322 ms). The consumers pass:
jouledesk-github (12), jouleclaw-loader-gguf --features fabric-meter (367),
jouleclaw-harness-econ (14), mesh-meter --features hw-meter-apple (10),
l3-jouleclaw-binding (6). clippy --no-deps --features ioreport gives the
same six warnings as before, none new; rustdoc adds none.

Not done here:
- The freshness test's Live assertion never runs on either Mac here, since
  both are CpuRailFrozen; it guards a live host.
- The budget rule stops at five intervals once 1 s has passed, even if the
  window is still under 300 ms. That only happens if the first sample took
  more than ~700 ms, and it declines a live rail, which is the safe side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
proof: a live vendor counter's reading is HwShunt, and the docs say so
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m1s
standards workspaces build + test / aac-rs (pull_request) Successful in 41s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 43s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 39s
standards workspaces build + test / map-rs (pull_request) Successful in 33s
standards workspaces build + test / mesh-rs (pull_request) Successful in 36s
standards workspaces build + test / openpay (pull_request) Successful in 2m53s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m52s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m18s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m14s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m8s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m35s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
a96c6154c4
The owner settled the grade question this branch left open: a vendor energy
counter (RAPL, NVML, Apple IOReport) gives a real reading; what it lacks is
independent provenance, not accuracy. So a figure read from a counter that is
live for the domain it claims is HwShunt. proof-cli already tagged a --measure
figure that way, but the crate still said elsewhere that a macOS reading was
ModelBased, and the CLI's own comment called the grade "an open grade decision,
not settled here". This commit makes the specification, the schema and the docs
agree with the code, and adds the liveness requirement where the grades are
defined. No behaviour changes; one test is tightened.

What changed:

- SPEC.md section 5 and proof-schema's Provenance::HwShunt doc. HwShunt now
  names vendor energy counters next to a shunt, a power meter and the SMC. Both
  carry the family's liveness MUST word for word: a vendor counter's reading MAY
  be graded as a hardware reading only while the counter is demonstrably live
  for the domain the figure claims, observed advancing under load (for Apple
  IOReport, the CPU energy rail itself, in at least 5 sample intervals, in at
  least 4 of every 5, over at least 300 ms, while the probing thread burns CPU).
  A counter that is present but not live, frozen or republished in lumps, MUST
  NOT produce a hardware-graded figure. The spec adds that the fallback here is
  ModelBased or Estimator. Energy::measured's doc points at the requirement.
  No conformance vector is touched.

- proof-cascade meter.rs. The module header said "a macOS reading is
  ModelBased, not a true coulomb-shunt HwShunt; Linux RAPL is the shunt-grade
  path". It now says a figure from this meter is HwShunt on both platforms,
  states the liveness condition, and says what each platform does about it. The
  docs on measure_marginal_uj and power_over say the same, and power_over's
  doc gives the probe's thresholds. The Linux section is new and honest about
  a gap: there is no liveness probe for RAPL. It is selected whenever a zone is
  readable, a frozen counter gives Some(0), which is not a figure, and nothing
  tells a counter that tracks the load from one that drops a lump into the
  busy window.

- meter.rs's RAPL comment said energy_uj is "world-readable on most
  distros". It has not been since late 2020: kernel commit 949dd0104c49
  ("powercap: restrict energy meter to root access", 5.10, backported to
  stable) made it root-only as the CVE-2020-8694 / PLATYPUS mitigation. An
  unprivileged process usually reads no zone, so the meter declines unless it
  runs as root or read access has been granted.

- proof-cli main.rs. The module doc called T0 and T1 "both real with measured
  energy". Their energy is proof-solvers' wall time x a ~15 W power model,
  tagged ModelBased. It also said "T2/T3 reference stand-ins", but the CLI runs
  resident_z3_ladder, which has no T2/T3 rung. Those three goals come back
  unproved, as `proof` prints ("proved 6/9 ... T2=0 T3=0"). Both are fixed,
  and the --measure comment now states the grade.

- proof/README.md. It said `--measure` gives an HwShunt reading "where an
  energy interface is present". It now says where the meter produces one, which
  on Apple Silicon needs a CPU rail seen to be live. It adds that on the Macs
  measured so far (macOS 27.0.1, M5 Max and M3 Ultra) --measure declines and
  every figure stays ModelBased, and that RAPL is root-only on Linux. It no
  longer says the solver rungs have "measured energy". It no longer says
  clippy is warning-free: that holds for the Linux target, but on macOS
  proof-cascade's IOReport FFI has two lints. (The "via macmon" text was
  already gone in bedef9b0.)

- proof-cli tests/measure.rs. bedef9b0 had already moved this test off a
  second probe in the test process and onto the decision the CLI prints. But
  the printed status was tied to the CLI's behaviour for only two of its four
  values. A CLI that printed ioreport_status=CpuRailAbsent while acting on
  CpuRailFrozen passed: run against the unmodified test at 906ed1a6, it gave
  "ok. 1 passed". Now every IOReport reason in the output must belong to the
  printed status. A declined meter must print its own status's reason. The
  status must be one of the four. Off macOS it must be Unavailable, with no
  IOReport reason.

Verified on the M5 Max (macOS 27.0.1, z3 present). The whole proof-rs
workspace passes: proof-cascade 68, ioreport_once 1, ioreport_unopenable 1,
measure 1, conformance pack 4 + soundness 5 + lib 1, receipt 8, schema 6,
solvers 11. proof-conformance prints "all checks passed". The measure test
passed 3 runs, each "declined=true available=false
ioreport_status=CpuRailFrozen". clippy --no-deps --tests shows only the two
existing proof-cascade lints, meter.rs's duplicated #[link] attribute and its
hand-built nul-terminated string. It is clean for x86_64-unknown-linux-gnu,
which type-checks the test's off-macOS branch but does not run it. rustdoc
reports no warnings.

Falsified, each mutation restored from a saved copy and its hash checked
afterwards:
- the CLI prints IoReportStatus::CpuRailAbsent in place of the status it
  used: "the CLI printed ioreport_status=CpuRailAbsent but gave the reason for
  CpuRailFrozen";
- prints Unavailable: "... printed ioreport_status=Unavailable but gave the
  reason for CpuRailFrozen";
- prints Live: "on macOS the meter is available exactly when IOReport is
  Live";
- prints !available: "the CLI's output disagrees with the decision it
  printed";
- the frozen reason is replaced by a generic one: "the meter declined with
  ioreport_status=CpuRailFrozen, but not for that status's reason".

Not done here:
- a RAPL liveness probe. Under the new MUST, the Linux path's only evidence is
  the bracket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp: a live vendor counter is a reading; intercept/1 keeps a partial figure partial
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m14s
standards workspaces build + test / aac-rs (pull_request) Successful in 43s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 46s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 40s
standards workspaces build + test / map-rs (pull_request) Successful in 40s
standards workspaces build + test / mesh-rs (pull_request) Successful in 34s
standards workspaces build + test / openpay (pull_request) Successful in 2m27s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m52s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m21s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m32s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m35s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
340313d6b3
The questions this branch left open are now decided. A vendor energy counter
(RAPL, NVML, Apple IOReport) is a real reading; what it lacks is an
independent root, which is a gap in provenance, not in accuracy. It earns the
hardware grade only while it is live. The live GPU rail is not reported as a
partial reading of CPU work. And a partial figure must stay partial on the
intercept/1 wire.

The grade. jcp-energy already graded RAPL and NVML `HwShunt`. The IOReport rung
carried a live reading at `ModelBased`, in the modeled slot. It now takes the
same grade: `Meter::provenance()` says `HwShunt` and a live span is
`Energy::measured`. The declined paths keep their own grades: the time×power
model is `ModelBased`, the constant estimator `Estimator`.

Raising the grade raises the stakes of what `measure()` accepts, so it is
gated per span as well. It took any rise of the summed SoC total as the span's
energy, and that total rises through the GPU rail, which is machine-wide. On
this M5 Max, in this commit's own test runs, the counter counted 32 mJ and then
9.1 J between two reads while an independent oracle saw the CPU rail hold and
one other rail move. A rail that was live at selection can still hold across
one span: a span shorter than its update interval, or a rail that stops
publishing after the probe. The counter now reports the CPU rails' share beside
its total, from the same delta sample. `measure()` grades a span `HwShunt` only
when the CPU rail itself advanced. The figure is still the SoC total's rise,
which, like a RAPL package counter, over-debits the work rather than leaving
part of it out. A span whose CPU rail held, or whose read failed, is the
time×power figure at `ModelBased`. A model figure is never `HwShunt`. The grading
is one pure function, `ioreport_span`, so every branch runs on scripted reads on
any platform. The oracle test that checks the counter's total value for value,
against its own all-channel subscription, now checks the CPU share the same way.

The GPU rail is not reported as a partial reading. 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 because the
CPU's share would go unmetered. The rung is declined to the whole-task estimate.
That rationale now sits in the rung's docs and in SPEC §5.1.

SPEC. §5 says `HwShunt` covers vendor counters as well as shunts, and carries
the liveness MUST in the shared wording. Its dashes are typeset and `Partial`
is code-formatted; the text is otherwise as decided. §5.1 adds the IOReport
rung. A new §5.2 defines coverage and its wire encodings, carried only when
`Partial`. One is jcp/2's `energy_coverage`, which jcp-receipt already emitted
but the SPEC never listed, so §8's core field list gains it. The other is
intercept/1's `coverage`.

intercept/1. jcp-intercept dropped coverage, so a `Partial` figure handed to it
was sealed as the whole step's energy. It now adds `"coverage": "Partial"` only
when the figure is partial. `verify` recovers it in a new
`InterceptReceipt::coverage`, and refuses any other value, an explicit "Whole"
included, because a sealer never produces one. A whole figure seals the same
bytes as before. The two envelopes a Redact-then-CallLimit chain sealed at
3fd35db5 were captured by building that commit in a scratch crate before the
change, and are pinned hex for hex. A whole `HwShunt` figure's object is
compared, key for key and in canonical bytes, against the pre-coverage object.
No conformance vector changes, and none contains an intercept/1 receipt.

Prose. Provenance::HwShunt's doc names vendor counters and the liveness rule.
The README's jcp-energy bullet lists the IOReport rung. Its jcp-gateway bullet
no longer says "metered on real hardware ... energy actually spent". jcp-efficiency's
MAPPING §4 now says "real readings of the vendor's own energy counter" instead of
"power-sensor readings", and that a counter counts only while live. It also no
longer calls RAPL "a model, not a meter".

Tests. jcp-energy has 54 lib tests (51 before) plus its integration test.
jcp-intercept has 12 (8 before). jcp-schema has 30, jcp-conformance its unit
test and 5 pack tests, all passing. `jcp-conformance` prints "all checks
passed", and jcp-cli builds and runs. clippy --no-deps is clean on jcp-energy
and jcp-intercept. jcp-schema's one warning, in flow.rs, is pre-existing.

Falsified, each by a temporary edit restored with git checkout:
- A live span built with `Energy::modeled` fails
  `a_span_the_ioreport_cpu_rail_advanced_over_is_a_hardware_reading` and
  `the_ioreport_rung_stamps_the_hardware_grade`.
- `Meter::provenance()` for IOReport set back to `ModelBased` fails
  `the_ioreport_rung_stamps_the_hardware_grade`.
- Gating the span on the SoC total instead of the CPU rail fails
  `a_span_the_ioreport_cpu_rail_held_over_is_the_models_figure` with
  "the GPU rail rose, the CPU rail held ... provenance: HwShunt".
- A CPU share that counts every rail fails the hardware oracle test: "CPU share
  rose 18127 µJ ... CPU energy rails rose 0 µJ". That one needs a non-CPU rail
  to move during the window, and the GPU rail did.
- Sealing coverage even when Whole fails the byte-identity tests (and every
  test that verifies a whole receipt).
- Dropping coverage when Partial fails `a_partial_figure_seals_coverage_partial`.
- Letting verify read an explicit "Whole" fails
  `verify_refuses_a_coverage_other_than_partial`.

Not done here:
- jcp-gateway still seals `Energy::measured` for whatever sandbox-meter returns,
  estimates included. The sandbox-meter liveness branch fixes that, so the
  README's gateway line states the rule rather than this branch's gateway.
- The NVML rung reports GPU energy as a whole-task `HwShunt` figure. Under the
  new MUST, a figure for work its counter does not cover (the CPU's share) is
  `Partial`. That needs its own decision, and nothing here is built with
  `--features nvml`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
An allowed `tools/call` comes back with `_meta["science.transaction/jcp"]`:
the sealed receipt, `measured_uj` and `budget_remaining_uj`. Since this branch
made the gateway seal each call's real provenance, `measured_uj` is the call's
cost, which is a hardware reading only where the span was one. On a host with
no live energy counter (every macOS 27 host checked) it is the meter's
estimate, and the plaintext said nothing about which: the grade existed only
inside `receipt_b64`, so a client that read the number without decoding the
COSE envelope took an estimate for a measurement.

The key keeps its name, because clients already read it. Beside it the block
now carries `energy_provenance` (`HwShunt`, `ModelBased` or `Estimator`) and,
when the figure reads only part of the call (a GPU counter with no CPU
record), `energy_coverage: "Partial"`: the same two labels, with the same
omit-when-Whole rule, that the sealed receipt carries. `attach_receipt` takes
the cost `Energy` instead of its bare `u64`, so the label cannot drift from
the figure. The change is additive: nothing is removed from the block, the
receipt bytes are unchanged, and no published JCP or AR-1 vector moves.

AR-1's `joules_uj` was documented as "Measured cost in microjoules". It is the
work's cost however it was obtained, graded by `energy_provenance`; the field
doc, the family table ("measured joules") and AR-1-spec §2.2 now say so, and
§9's energy cross-check says it holds only where the figure is `HwShunt`.
Doc-only: the encoding and the pack are untouched.

Tests. `the_caller_gets_its_receipt_back` now decodes the receipt and asserts
that the plaintext `energy_provenance` and `energy_coverage` equal what it
sealed, and that a non-hardware meter's call is labelled `Estimator`; on this
host it observes `"energy_provenance":"Estimator","measured_uj":26577`. A new
`the_plaintext_figure_carries_its_grade` covers every cost shape: Estimator,
HwShunt whole, HwShunt Partial, ModelBased.

Falsified: removing the `energy_provenance` insert fails both tests
(`energy_provenance beside measured_uj`; `left: None, right:
Some("Estimator")`). jcp-gateway 13/13, jcp-action-receipt 17 + 3 conformance
pass, and `jcp-conformance` reports all checks passed. Clippy is clean on
jcp-gateway; its one jcp-action-receipt warning is in the untouched
`ar-verify` binary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Three places where joule-ui let a figure be read as more than it was.

The `Meter` trait defaulted `provenance()` to `HwShunt`. Any meter that did
not think to override it, a deterministic stub included, had its figures
sealed as hardware readings with `measured_uj` set. The default is now
`Estimator`: a meter opts in to `ModelBased` for a model and to `HwShunt` only
for readings of a hardware counter it has seen to be live. In this workspace
the only meters that are hardware readings are the two `SpanMeter` adapters
(the examples' and joule-ui-serve's), and both already return their span's
own grade, so nothing that reads hardware changes. The stubs in the
joule-ui-interaction, joule-ui-serve and joule-ui-mcp tests and the
`metered_surface` example now come out `Estimator`, as they should.

The conformance pack's `StepMeter` now declares `HwShunt` explicitly. C2 and
C4 were sealed with it standing in for a hardware counter, so they carry
`measured_uj` and exercise that path of the signed core; the explicit
override, documented as a fixture grade that attests nothing (like the
pack's steward key), is what keeps them byte-identical. SPEC §5 and §8 and
the conformance README say so.

`Authorizer::charge(action, measured_uj: u64)` handed the authorizer a bare
number, so an authorizer that keeps its own record of the debit (a JCP grant)
could not tell an estimate from a reading; the `jcp_metered` example worked
around it by reading the provenance back off the meter. `charge` now takes
`&Energy`, the exact figure the interaction is sealed with, provenance
included. Every implementor and caller in the workspace is updated, and
`jcp_metered`'s authorizer maps the charged figure's own provenance onto
`jcp_schema::Energy` instead of holding an `Rc` to the meter.

`run_interaction` turned a missing sample into `Energy::estimator(0)`. Work
that ran was sealed at 0 µJ and charged nothing, so on a host with no reading
a budget never bound, which SPEC §6 says is exactly what the budget is for.
Now, when either sample is `None`, the handler's own wall-clock time is
charged at the meter's `estimator_milliwatts()` (default
`DEFAULT_ESTIMATOR_MILLIWATTS` = 20 W, the Sandbox estimator's default),
rounded up and never 0 (`estimate_uj`), graded `Estimator`. A denial, whose
handler never ran, still records 0 µJ. The signed core and its profile tag
are unchanged.

SPEC: §5 states the default and carries the shared vendor-counter liveness
MUST verbatim (HwShunt only while the counter is demonstrably live for the
domain it claims; for IOReport the CPU rail advancing in >= 5 intervals, in
>= 4 of every 5, over >= 300 ms while the probe burns CPU; a frozen or lumped
counter never yields a hardware-graded figure; no claim of a domain the
counter does not read). The "real RAPL/IOReport" wording at §5 and "the
hardware Meter" at §9 now describe the reference meter as a hardware reading
while a vendor counter is live and its labelled estimate otherwise. §6.2
specifies the no-reading estimate and forbids recording work that ran at
0 µJ; §6.3 becomes "charge the cost, with its provenance".

Tests (joule-ui-interaction, new): `a_meter_is_an_estimator_unless_it_opts_in`,
`work_that_ran_without_a_reading_is_estimated_never_zero`,
`the_estimate_is_wall_time_at_the_declared_power_rounded_up`,
`a_budget_binds_on_a_host_with_no_reading` (a 1 J budget stops ~20 ms
interactions at 20 W within three, every debit `Estimator`), and
`charge_is_handed_the_sealed_figure_and_its_provenance`. All 31 tests across
joule-ui-interaction, -conformance (incl. the published-pack tests), -serve
and -mcp pass; the workspace builds with all targets; the three examples run
(`jcp_metered` here: five ~3 J `Estimator` interactions spend the 15 J grant
and the real JCP runtime denies the sixth). `gen_vectors` regenerated into a
scratch directory is byte-identical to `conformance/v1/` (all five files,
MANIFEST included).

Falsified, each restored and re-run green:
- default back to `HwShunt` with the fixture override removed: the vectors
  regenerate identical (under the old default they rested on the default),
  and `a_meter_is_an_estimator_unless_it_opts_in`,
  `charge_is_handed_the_sealed_figure_and_its_provenance`, joule-ui-serve's
  `firing_an_action_meters_seals_and_returns_the_next_surface` and
  joule-ui-mcp's `an_mcp_app_action_is_metered_sealed_and_attributed` fail;
- new default, override removed: `c2_interaction_is_reproduced_from_the_pack`
  and `c2_interaction_is_metered_and_allowed` fail, and the regenerated c2,
  c4 and MANIFEST differ (`"measured_uj": null, "provenance": "Estimator"`),
  so the override is what keeps the pack;
- `charge` handed `Energy::measured(uj)` regardless of grade:
  `charge_is_handed_the_sealed_figure_and_its_provenance` and
  `a_budget_binds_on_a_host_with_no_reading` fail;
- the no-reading arm back to `Energy::estimator(0)`:
  `work_that_ran_without_a_reading_is_estimated_never_zero` and
  `a_budget_binds_on_a_host_with_no_reading` fail.

Clippy --no-deps on the changed crates adds nothing; the remaining warnings
are in untouched code (joule-ui-core's tests, the conformance pack test).

Not done here: joule-ui's energy figure has no coverage field, and both
`SpanMeter` adapters grade a span whose only records are hardware GPU
records (NVML without a CPU probe) `HwShunt`, which claims the whole
interaction. §5's coverage clause forbids that; fixing it needs either a
coverage field in the signed core (a new profile tag) or a downgrade rule,
which is a decision, not a cleanup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The README's metering guarantee said the reference meter reads IOReport,
RAPL and NVML "each tagged `HwShunt`", and that "where no counter exists the
reading degrades to a labeled estimator, never a fabricated measurement". On
macOS 27 the IOReport CPU energy rails exist but are frozen, or republished in
lumps; read at face value, the sentence promised `HwShunt` there, which is the
fabricated measurement it disclaims. The meter on this branch does not do
that, so the README was describing the old behaviour.

It now says what the meter does: a CPU reading is `HwShunt` only while the
CPU counter is demonstrably live; IOReport is used only if its CPU energy rail
keeps advancing while the probe burns CPU (in at least 5 sample intervals, at
least 4 of every 5, over at least 300 ms), and a span across which the CPU
counter held is never a reading. A counter that is absent, or present but not
live, yields no hardware-graded figure: the span is the labelled `Estimator`
(wall-clock × TDP), and `ioreport_status()` says why an Apple Silicon host is
estimated. The heterogeneity paragraph's "`HwShunt` where the device exposes
an energy interface" becomes "a live energy interface" to match.

README only: no code, spec text or vector changes. sandbox-rs tests pass
(`cargo test --workspace`), and the vanity-metrics scanner is clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
joule-code-remediate: the description grades its joules, and the z3 tests never pass silently
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m24s
standards workspaces build + test / aac-rs (pull_request) Successful in 41s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 45s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 35s
standards workspaces build + test / map-rs (pull_request) Successful in 33s
standards workspaces build + test / mesh-rs (pull_request) Successful in 33s
standards workspaces build + test / openpay (pull_request) Successful in 2m22s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m28s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m50s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m18s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m15s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
ccbec44ab2
The crate description promised "a JouleContract receipt with
hardware-measured joules sealed by a sandbox meter attestation". The crate
does not do that on a host without a live CPU energy counter (every macOS 27
host checked): there its receipts carry an estimate, sealed as JCR-1
`Estimator` / JouleContract `Unaccounted`, as its own module doc says. The
description now says the joules are graded by how they were obtained: a
hardware reading apportioned to the run and anchored to a sealed meter
envelope only where the host exposes a live CPU counter, an estimate sealed
as one everywhere else. Two `FormalEntry`/`FormalOutcome` docs that called
the proof cascade's `ModelBased` accounting "measured joules" are corrected
too.

`a_proven_formal_obligation_lets_the_fix_ship` and
`a_refuted_formal_obligation_reverts_the_fix` returned before asserting
anything when `z3` was absent, so on a solverless host (the joule-code-rs CI
job installs none) they passed in 0.00 s whatever the formal gate did. They
now follow the convention proof-solvers and joulecontract-proof already use,
and that the standards-build workflow's "Install z3" step documents: the
suite passes with or without a solver and asserts something either way. With
`z3` the solver's verdict is asserted as before. Without it, both assert the
gate fails closed: the obligation is Unproved (neither proved nor refuted),
the `formal-proof` gate fails while every other gate passes, the fix is
reverted and not shipped, and the discharge evidence is still written. Each
logs which branch it asserted. Where a solver is supposed to be installed,
`JOULE_FIX_EXPECT_Z3=1` turns a missing `z3` into a test failure instead of
the solverless branch; this is documented on the test helper and in
Cargo.toml beside the proof dependencies. No workflow is changed, so CI
still runs the solverless branch until a job installs z3 and sets the
variable.

The formal gate's detail, which a failed gate copies into its receipt entry,
and joule-fix's summary line both said "real z3" even with no solver on
PATH. They now say "no z3 on PATH: SMT goals left undecided" in that case.

Verified, 13/13 each way: with z3 on PATH; with z3 removed from PATH; with no
git identity (GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 and
user.useConfigOnly=true); and with no identity and no z3.
`JOULE_FIX_EXPECT_Z3=1` with no z3 fails both tests ("JOULE_FIX_EXPECT_Z3=1,
but there is no working `z3` on PATH"); with z3 present it passes.

Falsified: letting an unproved obligation pass the gate (`all_proved` without
`unproved == 0`) fails both tests with no z3 on PATH. The same mutation
against the previous tests passes both, in 0.00 s. Clippy --no-deps on the
crate is clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
c6378629 listed two defects here as not done. `JouleCost::zero()` was tagged
`Measured`, and eoc-cache and the cascade's memoizer stamp every cache hit with
it, so each hit claimed a measured 0 µJ although no meter was read for it. The
receipt seals that tag. And `attach_cascade_cost` added the meter's delta over
the whole resolve to the stage's own cost, then tagged the sum `Measured` as
soon as the delta was positive. With a live counter (RAPL, NVML, a live
IOReport rail) a 50 J estimate plus a 500 µJ reading came out as 50,000,500 µJ
measured. A stage that meters itself on the cascade's counter reads a span
nested inside the cascade's own span, so its reading was counted twice: 300 µJ
inside a 500 µJ span came out as 800 µJ.

The rule now, as the owner set it: a live vendor counter's figure is
`Measured`, and nothing that was not read from one may be. So a total is
measured only when every microjoule of it was read from a live counter.

- `JouleCost::zero()` is `Estimated`. Its doc says what it is: a figure no
  counter read, which is what a cache hit costs by assumption. Every caller
  becomes honest at once: eoc-cache, the memoizer, and eoc-route-learned's two
  empty fallback responses, which also claimed a measured zero. No caller
  needed a measured zero; a counter that read but did not move has measured
  nothing. `JouleSource`'s and `JouleCost::measured`'s docs now say what
  measured means.
- `attach_cascade_cost` takes the span only when it is live: both reads
  succeeded and the counter advanced. Otherwise it adds nothing, and the
  stage's cost stands, figure and tag, as before.
- A live span and an estimated stage: the estimate is added, and the total is
  an estimate. The estimate may be energy the counter never saw (a remote
  backend), so it is added, not dropped.
- A live span and a measured stage: the stage read a counter on this host
  during the resolve, so inside the span. The total is the larger of the two
  readings, measured, never their sum. That is exact when the stage read the
  cascade's counter, and a lower bound if it read another; the cost carries no
  counter identity, so the cascade cannot tell.
- A live span and an estimated zero (a cache hit): the zero adds nothing, so
  the total is the span's reading alone and is measured. Every microjoule of
  it was read. Tagging it estimated would under-claim a real reading.

The eoc CLI test's hardware-counter branch asserted the old rule (measured
whenever the counter added anything). It now asserts the new one: the
backend's estimate plus the reading, tagged estimated. That branch does not run
on this Mac, where IOReport declines and the meter is the powermetrics stand-in
("eoc cost on this host: meter=powermetrics cost=50000000 µJ (estimated)").

Tests. The cascade is driven with a scripted counter that one test shares with
a self-metering neural backend, as a host's one counter would be. An estimate
plus a live 1,000 -> 1,500 span is `estimated(50_000_500)`, and its receipt
verifies. A stage that read 1,100 -> 1,400 inside 1,000 -> 1,500 costs
`measured(500)`, not 800. A cache hit inside a live 2,000 -> 2,250 span is
`measured(250)`. A counter that holds, fails its first read, or fails its last
leaves `estimated(42)` alone. Memoized and hand-inserted hits through a stub
cascade are `estimated(0)`. A table test covers every branch of the cost rule
and of the liveness check.

Falsified on the uncommitted tree. Each mutant was restored byte-for-byte and
re-touched so cargo rebuilt from the real source; a diff fingerprint taken
before and after matched:
- `zero()` back to `Measured` fails three tests, in eoc-core, eoc-cache and
  eoc-cascade;
- the cache stamping hits `measured(0)` fails the hand-inserted hit
  (`left: JouleCost { microjoules: 0, source: Measured }`);
- the memoizer stamping `measured(0)` fails the memoized hit;
- tagging estimate + span `Measured` fails with
  `left: JouleCost { microjoules: 50000500, source: Measured }`;
- summing a measured stage onto the span fails with `left: ... 800 ...`,
  `right: ... 500 ...`;
- HEAD's rule, verbatim, fails three of the new tests;
- counting a counter that held (`e >= s`) as live fails two.

eoc-bench, a separate workspace that builds against eoc-core, eoc-cache and
eoc-cascade, passes too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The owner's decision, applied: a vendor energy counter (RAPL, NVML, IOReport)
is a real reading, and what separates one such reading from another is
provenance, not accuracy. This crate gave RAPL and NVML `HwShunt` and gave the
IOReport counter `ModelBased`, on the reasoning that Apple's Energy Model is a
vendor model. That reasoning grades accuracy. What the counter could not
establish was provenance: on macOS 27.0.1 its CPU rails are frozen for an
unprivileged reader, and that is now caught by the liveness probe. A counter
that has passed it is as much a reading as RAPL is.

The grade. `AppleIoReportCounter` declares `HwShunt`, and stamps it on every
reading through one constant, `READING_GRADE`, shared by `provenance()` and
`read()`. It earns the grade by being live: the counter exists to read only
where `admits()` let it through (CPU rail `Live`), and a declined counter
produces no reading to carry any grade, so a frozen host still meters through
the load-average estimator at `Estimator`.

The rule, written once and shared (jouleclaw SPEC §1.3, §9.4; JCR-1 §2.3;
the crate docs; vendor-energy PROTOCOL/README): a vendor counter's reading 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 IOReport: the CPU rail itself, in
at least 5 intervals, 4 of every 5, over at least 300 ms while the probing
thread burns CPU). A counter present but not live 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.

No partial reading from the GPU rail. Where the CPU rail is frozen but `GPU
Energy` still advances (the M5 Max), the counter stays declined. The GPU rail
is machine-wide: every GPU client on the host moves it, display compositing
included, with nothing of the metered work running. It does not cover part of
the work; it covers other work, so no coverage field makes it honest. The
rationale is in the module docs and SPEC §1.3.

Docs and data that said otherwise:
- SPEC §1.3 table and floors, §9.4; JCR-1 §2.3 and the crate README (toy
  vectors become measurements only off counters that were live).
- JCR-1 vector 6's description said "measured on Apple IOReport". Its joules
  are toy values like every vector's. Only the `description` changed; it sits
  outside the signed core, which `verify.py` builds from `receipt` alone. Both
  verifiers pass 10/10 after the edit: `verify.py` (cbor2 6.1.4, cryptography
  50.0.2, exit 0) and the independent `verify.go` (exit 0). `git diff` of the
  file is that one line; a field-by-field JSON diff finds only (6,
  description). The conformance README's slot 6 line follows.
- vendor-energy conformance: README row 27 names `AppleIoReportCounter` and the
  `HwShunt` grade, with the liveness condition a filled fixture must meet;
  `apple-silicon-ioreport.json` is an unsigned, empty stub (`signed_by` null,
  every workload not run, no hash of it anywhere), so its `impl` and
  `provenance` are corrected in place.
- MEASURED_ENERGY.md: a dated note that the 74.1 J / 25.5 W row was committed
  2026-06-12, predates macOS 27, and recorded neither chip nor OS. The "reads
  CPU/GPU/ANE/DRAM with no sudo" claim is replaced by what macOS 27.0.1 does:
  the counter opens without sudo, but the CPU rails are frozen (all 645 rails
  on the M3 Ultra), so those hosts are estimated.
- local-index / rerank tier SHOULDs, the CLI's `meter` help, meter.rs,
  apple.rs, energy_probe, Cargo feature comments, harness-econ docs.
- l3-jouleclaw README: it claimed `HwShunt` on RAPL/NVML hosts, but the
  binding builds jouleclaw-energy with `ioreport` only, so off Apple Silicon
  the receipt is the token estimate. It now says what happens on each host.
- mesh-schema's `HwShunt` definition includes vendor counters subject to
  liveness; mesh-meter, the mesh README and mesh-engine-gguf no longer say
  real silicon alone yields `HwShunt`.
- The Live-branch assertions in mesh-meter, l3-jouleclaw and jouledesk-github
  expected `ModelBased` for IOReport; they expect `HwShunt`. jouledesk-github's
  fixtures that paired `apple-ioreport` with `ModelBased` now pair it with
  `HwShunt`, and keep a `ModelBased` case under a neutral meter name.

Tests. `a_live_counters_readings_are_graded_as_hardware_readings` builds a
counter on `Live` directly. That opens the real all-channel subscription, which
opens here even though the gate declines this host. It reads it twice and
asserts each reading is `HwShunt` over `SocTotal`. On this M5 Max (macOS 27.0.1):
"an admitted IOReport counter: opens=true, 2 readings, each graded HwShunt",
so the read path is covered on a frozen host. With `--features "ioreport
rapl"`, `a_live_ioreport_reading_has_the_grade_of_a_rapl_reading` compares the
grade with `RaplCounter`'s own. The used/declined tests assert `HwShunt` in
their live branch. This host still takes the declined branch: "ioreport
declined (CpuRailFrozen): 154 ms of CPU work metered by `apple-silicon` as
6200000 µJ, provenance estimator".

Run on this M5 Max: jouleclaw-energy 24+1+1 (default), 76 lib + 1 + 1
(`ioreport`), 79 lib (`ioreport rapl`); jouleclaw-jcr1 50; harness-econ 14;
local-index 41; rerank 37; cli 10; mesh-meter (`hw-meter-apple`) 10;
mesh-engine-gguf 1; l3-jouleclaw 2 + 4 (2 ignored, need a model);
jouledesk-github 12. All pass.

Falsified, each mutant restored with `git checkout` and the tree confirmed clean:
- `READING_GRADE` set back to `ModelBased`: the new test fails at the
  declared grade (left: ModelBased, right: HwShunt), 75 passed / 1 failed.
- Only `read()`'s stamp set back to `ModelBased` (the declared grade left at
  `HwShunt`): the new test fails on the reading itself, "a live counter's
  reading is not graded as a hardware reading" (ModelBased, SocTotal) vs
  (HwShunt, SocTotal), 75 / 1. The read path is what the test pins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`sci_from_eval_report` wrapped every eval report's energy in
`JouleCost::measured`, whatever produced it. An eval run against the echo
backend, a vendor API's token estimate or a stub-metered cascade came out as a
measured energy term. It could not do better: `EvalReport` dropped the
provenance of the costs it summed, and `SciScore` had nowhere to put one. The
end-to-end test `sci_for_ai` did the same in miniature. Its comment said
"pretend the meter reported 2000 J on the stub backend", and it built that
figure with `JouleCost::measured`.

What holds now:

- `EvalReport` gains `joule_source`. It is `Measured` only when every case's
  cost was read from a live counter. One estimated case makes the total an
  estimate, and so does a run with no cases, since nothing was read. Each case
  is separate work that ran, so an unmeasured case is not covered by any other
  reading. A report serialized before the field existed reads back as
  `Estimated` (`#[serde(default)]`): nothing in it says it was measured.
- `SciScore` gains `energy_source`, the input `JouleCost`'s provenance
  unchanged. It has the same serde default. The numbers do not change.
- `sci_from_eval_report` builds the energy term with the report's own
  `joule_source`.
- `sci_for_ai` uses `JouleCost::estimated` for its fixed 2000 J. No counter
  read that figure. It now asserts that the score says so.

Tests: a measured backend gives a measured report. An estimated one gives an
estimated report, and so does one that turns estimated from its third answer
on. An empty run is estimated. A report and a score without provenance read
back as estimated. A score carries its input's provenance, with the same SCI
either way. Under `eval-bridge`, a report's score keeps the report's
provenance.

Falsified, each mutant restored byte-for-byte:
- HEAD's `JouleCost::measured(total_microjoules)` in the bridge fails
  `an_eval_reports_score_keeps_the_reports_provenance` (`left: Measured`,
  `right: Estimated`). That test is behind the `eval-bridge` feature, which
  nothing in the workspace enables, so a plain `cargo test --workspace` never
  compiles it. It was run with `cargo test -p eoc-carbon --features
  eval-bridge`, 39 + 8 passing.
- `SciScore` ignoring its input's provenance fails two tests, one in the
  default build.
- the runner ignoring per-case provenance fails
  `the_report_is_measured_only_when_every_case_was`.
- `sci_for_ai` tagging its figure measured again fails that test.

Not done: `CarbonAccount` still drops provenance (`account_request` is the
other public way in). And a report with zero correct answers has
`joules_per_correct == u64::MAX` as a sentinel, which `sci_from_eval_report`
multiplies by `correct.max(1)` into a u64::MAX µJ energy term.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
467acb38 made a stream's energy measured only when its counter advanced. It
left this as not done: `StreamAccount` had no way to say "not metered". So a
stream metered by the stub, by a counter whose read failed, or by one that did
not move reported `0 µJ (estimated)`. That is work that ran, presented as a
figure of zero. Saying so honestly needed a change to a public type. Nothing
outside this file uses `StreamAccount` or `StreamMeter`, in this workspace or
any other in the monorepo, so the change is contained.

The fix is the honest one, not a duration-based estimate: the account does not
invent a figure. Any per-second or per-token coefficient would be a number
nobody measured, and callers already hold their own estimates.

- `StreamAccount.microjoules` is now `Option<u64>`: `Some` only when the
  counter read at the start and at the latest checkpoint and advanced between
  them, and `None` otherwise. The separate `measured: bool` is gone, since the
  `Option` says it.
- `joule_cost()` returns `Option<JouleCost>`: `Some(JouleCost::measured(..))`
  for a metered stream and `None` for one nothing metered. There is no
  estimated zero; a caller with no reading supplies its own estimate and tags
  it as one.
- Token counts are kept, metered or not. A failed later read still keeps the
  last reading, and a failed start still never turns a later read into the
  counter's since-boot total.

Tests: a counter that holds gives `{ tokens: 3, microjoules: None }` and no
cost. A counter that advances 450 µJ gives `Some(measured(450))`. A failed
start gives `None`. A stream that advanced 200 µJ keeps that figure when a
later read fails. The stub gives five tokens and no figure, and so does
`StreamAccount::default()`.

Falsified, each mutant restored byte-for-byte:
- recording a zero delta as the stream's figure fails three tests
  (`left: Some(0)`, `right: None`);
- returning HEAD's `0 µJ (estimated)` for an unmetered stream fails three
  (`left: Some(JouleCost { microjoules: 0, source: Estimated })`).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The crate's own honesty rule: energy figures carry provenance (`Measured`,
`Benchmarked`, `Estimated`), and nothing upgrades one into another.
`apply_calibration_file` broke it. It loaded every row of
`~/.jouleclaw/fabric-calibration.json` as `Measured`, whatever the row's source,
and dropped the row's `method`, the field where the writer records that the
figure is an idle-subtraction model on a machine-wide counter. It also loaded
any figure, so a 0 µJ/GFLOP row made a member free to place on.

The fix.
- `CalibrationEntry` gains optional `provenance`, `date`, `os` and `chip`
  (serde default and skipped when absent), so a file written before them still
  loads.
- A row loads at the grade its writer declared. A row that declares none, which
  is every row written before this commit, loads at `UNDECLARED_ROW_PROVENANCE`
  = `Benchmarked`. It is this machine's recorded run, so it still replaces the
  seeded figure, but nothing says a live meter read it. Rows like that were
  written before the IOReport counter had to show its CPU rail live, and on a
  frozen rail they priced GPU-only energy, or zero.
- The row itself rides along on the figure as `EnergyCost::calibration`, so the
  method, the declared grade, and when and on what the figure was taken stay
  with it. `EnergyCost` loses `Copy` for this. Nothing in the workspace copied
  one.
- A row whose `uj_per_gflop` is not positive and finite (0, -0, negative, NaN,
  ±inf) is refused. The member keeps its figure, and the row comes back from
  `apply_calibration` / `apply_calibration_path` / `apply_calibration_file` as a
  `RejectedCalibration` with the reason. `discover()` ignores the list, as it
  ignored the file's errors before.
- The writers declare their grade. `marginal_energy::calibration_row` (under
  `fabric-meter`) grades a row from the meter that read it: `HwShunt` is
  `Measured`, which since the previous commit means a live counter; a vendor
  model is `Benchmarked`; an estimate is `Estimated`. Nothing is upgraded. It
  stamps the date (RFC 3339 UTC), the OS (`sw_vers`) and the chip (`sysctl
  machdep.cpu.brand_string`). `fabric_calibrate` and `mpp_energy` write through
  it, and their `method` also records the IOReport status. On this host:
  date 2026-10-01T00:15:52Z, os "macOS 27.0.1 (26A434)", chip "Apple M5 Max".
  Both examples still refuse to write here, because the counter is declined.
  Neither was run, and `~/.jouleclaw/fabric-calibration.json` was not touched.

Tests (jouleclaw-fabric 16, none of which reads the real calibration file or
sets the env var other tests' `discover()` reads):
- `a_calibration_row_loads_at_its_declared_grade_with_its_method`:
  Measured / Benchmarked / Estimated rows each load at their own grade, with
  the row carried.
- `a_row_that_declares_no_grade_loads_but_never_as_measured`: a file in the old
  format, with no new fields, loads through `apply_calibration_path`, replaces
  the seed, and is `Benchmarked`.
- `a_non_positive_or_non_finite_figure_is_refused`: six bad values refused,
  each saying why, and the seeded figure kept; a zero from a file too.
- `a_calibration_row_round_trips`.
loader-gguf: `a_calibration_row_is_dated_in_rfc3339_utc` (epoch, 2000-02-29,
2026, 2099) and, under `fabric-meter`,
`only_a_hardware_reading_is_a_measured_calibration_row`. loader-gguf lib: 297
(default), 304 (`fabric-meter`), 308 (`rayon fabric-meter attest`), all pass.

Falsified, each mutant restored with `git checkout` and the tree confirmed clean:
- Every row loaded as `Measured` regardless (the old behaviour): the
  declared-grade test fails (Some(Measured) vs Some(Benchmarked)) and so does
  the no-grade test. 14 passed / 2 failed.
- The positivity check dropped, so 0 is accepted: "uj_per_gflop 0 was
  accepted". 15 / 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-meter, eoc-local: say what a counter's provenance is, and what each backend reports without one
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
722d80d90e
Docs only. Each statement changed was false against the code.

- eoc-meter's module doc said `StubCounter` "reports its source as
  `JouleSource::Estimated`". `JouleCounter` carries no source; it only reads.
  The doc now says consumers decide: eoc-cascade, eoc-streaming and eoc-local's
  `mlx`, `mlc` and `onnx` call a delta measured only when the counter advanced
  over their span. It also says what each does when nothing was read: no
  figure, the stage's own cost, or an estimate.
- Where the IOReport counter is described (the module doc and
  `MacosIoReportCounter`): a live IOReport reading is a vendor energy counter's
  reading, so its delta is `Measured`, as RAPL's and NVML's are. That holds only
  while the rail is live, which is why the counter is never constructed when it
  is not. The struct's "Measured-grade" tag is replaced by that sentence.
- The powermetrics stand-in's read comment said the error makes "the cascade
  attach `JouleSource::Estimated`". The cascade keeps the stage's own cost,
  whatever its tag; the comment now says no consumer gets a figure from it.
  The rung's doc, that it "never degrades to `StubCounter`", was already
  corrected in 3b827e2a.
- eoc-local's "Joule attribution" said every backend reports the delta as
  measured and that a `StubCounter` yields zero cost. With nothing measured,
  `mlx` falls back to its per-token coefficient; `mlc` and `onnx` report 0 µJ,
  estimated; `llamacpp` reports the delta since its first read, estimated if
  any chunk saw a zero delta. Each is now stated per backend. The crate intro
  said local backends measure; they can, when the host has a live counter.
- mlx's module doc said attribution "uses `powermetrics`". It reads whatever
  counter it is given, and the powermetrics stand-in never reads. The
  `joule_attribution` test's doc said the stub always yields 0, but its own mlx
  test asserts the per-token estimate.

Checked: `cargo doc` raises no new warning on the crates touched, and
`cargo test -p eoc-local --features mlc,gguf` passes. The `mlx` and
`llamacpp` modules need native dependencies (mlx-rs, llama.cpp) that are not
built here, so their doc changes are not compiled. They are comment text and
an intra-doc link to `JouleCounter`, which that module imports.

Not done, found while making these true. Both are behaviour, not docs:
- `llamacpp` takes a failed first read as 0 (`unwrap_or(0)`), so a later
  successful read reports the counter's since-boot total, tagged measured. This
  is the defect 467acb38 fixed in eoc-streaming.
- `mlc` and `onnx` report 0 µJ for inference that ran when nothing was
  measured. It is tagged estimated, but it is still a zero presented as a
  figure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`TuningProfile::record` keeps the joules-minimal cost per shape, so a later,
noisier measurement can never regress a decision. That rule has one hole. A
cost too low to be true, recorded once, wins forever. A cost taken before the
IOReport counter had to show its CPU rail live is exactly that: on a frozen
rail a CPU window metered as a sliver of GPU energy. A later honest
measurement draws more joules, so under the old rule it could never displace
the bad one. Zeros are already refused (c0813bcf). But a zero the shape
already held, which an older build recorded, could never be replaced either.

The fix.
- A record now carries the meter that took it, as `Metered { meter,
  energy_provenance, domain }`. Records are ranked by that grade: a hardware
  reading, then a vendor model, then an estimate, then a cost recorded with no
  meter, which ranks last as the worst-counter rule ranks an unknown.
  `record_metered` replaces a lower-ranked record however many joules either
  drew, because the decision is re-made on better evidence. Within one rank
  the old joules-minimal rule holds, and a lower-ranked record never displaces
  a higher one. `record` (no meter) keeps its signature and ranks last. A zero
  the shape holds is replaced by any figure. A zero is still never recorded.
- `tune_shape_live` records through `record_global_metered` with
  `Metered::of(&counter)`. The counter is only ever the live IOReport counter,
  so that is `apple-ioreport` / `HwShunt` / `SocTotal`.
- The sealed `autotune/1` receipt carried no provenance, meter or domain. No
  published vector pins its bytes: there is no autotune/1 vector or verifier
  anywhere in the repo, only the crate's own round-trip test. So each sealed
  shape now carries `meter`, `energy_provenance` and `domain`, and a cost
  recorded with no meter is sealed as `unrecorded` / `Unrecorded` /
  `unrecorded`, not left blank. `global_snapshot_metered()` is the new
  preimage. `global_snapshot()` and `entries()` keep their shapes.

Tests: `a_cost_recorded_without_its_meter_is_replaced_by_a_metered_one`
covers an unrecorded 5 µJ CPU entry replaced by a metered 500 µJ GPU reading,
never displaced back, joules-minimal within a rank, and ranks climbing
Estimator → ModelBased → HwShunt. `a_zero_the_shape_holds_is_replaced` covers
both meter states. `a_meter_is_recorded_as_its_name_grade_and_domain`
(fabric-meter, macOS) checks the IOReport counter and the static estimator.
The seal test now checks the three fields for a metered and an unrecorded
shape. On this host `tune_shape_live` still records nothing ("ioreport=
CpuRailFrozen tuned=None recorded=None"). loader-gguf lib: 297 / 304 / 308
pass (default / `fabric-meter` / `rayon fabric-meter attest`).

Falsified (restored with `git checkout`, tree clean): with `insert` set back
to the joules-only rule, both new tests fail ("a metered measurement did not
replace an unrecorded one"; the held zero is never replaced). 10 / 2 of the
module's tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jouleclaw-compliance: a passport calls inference energy measured only on a live hardware reading
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 3m7s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Has been cancelled
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Has been cancelled
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
ea61169ca6
`PassportBuilder::build` accepted any pairing of `measurement_method` with
`compliance.eu_ai_act.inference_energy_measured`. That includes `IoReport`, which
`MeasurementMethod::provenance` itself grades `Estimator`, paired with
`inference_energy_measured: true`. A sealed passport could then disclose
estimator energy as measured inference energy under the EU AI Act field. It
also accepted `Estimated` with `true`.

The rationale beside `IoReport` was "estimator until the IOReport FFI ships".
The FFI shipped long ago. The real reason, after the previous commits: a vendor
energy counter is a real reading, and what decides its grade is provenance.
Was the counter live for the domain the figure claims? A bare `IoReport` records
no such observation, and where the CPU rail is frozen (macOS 27.0.1, for an
unprivileged reader) the host's figure is the estimator's. So `IoReport` stays
`Estimator`, and the stale wording is replaced in the variant doc, the crate
doc and the test.

The fix.
- New `MeasurementMethod::IoReportLive`: IOReport read while its CPU energy rail
  was observed live (`IoReportStatus::Live`). It is graded `HwShunt`, like RAPL
  and NVML. Display: "IOReport (live)". It is additive, so existing serialized
  passports still deserialize.
- `is_live_hardware_reading()` is true iff the method's grade is `HwShunt`.
- `build()` returns `PassportError::MeasuredWithoutHardwareReading { method,
  provenance }` when `inference_energy_measured` is true and the method is not
  a live hardware reading. That means a bare `IoReport` or `Estimated`.
  Unclaimed, every method still builds.

Tests: `a_measured_claim_needs_a_live_hardware_reading`. `IoReport` and
`Estimated` with a measured claim are refused with their method and grade, and
build without it. `IoReportLive`, `Rapl`, `Nvml` and `NvmlRapl` build with the
claim. The message names the method, the grade and the rule. The mapping test
covers `IoReportLive` → `HwShunt`, and the display test covers its label.
jouleclaw-compliance: 53 pass.

Falsified (restored with `git checkout`, tree clean): a gate that refuses
only `Estimated`, and so accepts `IoReport` with a measured claim, fails the
test with "IOReport paired with a measured claim was not refused". 52 / 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
SPEC §5 says a vendor energy counter is a hardware reading only while it is
demonstrably live for the domain the figure claims. The macOS path enforces
that for IOReport's CPU rail; the Linux path did not. `hardware_meter_available`
selected RAPL whenever any `intel-rapl:<n>/energy_uj` was readable, and the
module doc admitted it: "On Linux there is no such probe." A counter that holds,
or republishes in a lump inside the busy window, would have been bracketed and
graded HwShunt.

RAPL now goes through the same probe loop and the same sustained-advance rule
as IOReport: while the probe burns 25 ms of its own thread's CPU before each
re-read, a counter is live only if it advanced in at least five sample
intervals, in at least 80% of the intervals observed, over at least 300 ms. The
domain is the package: the rule watches the zones whose `name` is
`package-<n>`, and because the figure sums all of them, EVERY package counter
must meet it (IOReport needs one CPU rail of either die; RAPL needs each
counter it adds up). A decrease (a wrap) is not an advance. `rapl_status()` is
probed once per process behind a `OnceLock` and cached; the selection and
`rapl_power_over` gate on it through `admits_counter`, which admits only
`Live`, so a counter that is readable but not live yields `None` before any
window runs and the work never runs. The new public `CounterStatus`
(`Unavailable`, `Absent`, `Frozen`, `Live`) says why, and `proof --measure`
prints `rapl_status=` beside `ioreport_status=` and gives the RAPL reason on
Linux.

The figure itself changes in one place: it sums the package zones only.
Before, every readable top-level zone was summed, and on machines that expose
`psys` (the platform zone, which covers the package) that counted the package
energy twice. `psys` is neither summed nor probed now.

To share the machinery rather than copy it: the thread CPU clock, the wall
clock and the burn tick move out of the macOS-only IOReport module into a
`probe_clock` module built on macOS and Linux (CLOCK_THREAD_CPUTIME_ID is 16
on macOS, 3 on Linux). `RailWatch` takes a `Watched` — which channels, and one
or every one — whose default is the IOReport CPU rails with "any one", so the
IOReport rule and every IOReport test are unchanged. `probe_with` is now
`probe_watching(CPU_RAILS, ..)`. The RAPL composition, `rapl_probe_with`, is
generic over its zone reader, so the tests run that very composition on
scripted zones on every platform.

Tests (all pure and scripted, run here on macOS):
- a package counter advancing at every read is Live at the twelfth 25 ms
  interval, and the rule was handed each value as read;
- constant, one-step and two-step counters are Frozen after the whole budget;
- one package advancing and one holding is Frozen — the same reads under the
  any-one-rail rule are Live, which pins what `every` decides — both advancing
  is Live, and two packages advancing in alternate intervals are Frozen;
- a moving `psys` beside a holding package is Frozen, and zones with no package
  are Absent; a wrap that then holds is Frozen;
- no readable zone is Unavailable with no read or tick, and zones readable only
  at the open give no verdict after the five-budget cap;
- the status maps one to one and only `Live` meters: declined statuses run no
  bracket and no work;
- the package name and the read: `package-<n>` only, each package counter with
  its own value, `psys` left out;
- `tests/rapl_once.rs`: eight threads asking at once see one status, and on
  Linux the production probe ran once (off Linux: Unavailable, zero runs).

Falsified, each restored through git before the next: RAPL's `every` set to
"any" fails the every-package test; reading `psys` into the sample fails the
read and psys tests; watching IOReport's CPU rails instead of the package
counters fails five tests; mapping Frozen to Live fails five; admitting every
status fails four.

`cargo test --workspace` and the proof-conformance binary pass on macOS (M5
Max, macOS 27.0.1). The Linux path is compile-checked only:
`cargo check --workspace --tests` and `cargo clippy --workspace --all-targets`
for x86_64-unknown-linux-gnu are clean. Not run on Linux here, so the real
sysfs reader and the Linux-only gate (`hardware_meter_available` and
`rapl_power_over` gating on the cached status) have no runtime test; a mutation
of that gate cannot be caught on macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
proof-cli: "the same verified output" charges the baseline only for the goals proved
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m24s
standards workspaces build + test / aac-rs (pull_request) Successful in 1m0s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 4s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m23s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 48s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m50s
standards workspaces build + test / map-rs (pull_request) Successful in 56s
standards workspaces build + test / mesh-rs (pull_request) Successful in 48s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m21s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / openpay (pull_request) Successful in 1m59s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
4551c5f9a4
The headline said, on one run, "energy 375387 uj vs 450000000 uj frontier-only
→ 1198.762× less for the same verified output". The 450 J baseline charged a
frontier model for all nine goals; the cascade proved six. The three it did
not prove produced nothing, so the comparison was not for the same output: it
credited the cascade with 150 J of frontier work it never replaced.

`EnergySummary` gains `proved_baseline_uj`, the frontier-only baseline summed
over the proved goals alone, and `proved_ratio_milli()`, that baseline over
EVERY joule the cascade spent, the goals it failed on included, in thousandths
rounded down (`None` when nothing was spent; `Some(0)` when nothing was
proved). Both sides are charged conservatively: the frontier run only for the
output it would have to match, the cascade for its failures too. The existing
`baseline_uj` and `energy_ratio_milli()` are unchanged (the conformance check
C3 and the receipt wire use them), and their docs now say they charge every
goal.

The CLI now prints, verbatim from one run (the T1 figures are wall-time
models of a resident z3 and vary several-fold between runs):

  proved 6/9 · tiers T0=4 T1=2 T2=0 T3=0
  energy 58892 uj for all 9 goals  vs  300000000 uj frontier-only for the 6 proved  →  5094.070× less for the same verified output
  (frontier-only for all 9 goals: 450000000 uj, 7641.105× — but 3 of them were not proved here, so not the same output)

The all-goals figure stays, with its goal count, and is never called the same
output; with nothing proved, or nothing spent, the CLI says so instead of
printing a ratio.

Tests:
- `the_same_output_ratio_charges_the_baseline_only_for_proved_goals` (cascade):
  three goals, two proved, with the tests' own literals (T0 50 uJ, T1 5 mJ,
  T3 50 J): the same-output baseline is 2 × 50 J, the ratio is exactly
  100,000,000,000 / 10,150 = 9,852,216 thousandths, below the all-goals ratio;
  a cache hit is a proved goal for zero joules; nothing spent gives no ratio,
  nothing proved gives zero.
- `tests/headline.rs` (CLI): runs `proof` and checks the printed figures
  against the per-goal rows of the same run — the total is the sum of the rows,
  the same-output baseline is (proved rows) × 50 J, the all-goals one is (rows)
  × 50 J with the unproved count, both ratios are the printed baselines × 1000 ÷
  the total, rounded down, and "same verified output" is claimed exactly once,
  on the proved-goals line.

Falsified: printing the all-goals baseline and ratio on the same-output line
fails the CLI test ("the same-output baseline is 6 x 50 J"); charging the
proved baseline for every goal fails both tests. Each restored through git.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The crate's honesty rule, in its own docs and in JouleClaw SPEC §1.3: a vendor
energy counter (RAPL, NVML, IOReport) is a hardware reading only while it is
demonstrably live for the domain the figure claims. The IOReport backend has
been gated on that since its CPU rail was found frozen on macOS 27.0.1; RAPL and
NVML were not. `RaplCounter` was available as soon as `energy_uj` parsed once,
so a counter passed through by a hypervisor, or one that holds its value, would
have been selected and billed every span zero at HwShunt. `NvmlCounter` graded
every reading HwShunt, including its `power_usage()` integral, which the crate's
own Provenance docs and SPEC §1.3 call ModelBased (NVML `power.draw`), and it
never asked whether the cumulative-energy counter moved.

The rule now lives in one place, `liveness`, with the numbers the IOReport probe
uses: while the probing thread burns its own CPU time between samples, every
channel the figure sums must advance in at least five sample intervals, four in
every five of those observed, over at least 300 ms. The IOReport backend's
constants now alias these, so the backends cannot drift apart; what differs
there is only what is watched (any one CPU rail among many channels). Here every
channel must be live, since an NVML figure sums every GPU and one frozen device
would leave part of the claimed domain unread. `CounterLiveness` (`Unavailable`,
`Frozen`, `Live`) is exported so a caller can say why a host is not metered.

- RAPL: `RaplCounter` is available only when its `energy_uj` is readable and
  live, probed once per process per path and cached (`rapl_liveness`), 25 ms of
  thread CPU between samples. A root-only file (CVE-2020-8694) never gives a
  sample and is `Unavailable`; a held or lump-republished one is `Frozen`.
  Off Linux nothing is probed.
- NVML: the cumulative-energy counter must be supported on every GPU and live
  (100 ms ticks, two of its ~50 ms sample periods), probed once per process.
  Then readings are HwShunt. Otherwise the counter integrates `power_usage()`
  and grades it ModelBased: a Riemann sum advances whenever time passes, so no
  probe can vouch for it, and it is never HwShunt.
- `libc` becomes a Unix dependency (was macOS-only) for the probe's thread CPU
  clock on Linux; Cargo.lock does not change.

Tests. The rule is driven by scripts (`liveness`): live at the tenth 31 ms
interval and not before, a refresh in one to five steps frozen, four-in-five
kept and three-in-five refused, every channel required, no interval
unavailable, the probe stopping when live and capped when failing. RAPL's
production probe runs against real files on any OS: a file that holds its value
is Frozen after the full budget, a missing one Unavailable at once, and one a
second thread keeps advancing is Live in 0.3 to 1 s. Availability follows only
a Live verdict; the per-path cache probes once. NVML's mode and grade are
asserted for every (supported, liveness) pair, and a scripted two-GPU probe is
Frozen when one GPU holds.

Checked: `cargo test -p jouleclaw-energy` with default features, `rapl`,
`nvml`, `ioreport`, and all three; `cargo check --tests` and clippy on
`x86_64-unknown-linux-gnu` with `rapl,nvml`, and `cargo check --tests
--target x86_64-pc-windows-msvc --features nvml`. No NVIDIA GPU or readable
RAPL file was available, so neither real counter's probe has run on hardware.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Mesh SPEC §3.3 reads a mesh/1 figure as the energy of the whole request, and
mesh/1 has no coverage field. So a figure from a counter that does not cover the
whole request (a GPU counter, with the CPU that drives the GPU unmetered) must
not be graded HwShunt: it would claim the CPU it did not read. `HwMeter` mapped
every backend's grade through unchanged, so a live NVML counter, whose domain is
`Gpu`, would have sealed GPU-only joules as a measurement of the whole request.

The bridge now asks whether the reading's domain covers the whole request
(`covers_whole_request`): the whole SoC and a CPU package do; a GPU, NPU or ANE
alone, one core, DRAM, or an external rail of unknown scope do not. The match
is exhaustive, so a domain added to the family meter must be decided there
before it compiles. A reading that does not cover the request is graded as mesh
grades an estimate of the whole request, `Estimator` with no `measured_uj`, and
its figure is the counter's reading plus the bridge's own `EstimatorMeter` over
the same window for the rest of the host: a span is billed what the counter read
and an estimate of what it did not, never less than the counter read. Whole-
request backends pass through as before; the failed-read fallback continues
from the composed figure.

Tests. A stepping backend over `Gpu` at each grade (HwShunt, ModelBased,
Estimator) yields `Estimator`, no measured figure, an estimate above the GPU's
own step; the same backend over `SocTotal` passes through as a HwShunt
measurement of exactly its step, so only the domain differs. Every domain's
classification is listed. With `hw-meter-nvml`, the family's `NvmlCounter`
reports `Gpu` and is not whole-request.

Ran: `cargo test -p mesh-meter` with no features, `hw-meter`,
`hw-meter-apple` and `hw-meter-nvml`; `mesh-core --features hw-meter-apple`;
`mesh-conformance`; clippy clean on mesh-meter; `cargo check --tests` for
`x86_64-unknown-linux-gnu` with `hw-meter-rapl,hw-meter-nvml`.

Not done: a RAPL `CpuPkg` figure is still whole-request, which is right on a
host serving from its CPU and an undercount on one serving from a GPU it does
not read. The bridge cannot see where the work ran.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: RAPL and NVML count only while live, and an NVML figure is partial
Some checks failed
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
475680abcd
340313d6 left two vendor counters outside the rule it adopted. SPEC §5 says a
vendor counter (RAPL, NVML, IOReport) is a hardware reading only while it is
live for the domain the figure claims, and that a figure must not claim a
domain its counter does not read. IOReport was held to that. RAPL and NVML were
selected as soon as they opened, and NVML's GPU-only figure claimed the whole
task.

One rule. The sustained-advance rule (one rail advancing in at least five
intervals, at least 80% of them, over at least 300 ms), its loop, its stopping
rule and the CPU-burning tick lived inside the macOS-only IOReport module. They
now live in a crate-level `liveness` module that compiles everywhere, and all
three rungs apply it. IOReport's `RailWatch` is now the shared rule fed only
its CPU rails. Its 43 tests are unchanged apart from one import line, and all
pass. The thread clock gains Linux's CLOCK_THREAD_CPUTIME_ID (3) beside
macOS's (16), so on Linux the RAPL probe also burns by its own thread's CPU.

RAPL. `rapl_status()` probes once per process and caches the result. Each
package's `energy_uj` is a rail, sampled after every 25 ms of the probing
thread's CPU, for up to one second. `detect()` opens RAPL only on Live; on any
other status `select` does not call the opener. With no readable powercap tree
the status is Unavailable and nothing is burned, so RAPL declines as before. On
macOS that costs one failed read_dir. A span over which no package counter rose
now gets the time×power figure instead of a hardware reading of zero. This
mirrors IOReport's per-span rule: the counter updates about once a
millisecond, so a shorter span can see no rise even though the work ran.

NVML. `nvml_status()` applies the same rule to device 0's total-energy
counter. The probe cannot load the GPU, and burning CPU does not load it
either, so it waits 100 ms between samples instead of burning. A counter
refreshed at least that often advances on the device's own draw in every
interval. One refreshed less often fails the 80% share and is declined, so it
fails safe. An NVML span is now `Energy::measured_partial`, `HwShunt` with
`Coverage::Partial`, because the GPU counter does not read the CPU's share of
the work. A span over which the counter did not advance, or fell (a driver
reload resets it), gets the model's whole-task figure. Without the feature
`nvml_status()` is Unavailable. With it, everything compiles and clippy is
clean on macOS and Linux x86_64. But no host here has an NVIDIA GPU, so the
NVML probe has never run against a real counter.

API, additive: `CounterStatus { Unavailable, Frozen, Live }`, `rapl_status()`
and `nvml_status()`. `Meter::detect()` keeps its signature. Its private
`detect_with` now takes a `Ladder` of statuses and the RAPL opener, so the
tests can drive each gate with every status.

SPEC §5.1 and the README's jcp-energy bullet now say that RAPL and NVML are
selected only while live, and that NVML's figure is `Partial`.

Tests. jcp-energy has 63 lib tests (54 before) and its integration test, all
passing with and without `--features nvml`:
- Scripted package counters, through the probe the RAPL rung runs. Live at
  the tenth interval (310 ms) when a package keeps advancing, and the probe
  stops there. Frozen when the counters hold, step in one lump, step in two or
  in five quick steps, advance in turn, or wrap at every other sample.
  Unavailable when every read fails, or when no tree opens (then nothing is
  sampled and nothing ticks).
- The real RAPL probe on fake powercap trees in the temp directory: real file
  reads, the CPU-burning tick, the real clock. "a fake tree that holds ->
  Frozen in 1.004322458s"; "a fake tree advancing every 2 ms -> Live in
  417.847167ms"; an absent or unparsable tree is Unavailable.
- The gate: only Live admits a counter rung, and on any other status the
  counter is not opened. `detect_with` on a fake tree selects RAPL on Live
  only.
- Spans: `measure` on a fake tree reads a rise, wraparound-corrected, as
  `HwShunt` and whole, and a span with no rise as `ModelBased`. NVML spans are
  `HwShunt` and `Partial`, or the model's whole figure when the counter held,
  fell or failed to read.
- NVML-shaped scripts at 100 ms intervals: Live at the fifth interval; a
  counter advancing in 1 of 4 intervals is Frozen.
- The fresh-process pin also counts the RAPL probe (one run) and, under the
  feature, the NVML probe (one run).
jcp-intercept (12), jcp-schema (30) and jcp-conformance (1 unit test and 5
pack tests) pass. The jcp-conformance binary prints "all checks passed", and
jcp-cli runs. clippy --no-deps is clean on jcp-energy, with and without `nvml`,
for aarch64-apple-darwin, x86_64-apple-darwin and x86_64-unknown-linux-gnu.
`cargo doc` with `-D warnings` is clean.

Falsified, each by a temporary edit restored with git checkout (git diff was
empty after each):
- `detect` opening RAPL without asking its status fails
  `only_a_live_status_selects_the_rapl_rung` ("status Unavailable gave
  Rapl(...)"). It also fails the fresh-process pin, because the probe never
  ran.
- A gate that admits Frozen fails the gate tests ("Frozen admits the rung").
- A probe that reports Live for any counter that opened fails the scripted,
  fake-tree and status tests ("a powercap tree whose counters hold was not
  declined").
- Requiring only two advances fails four IOReport rule tests and the NVML
  script.
- A RAPL sampler that returns a constant fails the fake-tree test ("a powercap
  counter advancing every 2 ms was not live").
- Grading a span that did not rise as a reading fails three span tests
  ("measured_uj: Some(0) ... provenance: HwShunt").
- Grading an NVML span `Energy::measured` fails the partial test ("a GPU-only
  figure claimed the whole task").
- An uncached `rapl_status` fails the pin ("the RAPL liveness probe did not
  run exactly once in this process").
- A `RailWatch` that hands the rule every rail, GPU included, fails four
  IOReport rule tests.
- A RAPL span that ignores the rise fails
  `a_rapl_span_is_a_reading_only_if_a_counter_rose`.

Not done here:
- The NVML gate in `detect_with` (`select(nvml, Nvml::open)`) cannot be
  exercised on a host with no NVIDIA device. It is the same `select` the RAPL
  gate tests drive, and the rule it gates on is tested on NVML-shaped scripts.
- The 100 ms NVML wait is a design choice, not a measured refresh period,
  because no NVML counter was observed here.
- RAPL was not exercised on Linux hardware. On most distributions `energy_uj`
  is readable only by root, and there RAPL stays Unavailable as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The shared rule for vendor energy counters is that a reading is a hardware
reading only while the counter is demonstrably live for the domain the figure
claims. IOReport has been held to it since d35c99af. The Linux probes were
not: `RaplProbe::new()` and `NvmlProbe::new()` were selected the moment
`energy_uj` or `nvmlDeviceGetTotalEnergyConsumption` answered once, so a
counter that held, or was republished in lumps, would seal zero or a stale
refresh as a `HwShunt` figure.

Both are now gated at construction by the same sustained-advance rule, probed
once per process and cached (`rapl_status()`, `nvml_status()`, a new public
`CounterStatus` of `Unavailable`, `Frozen` or `Live`). A counter is live only
if it advanced in at least five sample intervals, at least four in five of
those observed, over at least 300 ms, while the probing thread burned CPU,
each interval read against the sample before it. One difference from IOReport
is deliberate: these probes sum their rails into one figure per class (every
RAPL package into `Cpu`, every NVML GPU into `Gpu`), and a sum claims every
rail in it, so EVERY rail must keep advancing (`Quorum::Every`). IOReport's
liveness stays one CPU rail's (`Quorum::Any`). The sampling interval is the
probe's choice, not part of the rule: RAPL ticks at IOReport's 25 ms, NVML at
150 ms, because NVML does not document how often its counter is published and
a live counter updated every 100 ms advances in only about one 25 ms interval
in four. A host where powercap is unreadable or NVML is absent is
`Unavailable` at once, with no burn.

The rule, the run and the gate are pure and live in `liveness`, which is now
compiled on Linux as well; `rapl.rs` and `nvml.rs` only open their counters and
read raw integers. `RaplProbe::given` and `NvmlProbe::given` are
`liveness::gated` over the cached status and nothing else, and
`detect_given` takes a `Statuses` (IOReport, RAPL, NVML) so every gate is
tested for every status without probing. libc becomes a Linux dependency for
the thread CPU clock the tick burns against (Cargo.lock unchanged).

Two small additions serve callers that grade a whole unit of work by one
figure (joule-ui, next commit): `Measurement::reads_cpu()` (a `Cpu` record that
is `HwShunt` with a measured figure) and `PowerMeter::estimate(wall)` (the
single estimator record `Span::stop` itself gives a span with no reading). A
GPU-only span is honest per class here; it is not a reading of work that ran
on the CPU, and a format with no coverage field must not grade it as one.

Tests. The scripted ones run on every platform (macOS here):
`a_counter_whose_only_rail_keeps_advancing_is_live` (a RAPL package moving
every ~1 ms is live at the 12th 25 ms interval, and the probe stops there),
`a_counter_with_one_rail_that_held_is_frozen` (and the same samples ARE live
under IOReport's one-rail quorum), `a_counter_whose_every_rail_keeps_advancing_is_live`,
`a_counter_that_steps_in_a_burst_is_frozen` (1 to 5 steps),
`a_counter_that_wraps_once_is_still_live_and_one_that_falls_is_not`,
`a_counter_that_cannot_be_opened_or_read_is_unavailable` (never sampled or
ticked when it does not open), `an_nvml_counter_is_judged_at_its_own_tick` (a
100 ms counter is declined at 25 ms and live at 150 ms; a 400 ms one is
declined), `a_gated_counter_is_opened_only_when_live`,
`only_a_live_counter_admits_its_probe`, `a_counter_tick_burns_its_own_length`,
`the_real_counter_tick_burns_this_threads_cpu`,
`detect_selects_rapl_and_nvml_only_for_a_live_status`,
`the_counter_statuses_explain_themselves`,
`a_span_reads_the_cpu_only_with_a_hardware_cpu_record`, and a new test file,
`counter_probes_once_per_process` (eight threads; each real probe runs once on
Linux, never elsewhere). Linux-only, against an independent oracle that reads
every counter's absolute value before and after three budgets:
`the_rapl_rung_is_never_selected_over_a_counter_that_held`,
`the_nvml_rung_is_never_selected_over_a_counter_that_held`, and the two
`given` gate tests. Those compile here (`cargo check --tests --target
x86_64-unknown-linux-gnu`, and clippy for that target is clean) but have not
run: no Linux host was used, so they first run on the Linux CI runner.

sandbox-meter: 97 lib tests + both once-per-process files pass on this M5 Max;
every sandbox-rs crate that depends on the meter passes (225 tests); clippy is
clean for aarch64-apple-darwin and x86_64-unknown-linux-gnu.

Falsified, each restored from git and the suite re-run green: counter_run on
IOReport's one-rail quorum, and the Every quorum counting any rail, fail
`a_counter_with_one_rail_that_held_is_frozen`; a gate that admits every
status fails `only_a_live_counter_admits_its_probe` and
`a_gated_counter_is_opened_only_when_live`; `gated` that opens without asking
fails the latter; NVML at the 25 ms tick fails
`an_nvml_counter_is_judged_at_its_own_tick`; `reads_cpu` ignoring the class
fails `a_span_reads_the_cpu_only_with_a_hardware_cpu_record`. Not falsifiable
here: a `RaplProbe::given`/`NvmlProbe::given` that bypassed `gated`, or a
`real_run` on the wrong tick, is caught only by the Linux tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
joule-ui: a span with no CPU reading is sealed as an estimate of the interaction, never as HwShunt
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Has been cancelled
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
7539c62e19
joule-ui's energy record carries one figure per interaction and has no
coverage field. Both `SpanMeter` adapters (joule-ui-serve's, and the
examples' `support/span_meter.rs`) graded a span by its records alone, so a
span from an NVML-only probe set (a Linux host whose only live counter is the
GPU's: a `Gpu` record, `HwShunt`, and no CPU record at all) was sealed
`HwShunt` for the whole interaction: the GPU's energy, passed off as a reading
of a handler that ran on the CPU. `Span::stop`'s held-CPU rule does not reach
it, because there is no CPU counter to have held.

The rule now: a span with no hardware reading of the CPU
(`Measurement::reads_cpu`) is sealed as the meter's `Estimator` figure for
that span's wall time (`PowerMeter::estimate`, wall x the meter's TDP, never 0
for work that ran). A span that reads the CPU passes through unchanged, with
any GPU record beside it (RAPL and NVML: the sum, `HwShunt`). A span that was
already the estimator's record is replaced by the same figure. The rule is
one function, `interaction_figure`, applied where each adapter closes a span.
The examples' adapter is now generic over the same `Spans` seam
joule-ui-serve's has (defaulting to `PowerMeter`), so it can be driven over
scripted spans, and a new integration test includes it by `#[path]`.

SPEC §5 gains the sentence the shared vendor-counter MUST implies for this
format: with no coverage field, a figure from a counter that does not read the
domain the work ran on (a GPU counter alone, for a handler on the CPU) MUST
NOT be graded `HwShunt` for the interaction. joule-ui-serve's Cargo comment no
longer lists NVML alone as a source of a `HwShunt` receipt.

Tests: `a_gpu_only_span_is_sealed_as_an_estimate_never_as_a_reading`, in
joule-ui-serve (through the real `Host`) and in joule-ui-interaction's new
`tests/span_meter.rs` (through the real `run_interaction` and the examples'
adapter): a scripted 8 ms GPU-only span (9,000 uJ `HwShunt`) is sealed as
160,000 uJ `Estimator` with no measured figure; a CPU+GPU span is sealed
`HwShunt` at their sum (10,500 uJ); a CPU-only span as itself; the chain
verifies. joule-ui-serve, -interaction, -conformance, -mcp, -core, -a2ui and
-render pass (72 tests); clippy adds nothing (one existing joule-ui-core
lint); `real_meter` and `jcp_metered` run as before on this M5 Max (every
interaction `Estimator`, the 15 J grant spent and the sixth denied).
`gen_vectors` regenerated into a scratch directory is byte-identical to
`conformance/v1/` (`diff -r` clean, MANIFEST included): the conformance
pack's `StepMeter` is not a `SpanMeter`, and nothing in the signed core
changed. Cargo.lock gains only the new sandbox-schema dev-dependency edge.

Falsified, each restored from git and re-run green: either adapter sealing the
span as it stands (`interaction_figure` returning it unchanged) fails its
`a_gpu_only_span_is_sealed_as_an_estimate_never_as_a_reading`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
l3-jouleclaw: a fabric-priced projection is graded by its weakest input
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Has been cancelled
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Has been cancelled
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
f521fb17be
The L3 stage tagged every per-call figure a backend reported as ModelBased, on
the premise that it was "priced by THIS machine's IOReport calibration". It is
priced by whatever matmul figure each fabric member loaded: a calibration row at
the grade its writer declared, or a seeded figure, and the seeded GPU figures are
`Estimated` (a coarse model). A projection priced from an estimate was sealed as
a calibrated per-operation model. Joule-code SPEC §6 keeps those apart:
ModelBased is "computed from a calibrated per-operation model", Estimator "a
coarse constant estimator".

The fix needs each input's grade, which the ledger did not carry.

- jouleclaw-loader-gguf: `LedgerEntry` gains `projected_grade`, the
  `EnergyProvenance` of the cost row that priced `projected_uj`, taken from the
  same row, so the two are present or absent together. Additive; nothing else
  constructs the struct.
- l3-jouleclaw: `projection_grade(window)` grades a projection by its weakest
  input. ModelBased only when every member that ran was priced by a `Measured`
  or `Benchmarked` figure; Estimator when any was priced by an `Estimated`
  figure, when any ran unpriced (its work is missing from the sum), or when the
  window is empty (a figure no priced input accounts for). A projection is never
  a reading, so it is never HwShunt. The stage now opens the attribution window
  around the call itself (reset before, snapshot after), so a figure is graded
  by this call's inputs and not by entries an earlier call left behind.

Tests. The grading table covers Measured, Benchmarked, both, Estimated alone and
beside each calibrated grade, an unpriced member, and no input. In the stage, a
backend that runs a real matmul through the dispatcher with the fabric off, and
one that runs nothing, both report 0.25 J and get Estimator (main: ModelBased).
Two integration tests, each its own process, switch the fabric on with
`JOULECLAW_FABRIC=1` and point `JOULECLAW_FABRIC_CALIBRATION` at a file they
write, pricing `local/cpu-neon`'s matmul at 2.5e8 µJ/GFLOP: declared `Measured`,
the window carries `Measured` and the receipt says ModelBased, 1024 µJ;
declared `Estimated`, the window carries `Estimated` and the receipt says
Estimator, 1024 µJ. The machine's own ~/.jouleclaw/fabric-calibration.json is
neither read nor written by these tests.

Ran: `cargo test` in l3-jouleclaw (the model-backed tests stay #[ignore], no
model was loaded), `cargo test -p jouleclaw-loader-gguf --features fabric-meter
--lib` (304 passed), loader-gguf examples checked; clippy reports nothing new.

Not done: a projection under 1 µJ still truncates to a 0 µJ receipt figure, as
before; real hybrid-model calls are far above that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
722d80d9 listed two defects here as not done.

`llamacpp` took a failed first counter read as 0 (`unwrap_or(0)`), so the next
successful read reported the counter's whole since-boot total as the
generation's energy. Its drain then tagged that figure measured unless some
chunk had seen a zero delta. This is the defect 467acb38 fixed in
eoc-streaming. And `mlc` and `onnx` reported 0 µJ for inference that ran
whenever nothing was measured: tagged estimated, but still a zero presented as
a figure.

The rule now lives once, in a new `span` module, and every backend meters
through it:

- `MeterSpan::open` reads the counter; `reading` gives `Some(delta)` only when
  that opening read succeeded, the read now succeeds, and the counter advanced
  between them. A failed read at either end, a counter that held, one that
  went down, or the stub: `None`, never 0 and never a since-boot total.
- `span_cost` makes a reading `JouleCost::measured` and asks for the estimate
  only when there was none.
- `per_token_estimate` is tokens × `fallback_joules_per_token`, to the nearest
  µJ, and never 0: at least one token is counted, a coefficient that is not a
  finite positive number gives way to the default (0.05 J/token, mlx's figure
  since the start), and a product under half a µJ is 1 µJ.

Per backend:

- `llamacpp`: `TokenChunk::cumulative_microjoules` is now `Option<u64>`, the
  reading so far, `None` when nothing was measured. The inference's cost is
  the final chunk's reading, measured, or the per-token estimate over the
  tokens streamed. An earlier chunk's reading covers part of the generation,
  so it is never taken as the cost of the whole. The backend gains
  `fallback_joules_per_token` and its builder. Nothing outside this file uses
  `TokenChunk`.
- `mlc` and `onnx` gain `fallback_joules_per_token` (default 0.05 J) and its
  builder, and estimate over the placeholder's own token count, as `mlx` does.
- `mlx` already had the right rule; it now goes through the same functions, so
  a zero coefficient no longer yields 0 µJ.

Factored so it tests without native code. Neither the `llamacpp` nor the `mlx`
module binds a single symbol of its native crate (both generators are
placeholders), so this crate's tests compile them without their features
(`cfg(any(feature, test))`; mlx on macOS). Their metering now runs in every
`cargo test --workspace`, where before it ran in none: llama.cpp is not built
here, and `--features mlx` fails in mlx-sys's Metal kernel build on this
toolchain. The `not(feature = "llamacpp")` error stub, unreachable since the
module itself was gated on the feature, is gone.

Tests, all on a scripted counter that reads a list (`None` is a failed read)
and counts its reads:

- span: an advance of 450 µJ reads 450; a failed first read followed by
  9,000,000 reads nothing; so does a failed last read, a held counter, a
  counter that went down, and the stub. The estimate is 800,000 µJ for 8 × 0.1
  J, 100,000 for 0 tokens at 0.1 J, the default for 0, -1, NaN and infinity,
  1 µJ for 1e-12 J, and saturates at u64::MAX.
- llamacpp, through `stream` and `infer`: the stub gives the 4-token estimate
  (200,000 µJ, estimated) and no chunk a reading; a failed first read then 9 J
  gives no chunk a reading and the estimate; 1,000 -> 1,100 ... 1,450 gives
  chunk readings 100, 200, 300, 400 and a measured 450 over exactly six reads;
  a held counter and a failed last read give the estimate. A table test pins
  that only the final chunk's reading is a cost.
- mlc, onnx, mlx: measured 450 for an advance; the estimate (800,000 µJ) for a
  held counter, a failed first read and a failed last read; the stub's
  estimate, never 0. The `joule_attribution` integration test now asserts the
  mlc stub's estimate (500,000 µJ, estimated) where it asserted 0.

Falsified, each mutant applied to the uncommitted tree, run, and restored
byte-for-byte (sha256 checked):

- a failed start taken as 0 fails 5 tests (llamacpp, mlc, mlx, onnx, span),
  the llamacpp one with every chunk at `Some(9000000)`;
- `>=` in place of `>` (a held counter measured) fails 9;
- llamacpp reading the counter's absolute value per chunk (HEAD's rule with a
  0 baseline) fails 3;
- a partial chunk reading kept as the cost fails the tally test;
- mlc's and onnx's HEAD estimate of 0 fail 2 each;
- the estimate without its 1 µJ floor, without the one-token minimum, or
  without the coefficient check fails the estimate test.
One mutant survived and is equivalent: `final_reading = reading.or(previous)`
cannot differ, since nothing sets the field before the final chunk.

`cargo test -p eoc-local` passes (36 + 1), with `--features mlc,gguf` (47 +
3) and `--features onnx --lib` (43). The onnx leg runs `--lib` because the
`joule_attribution` test binary cannot link ort-sys without ONNX Runtime
installed when mlc is also enabled; that is unchanged. No clippy warning in
this crate, before or after.

Not done: the `llamacpp` error path still returns 0 µJ, estimated, with the
error text. Whether any work ran before an error is unknown there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
0db9b8be listed three things here as not done. `CarbonAccount` dropped
provenance, and `account_request` is a public way in. A report with zero
correct answers carries `joules_per_correct == u64::MAX` as a sentinel, which
`sci_from_eval_report` multiplied by `correct.max(1)` into a u64::MAX µJ
energy term (18.4 TJ). And the test that pins the bridge's provenance sat
behind the `eval-bridge` feature, which nothing in the workspace enables, so
`cargo test --workspace` never compiled it, nor any mutation it catches.

What holds now:

- `CarbonAccount` gains `it_energy_source`, the input `JouleCost`'s provenance
  unchanged, and `facility_energy_source`. The facility joules are `it_joules`
  × PUE, so they are measured only when every part of them was: the IT joules
  were measured, and the PUE added no overhead. A PUE above 1 is a published
  or assumed facility ratio applied to the reading, energy no counter on the
  host read; a figure containing any of it is an estimate. A PUE of 1 adds
  nothing, so measured IT joules stay measured, as an estimated zero adds
  nothing to a live span in eoc-cascade. Grid intensity has its own provenance
  (the reading's provider and kind) and does not enter the tag. Both fields
  read back as `Estimated` when absent. Nothing outside this crate builds a
  `CarbonAccount`.
- `SciScore::energy_source` is now the account's facility provenance, because
  the score's energy term is facility energy. Before, it was the input's
  provenance, so measured IT joules at the default PUE of 1.58 gave a
  "measured" term of which 37% no counter read. This changes what 0db9b8be
  set: an SCI score at PUE > 1 is now estimated even from measured joules.
  The SCI numbers do not change.
- The bridge's pure part is compiled in every build. `EvalEnergy`
  (micro-joules per correct answer, correct answers, provenance) and
  `sci_per_correct_answer` hold all the scoring, including the `JouleCost`
  construction 0db9b8be's mutation targeted. `sci_from_eval_report`, still
  behind `eval-bridge`, only copies the report's three fields across.
- A run with no correct answer has no score per correct answer:
  `sci_per_correct_answer` and `sci_from_eval_report` return `None` for
  `correct == 0` and for the `u64::MAX` sentinel, whatever `correct` says
  beside it. The sentinel is never read as energy. `sci_from_eval_report` now
  returns `Option<SciScore>`; its only callers are this crate's tests.

Tests in the default build: an account's (IT, facility) tags are (Measured,
Measured) at PUE 1, (Measured, Estimated) at 1.2, (Estimated, Estimated) from
an estimate at either, and (Measured, Measured) at a PUE of 0.5, which is
clamped to 1; the figures are the same whichever tag. An account round-trips
and an old one reads back estimated. A score is measured at PUE 1, estimated
at 1.2 from measured joules, with the same SCI. An eval run's score keeps the
run's provenance (2 × 1 J × 1.2 = 2.4 J over R = 2), and a run with 0
correct, or the sentinel with 3 correct, has none. `account_per_request` now
asserts its tags. Under `eval-bridge`, a report's score equals its run's score
for both provenances and both PUEs, and a 0-correct report has none.

Falsified, each mutant restored byte-for-byte (sha256 checked); every one but
the last runs in the default build:

- 0db9b8be's mutant, `JouleCost::measured(total)` in the bridge, now fails
  `an_eval_runs_score_keeps_the_runs_provenance` in a plain `cargo test`;
- an account dropping the IT provenance fails 1 test;
- the facility tag ignoring the PUE fails 3, ignoring the IT provenance 3;
- the score taking the input's provenance (0db9b8be's rule) fails 2;
- removing the no-correct-answer guard fails 1, and so does checking
  `correct == 0` without the sentinel;
- the `eval-bridge` wrapper tagging every report measured fails
  `an_eval_reports_score_is_its_runs_score` under `--features eval-bridge`.
That wrapper's three-field copy is the only part a default build cannot see.

`cargo test -p eoc-carbon` passes (42 + 8), and with `--features eval-bridge`
(43 + 8). No clippy warning in this crate, before or after.

Not done: `joules_per_correct` is an integer quotient, so the total
reconstructed from it can fall short of the run's by less than one µJ per
correct answer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-meter: RAPL and NVML are selected only when their counter keeps advancing, as IOReport's CPU rail is
Some checks failed
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m42s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m25s
standards workspaces build + test / aac-rs (pull_request) Successful in 45s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 42s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
2c353d68cd
eoc-meter has both vendor counters: `LinuxRaplCounter` (Linux, package 0's
powercap `energy_uj`) and `NvmlCounter` (feature `cuda`, GPU 0's total-energy
counter). `detect()` selected RAPL whenever the file existed and NVML whenever
it initialised. Neither was held to the rule every IOReport backend in the
family now uses, so a counter that held, or was republished in lumps (a
virtualised powercap tree can do either), was sealed as a reading by every
consumer that tags a live counter's delta `Measured`. And `energy_uj` is
root-only on most current kernels, so on those hosts RAPL was "selected" with
every read failing.

The rule is now one rule for every rung. `rail_liveness`, which held the
IOReport rule, is compiled on every platform and generalised: a `RailWatch`
carries which channels are rails (`default()`: IOReport's CPU energy
aggregates; `every_channel()`: a single vendor counter's one channel), and
`probe_counter` runs the same `watch` loop, stopping rule and thresholds over a
counter that opens. A counter is live only if it advanced in at least five
sample intervals, at least 80% of those observed, over at least 300 ms. A fall
(RAPL's wrap at `max_energy_range_uj`) is no advance.

- `CounterStatus` (`Unavailable`, `Frozen`, `Live`) is public, with
  `rapl_status()` and `nvml_status()`, each probed once per process and
  cached, as `ioreport_status()` is. Off Linux, or without `cuda`, they are
  `Unavailable` without probing.
- RAPL's probe burns 25 ms of the calling thread's own CPU between samples,
  on its CPU clock, so the package counter is watched under load. Where
  `energy_uj` cannot be read it is `Unavailable` without burning anything.
- NVML's probe waits 100 ms between samples instead: burning CPU does not load
  the GPU, and a powered GPU's own draw advances a live counter. That shows
  the counter is published continuously, not that it tracks a load.
- `LinuxRaplCounter::try_detect` and `NvmlCounter::try_detect` go through
  `try_detect_given(status)`, which declines every status but `Live` before
  NVML is touched. `detect_with` takes each rung's status, asked only when the
  ladder reaches that rung.
- The thread CPU clock, the wall clock and the CPU-burning tick moved out of
  `macos_ioreport` into a `clocks` module (macOS and Linux, the crate's second
  scoped `allow(unsafe_code)`, one `clock_gettime`), so RAPL's probe burns
  exactly what IOReport's does.

Behaviour on a Linux host whose `energy_uj` is root-only: `detect()` now
returns the stub, where it returned a RAPL counter whose every read failed.
Consumers got no reading either way. On a host whose RAPL is readable, the
first `detect()` now costs about 300 ms of CPU (a second if the counter is not
live).

Tests. The rule for a single counter runs on scripted samples and a scripted
clock on every platform: advancing every 25 ms is live at 12 intervals, 13
samples (at 100 ms, at 5 intervals); a held counter is frozen after 41
samples; one step, a two-step refresh, and advancing in 2 of every 3 intervals
are frozen, while advancing in 4 of every 5 is live; a wrap is no advance; a
counter that never reads is sampled to the five-budget cap (201 samples) and
is unavailable, and one that does not open is never sampled or ticked; a
vendor watch counts every channel where IOReport's counts only `CPU Energy`.
The RAPL module is compiled for tests on a Mac too, so its own probe runs on
scripted reads (live; held, one step: frozen; EACCES on every read, or one
read then none: unavailable), its sampler is pinned (raw value, saturating at
`i64::MAX`), and only `Live` constructs it. Selection: a frozen or unavailable
RAPL or NVML status never selects that rung, and a live RAPL status selects
it exactly on Linux. Under `cuda`, a declined NVML status never even tries to
initialise NVML (counted per thread), and `Live` tries once. On Linux, a
hardware test checks the cached RAPL status against the counter itself across
200 ms of CPU work, and that `detect()` never picks a RAPL counter that is not
live.

Falsified, each mutant restored byte-for-byte (sha256 checked):
- the gate admitting `Frozen` fails 2 tests;
- a frozen verdict mapped to `Live` fails 3;
- the vendor probe watching only CPU rails fails 4;
- RAPL's constructor ignoring its status fails 1;
- NVML's constructor ignoring its status fails 1 under `cuda` (it survived
  until the open attempt was counted: on a host with no NVIDIA GPU, opening
  fails either way);
- two advances in place of five fails 4.

What ran where. On this M5 Max (macOS 27.0.1): `cargo test -p eoc-meter`
57 + 1 passed, with `--features cuda` 58 + 1, and the whole eoc workspace 665
passed, 0 failed, 3 ignored. For Linux, `cargo check -p eoc-meter --tests
--target x86_64-unknown-linux-gnu`, with and without `cuda`, is clean. The
Linux production probe and the RAPL hardware test were type-checked, not run:
there is no Linux host with readable RAPL here, and no NVIDIA GPU, so neither
rung has been seen live. clippy raises no new warning; the crate's two
existing ones (a manual C-string, a duplicated `link` attribute) predate this.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
sandbox-meter: IOReport is a hardware reading only while every CPU rail it sums keeps advancing
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m28s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 4s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 3m30s
standards workspaces build + test / aac-rs (pull_request) Successful in 56s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m21s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m16s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 41s
standards workspaces build + test / map-rs (pull_request) Successful in 38s
standards workspaces build + test / mesh-rs (pull_request) Successful in 31s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m11s
standards workspaces build + test / openpay (pull_request) Successful in 3m1s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m36s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
5e945eed4a
The IOReport liveness rule said Live as soon as ONE CPU rail sustained its
advances (`Quorum::Any`), but `snapshot` sums every CPU rail into the one
`Cpu` figure. On a multi-die part that is two rails, `DIE_0_CPU Energy` and
`DIE_1_CPU Energy` on an M3 Ultra, so one die live beside a frozen one would
have selected the probe and sealed one die's energy as the whole CPU's, as
`HwShunt`. RAPL and NVML were already held to every rail they sum
(6b693aa4); IOReport now is too.

The rule: Live only when every CPU rail present in the samples advanced in
at least five intervals, four in five of those observed, over at least
300 ms; Frozen otherwise; Absent with no CPU rail. A rail is present if it is
in any sample the probe read, the baseline included, and it is judged over
every interval observed. Before, a rail seen only in the first sample was
never counted, because rails were registered from the later sample of each
interval; now the baseline's rails are seen with no advance. That holds for
the RAPL and NVML runs too, which share the watch. With one rule left there
is no quorum to choose, so `Quorum` and `watch_with` are gone: `RailWatch` is
the rule, and `counter_run` is `watch` over the counter.

The oracle behind the real-IOReport tests now says "moved" only when every
CPU rail it reads changed at four reads in five or more, so a Live claim is
checked against every die. It reads exactly the probe's rails on both hosts:
one on this M5 Max, the two dies on the M3 Ultra.

Docs: the liveness and ioreport module docs, `CpuRail`, `RailWatch`,
`IoReportStatus` and its Display text, `ioreport_status`, the probe module
doc, and the sandbox README's metering guarantee.

Tests, all scripted (macOS here). The rule:
`two_dies_that_both_keep_advancing_are_live` (live at the tenth interval, or
at the fifteenth when DIE_1 misses its first three),
`one_die_that_keeps_advancing_beside_a_frozen_one_is_frozen` (the other die
held, stepped once, stepped five times, or advanced three intervals in five;
in either listing order; and twin `CPU Energy` channels),
`one_die_that_keeps_advancing_with_no_other_die_in_the_samples_is_live`, and
`a_die_in_only_some_samples_is_a_rail_the_watch_needs`. A whole probe run:
`a_probe_run_calls_two_dies_that_keep_advancing_live`,
`a_probe_run_calls_one_live_die_beside_a_frozen_one_frozen`,
`a_probe_run_calls_one_die_alone_that_keeps_advancing_live`. The production
status through the scripted IOReport seam:
`the_production_status_of_two_dies_that_keep_advancing_is_live`,
`the_production_status_of_one_live_die_beside_a_frozen_one_is_frozen`. The
single-rail tests are unchanged. `a_counter_with_one_rail_that_held_is_frozen`
asserted that the IOReport loop called its two-rail samples live; it now
asserts Frozen.

sandbox-meter: 106 lib tests and both once-per-process files pass on this M5
Max. The same lib test binary passes 106/106 on the M3 Ultra (macOS
27.0.1), where the real probe gave `CpuRailFrozen` with `DIE_0_CPU Energy`
and `DIE_1_CPU Energy` at 0 advances each in 28 intervals over 991 ms, and
the oracle saw 0 changes on both dies in 17 reads. The sandbox-rs crates that
depend on the meter pass (126 tests), as do jcp-gateway and
jcp-action-receipt (33), joule-code-remediate (13), and joule-ui-interaction
and joule-ui-serve (19). joule-ui `gen_vectors` regenerated into a scratch
directory is byte-identical to `conformance/v1/`. Clippy is clean for
aarch64-apple-darwin and x86_64-unknown-linux-gnu.

Falsified, each restored from git and the suite re-run green: the rule back
to any one rail fails the three one-live-die-beside-a-frozen-one tests (rule,
run, production status), `two_dies_that_both_keep_advancing_are_live`,
`a_die_in_only_some_samples_is_a_rail_the_watch_needs` and
`a_counter_with_one_rail_that_held_is_frozen`; not seeing the baseline's
rails fails `a_die_in_only_some_samples_is_a_rail_the_watch_needs`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: a counter is live only if every rail it sums keeps advancing
Some checks failed
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
4f9d90ca94
The IOReport counter sums every CPU energy rail: `CPU Energy` on an M5 Max,
`DIE_0_CPU Energy` and `DIE_1_CPU Energy` on an M3 Ultra. The shared liveness
rule called a counter Live as soon as any one of its rails sustained advances
(five intervals, 80% of them, 300 ms). On a two-die part, one live die beside
a frozen one would select the rung. Every span's figure would then miss the
frozen die's energy and still be graded a whole-CPU HwShunt reading. SPEC §5
forbids that: a figure must not claim coverage of a domain its counter does
not read.

RAPL had the same gap. A RAPL span sums every top-level package's
`energy_uj`, and the rung was held to the same any-rail rule. The fake-tree
test even asserted Live with package 1 holding. proof-cascade's RAPL already
requires every package (9334ec0c); this crate's did not.

The fix is in the one shared rule, `liveness::Advances::observe`. A counter
is now Live once every rail seen meets all three conditions at the same
interval. A counter with no rail at all is never Live. That guard matters:
`all` over an empty list is true, so without it a group with no CPU rail would
read as Live once the window passed 300 ms. A rail missing from some samples
is still counted against every interval, so a rail that comes and goes holds
the counter Frozen. NVML and a single-die host have one rail, so for them
nothing changes: a single rail is Live at the tenth interval, as before.

On a two-die part whose rails are both live, the slower die now decides. If
an idle die's rail advanced in fewer than 80% of the intervals, the rung is
declined and the model estimates. That is the safe side: estimated, never
over-claimed. On macOS 27.0.1 every CPU rail is frozen on both machines here,
so nothing changes there.

The crate docs, `CounterStatus`, `IoReportStatus`, `rapl_status`, SPEC §5 and
§5.1, and the README's jcp-energy bullet now say "every rail". The SPEC adds
one sentence: where the figure sums several counters of the domain, every one
of them must be observed live.

Tests. jcp-energy has 66 lib tests (63 before) and its integration test, all
passing with and without `--features nvml`:
- `one_live_die_beside_a_frozen_die_is_frozen`, on scripted M3 Ultra-shaped
  samples. Both dies rising: Live at the tenth interval, the same interval as
  a single rail. DIE_0 rising, DIE_1 holding: Frozen after the whole budget
  (DIE_0 33 advances, DIE_1 0), and never Live when fed 200 intervals by hand.
  The mirror is Frozen too, and so is DIE_0 rising beside a DIE_1 republished
  once.
- The same through `probe_with`, and through the production composition
  `host_status` with its real tick and clocks:
  `host_status_on_one_live_die_beside_a_frozen_die_is_frozen`.
- The existing 300 ms test's second case was "the second die of two, the
  first holding -> Live". That was the any-rail behaviour, and it is now the
  mixed test. In its place, both dies rising -> Live.
- RAPL, scripted: package 0 rising beside a held package 1 is Frozen. RAPL,
  the real probe on fake powercap trees: "packages [0, 1] advancing every 2 ms
  -> Live in 301.846625ms"; "packages [0] advancing every 2 ms -> Frozen in
  1.06510775s".
- No rail at all, watched past 300 ms: an Energy Model group with no CPU rail
  stays Absent over the whole budget, and a counter whose samples carry no
  rail is never Live.

Falsified, each by a temporary edit restored with git checkout (git diff was
empty after each):
- The rule back to any rail fails five tests: the three two-die tests ("DIE_0
  beside a frozen DIE_1 became live", left Some(10), right None; Live vs
  Frozen; Live vs CpuRailFrozen) and both RAPL tests ("one live package beside
  a held one read as live"; "package 0 advancing beside a held package 1 was
  not declined").
- Dropping the no-rail guard first passed all 66 tests. The only no-rail test
  spanned 62 ms, short of the window. With the full-budget cases added, it
  fails two: "a group with no CPU rail, watched 310ms", left Some(Live), right
  Some(Absent), and the counter-status test.

jcp-intercept (12) and jcp-conformance (1 unit test and 5 pack tests) pass.
The jcp-conformance binary prints "all checks passed", and jcp-cli runs and
exits 0. clippy --no-deps -D warnings is clean on jcp-energy for macOS (with
and without `nvml`) and x86_64-unknown-linux-gnu, and `cargo doc` with
`-D warnings` is clean.

On an M3 Ultra (macOS 27.0.1), the lib test binary passed 66 of 66: "status=
CpuRailFrozen oracle=CpuRailMoved(false) selected=false", advances per CPU
rail [("DIE_0_CPU Energy#0", 0), ("DIE_1_CPU Energy#0", 0)].

Not done here:
- The hardware oracle in the IOReport tests still vouches for a Live when any
  one CPU rail kept moving. It matches `*CPU Energy` in every group, not only
  the Energy Model's. Making it require every rail could convict a correct
  probe over a channel the counter never reads, so it is unchanged. On a mixed
  host it would not convict an any-rail probe; the scripted tests do.
- `ioreport_span` still grades a span a reading when the summed CPU rails
  rose, that is, when any die advanced. Requiring every die per span would
  decline short spans over an idle die.
- This crate's RAPL reader sums every top-level `intel-rapl:N`, `psys`
  included where a platform exposes it. proof-cascade found that double count
  (9334ec0c). It is untouched here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jouleclaw-energy: IOReport is live only when every CPU rail it sums is live
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m28s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m24s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 58s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / aac-rs (pull_request) Successful in 1m38s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m23s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m56s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 1m2s
standards workspaces build + test / map-rs (pull_request) Successful in 37s
standards workspaces build + test / mesh-rs (pull_request) Successful in 30s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / openpay (pull_request) Successful in 1m47s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
c7a4d7102a
The IOReport counter sums every CPU energy aggregate in the Energy Model group:
`CPU Energy`, or on a multi-die part each `DIE_<n>_CPU Energy` (`counts_once`).
Its liveness rule (`RailWatch`) called the counter live once ANY one of those
rails sustained advances (five or more intervals, four in every five, over at
least 300 ms). On an M3 Ultra, `DIE_0_` live beside a frozen `DIE_1_` would
have passed, and every reading would have been graded HwShunt over SocTotal
while leaving the frozen die's energy out. The shared `liveness` rule that RAPL
and NVML are gated on already asks every channel the figure sums to be live,
for exactly this reason (one frozen GPU of two); IOReport was the one backend
that did not.

`RailWatch::is_live` now asks every CPU rail present in the samples to be
sustained, each on its own count, and no rail at all is never live (`all` over
nothing would be). A rail counts from the first sample it appears in, the
baseline included, so a die that drops out of later samples holds the counter
back instead of leaving the rule. Nothing else moves: the numbers, the keying
(name and ordinal), the probe loop, the cache and the gate are as they were, and
a single-die host (one `CPU Energy` rail, as on the M5 Max) is judged exactly as
before.

The trade-off, stated in the docs: the probe burns on one thread, so a die it
does not run on must still be seen advancing on its idle draw. If a live die's
idle draw cannot step its rail in four of every five ~31 ms intervals, the
counter is declined and the host estimated, which is the safe side. That cannot
be measured today: every CPU rail is frozen for an unprivileged reader on macOS
27.0.1, on both Macs here.

The test oracle follows the rule. `oracle_moved` said "moved" when one rail kept
changing, so the real-host check of the dangerous direction (probe Live, so the
oracle must have seen the rails move) would have let an any-rail probe through
on a mixed host. It now requires every CPU rail it finds to keep changing, and
the freshness test's Live check likewise requires every CPU rail to change
inside the probe's window.

Tests. One existing case asserted the defect itself: `DIE_0_` held beside an
advancing `DIE_1_` was expected Live. It now has both dies advancing. New, all
scripted:

- `both_dies_sustained_are_live`: Live at the 10th interval unloaded, the 5th
  loaded, and at the 10th with each die missing a different one in five.
- `one_die_sustained_beside_a_frozen_die_is_frozen`: `DIE_0_` sustained with
  `DIE_1_` held, the reverse, and `DIE_1_` moving only in a two-step refresh,
  all Frozen at both paces over 39 intervals.
- `watch_one_die_sustained_beside_a_frozen_die_is_frozen` and
  `the_production_status_of_one_live_die_beside_a_frozen_one_is_frozen`: the
  same through the probe loop, and through the real status function with only
  the IOReport group scripted. `CpuRailFrozen`, decided after the whole budget;
  both dies advancing is `Live`.
- `a_single_die_host_is_unchanged`: for six single-rail patterns at both paces,
  the verdict after every interval equals the shared one-channel `AdvanceWatch`,
  first live where the existing tests pin it.
- `a_die_that_drops_out_of_the_samples_holds_the_counter_back`, and
  `no_cpu_rail_is_absent_and_no_interval_is_no_verdict` extended over a whole
  window.

Falsified, each restored with git checkout: `all` back to `any` in `is_live`
(the old rule) fails four tests, the mixed ones going live at the 10th interval;
dropping the empty-rails guard fails `no_cpu_rail_is_absent...` and
`the_production_status_with_no_cpu_rail_is_absent`; counting rails only from
the first interval fails `a_die_that_drops_out_of_the_samples...`.

Checked: jouleclaw-energy with `--features ioreport` (92 lib tests, 2
integration) and default features (34, 2); mesh-meter with `--features
hw-meter-apple` (12); jouledesk-github (12). Clippy adds no warning on
jouleclaw-energy. On the M3 Ultra (macOS 27.0.1), the same lib test binary, run
once: 92 passed, `status=CpuRailFrozen oracle=CpuRailMoved(false)
selected=false`. The oracle found exactly two CPU rails across every group
there (`DIE_0_`, `DIE_1_`, both held), the rails the counter sums, and on the
M5 Max one (`CPU Energy`). Neither Mac can show one live die beside a frozen one
for real; that case is covered by the scripted tests only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
proof-cascade: IOReport is a hardware reading only while every CPU rail it sums keeps advancing
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m57s
standards workspaces build + test / aac-rs (pull_request) Successful in 39s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 38s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 37s
standards workspaces build + test / map-rs (pull_request) Successful in 31s
standards workspaces build + test / mesh-rs (pull_request) Successful in 28s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / openpay (pull_request) Successful in 1m41s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m7s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / arl-rs (pull_request) Successful in 5m52s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 5m24s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 4m25s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 10m23s
standards workspaces build + test / sense-rs (pull_request) Successful in 7m31s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 4m38s
0503e69b32
The IOReport watch, `CPU_RAILS`, was "any one": Live as soon as ONE CPU rail
met the sustained-advance rule. The power reading sums every CPU rail
(`counts_once`), and on a multi-die part that is two rails, `DIE_0_CPU
Energy` and `DIE_1_CPU Energy` on an M3 Ultra. So one die live beside a
frozen one would have admitted the meter and bracketed one die's energy as
the whole CPU's, as `HwShunt`. RAPL was already held to every package
counter it sums (9334ec0c); IOReport now is too.

The rule: Live only when every CPU rail present in the samples advanced in
at least five intervals, in at least 80% of those observed, over at least
300 ms; Frozen otherwise; Absent with no CPU rail. A rail is present if it is
in any sample, the first included (the watch already registered every
sample's rails; a test now pins it), and is judged over every interval
observed. With every source on the same rule there is nothing for
`Watched.every` to choose, so the field is gone: a `Watched` is which
channels to watch, and the watch needs every one of them.

Two test-side checks encoded the old rule and now encode this one: the
oracle behind the real-IOReport tests says "moved" only when every CPU rail
it reads changed at five reads or more and at 80% of them, and
`assert_verdict_matches_record`, the tests' own reading of a probe record,
calls a run Live only when every CPU rail seen, the first sample's included,
rose in at least 5 intervals at 80% over 300 ms. The oracle reads exactly the
probe's rails on both hosts: one on this M5 Max, the two dies on the M3
Ultra.

Docs: the meter module doc, `IoReportStatus`, `CpuRail`, `Watched`,
`CPU_RAILS`, `RailWatch` and `PACKAGES`; SPEC §5's liveness clause (for
IOReport, every CPU rail the figure sums, one per die, must advance on its
own); the proof README.

Tests, all scripted (macOS here). The rule:
`watch_two_dies_that_both_keep_advancing_is_live` (live at the tenth
interval, or at the fifteenth when one die misses its first three, either
die), `watch_one_die_that_keeps_advancing_beside_a_frozen_one_is_frozen` (the
other die held, stepped once, stepped five times, or advanced three
intervals in five, either die; and twin `CPU Energy` channels),
`watch_one_die_alone_that_keeps_advancing_is_live`, and
`watch_a_die_in_only_some_samples_is_a_rail_the_watch_needs`. The whole probe
loop: `the_probe_needs_every_die_to_keep_advancing`. The real `host_status()`
on scripted reads: `host_status_calls_two_dies_that_keep_advancing_live` and
`host_status_calls_one_live_die_beside_a_frozen_one_frozen`. The single-rail
tests are unchanged. Two existing tests encoded "any one":
`two_advances_of_the_same_rail_are_not_live_and_sustained_ones_are` held
DIE_0 constant and expected Live from DIE_1 alone, so DIE_0 now advances at
every interval and the verdict is still DIE_1's; and
`rapl_every_package_counter_must_keep_advancing` drops its contrast with the
any-one-rail watch, which no longer exists.

proof-cascade: 86 lib tests and the three integration files pass on this M5
Max. The same lib test binary passes 86/86 on the M3 Ultra (macOS 27.0.1),
where the real probe gave `CpuRailFrozen` over 25 and 26 intervals and the
oracle saw 0 changes on both dies in 19 reads. `cargo test --workspace`
passes (126 tests) and the proof-conformance binary passes all 10 checks.
Clippy adds nothing for aarch64-apple-darwin (two warnings in the IOReport
FFI predate this) and is clean for x86_64-unknown-linux-gnu.

Falsified, each restored from git and the suite re-run green: the rule back
to any one rail fails the one-live-die-beside-a-frozen-one tests (rule, loop
and `host_status`), the two-live-dies test, the some-samples test, the
updated two-advances test and the RAPL every-package test; registering a
rail only once an interval has closed fails
`watch_a_die_in_only_some_samples_is_a_rail_the_watch_needs`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
proof-cascade: meter CPU work with this process's own energy counter while it is live
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m8s
standards workspaces build + test / aac-rs (pull_request) Successful in 45s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m7s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 51s
standards workspaces build + test / map-rs (pull_request) Successful in 43s
standards workspaces build + test / mesh-rs (pull_request) Successful in 37s
standards workspaces build + test / openpay (pull_request) Successful in 1m48s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m52s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m18s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m15s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m10s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m37s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m45s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m16s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m48s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m11s
standards workspaces build + test / sense-rs (pull_request) Successful in 3m10s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m43s
0e9e3c8f6d
On both Macs that run this suite (macOS 27.0.1, an M5 Max and an M3 Ultra)
IOReport's CPU energy rail is frozen for an unprivileged reader, so the meter
has correctly declined and `proof --measure` has never printed a figure there.
The kernel keeps a live counter of its own: `proc_pid_rusage(getpid(),
RUSAGE_INFO_V6)`'s `ri_energy_nj`, the cumulative CPU energy it attributes to
this process's threads, in nanojoules. It is public libproc API and needs no
privilege. On the M5 Max it changes every ~0.5-0.7 ms under load, holds near
1-2 mJ/s idle, and keeps the energy of threads that have exited; on both Macs
the probe below calls it Live.

Design. `measure_marginal` (new; `measure_marginal_uj` is now its figure)
runs `work` on every spare core and meters it with the first live source, in
order: on macOS this process's counter, then IOReport; on Linux RAPL. For the
process counter there is no bracket and no idle subtraction: the counter is
read before the workers start and after every one has been joined, and the
figure is its advance ÷ every call that completed, so it covers exactly the
calls counted, and other processes are excluded by construction. What the
figure claims, `MeterSource::domain`, says so: this process's CPU energy as
the kernel attributes it to its threads — not other processes, not child
processes, not GPU, ANE or DRAM — and everything this process burns in the
window, so a caller must not run other work in the process while it meters.
It is graded HwShunt (`MeterSource::provenance`; the owner's decision: a
kernel reading of the CPU's own energy counters), and `Measurement::energy()`
builds the schema reading from it.

The counter is used only while it is live, by the same sustained-advance rule
as IOReport and RAPL: through the same probe loop, it must advance in at least
five sample intervals, in at least 80% of them, over at least 300 ms, while
the probe burns 25 ms of thread CPU before each re-read. `process_energy_status`
probes once per process and caches it; `meter_source` admits only `Live`, and
the process rung has its own gate on the cached status besides. The bracket
paths keep their own gates whatever ranks above them: `power_over` now gates
on IOReport's own status and `rapl_power_over` on RAPL's, not on the overall
selection, so a live process counter can never unlock a frozen IOReport rail.
IOReport is not probed by the selection when the process counter is live.

`proof --measure` prints the decision it acted on (`source=`,
`process_energy_status=`, `ioreport_status=`, `rapl_status=`), why each source
not used was not used, the figure with its source and provenance, and its
domain. On this M5 Max, verbatim:

  --measure: hardware meter available=true · source=ProcessEnergy · process_energy_status=Live · ioreport_status=CpuRailFrozen · rapl_status=Unavailable
  measured: 20819 uj / proof  ·  provenance HwShunt (measured_uj present, the schema's strongest tier)  ·  source ProcessEnergy
  receipt: closed at T0:automation · 215 uj · provenance ModelBased · spec Ungrounded · verified true

The receipt is unchanged: still sealed from `first`, the cascade's own
ModelBased figure for the first goal. The CLI's old line "the standard's
headline is now a real hardware reading" is gone: the per-goal figures stay
ModelBased, and it now says so.

Tests. Hardware tests check the meter against an independent oracle — the
tests' own binding of `proc_pid_rusage`, their own `getpid`, a raw 464-byte
buffer read at byte 336, their own loop and literals — and every branch
asserts:
- the FFI pin: three production reads each lie between two oracle reads, and
  advance across 50 ms burns; the struct is 464 bytes with `ri_energy_nj` at
  336, the macOS 27.0 SDK header's numbers;
- `tests/process_energy.rs` (serialized): the probe is Live exactly when the
  oracle sees the counter keep moving, and then it is selected first; a fixed
  cheap closure meters as a non-zero HwShunt figure three times within 3x; a
  closure that sleeps 2 ms meters at under a tenth of one that computes for
  2 ms, also while four busy child processes burn CPU beside it;
- `tests/process_energy_frozen.rs` drives the whole chain with the counter held
  (`simulate_frozen_process_energy`, a hook that can only turn the meter off):
  Frozen, never selected, no figure, the work never runs, one probe run;
- `proof --measure` (proof-cli test): the source is the first live one in
  order, every printed reason is its printed status's own, the receipt equals
  the first goal's row and stays ModelBased, and where the test process's own
  counter keeps moving the CLI must have printed a non-zero HwShunt
  ProcessEnergy figure with its domain;
- pure, on every platform: the probe composition on scripted counters (live at
  the twelfth 25 ms interval; constant, one-, two- and four-step counters
  Frozen; unreadable Unavailable), the measurement's arithmetic and read order
  on a scripted counter each call raises by exactly 7,000 nJ (exactly 7 uJ; it
  holds only if the counter is read before the first call and after the join),
  rounding, a falling or unreadable counter giving no figure and an unreadable
  one never running the work, the macOS order (IOReport not even asked when the
  process counter is live), and the grading and domain of every source.
The IOReport tests now assert on IOReport's own gate (`bracket_admitted`,
`bracket_marginal_uj`), and `ioreport_once.rs` / `ioreport_unopenable.rs` cover
both probes (each runs once per process; with no Energy Model group and the
counter held, every meter declines).

Measured, verbatim from the test logs (debug builds, macOS 27.0.1):
- M5 Max: `process liveness: status=Live source=Some(ProcessEnergy) oracle=Oracle
  { moved: true, reads: 35, changes: 35, advanced_nj: 1061927078 }`; cheap
  closure, three calls: 355, 355, 360 uJ; compute 2 ms = 2732 uJ, sleep 2 ms =
  7 uJ, sleep 2 ms beside 4 busy children = 6 uJ.
- M3 Ultra (the lib and process_energy test binaries and `proof`, built from
  this commit's code before its last doc-comment and README edits, copied to
  /tmp on 10.10.10.2, run once with --nocapture, deleted): `process liveness: status=Live
  source=Some(ProcessEnergy) oracle=Oracle { moved: true, reads: 29, changes:
  29, advanced_nj: 5293659397 }`; cheap closure 671, 676, 685 uJ; compute 2 ms
  = 6520 uJ, sleep 2 ms = 2 uJ, beside 4 busy children = 9 uJ; `measured: 44537
  uj / proof · provenance HwShunt · source ProcessEnergy`; 88/88 lib tests and
  3/3 process_energy tests passed; IOReport there `CpuRailFrozen` as before.

Falsified, each restored through git before the next (proof-cascade and
proof-cli test suites):
- reading the parent's pid: the FFI pin fails ("round 1: the production read
  177161340 is outside the oracle's [897071438, 897071438]"), and so do the
  liveness test ("the probe says Frozen; the oracle saw the counter change at 35
  of 35 reads"), both metering tests and the CLI test ("this test's own process
  counter changed at 24 of 24 reads, yet the CLI metered with none");
- a constant counter: the same five tests ("the production read 123456789 is
  outside the oracle's [...]");
- liveness ignored at the selection (the counter chosen whenever it reads): the
  frozen-chain test ("a held process counter was selected"), the unopenable test
  and the pure ordering test fail;
- the probe run but its verdict discarded (always Live): the frozen-chain and
  unopenable tests fail ("a held counter was called Live").

`cargo test --workspace` and the proof-conformance binary pass; `cargo check
--workspace --tests` and `cargo clippy --workspace --all-targets` for
x86_64-unknown-linux-gnu are clean; clippy on macOS reports only the two
IOReport FFI lints the README already lists.

Not done: the counter's accuracy is the kernel's; nothing here checks it
against an external meter. Its domain is CPU only, so GPU-bound work would
meter low, and a caller that runs other threads in the process while it
meters is charged for them. The process figure resolves 1 uJ per call;
`Some(0)` from it means under half a microjoule, and the CLI says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The IOReport liveness rule said Live once ANY CPU rail sustained its advance
(five intervals, 80% of them, 300 ms), but the counter it admits SUMS every CPU
rail. 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 have selected a counter whose
figure leaves the frozen die out, or carries its refreshes, while claiming the
whole CPU, tagged Measured. RAPL and NVML were never exposed to this: each
hands its one counter as the only channel, so "any" and "every" agree there.

`RailWatch::observe` now says Live the first time EVERY rail seen in an
interval passes `sustained`, and at least one does (`all` of no rails is
vacuously true, and a sample with no CPU rail is Absent, never Live). Each rail
is still counted on its own; a rail that joins late is held to every interval
observed since the first, so it is live only once its own advances reach 80% of
all of them. The rule stays one rule: for a single vendor counter, every rail is
its one channel. `CpuRail`, `IoReportStatus` and the crate docs say so.

The hardware oracle is held to the same property. `oracle_moved` required one
CPU rail to change at 80% of its reads; it now requires every CPU rail it found
to, so on a multi-die host the oracle can no longer vouch for a Live verdict that
one die alone earned.

Tests, all pure:

- `m_every_die_must_sustain_its_own_advance`: two dies advancing at every 31 ms
  interval are Live at the tenth (310 ms), exactly where one die is (d); DIE_0
  live with DIE_1 holding, stepping once, or stepping twice in a refresh is
  Frozen over the whole budget and over 1,000 samples; DIE_1 at 60% is Frozen;
  DIE_1 frozen for 20 intervals then live is Live at 80 of 100, never at DIE_0's
  tenth, and Frozen within one probe budget.
- `probe_with_one_die_live_and_one_frozen_is_frozen`: the same through
  `probe_with`, both dies' own values handed to the rule; both live is Live at
  12 intervals, as one die is.
- `host_status_is_the_rule_on_the_production_composition` gains the mixed case:
  `CpuRailFrozen` through the real opener closure, tick and clocks.
- (c) changes meaning: of two same-named `CPU Energy` rails, one live and one
  held was Live and is now Frozen; both live is Live.
- The oracle test now pins `[0, 11]` and `[11, 2]` as not moving and `[11, 9]`
  as moving, and an empty set as not moving.
- Single-die behaviour is unchanged: (a)-(l), the RAPL and NVML tests and the
  composition's single-rail cases pass untouched.

Falsified, each mutant applied to the uncommitted tree, run, and restored
byte-for-byte (sha256 checked):

- `any` in place of `all` (HEAD's rule) fails 4: (m), with "DIE_0 live and
  DIE_1 frozen (holds) counted as live: the counter would leave DIE_1 out ...
  left: Some(Live) right: Some(Frozen)", plus (c), the probe_with mixed test
  and the production-composition test;
- `all` without the non-empty guard fails 2 (`no_cpu_rail_is_absent`,
  `a_rail_new_in_a_sample_only_sets_its_baseline`);
- the oracle back on `any` fails the oracle test.

On hardware, macOS 27.0.1: the M5 Max (one `CPU Energy` rail) and the M3 Ultra
(`DIE_0_`/`DIE_1_CPU Energy`) both report `status=CpuRailFrozen
oracle=CpuRailMoved(false) selected=false`; the M3 Ultra's oracle saw changes
per CPU rail [0, 0] over 19 compared pairs, the GPU rail [19]. eoc-meter's lib
tests pass 59/59 on each.

Not done: on a part whose idle die's rail is live but does not advance while
the probe burns one thread elsewhere, every-die declines the rung. That fails
safe (estimated, never over-claimed) and is not observable on these hosts, where
every CPU rail is frozen to an unprivileged reader.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`AgentLoop::read_meter` took a failed read as 0 (`unwrap_or(0)`), and
`measured` charged `after - before`. A failed first read followed by a good one
therefore charged the counter's whole since-boot total to one ReAct tool call
or CodeAct execution, which on any real counter exhausts a joule budget at once.
This is the defect 15cf07c2 fixed in eoc-local and 467acb38 in eoc-streaming.

The same rule now holds here:

- `read_meter` returns `Option<u64>`: `None` for a failed read, never 0. Its
  only callers are in this file; nothing else in the monorepo uses it.
- `span_reading(start)` is `Some(delta)` only when the opening read succeeded,
  a read now succeeds, and the counter advanced. Without an opening reading the
  counter is not read again.
- `measured` charges the larger of that delta and the step's own estimate, as
  before, when there is a delta; otherwise the estimate alone.

Tests, on a scripted counter that reads a list (`None` is a failed read) and
counts its reads:

- a failed read is `None`, the next good one `Some`;
- a failed first read then 9 J charges the estimate (250, or 0), with one read;
- 1,000 -> 1,450 charges 450, or the estimate when larger;
- a failed last read, a held counter, one that went down, and the stub charge
  the estimate;
- through `ReActLoop`, with a 1 J budget and a counter that fails once then
  reads 9 J, the agent finishes `Done` having charged its two replies (2,000
  µJ), and the tool step's span carries 0.

Falsified, each mutant applied to the uncommitted tree, run, and restored
byte-for-byte (sha256 checked):

- the failed first read taken as 0 in `measured` fails 2: the unit test with
  `left: (9000000, 2) right: (250, 1)`, and the ReAct test, which stopped
  with `BudgetExhausted("joules")` after the tool step instead of `Done`;
- `read_meter` as at HEAD (`Some(unwrap_or(0))`) fails 3.

`cargo test -p eoc-agent` passes (25 unit, 9 integration); no new clippy
warning (the two in `codeact.rs` and `react.rs`'s parsers predate this).

Not done: ReAct and CodeAct pass an estimate of 0 µJ for a tool step, so an
unmeasured tool step is charged 0 and its span carries 0, and `Span` has no
field to say whether its figure was measured. Giving tool steps an estimate,
and spans a provenance, are separate decisions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-local: a llamacpp generation that errors costs the per-token estimate over the tokens it streamed, never 0 µJ
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
1535b8eb78
15cf07c2 left one path at 0 µJ: when the stream carried an error, `infer`
returned the error text with `JouleCost { microjoules: 0, source: Estimated }`.
The prompt may have been processed and tokens generated before the error, so
that is a zero presented as a figure for inference that may have run.

The drain is now a function of the stream, `drain(rx, id, joules_per_token)`,
which `infer` calls. On an error it returns the same error text, costed at
`span::per_token_estimate` over the tokens streamed before the error — at least
one, as every other unmeasured path — tagged estimated. A reading an earlier
chunk carried covers only part of the generation, so it is not taken as the
cost, as on the normal path.

Test `an_error_costs_the_per_token_estimate_never_zero`, on an injected channel:
three tokens then an error cost 150,000 µJ estimated at 0.05 J/token, with or
without a reading on the earlier chunks, and with the payload
"[llamacpp-error: backend error (llamacpp): decode failed]"; an error before any
token costs one token, 50,000 µJ.

Falsified, each mutant applied to the uncommitted tree, run, and restored
byte-for-byte (sha256 checked): HEAD's 0 µJ fails it ("left: JouleCost {
microjoules: 0, .. } right: 150000"); an estimate that ignores the tokens
streamed fails it (50,000 for 150,000).

`cargo test -p eoc-local` passes (37 + 1), and with `--features mlc,gguf` (48 +
3). No clippy warning in this crate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: the RAPL figure sums the package zones by name, never psys or dram
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m15s
standards workspaces build + test / aac-rs (pull_request) Successful in 1m0s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 47s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 59s
standards workspaces build + test / map-rs (pull_request) Successful in 38s
standards workspaces build + test / mesh-rs (pull_request) Successful in 35s
standards workspaces build + test / openpay (pull_request) Successful in 1m47s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m47s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m19s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m7s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m28s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m50s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m47s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m45s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m45s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m54s
25bfdcd98d
The RAPL reader opened every top-level powercap directory named
`intel-rapl:<n>` and summed them as the CPU package figure, and its liveness
probe watched every one of them. A top-level zone is not necessarily a
package. Many Intel hosts expose `psys`, the platform zone, at the top level
(`intel-rapl:1` on a typical laptop), and its count already includes the
packages', so the figure counted package energy twice and graded it
`HwShunt`. Some platforms put a `dram` zone at the top level, which is not
CPU energy at all. The probe had the mirror-image fault: a live `psys` and a
held `dram` zone decided whether the package counter was live.

The fix is the rule proof-cascade already follows (9334ec0c): the figure is
the sum of every top-level zone whose `name` file reads `package-<digits>`,
every socket's, and nothing else. `psys`, `dram`, a package's subzones
(`intel-rapl:0:0`, its `core`, `uncore` or `dram`) and the second interface
to the same packages (`intel-rapl-mmio:<n>`) are neither summed nor probed.
The liveness sample is built from the opened packages, so the rule that
every rail must keep advancing now applies to every package and only to
packages. A host whose top level holds no package zone (only `psys`) opens no
reader and is `Unavailable` without a probe.

Two holes that would leave a package out of a figure graded whole close with
it. A package zone whose `energy_uj` does not read used to be skipped, so on
a two-socket host the figure could be one socket's energy claiming the CPU;
now the reader does not open. A top-level zone whose `name` does not read may
be a package, so it declines the reader too.

Wrap handling, checked per zone. Each package was already corrected at its
own `max_energy_range_uj`, but a range that did not read defaulted to
`u64::MAX`, so a counter that fell across a span was booked as about 1.8e19
µJ, `HwShunt`. A range that does not read, or reads zero, is now unknown,
and a fall with an unknown range, or from above the range, gives no figure:
the span is the time×power model's, as a failed read is. A package snapshot
that does not match the opened packages gives none either.

The SPEC §5.1 RAPL entry now names the package zones and says why `psys`
and `dram` are not summed.

Tests, on fake powercap trees in the temp directory (they run on macOS):
- `rapl_sums_and_probes_the_package_zones_only`: a two-socket tree with
  `psys`, a top-level `dram` zone, `core`/`dram` subzones and an MMIO
  interface. The reader holds exactly `intel-rapl:0` and `intel-rapl:2`, in
  zone order, each with its own range; the liveness sample names those two;
  a span over which every zone rose is metered as the two packages' 8,000 µJ
  alone; package 1 wrapping at its own 65,532,610,987 µJ range while `psys`
  and `dram` also fall is 12,487 µJ. The real probe is Live with both
  packages advancing and `psys`/`dram` holding, and Frozen with `psys`,
  `dram`, the MMIO zone and package 0 advancing while package 1 holds.
- `a_rapl_figure_never_leaves_a_package_out_or_takes_another_zone`: a tree of
  `psys` and `dram` (with a `package-0` subzone) opens nothing and is
  Unavailable without burning; a package whose `energy_uj` does not parse
  beside a readable one, and an unnamed top-level zone, do not open; a
  package with no range meters a rise and gives the model's figure for a
  fall.
- `a_rapl_zone_wraps_at_its_own_range_or_gives_no_figure` and
  `a_rapl_package_is_a_zone_named_package_n`: the per-zone delta and the
  name and directory matchers, edge cases included.
- The existing fake trees now carry `name` files; every earlier RAPL test is
  unchanged in what it asserts.

Falsified, each restored through git before the next:
- selecting by directory name again (the `name` check disabled) fails
  `rapl_sums_and_probes_the_package_zones_only` — the reader held
  `intel-rapl:0..3`, `psys` and `dram` among them — and
  `a_rapl_figure_never_leaves_a_package_out_or_takes_another_zone` — "a tree
  with no package zone opened";
- an unreadable range defaulting to `u64::MAX` again fails the latter: the
  fall was metered as `HwShunt`, 18,446,744,073,709,550,715 µJ.

jcp-energy (70 unit tests + the fresh-process pin), jcp-intercept,
jcp-conformance (its tests and the binary: all checks passed) and jcp-cli
pass on macOS (M5 Max, macOS 27.0.1). `cargo clippy --all-targets` on those
four is clean for aarch64-apple-darwin and x86_64-unknown-linux-gnu apart
from a pre-existing jcp-schema lint. The reader was not run against a real
Linux sysfs here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
On macOS 27.0.1 an unprivileged reader sees IOReport's CPU rails republished
in lumps, so the IOReport rung is declined on both the M5 Max and the M3 Ultra,
and every span gets the time×power estimate. The kernel keeps a live count
that this crate did not read. `proc_pid_rusage(getpid(), RUSAGE_INFO_V6, ...)`
from libproc (public API, no entitlement, no privilege) fills
`rusage_info_v6`, and its `ri_energy_nj` is the cumulative CPU energy the
kernel attributes to this process's threads. Measured on the M5 Max: 0.002 J/s
idle, 2.4 J/s with one thread burning, 5.3 J/s with two, changing about every
0.5 ms while polled. This commit adds it as a rung.

The rung. `Meter::ProcessEnergy` reads `ri_energy_nj` before and after the
closure `measure` runs. It is graded `HwShunt` (the owner's decision is that
vendor counters are readings) with `Coverage::Whole` for in-process CPU work.
Its domain is documented on the variant, on its module and in SPEC §5.1: the
CPU energy of this process's threads during the span, every thread of the
process and not only the one running the closure. It excludes other
processes, child processes, the GPU, the Neural Engine and DRAM.

Order. The rung sits below RAPL and NVML and above IOReport. Both it and
IOReport are vendor readings, but they read different domains. The
per-process count is the work's own CPU energy. IOReport's counter is
SoC-wide: it takes in every other process and the machine-wide GPU rail.
Where both are live, the work's own figure wins. RAPL and NVML do not exist
on macOS, so its place relative to them changes nothing there.

Liveness. The rung uses the sustained-advance rule that 475680ab shared
(`liveness::probe`), applied to `ri_energy_nj` while the probing thread burns
25 ms ticks of its own CPU. The result is cached once per process as
`process_energy_status()`, and the counter is Live in about 310 ms on both
chips. A span over which `ri_energy_nj` did not advance gets the time×power
figure at `ModelBased`, as IOReport's held spans do. A rise is rounded up to
whole µJ, so a span that spent energy never reads as zero.

FFI. The decoding is pure: `rusage_v6::decode` reads the two words at bytes
336 and 344 of the 464-byte struct. Those are the C compiler's `sizeof` and
`offsetof` for `struct rusage_info_v6` against the macOS 26, 26.5 and 27 SDKs,
which agree. The 27 SDK named three of the nine reserved words the 26 SDKs
had, and nothing before them moved. The read goes into a 464-byte buffer with
8-byte alignment.

Status. All additions; no existing name changes. `process_energy_status()`,
`enum Rung`, `Meter::rung()`, `struct Detection` and
`Meter::detect_explained()`, which returns the meter together with the
selected rung and the cached status of every rung asked on the way to it. A
rung below the selected one is never asked, so its probe never runs and its
status is `None`. `Meter::detect()` is now `detect_explained().0`.

Consequences. On this M5 Max `detect()` now selects ProcessEnergy and never
probes IOReport: in a fresh process the IOReport probe runs 0 times. The first
`detect()` therefore burns about 310 ms of CPU, where the IOReport probe
burned about one second. Spans shorter than the count's update interval fall
to the model's figure. jcp-cli's demo probe, a 20,000-term sum, now prints
"meter   HwShunt backend → 2171 µJ probe (estimated, and says so)". Three
IOReport hardware tests asked `detect()` whether IOReport was selected, and
`detect()` no longer reaches IOReport here. They now ask the IOReport gate
itself, with the rungs above it declined (`Ladder::ioreport_only()`).

Tests, with no skip-as-pass. jcp-energy has 75 lib tests (63 before) and its
integration test. All pass on the M5 Max with and without `--features nvml`,
and 75/75 pass on the M3 Ultra.
- Pure, on every platform. The layout constants equal the C compiler's
  literals and `offset_of!` on a field-by-field transcription of the SDK
  struct. On a buffer of distinct bytes, decoding reads exactly bytes 336..344
  and 344..352, and a literal little-endian buffer decodes to 2,400,000,000 nJ
  and 1. A span is a reading only on a rise: 480,000,001 nJ gives 480,001 µJ,
  and 500 nJ gives 1 µJ. A count that held, fell or failed to read gets the
  model's figure. The probe on a scripted count is Live at the tenth interval
  and Frozen when the count holds.
- The gate and the order, on macOS. Only Live selects the rung and opens its
  counter. When it is declined, the ladder goes on to IOReport and
  `Detection` says so. With the per-process rung Live, IOReport is never
  asked; with RAPL Live, the per-process status is never asked. Off macOS the
  rung is never selected.
- Against the hardware, on macOS. Each test runs in a fresh process of its
  own, because the count covers the whole process and tests running beside it
  would land in its spans. The rung is Live, and `detect_explained()` reports
  it with IOReport unasked. The reader's count sits between two reads of this
  pid by the tests' own oracle, which uses its own FFI declaration and the
  transcribed struct. 200 ms of one thread's CPU is `HwShunt`, whole, and
  between 0.2 and 20 J. 200 ms asleep meters under 0.1 J. Two threads meter
  more than one. A span that runs `yes > /dev/null` for 200 ms, then kills
  and reaps it, meters under 0.1 J, while the oracle reads the child's own
  count at 0.1 J or more.

M5 Max, verbatim from the final run:
  process energy: status=Live (first call took 316.761333ms); a fresh probe:
    Live after 12 intervals over a 314.907375ms window, advances
    [("ri_energy_nj", 12)], in 314.914166ms; detect_explained: Detection {
    selected: ProcessEnergy, rapl: Some(Unavailable), nvml: Some(Unavailable),
    process_energy: Some(Live), ioreport: None }; IOReport probe runs = 0
  process energy: 200 ms of one thread's CPU -> 402729 µJ (0.403 J), HwShunt,
    coverage Whole
  process energy: 200 ms asleep -> 77 µJ (0.000077 J), HwShunt
  process energy: 200 ms of CPU on 1 thread -> 623347 µJ (0.623 J), on each
    of 2 threads -> 1221565 µJ (1.222 J), ratio 1.96
  process energy: a span running `yes > /dev/null` for 200 ms -> 6993 µJ
    (0.006993 J), HwShunt; the child's own count 350042940 nJ (0.350 J)
Over five more runs of these five tests: one thread 0.41-0.53 J, a 2-to-1
thread ratio of 1.73-2.09, asleep 0.2-1.0 mJ, and the child span 1.2-25 mJ
against the child's own 0.32-0.46 J.

M3 Ultra, verbatim. The lib test binary was built here with
`target-cpu=apple-m1` (the global config builds native to the M5), copied to
10.10.10.2 (sha256 acf9bac254c0f97b... on both ends), run with --nocapture,
then deleted:
  process energy: status=Live (first call took 300.089625ms); a fresh probe:
    Live after 12 intervals over a 300.076ms window, advances
    [("ri_energy_nj", 12)], in 300.079042ms; detect_explained: Detection {
    selected: ProcessEnergy, rapl: Some(Unavailable), nvml: Some(Unavailable),
    process_energy: Some(Live), ioreport: None }; IOReport probe runs = 0
  process energy: 200 ms of one thread's CPU -> 1354645 µJ (1.355 J),
    HwShunt, coverage Whole
  process energy: 200 ms asleep -> 744 µJ (0.000744 J), HwShunt
  process energy: 200 ms of CPU on 1 thread -> 1328119 µJ (1.328 J), on each
    of 2 threads -> 2172758 µJ (2.173 J), ratio 1.64
  process energy: a span running `yes > /dev/null` for 200 ms -> 3351 µJ
    (0.003351 J), HwShunt; the child's own count 1213689002 nJ (1.214 J)
  ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
    selected=false
jcp-intercept (12), jcp-schema (30) and jcp-conformance (1 unit test and 5
pack tests) pass. The jcp-conformance binary prints "all checks passed", and
jcp-cli runs. clippy --no-deps is clean on jcp-energy with and without `nvml`,
for aarch64-apple-darwin, x86_64-apple-darwin and x86_64-unknown-linux-gnu.
`cargo doc` with `-D warnings` is clean.

Falsified, each by a temporary edit restored with git checkout (git diff was
empty after each):
- Reading another pid (the parent's, via getppid) fails all five hardware
  tests. The probe sees the idle parent's count hold: "detect() did not
  select the per-process rung: Detection { selected: TimePower, ...,
  process_energy: Some(Frozen), ioreport: Some(CpuRailFrozen) }".
- A reader that returns a constant fails the same five, the same way.
- Ignoring liveness in `detect` (opening the rung whatever its status) fails
  `only_a_live_status_selects_the_process_energy_rung` ("status Unavailable
  gave ProcessEnergy(...)") and the order test. A cached status of Live with
  no probe fails the fresh-process pin ("the per-process liveness probe ran 0
  times").
- Grading the rung `ModelBased` fails `the_process_energy_rung_is_live_here`.
  Grading an advancing span with `Energy::modeled` fails the pure span test
  and four hardware tests ("provenance: ModelBased").
- Grading a span with no advance as a reading fails the span test ("held:
  ... measured_uj: Some(0) ... HwShunt"). Truncating nJ to µJ fails it too
  (480000, not 480001).
- Decoding the word before `ri_energy_nj` fails the decoding test and the
  oracle check ("the reader's ri_energy_nj 977683212 is not this process's
  (1464983856 .. 1464983856)").
- Ranking IOReport above the per-process rung fails three tests. An uncached
  status fails the pin ("ran 32 times").

Not done here:
- `Whole` assumes the work stays on this process's CPU threads. A closure
  that hands work to a child process or to the GPU is under-counted, and the
  figure still says `Whole`, because the meter cannot see that. The domain
  says so, and the child test pins the boundary. Grading such work `Partial`
  would need the caller to say what it does.
- The figure is process-wide. Concurrent unrelated threads of the same
  process land in a span and over-debit it, as other processes do under a
  package counter.
- The one-thread lower bound of 0.2 J has the least margin: the lowest seen
  on the M5 Max under load was 0.351 J.

Rebased onto 25bfdcd9 (the RAPL figure sums the package zones by name),
which adds four RAPL lib tests and changes no line of this commit; the counts
above predate it. After the rebase jcp-energy has 82 lib tests at this
commit (70 before it). Three full runs on the M5 Max passed. A fourth, under
a host load average near 30, failed
`one_thread_burning_200_ms_is_a_whole_hardware_reading` at its
200,000..=20,000,000 µJ range check (the figure was not captured); ten runs
of that test alone then metered 218,593 to 298,209 µJ, below the 0.351 J
noted above, so the 0.2 J lower bound is thinner than it says. The M3 Ultra
run was not repeated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: a per-process span a child process worked in is partial
Some checks failed
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
830fc085f2
The per-process rung graded every span that advanced `ri_energy_nj` as
`HwShunt`, `Coverage::Whole`. That count covers only this process's threads.
A child's energy is its own, and the kernel never adds it to the parent's
count, not even when the child is reaped. So a closure that drives a child
process was under-counted while its figure claimed the whole span. SPEC §5
forbids that: a figure must not claim coverage of a domain its counter does
not read. The previous commit named this under "Not done here" and said
grading it `Partial` would need the caller's help. It does not: the same
`rusage_info_v6` shows it, and so does the kernel's list of children.

The rule, in `process_span`: a span whose count advanced is whole only when
no child process can have worked in it unseen. That means two things. The
process's reaped children's CPU time (`ri_child_user_time` +
`ri_child_system_time`) held across the span, and at the span's end
`proc_listchildpids` lists no child, running or exited and not yet reaped.
Otherwise the span is `Energy::measured_partial`: `HwShunt`, `Partial`, the
same rise. A failed child count is `Partial` too, since nothing then shows the
figure whole. A child the process had before the span counts as well, because
the meter cannot tell whether the span's work drove it. `measure` lists the
children before it reads the count, so a child reaped between the two shows
up in one or the other. A span whose count did not advance is still the
model's whole-task figure.

What the kernel does, measured on an M5 Max, macOS 27.0.1, with the C
compiler and small C and Rust programs rather than assumed:
- `ri_child_user_time` and `ri_child_system_time` are at bytes 96 and 104
  (`offsetof` against the 27 SDK). They are in Mach absolute time units: a
  `yes` that ran ~190 ms showed 4,565,473 of its own, and reaping it raised
  the parent's by 4,565,861. `ri_energy_nj` did not move on the reap.
- Spawning a child by absolute path, or letting it run, never moved those
  times; only the reap did (24 of 24 trials, posix_spawn and fork+exec
  alike).
- `proc_listchildpids` returns a count of pids when given a buffer. It lists
  a killed child until it is reaped. Without a buffer it returns a size
  estimate (1,098 with no child), so the reader always passes one.
- A failed `posix_spawn` can move the reaped children's time too: a spawn of a
  missing path moved it in 2 of 200 attempts (90-127 units). A program
  spawned by name is looked up by trying each `PATH` directory. Here, with 12
  ahead of `/usr/bin`, `Command::new("yes")` moved the time at spawn in 7 of
  24 spawns, and `/usr/bin/yes` by absolute path in none of 12. Such a span
  is `Partial` on that alone, which errs on the side of claiming less. It is
  documented on `process_span`. A run of the new left-running test, which then
  spawned `yes` by name, caught it: "reaped children's CPU 0 -> 115 Mach
  units" with nothing reaped. The tests now spawn `/usr/bin/yes`.

The decoder reads the two child-time words next to the energy words.
`process_energy::child_count()` wraps `proc_listchildpids` with a 64-pid
buffer. Docs: the crate's ladder entry, the module, `Meter::ProcessEnergy`,
`process_span`, SPEC §5.1 item 3 and the README's jcp-energy bullet now say
that a span that reaps a child or ends with one is `Partial`.

Tests. jcp-energy has 80 lib tests (78 before) and its integration test. All
pass with and without `--features nvml`, and three runs in a row were green:
- Layout: the child-time offsets equal the C compiler's 96 and 104 and
  `offset_of!` on the transcribed struct. Decoding reads all four words and
  nothing else; on a literal buffer, 446,368 + 4,119,493 = 4,565,861.
- Pure, on every platform: on a count that rose 0.48 J, a reaped child, a
  child left at the end, both, and a failed child count are each
  `HwShunt`/`Partial` with the same 480,001 µJ. Neither is whole. A held count
  is the model's whole figure whatever the children did.
- Hardware, each in a fresh process. A span that runs `/usr/bin/yes >
  /dev/null` for 200 ms and reaps it is `Partial`. The tests' own reads show
  that the reaped time rose and no child is left, so the reap alone decided
  it. A span that leaves `yes` running is `Partial`; the child is listed and
  the reaped time held, so the left child alone decided it. A pure 200 ms
  in-process burn, with no child before or after, is whole. The old
  `a_child_process_work_is_not_attributed` is now the reap test, and keeps
  its check that the child's energy is not in the figure.
- The tests' oracles are their own: `proc_listchildpids` through their own
  declaration, counting nonzero pids in a zeroed buffer, and the child times
  through the transcribed struct. A guard kills and reaps every child, so a
  failing test leaves none running.

Falsified, each by a temporary edit restored with git checkout (git diff was
empty after each):
- Ignoring children (`whole = true`) fails the pure test and both hardware
  child tests: "a span that reaped a child claimed to cover it: Energy {
  measured_uj: Some(1368), ..., coverage: Whole }" and "a span that left a
  child running claimed to cover it: ... coverage: Whole".
- Ignoring the reaped children's time fails the pure test and the reap test
  only.
- Ignoring the children left at the end fails the pure test and the
  left-running test only.
- A `child_count` that always says 0 fails the left-running test.

jcp-intercept (12) and jcp-conformance (1 unit test and 5 pack tests) pass.
The jcp-conformance binary prints "all checks passed", C9 included, and
jcp-cli runs and exits 0. clippy --no-deps -D warnings is clean on jcp-energy
for aarch64-apple-darwin (with and without `nvml`), x86_64-apple-darwin and
x86_64-unknown-linux-gnu, and `cargo doc` with `-D warnings` is clean.

M5 Max, verbatim:
  process energy: a span running `yes > /dev/null` for 200 ms, then reaping
    it -> 409 µJ (0.000409 J), HwShunt, coverage Partial; the child's own
    count 229352810 nJ (0.229 J); reaped children's CPU 0 -> 4673857 Mach
    units; children left []
  process energy: a span leaving `yes > /dev/null` running -> 29048 µJ
    (0.029048 J), HwShunt, coverage Partial; children at its end [41504]
    (the child 41504); reaped children's CPU 0 -> 0 Mach units

M3 Ultra, verbatim. The lib test binary was built here with
`target-cpu=apple-m1`, copied to 10.10.10.2 (sha256 060761dd2b0e2efc... on
both ends), run with --nocapture, then deleted. 80 of 80 passed:
  process energy: a span running `yes > /dev/null` for 200 ms, then reaping
    it -> 1322 µJ (0.001322 J), HwShunt, coverage Partial; the child's own
    count 1245873027 nJ (1.246 J); reaped children's CPU 0 -> 5041160 Mach
    units; children left []
  process energy: a span leaving `yes > /dev/null` running -> 137541 µJ
    (0.137541 J), HwShunt, coverage Partial; children at its end [30980]
    (the child 30980); reaped children's CPU 0 -> 0 Mach units
  process energy: 200 ms of one thread's CPU -> 1366000 µJ (1.366 J),
    HwShunt, coverage Whole
  ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false)
    selected=false

Not done here:
- Work handed to the GPU, the Neural Engine, or another process over IPC (a
  daemon serving this one) is still not in the figure and is not detected.
  rusage has no per-process GPU energy.
- A grandchild that outlives the child that started it is reparented to
  launchd and is not listed. The reap of its parent still makes that span
  `Partial`, but a later span cannot see it.
- A long-lived child the process keeps makes every span `Partial` while it
  lives. That is safe, but coarse.

Rebased onto 25bfdcd9 (the RAPL figure sums the package zones by name),
which adds four RAPL lib tests and changes no line of this commit; the counts
above, the M3 Ultra run's included, predate it. After the rebase jcp-energy
has 84 lib tests (82 before this commit), all passing on the M5 Max with and
without `--features nvml`, as do jcp-intercept, jcp-conformance (its tests
and the binary) and jcp-cli.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The RAPL rung read one file, `/sys/class/powercap/intel-rapl:0/energy_uj`, and
its liveness probe watched that one file. On a multi-socket server that figure
is package 0's energy alone while the counter claims the CPU, so every other
socket's energy was missing from every span it measured. And nothing makes zone
0 a package: `intel-rapl:<n>` is only an identifier, and each zone says what it
is in its `name` file. A host whose zone 0 is the platform zone (`psys`) or a
`dram` zone had that domain read and probed as the package. proof-cascade does
this right since 9334ec0c; this follows it.

- The package set is listed from the powercap directory: every top-level
  `intel-rapl:<n>` zone whose `name` reads `package-<digits>`. Left out wherever
  they sit, zone 0 included: `psys` (the platform zone, whose energy includes
  the packages', so summing it beside them counts them twice), `dram`, the
  `core`/`uncore`/`dram` sub-zones (`intel-rapl:0:0`), the control type
  (`intel-rapl`), and the MMIO interface (`intel-rapl-mmio:0`, also named
  `package-0`, which republishes a package). The listing reads the directory
  rather than trying fixed indices, and takes the root path as an argument, so
  the tests run it on fake trees.
- Liveness: each package counter is its own rail in the sample, named for its
  zone, and the existing every-rail rule (the one IOReport's dies are held to)
  makes RAPL live only when EVERY package keeps advancing. A host with no
  package zone (only `psys`) is `Unavailable` with nothing read or burned, so
  no counter is constructed.
- The probe's verdict is cached together with the package set it watched
  (`linux_rapl::host()`, one `OnceLock`). `rapl_status()` and the counter's
  constructor both read that cache, so the counter sums exactly the packages
  that were proved live.
- The counter returns a running total: the first read's package counters
  summed, then each package's delta added, each taken modulo its own
  `max_energy_range_uj`. Summing the raw counters would make one package's
  wrap a fall of the sum, and with sockets at different ranges no single range
  is right. Before this, a wrap of the one zone was a fall, which callers take
  as no reading; now a wrap is read across. The files are read under the
  counter's lock, so two threads' reads are folded in the order they were
  taken, and an older read is never taken for a wrap. A package that cannot be
  read fails the read: no figure that leaves a package out. A package whose
  counter falls while its range is unknown (absent, or 0) fails that read and
  every later one, because the total would be short by energy no one can know.

Tests, on fake powercap directories under the temp dir (removed when done), so
they run on macOS:
- a two-socket server (the control type, package-0 and package-1 with core and
  dram sub-zones, psys, a top-level dram, intel-rapl-mmio:0) lists packages 0
  and 1 only, and with every zone moving, the counter's delta is the two
  packages' energy (250,000 + 400,000 uJ) and nothing else;
- zone 0 named psys beside package zone 1: zone 1 alone is listed, sampled
  and summed;
- a psys zone 0 moving at every tick beside a held package: Frozen;
- both packages advancing: Live; package 1 held while package 0 and psys move:
  Frozen; scripted, either package held: Frozen;
- per-zone wrap: package 0 wraps at 262,143,328,850 while package 1 advances,
  then package 1 wraps at 65,532,610,987: deltas of 1,700 and 2,500 uJ; taken
  at the other zone's range, package 1's wrap would be off by the difference
  of the two ranges;
- a package with no range, absent or 0, is summed while it rises; its fall
  fails that read and the next;
- an unreadable package fails the read;
- an only-psys host, an empty directory and a missing one: Unavailable, no
  tick, no counter; a counter forced over no package never reads;
- the name and directory rules: `package-<digits>`, `intel-rapl:<digits>`.
On Linux, the hardware test reads every package in the cached set and, when it
is Live, requires each one to move across 200 ms of CPU work.

Falsified, each mutant applied to this commit, run, and restored with
`git checkout` (blob hash equal to HEAD's):
- back to `intel-rapl:0` only, whatever its name: 8 of the 12 RAPL tests fail;
- every top-level zone summed (no name check): 5 fail;
- every package wrapped at package 0's range: 1 fails, the per-zone wrap test;
- the liveness sample holding the first package only: 3 fail.

What ran where. On this M5 Max (macOS 27.0.1): `cargo test -p eoc-meter` 68 + 1
passed, with `--features cuda` 69 + 1; `cargo test --workspace` (with the
eoc-agent commit that follows) 686 passed, 0 failed, 3 ignored. For
x86_64-unknown-linux-gnu, `cargo check --all-targets` and `cargo clippy
--all-targets` over every workspace member except the five that link
aws-lc-sys (eoc-embeddings, -multimodal, -rerank, -tools, -vendor-api, whose
build script needs `x86_64-linux-gnu-gcc`, not installed here) pass, with no
warning in eoc-meter, with or without `cuda`. The sysfs reader, the host cache
and the hardware test are type-checked, not run: there is no Linux host with
readable RAPL here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-agent: a tool step the meter did not measure is charged its duration at a stated power, never 0 µJ
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
371fbe5eaa
ReAct and CodeAct passed `measured` an estimate of 0 µJ for a tool step. Where
the meter measured nothing (the `StubCounter` that `AgentLoop::new` builds every
loop with, a failed read, a counter that held), the step was charged 0 and its
span carried 0, for a tool or a block of code that ran. 52d9d1e2 left this open.

This crate has no energy model for a tool, and what a model step is charged is
the provider's own figure (`LlmReply::microjoules`), which a tool has no
counterpart of. So a tool step's estimate is time by power:

- `duration_estimate(elapsed, watts)`: elapsed times watts, in micro-joules,
  rounded. Never 0, because the step ran: under half a micro-joule is 1 µJ.
  A power that is not finite and positive gives way to the default.
- `DEFAULT_TOOL_WATTS` = 15 W, about one busy core: a reference figure, said to
  be one, to be replaced by one calibrated on the host, as eoc-local's
  `DEFAULT_FALLBACK_JOULES_PER_TOKEN` is.
- `AgentLoop::measured_with(estimate, f)` times the step on the monotonic clock
  and makes the estimate from its duration once it has run. `measured` is now
  `measured_with` with a fixed estimate, its behaviour unchanged, and
  `measured_tool(f)` is `measured_with` at the default power.
- ReAct and CodeAct charge tool steps through `measured_tool`, under the rule
  `measured` already had: the meter's reading when both reads succeed and the
  counter advanced, or the estimate if that is larger; otherwise the estimate.
  A tool that fails ran too, and is charged for it.

Tests:
- the estimate: 2 s at 15 W is 30,000,000 µJ, 20 ms is 300,000, 10 µs at
  2.5 W is 25; zero and 1 ns are 1 µJ; zero, negative, NaN and infinite power
  fall back to 15 W; `Duration::MAX` saturates;
- `measured_tool` over a step of at least 20 ms charges at least 300,000 µJ
  with the stub, with a failed first read, and with a held counter (and less
  than the 9 J since-boot total), and charges a 50 J reading when the counter
  advanced by that;
- ReAct with the stub meter and a tool of at least 20 ms: the act span carries
  at least 300,000 µJ, and the budget the two replies plus that;
- CodeAct with the stub meter and a runtime of at least 20 ms, once succeeding
  and once failing: the same;
- the existing failed-first-read ReAct test now expects the tool step's
  non-zero estimate on the span and in the budget (2,000 plus the span), still
  far below the 9 J since-boot total. Its budget is 5 J instead of 1 J, so a
  slow scheduler cannot push a microsecond echo's estimate past it (1 J is
  67 ms at 15 W).

Falsified, each mutant applied to this commit, run, and restored with
`git checkout`:
- `measured_tool`'s estimate back to 0: 4 tests fail;
- ReAct back to `measured(0, ..)`: 2 fail;
- CodeAct back to `measured(0, ..)`: 1 fails.

`cargo test -p eoc-agent`: 29 unit and 9 integration tests pass, and the eoc
workspace 686 passed, 0 failed, 3 ignored, on an M5 Max (macOS 27.0.1).
`cargo check` and `cargo clippy --all-targets` for x86_64-unknown-linux-gnu
pass; clippy's two collapsible-if warnings in the ReAct and CodeAct parsers
predate this.

Not done: Plan-and-Execute charges each worker step `charge_tool(0)` without
metering it, the same defect in a third loop; and `Span` still has no field to
say whether its figure was measured or estimated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
sandbox-meter: the RAPL figure sums the package zones by name, never psys or dram
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Has been cancelled
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
7cf1f0366c
`RaplProbe::open` took every top-level powercap directory named
`intel-rapl:<n>` as a package and summed them into the `Cpu` figure, and the
liveness probe's `raw_sample` watched every one of them. A top-level zone is
not necessarily a package. Many Intel hosts expose `psys`, the platform zone,
at the top level, and its count already includes the packages', so the
`Cpu` figure counted package energy twice and sealed it `HwShunt`; some
platforms put a `dram` zone at the top level, which is not CPU energy at
all. The liveness rule was judged over the same wrong set: a live `psys`
counted toward a package counter being live, and a held `dram` zone could
decline a host whose packages were live.

The fix is the rule proof-cascade (9334ec0c) and jcp-energy (25bfdcd9)
follow: the figure is the sum of every top-level zone whose `name` file
reads `package-<digits>`, every socket's, and nothing else. `psys`, `dram`,
a package's subzones and the second interface to the same packages
(`intel-rapl-mmio:<n>`) are neither summed nor probed; `raw_sample` reads
the opened packages, so every package must be live and only packages are
judged. A host with no package zone (only `psys`) does not open and is
`Unavailable` with no burn.

Every package or none. A package zone whose `energy_uj` did not read was
skipped, so a two-socket host could seal one socket's energy as the CPU's;
now the probe does not open, and neither does it when a top-level zone's
`name` does not read (it may be a package).

Wrap handling, checked per zone. Each package was already corrected at its
own range, but an unreadable `max_energy_range_uj` defaulted to `u64::MAX`,
so a wrap would add about 1.8e19 µJ to the cumulative count, `HwShunt`. The
probe is cumulative across spans, so a per-span "no figure" cannot be
expressed without a later span, bracketing the read from both sides,
coming back measured but short. So a package with no readable, non-zero
range does not open, and a fall from above the declared range (a counter
contradicting its range, the one unknown delta left) breaks the count for
good: that snapshot and every later one fail, and the meter estimates every
span. Snapshots now read every package before updating any.

To test this on macOS, the zone listing takes the powercap root
(`open_in(root)`, with `open()` the sysfs tree), the liveness run takes it
too (`run_over(root, tick)`, with `real_run` the sysfs tree and the CPU-burning
tick), and `rapl` is built under test on every platform, its Linux-only
parts (`given`, `open`, `host_status`) gated as before. Production builds
are unchanged off Linux.

Tests, on fake powercap trees in the temp directory, running on macOS here
and on Linux in CI:
- `the_probe_sums_and_samples_the_package_zones_only`: a two-socket tree with
  `psys`, a top-level `dram` zone, `core`/`dram` subzones and an MMIO zone.
  The probe holds exactly `intel-rapl:0` and `intel-rapl:2` with their own
  ranges; the liveness sample is those two; a span through
  `PowerMeter::measure` over which every zone rose is `Cpu` 8,000 µJ, the
  packages' rise alone; package 1 wrapping at its own 65,532,610,987 µJ
  range while `psys` and `dram` fall is 12,487 µJ.
- `the_liveness_run_watches_the_package_zones_only`: the run `rapl_status`
  uses, over the fake tree with a sleeping tick, is Live with both packages
  advancing and `psys`/`dram` holding, Frozen with `psys`, `dram`, the MMIO
  zone and package 0 advancing while package 1 holds; a `psys`-only tree is
  Unavailable and never ticked.
- `the_probe_never_leaves_a_package_out_or_takes_another_zone`: `psys` and
  `dram` with a `package-0` subzone, an unreadable package beside a
  readable one, an unnamed top-level zone, and a package with no or a zero
  range each fail to open, `Unavailable`, saying why.
- `a_package_wraps_at_its_own_range_and_a_contradiction_breaks_the_count`:
  the per-zone delta; a snapshot sequence through a wrap; a span opened at
  5,000 µJ, sampled mid-span at 10 (a fall from above the 1,000 µJ range)
  and closed at 6,000 is `Estimator`, as is every later read and span.
- `a_package_is_a_top_level_zone_named_package_n`: the matchers.
- The Linux-only oracle test against the real sysfs is unchanged.

Falsified, each restored through git before the next:
- selecting by directory name again (the `name` check disabled) fails
  three: the probe held `intel-rapl:0..3`, `psys` and `dram` among them; the
  run was Frozen ("both packages advancing, psys and dram holding, was not
  live"); the `psys`/`dram` tree opened `["intel-rapl:0", "intel-rapl:1"]`;
- skipping an unreadable package again fails the every-package test: the
  tree opened `["intel-rapl:0"]` beside an unreadable `intel-rapl:1`;
- an unreadable range defaulting to `u64::MAX` again fails it too: a package
  with no range opened;
- dropping the sticky break fails the wrap test: "a span over an unknown
  delta was measured: [... measured_uj: Some(1000) ... HwShunt]".

sandbox-meter (112 lib tests on macOS, 106 before: the six in `rapl` now run
here, five of them new; and both once-per-process tests) and the whole
sandbox-rs workspace pass on macOS (M5 Max, macOS 27.0.1), as does the
sandbox-conformance binary ("All classes passed with no skips");
jcp-gateway, jcp-action-receipt, joule-code-remediate, joule-ui-interaction
and joule-ui-serve pass, and joule-ui's `gen_vectors` regenerated into a
scratch directory is byte-identical to `conformance/v1/`. `cargo clippy
--all-targets -p sandbox-meter` is clean for aarch64-apple-darwin and
x86_64-unknown-linux-gnu. Not run on a Linux host with a real RAPL tree
here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jouleclaw-energy: RAPL is the sum of every package zone, each of which must be live and each wrapped at its own range
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m25s
standards workspaces build + test / aac-rs (pull_request) Successful in 42s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 44s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 40s
standards workspaces build + test / map-rs (pull_request) Successful in 36s
standards workspaces build + test / mesh-rs (pull_request) Successful in 28s
standards workspaces build + test / openpay (pull_request) Successful in 1m38s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m27s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 57s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m18s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m47s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m49s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m45s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m44s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m21s
c07d4c5b47
`RaplCounter` read one file, `/sys/class/powercap/intel-rapl:0/energy_uj`, and
its liveness probe watched that one file, cached per path. On a multi-socket
server that is package 0's energy alone, graded `HwShunt` for the domain
`CpuPkg` while every other socket's energy is missing. And nothing makes zone 0
a package: `intel-rapl:<n>` is only an identifier, and each zone says what it
is in its `name` file, so a host whose zone 0 is the platform zone (`psys`) or
a `dram` zone had that domain read and probed as the package. proof-cascade
does this right since 9334ec0c; eoc-meter now does too (26165a77); this is the
same rule here.

- The package set is listed from the powercap directory: every top-level
  `intel-rapl:<n>` zone whose `name` reads `package-<digits>`. Left out wherever
  they sit, zone 0 included: `psys` (the platform zone, whose energy includes
  the packages', so summing it beside them counts them twice), `dram`, the
  `core`/`uncore`/`dram` sub-zones (`intel-rapl:0:0`), the control type, and
  the MMIO interface (`intel-rapl-mmio:0`, also named `package-0`, which
  republishes a package). The listing reads the directory, not fixed indices.
- Liveness: one channel per package, and the shared rule asks every channel to
  be live, so RAPL is live only when EVERY package keeps advancing while the
  probe burns CPU. A host with no package zone (only `psys`) is `Unavailable`
  with nothing opened or burned.
- The cache is per package set, keyed by the package zones' directories, not
  per file path. `RaplCounter::new()` lists the set and reuses a verdict for
  the same set; the counter sums exactly the packages that were probed.
- `read()` returns a running total: the first read's package counters summed,
  then each package's delta added, each taken modulo its own
  `max_energy_range_uj`. Summing the raw counters would make one package's
  wrap a fall of the sum, and with sockets at different ranges no single range
  is right. Before this, a wrap of the one zone was a fall, which `jcr1_mint`
  and the like take as no reading; now a wrap is read across. The files are
  read under the counter's lock, so two threads' reads are folded in the order
  they were taken, and an older read is never taken for a wrap. A package that
  cannot be read fails the read (`Platform`): no figure that leaves a package
  out. A package whose counter falls while its range is unknown (absent, or 0)
  makes that read `CounterWrap`, and every later one too, because the total
  would be short by energy no one can know.
- The read path is no longer compiled only for Linux: off Linux the counter is
  still never available (`rapl_liveness` is `Unavailable` there, with no
  probe), but the code it would run is the code the tests run on a Mac.

API: `RAPL_ENERGY_PATH` and `RaplCounter::with_path(&'static str)` are gone, as
a single `energy_uj` path cannot say which domain it is or stand for every
package; `RaplCounter::with_root(dir)` takes a powercap directory instead (a
container or chroot that mounts sysfs elsewhere), and `POWERCAP_ROOT` names
the default. `rapl_liveness()` takes no argument: it is this host's package
set. Nothing in the monorepo called the removed items. Docs that said a RAPL
figure is one file are corrected (`liveness.rs`, `detect_meter`, and the
`jcr1_mint` example's header).

Tests, on fake powercap directories under the temp dir (removed when done), so
they run on macOS:
- a two-socket server (the control type, package-0 and package-1 with core and
  dram sub-zones, psys, a top-level dram, intel-rapl-mmio:0) lists packages 0
  and 1 only, and with every zone moving the delta is the two packages' energy
  (250,000 + 400,000 uJ) and nothing else;
- zone 0 named psys beside package zone 1: zone 1 alone is listed, sampled and
  summed;
- per-zone wrap: package 0 wraps at 262,143,328,850 while package 1 advances,
  then package 1 wraps at 65,532,610,987: deltas of 1,700 and 2,500 uJ; taken
  at the other zone's range, package 1's wrap would be off by the difference
  of the two ranges;
- a package with no range, absent or 0, is summed while it rises; its fall is
  `CounterWrap`, and so is the next read;
- an unreadable package fails the read and the opener;
- an only-psys host, an empty directory and a missing one: no package, the
  real probe returns `Unavailable` in under 100 ms (no burn), and
  `RaplCounter::with_root` is unavailable and reads `NoCounter`;
- scripted: two packages advancing are Live, one held is Frozen;
- the cache: once per package set, and a set with one more package is another
  set; the key of a listed tree is its package directories;
- the production probe (real reads, real CPU burn, real clock) on a two-socket
  tree: held packages are Frozen after the whole budget; both packages advanced
  by a writer thread are Live inside it; package 0 and psys advanced while
  package 1 holds is Frozen.

Falsified, each mutant applied to this commit, run, and restored with
`git checkout` (blob hash equal to HEAD's):
- back to `intel-rapl:0` only, whatever its name: 8 of the 14 RAPL tests fail;
- every top-level zone summed (no name check): 5 fail;
- every package wrapped at package 0's range: 1 fails, the per-zone wrap test;
- the probe watching the first package alone: 1 fails, the production probe
  with package 1 held;
- the cache keyed by the first package's path alone: 1 fails.

What ran where, on this M5 Max (macOS 27.0.1): jouleclaw-energy `--features
rapl` 48 lib + 2 integration tests pass, `--features ioreport,rapl,nvml` 111 +
2, default features 34 + 2; mesh-meter 2 by default, 10 with
`--features hw-meter-rapl`, 12 with `--features hw-meter-apple`;
jouledesk-github 12; the `jcr1_mint` example compiles. For
x86_64-unknown-linux-gnu, `cargo check --all-targets --features rapl` and
`cargo clippy --all-targets` with `rapl` and with `rapl,nvml` pass with no
warning in a file this touches (clippy's existing ones are in jouleclaw-core,
the ledger tests and the IOReport FFI), and mesh-meter's clippy with
`hw-meter-rapl` is clean. The sysfs reads under the real powercap directory
and the Linux-only cached probe are type-checked, not run: there is no Linux
host with readable RAPL here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
jouleclaw-harness-econ: an in-process decode is metered by the process counter, and only that work
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m10s
standards workspaces build + test / aac-rs (pull_request) Successful in 39s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 44s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 36s
standards workspaces build + test / map-rs (pull_request) Successful in 31s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m29s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m1s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m47s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m9s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m35s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m17s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m49s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 4m29s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m44s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m47s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m22s
df42af5c22
`detect_in_process_meter` is for work that runs on this process's own threads.
Each caller that meters model work was checked against that, and adopted it
only where it holds.

- jouleclaw-harness-econ: `local_model_econ`, `swebench_local_coder` and
  `swebench_local_suite` meter a decode that runs in this process, on the
  loader's CPU kernels and rayon pool; the examples build the loader with
  `rayon` only, without its GPU `fabric`, and the metered window is the decode
  alone (the `python3` judge, a subprocess, runs outside it). They now use the
  in-process meter, so on macOS 27.0.1, where the host meter is the load-average
  estimator, a dispatch is a live hardware reading of its own CPU energy.
  `window_joules` already refuses a window the counter did not advance across.
  `meter_probe` reports both meters. Run here on a host under load average ~50:
  the host meter was the estimator (115.6 J over 2.89 s, Estimator, SocTotal),
  the in-process meter process-cpu (2.397 J over 2.84 s, HwShunt, ProcessCpu)
  for the same one-thread spin.
- jouledesk-github stays on `detect_meter`: its model runs in a
  `llama-completion` subprocess, which a per-process counter would bill
  nothing. Its docs say so, and a test pins the meter to the host meter.
- l3-jouleclaw's `run` binary stays on the host counter, and says why: `llama`
  is a subprocess, and the in-process `jouleclaw-runtime` engine boots the Apple
  backend's GPU kernels (Metal and MPS matmuls, the quantized GEMVs that serve
  decode), whose energy is the GPU's.
- jouleclaw-loader-gguf is unchanged: its metered paths (`tune_shape_live`,
  `fabric_calibrate`, `mpp_energy`) compare or calibrate GPU against CPU
  members, so they need the whole SoC, not one process's CPU.

Ran: `cargo test -p jouleclaw-harness-econ`, `cargo check` of its examples,
`cargo test -p jouledesk-github` (13 passed), and `cargo test` in
l3-jouleclaw. No model was loaded; the examples' model runs were not repeated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Plan-and-Execute charged each worker step `charge_tool(0)` and put no figure on
its span, and its plan step was charged nothing at all, under a comment saying
it was charged a notional cost. A planner or worker that ran was 0 µJ in the
trace and against the budget. 371fbe5e fixed this for ReAct and CodeAct and
left it open here.

Neither a `Planner` nor a `Worker` reports what its step cost: the traits
return subgoals and text, and `LlmPlanner` and `LlmWorker` drop the reply's
figure. So each step is metered in the loop, through `measured_tool`, under
the rule ReAct and CodeAct now follow: the meter's reading when both reads
succeed and the counter advanced, or the step's duration at
`DEFAULT_TOOL_WATTS` if that is larger; otherwise that duration estimate,
which is never 0.

- The plan step's span carries its figure, and the figure is charged against
  the joule cap. It is not a tool call, so it takes none of the tool-call
  quota. A plan step that spends the cap stops the loop before any worker runs.
- Each worker step's span carries its figure, charged with `charge_tool`.
- A step that fails ran too. Before, a planner or worker error was returned at
  once, unrecorded and uncharged; now the span records `ERROR: ...` with the
  step's figure, the budget is charged, and then the error is returned.
- The comment claiming planners self-meter through `charge_llm_step` is gone:
  a `Planner` has no access to the loop and cannot.

Tests:
- with the stub meter, a planner and two workers that each take at least
  20 ms: every span carries at least 300,000 µJ, the budget is charged
  exactly the trace's total, and two tool calls;
- with a scripted meter: 40 J over the plan step and 50 J over worker `b` are
  charged as read, and worker `a`, over which the counter held, its own
  estimate (above 0, below 1 J for an echo); the budget is 90 J plus that;
- a worker that fails after 20 ms: the error is returned, its span says
  `ERROR:` and carries at least 300,000 µJ, the budget is charged that and one
  tool call;
- a 1 µJ joule cap stops the loop after the plan step, before any worker.

Falsified, each mutant applied to this commit, run, and restored with
`git checkout`:
- a worker step charged `charge_tool(0)` again, its span keeping the figure:
  3 tests fail;
- a worker step's figure 0 on the span and in the charge: 3 fail;
- the plan step's figure 0: 3 fail;
- the plan step not charged against the budget: 4 fail;
- a failed worker step returned before it is recorded and charged: 1 fails.

`cargo test -p eoc-agent`: 33 unit and 9 integration tests pass. clippy on
eoc-agent adds no warning; its two collapsible-if warnings in the ReAct and
CodeAct parsers predate this.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-agent: every span says whether its joule figure was measured or estimated
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 35s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 35s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 32s
standards workspaces build + test / map-rs (pull_request) Successful in 29s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m50s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m18s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m15s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m8s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m19s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m49s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m51s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m48s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m50s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m49s
1352aeea07
A trace `Span` carried a micro-joule figure and nothing about where it came
from. A provider's own figure, a tool step's duration estimate, a zero for an
observation, and a delta a live counter read all looked the same, so nothing
reading a trace could tell a measurement from an estimate. Every other eoc
surface that reports energy says which it is (`JouleCost`, eoc-eval's
`joule_source`); an agent trace did not.

`Span` gains `joule_source: JouleSource`, eoc-core's tag, under eoc-core's
rule: `Measured` only for what a live counter read over the step — both reads
succeeded and the counter advanced — and `Estimated` for anything else.

- The field is additive on the wire. It is `#[serde(default)]`, as eoc-eval's
  report field is: a trace written before it reads back with every span
  `Estimated`, since nothing in it says it was measured. Nothing in the
  monorepo outside eoc-agent uses eoc-agent, so no struct literal of `Span`
  breaks.
- `Span::new` is `Estimated` (0 µJ that no counter read). `with_joule_cost`
  attaches a figure and its tag together, and `joule_cost()` reads them back.
  `with_microjoules` attaches a bare figure, which is an estimate: it no
  longer leaves an earlier `Measured` tag on a figure the counter did not read.
- `AgentLoop::measured_with` and `measured_tool` return a `JouleCost` instead
  of a bare `u64`. The figure is unchanged — the reading, or the estimate when
  larger, or the estimate alone — and it is `Measured` exactly when it is the
  reading: a reading smaller than the estimate is not what is charged, so the
  estimate charged in its place is `Estimated`. `measured` still returns the
  bare figure, as on main. Both changed functions are new on this branch.
- Model steps: `charge_llm_step` tags the provider's own figure `Estimated`;
  `LlmProvider`'s contract calls it an estimate and no counter here read it.
  This covers every loop's model steps (ReAct, CodeAct, Reflexion, ToT, LATS).
- Tool steps: ReAct and CodeAct put `measured_tool`'s tag on the act span, and
  Plan-and-Execute on its plan and execute spans.
- Other steps: ReAct's observation of an unknown tool is `Span::new`'s
  estimated 0, as nothing ran.

Tests:
- a new span and a bare figure are estimates; a measured cost survives
  `with_joule_cost`, and a bare figure attached after it is an estimate;
- a trace round-trips through JSON with a `Measured` and an `Estimated` span,
  and a trace in the old format, without the field, reads back all
  `Estimated`;
- `measured_tool`: the stub, a failed first or last read, a counter that held
  or went down, and one that advanced 10 µJ over a 20 ms step are all
  `Estimated` and charged at least the 300,000 µJ estimate; a 50 J reading is
  `Measured`. `measured_with` at a fixed estimate: a 450 µJ reading is
  `Measured` at estimates of 100 and 450, and an estimate of 1,000 is charged
  as `Estimated`;
- `charge_llm_step` records the reply's figure as `Estimated`;
- ReAct, with a counter that advances 50 J over the tool step: think spans
  `Estimated` at 1,000 µJ, the unknown tool's observation `Estimated` at 0,
  the act span `Measured` at 50 J, and the budget 3,000 µJ plus 50 J; with
  the stub, every span `Estimated`;
- CodeAct, the same over code that succeeds and code that fails;
- Plan-and-Execute: with the stub every span `Estimated`; with the scripted
  meter the plan step (40 J) and worker `b` (50 J) `Measured`, and worker `a`,
  over which the counter held, `Estimated`.

Falsified, each mutant applied to this commit, run, and restored with
`git checkout`:
- every span pushed to a trace tagged `Measured`: 8 tests fail;
- `Span::new` and `with_joule_cost` always `Measured`: 9 fail;
- every span pushed to a trace tagged `Estimated`: 4 fail;
- a span without the field reading back `Measured`: 1 fails;
- `measured_with` tagging any reading `Measured`, including when the
  estimate is what is charged: 1 fails;
- `charge_llm_step` tagging the provider's figure `Measured`: 5 fail;
- `with_microjoules` keeping an earlier tag: 1 fails;
- ReAct, CodeAct, and Plan-and-Execute each dropping the tag
  (`with_microjoules(cost.microjoules)`): 1 fails in each.

`cargo test -p eoc-agent`: 38 unit and 9 integration tests pass.
The eoc workspace (`cargo test --workspace`): 695 passed, 0 failed,
3 ignored, on an M5 Max (macOS 27.0.1). clippy for x86_64-unknown-linux-gnu
on eoc-agent: the same two pre-existing warnings and no others.
On macOS too clippy adds no warning (the two collapsible-if warnings predate
this), and rustdoc reports nothing in the lines changed here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
On macOS 27, `proc_pid_rusage(getpid(), RUSAGE_INFO_V6)` (libproc: public,
no privilege, no entitlement) returns `ri_energy_nj`, the cumulative CPU
energy the kernel attributes to this process's threads. Unlike IOReport's CPU
rails, it is live for an unprivileged reader: it advanced in 20 of 20
intervals here, under 25 ms of CPU each. It is the first live CPU energy
counter eoc-meter has on a Mac.

Its domain is the trap, and the design follows from it: this process's
threads' CPU energy, all of them, and nothing else. Not a child process (a
child's energy is its own, and is not added to the parent's when reaped), not
the GPU, the Neural Engine or DRAM. eoc's counter brackets stages that may run
in a subprocess (eoc-tools' shell tool, HumanEval's python) or on the GPU
(mlx, mlc, llama.cpp's default offload). A span over such work would read only
this process's share and be tagged measured: a figure for work the counter did
not see. So:

- `MacosProcessEnergyCounter` (`JouleCounter`, cumulative µJ =
  `ri_energy_nj` / 1000, name `process-energy`) is constructed only where it
  is live. Its doc states the domain exactly.
- `process_energy_status()` probes it once per process and caches the answer,
  by the same sustained-advance rule and `probe_counter` the RAPL and NVML
  rungs use, burning this thread's CPU between samples (that burn is this
  process's work, so a live counter advances in every interval). It is
  `Unavailable` off macOS.
- `detect()` does **not** select it, under any status of any rung. A caller
  whose metered work runs on the CPU in this process opts in with
  `detect_in_process()` (this counter where live, otherwise `detect()`) or
  `MacosProcessEnergyCounter::try_new()`.
- A span the counter did not advance over is still no measurement; the
  consumers enforce that (eoc-cascade's `live_span`, eoc-streaming, eoc-local).

The FFI is hand-rolled: `struct rusage_info_v6` field for field from the
macOS 27 SDK, with compile-time asserts that it is 464 bytes and
`ri_energy_nj` is at byte 336, as a C program compiled against the SDK prints.
It is the crate's third scoped `allow(unsafe_code)` module.

Tests. In the library, on scripted reads: the probe calls a counter that
advances every read live; a constant, one step, or a failing
`proc_pid_rusage` is declined. Only `Live` constructs the counter, which then
reads this pid. A pid that does not exist reads nothing. `detect_with` never
returns the process counter for any of the 36 combinations of NVML, RAPL and
IOReport status, and `detect()` never does here; `detect_in_process` picks it
exactly for `Live`, on macOS.

Against the hardware, in an integration test binary of its own (`tests/
process_energy.rs`), because the counter's domain is the whole process and
other tests in the library binary burn CPU on other threads; its tests take
one lock. Each figure is checked against an oracle the test reads itself:
`proc_pid_rusage` into a raw buffer at the SDK's offsets. No test can pass
without asserting.

- live: the status agrees with the oracle (live if it advanced in 80% of 20
  intervals), and on Apple Silicon both must say live;
  `detect_in_process()` picks it; `detect()` does not.
- 200 ms of one thread's CPU reads 0.2-20 J, inside the oracle's bracket.
  The burn is 200 ms of the thread's own CPU, by its CPU clock, at
  user-interactive QoS. A 200 ms wall-time spin failed on this M5 Max under a
  load average near 70 ("174844 µJ for 200 ms of one thread's CPU work": it
  got 90 ms of CPU). And the bound is for a performance core: 200 ms of CPU on
  an M3 Ultra efficiency core (background QoS) reads 0.013 J, against 0.72 J
  at default QoS and 1.10 J at user-interactive (a C probe, not this test);
- 500 ms asleep reads under 0.1 J;
- a child process (`yes` to /dev/null, run at least 300 ms and until its own
  counter reads 0.5 J) is not attributed: the parent's counter, read before
  the spawn and after the reap, moves under 0.1 J and under a fifth of the
  child's own, while the reaped child's CPU time shows it worked.

Measured, verbatim from the test logs:

M5 Max (macOS 27.0.1, load average ~50):
  process-energy oracle: 20 of 20 intervals advanced
  status=Live oracle_live=true detect_in_process()=process-energy detect()=powermetrics
  one-thread burn of 200.001792ms thread CPU in 205.349583ms wall read 600514 µJ = 0.601 J (3.00 J per CPU-second)
  500 ms asleep read 512 µJ = 0.0005 J
  child ran 315.771709ms, its own counter read Some(700069134) nJ, its CPU (reaped) 289.941208ms; the parent's counter moved 1708 µJ = 0.0017 J
  (three runs before it: burns of 0.483, 0.444, 0.408 J; asleep 4894, 2735, 47 µJ; the parent moved 14311, 1404, 1199 µJ while a child read 0.54-0.61 J)

M3 Ultra (macOS 27.0.1, test binaries built for apple-m3, copied, run, deleted):
  process-energy oracle: 20 of 20 intervals advanced
  status=Live oracle_live=true detect_in_process()=process-energy detect()=powermetrics
  one-thread burn of 200.003584ms thread CPU in 200.008542ms wall read 1329909 µJ = 1.330 J (6.65 J per CPU-second)
  500 ms asleep read 184 µJ = 0.0002 J
  child ran 356.917167ms, its own counter read Some(1725566789) nJ, its CPU (reaped) 356.596542ms; the parent's counter moved 187 µJ = 0.0002 J

So the counter is live on both chips. The first M3 Ultra run failed
`a_child_processs_work_is_not_attributed` with "the child did not work:
120.042625ms of CPU": the child was stopped as soon as its counter read 0.5 J,
which the M3 Ultra's `yes` reached in 120 ms (0.716 J), under the 200 ms CPU
floor the test then demanded. A flaw in the test, not the counter; the parent
had moved 843 µJ. The child now runs at least 300 ms, and the floor is 150 ms.
The library's 62 tests passed there too.

Falsified, each mutant restored byte-for-byte (sha256 checked), run against
the library and the hardware binary:
- the process counter as a `detect()` rung fails 3 tests (`detect_with(
  Unavailable, Unavailable, Unavailable)` picks it; `detect()` picks it);
- a constant reading (counter only) fails the burn test ("0 µJ for 200 ms of
  one thread's CPU work"); a constant at the source makes the probe decline,
  and fails 4;
- reading the parent's pid fails 2 (the burn is not the parent's); probing
  the parent's pid fails 4;
- the constructor ignoring its status fails 2.

clippy raises no new warning. `cargo check --tests` for
x86_64-unknown-linux-gnu, with and without `cuda`, is clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`detect_in_process()` is for callers whose metered work runs on the CPU in
this process, and only for them. Each place eoc picks a counter was checked
against that:

- The `eoc` CLI's query cascade: adopted. Every stage it builds runs on the
  CPU in this process: the LRU cache, the in-memory KV store, the in-memory
  graph, and the echo backend, which formats a string. None spawns a
  subprocess or offloads to the GPU, and they run on this process's tokio
  workers. `build_cascade_with` now meters with `detect_in_process()`, and
  its doc says a backend passed to it must keep to the same.
- `eoc meter` still reports `detect()`'s counter and its idle draw, and now
  also names the counter `eoc query` meters with.
- Not adopted anywhere else, because no other eoc code picks a counter:
  eoc-cascade and eoc-agent default to the stub and take the caller's;
  eoc-streaming and eoc-local's backends take the caller's. eoc-agent must not
  be given this one in any case: its tools include eoc-tools' shell, a
  subprocess.
- eoc-local's crate doc now says which backends this counter covers. All four
  run in this process, but `mlx` (GPU, Neural Engine), `mlc` (GPU),
  `llamacpp` (GPU by default, `n_gpu_layers` 999) and `onnx` with a CoreML,
  CUDA, DirectML or TensorRT provider offload work it does not see. It covers
  `onnx` with the CPU provider and `llamacpp` with `n_gpu_layers` 0.

What the CLI reports on this M5 Max:

  counter: powermetrics
  query  : metered by process-energy (its stages all run on the CPU in this process)
  eoc query "what is 2+2"           -> stage kv, cost 50 µJ (estimated)
  eoc query "tell me something new" -> stage neural, cost 50000000 µJ (estimated)

Both lookups finish inside one update of the counter, so neither span
advanced and each stage's own figure stands, as eoc-cascade requires.

Tests. `work_on_an_unmetered_host_keeps_its_estimate` now expects the CLI's
meter to be `process-energy` exactly when it is live (and IOReport's rule
otherwise); its ~100 ms burn with a 50 J estimate costs the estimate plus the
reading, tagged estimated: "meter=process-energy cost=50230593 µJ
(estimated)" in one run. A new test, `in_process_work_is_measured_by_the_process_counter`,
runs ~100 ms of CPU work through the CLI cascade from a backend that claims
nothing (an estimated zero): it reads as measured, between 1 mJ and 10 J. The
floor is low on purpose, since a wall-time burn on a loaded host or an
efficiency core can draw little; the size of a reading is pinned in
eoc-meter's hardware test. Here: "eoc in-process cost: 273344 µJ (measured)"
and "meter=process-energy cost=50270761 µJ (estimated)". The two burning tests
take one lock, since each would otherwise read the other's burn: run in
parallel they read 380637 µJ each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc: the in-process burn test's backend is built only on macOS, where its one test runs
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
3513b9cc5d
`BurningUnestimatedBackend` exists for one test,
`in_process_work_is_measured_by_the_process_counter`, which is
`#[cfg(target_os = "macos")]`: it asserts this process's own counter is live,
and `process_energy_status()` is always `Unavailable` off macOS, where
`detect_in_process()` is `detect()`. The backend itself had no cfg, so on every
other target it was never constructed, and `cargo clippy --all-targets` for
x86_64-unknown-linux-gnu warned "struct `BurningUnestimatedBackend` is never
constructed". On macOS there was no warning, since the test builds it there.

No test off macOS was meant to use it. A backend that claims an estimated zero
for real work, on a host with no live counter, costs that zero under
eoc-cascade's rule (with no live span the stage's own figure stands); a test
there would pin a 0 for work that ran, not a measurement. The non-macOS path
of the CLI's cascade is already covered by
`work_on_an_unmetered_host_keeps_its_estimate`, with a backend that does
estimate. So the struct and its impl take the test's cfg instead of being
used somewhere they would assert the wrong thing.

clippy on `eoc` with `--all-targets`, for aarch64-apple-darwin and for
x86_64-unknown-linux-gnu: no warnings in the crate (the second had the one
above before this).
On an M5 Max (macOS 27.0.1) the test still builds it and passes: "eoc
in-process cost: 260170 µJ (measured)". The eoc workspace, rebased onto the
eoc-agent changes below it: 705 passed, 0 failed, 3 ignored; eoc-meter's
tests/process_energy.rs: 4 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jcp-energy: the one-thread burn's floor fits the energy its cores can take
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m41s
standards workspaces build + test / aac-rs (pull_request) Successful in 39s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 35s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 30s
standards workspaces build + test / map-rs (pull_request) Successful in 30s
standards workspaces build + test / mesh-rs (pull_request) Successful in 26s
standards workspaces build + test / openpay (pull_request) Successful in 1m22s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m54s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m24s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m8s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m34s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m38s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m23s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m22s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m29s
standards workspaces build + test / sense-rs (pull_request) Successful in 3m35s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 3m55s
22e5a6ad9f
`one_thread_burning_200_ms_is_a_whole_hardware_reading` asserts that 200 ms
of one thread's CPU meters between 0.2 J and 20 J. On a loaded host it read
218,593 µJ in one run and fell under the floor in another. The work was not
what varied. Since bcd00518 the burn has spun until the thread's own CPU
clock (`CLOCK_THREAD_CPUTIME_ID`) advanced 200 ms, so every run did the same
200 ms of CPU. What varied is the energy of that work: it follows the core
tier and the clock the scheduler gives the thread, and 0.2 J was at the
bottom of that range.

Measured on an M5 Max (6 Super and 12 Performance cores), macOS 27.0.1,
reading the process's own `rusage_info_v6` around each burn:
- At the default QoS, 200 ms of one thread's CPU read 0.20-0.24 J in one
  quiet period, 0.50-0.61 J in another, 0.32-0.54 J with 6 to 30 busy-loop
  spinners, and up to 1.24 J. The effective clock (`ri_cycles` over the CPU
  time) was 2.5-4.0 GHz in the runs that logged it.
- In two runs held on the slower cores by background QoS (`ri_user_ptime`
  about zero), it read 0.18 J and 0.24 J at about 2 GHz. The 0.18 J run
  failed the floor.

Asking for user-interactive QoS, as eoc-meter's process-energy test does,
does not keep the thread off the slower cores. XNU clamps user-interactive
and user-initiated QoS to the default in a process with no application role,
and a test binary run by `cargo test` is one. After
`pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0)` returned 0,
the burn's CPU time was all in `ri_cpu_time_qos_legacy` (the default QoS)
and none in `ri_cpu_time_qos_user_interactive`. A request for user-initiated
QoS landed in the default too. Requests for utility and background QoS were
not clamped: none of their CPU time was counted at the default. So the tests do not ask, and the burn helper's doc says why.

The changes, all in the test module:
- `burn_thread_cpu` stops at 5 s of wall time (`TESTS_BURN_WALL_CAP`) and
  fails the test with a message that says how much CPU the thread got, rather
  than spinning on unbounded. It returns the wall time the burn took, which
  the burn tests now log. The cap was seen to fire, with the burn held at
  background QoS under 18 spinners: "this thread got only 3.752959ms of the
  200ms of CPU it was to burn in 5s of wall time: the host is too loaded to
  meter a fixed amount of CPU work".
- The floor is 0.1 J, half the lowest reading above. It still means
  something. It meets the 0.1 J ceiling of
  `sleeping_200_ms_meters_under_a_tenth_of_a_joule`, so no counter that reads
  wall time at a fixed power passes both tests. A counter that holds fails
  before the floor is reached, and one that advances by a fixed step per read
  fails the floor (see below). The test's doc records the measured range.
- `two_threads_burning_meter_more_than_one` joins its second thread, so a
  burn that hits the cap there fails with its own message, and logs both
  spans' wall times.
- `a_span_that_runs_and_reaps_a_child_is_partial` had the same shape: `yes`
  ran for 200 ms of wall time and the test then required the child's own
  count to read at least 0.1 J. Under load it read 0.216 J once. The child
  now runs for at least 200 ms and until its own count, polled every 10 ms,
  reads 0.1 J. The test fails, saying so, if that takes 5 s.

Tests. `cargo test -p jcp-energy` (84 lib tests and the integration test)
passed 5 times in a row with 18 busy-loop spinners running, at load
averages from 24 to 100. The spinners were killed by a trap, and none
remained after. Per run, one thread's 200 ms of CPU, then the two-thread
test's one and two threads, then the reaped child's own count:
- 0.353 J (271 ms wall); 0.366 / 0.718 J; 0.340 J
- 0.358 J (297 ms); 0.346 / 0.739 J; 0.334 J
- 0.400 J (393 ms); 0.376 / 0.703 J; 0.324 J
- 0.367 J (367 ms); 0.411 / 0.762 J; 0.334 J
- 0.422 J (670 ms); 0.386 / 0.837 J; 0.316 J
Clippy is clean on jcp-energy with `--tests`. The change is inside
`#[cfg(test)] mod tests`, so no dependant compiles different code.

Falsified, each by a temporary edit restored with git checkout:
- `ri_energy_nj` read as a constant: the probe finds it Frozen, `detect()`
  declines it, and the test fails with "detect() did not select the
  per-process rung".
- The count held across the measured span only, detection untouched: the
  span is the model's figure (3.05 J, `ModelBased`) and the test fails on its
  provenance. That figure is inside the bounds, so this case rests on the
  provenance assertion, not the floor.
- The count advancing a fixed 1,000 nJ per read: the probe finds it Live, the
  span is 1 µJ `HwShunt`, whole, and the test fails with "200 ms of one
  thread's CPU metered 1 µJ".

Not done here: eoc-meter's process-energy test asks for user-interactive QoS
and its doc credits it with keeping the burn on the fast cores. The same
clamp applies there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-meter: the one-thread burn test's floor matches what 200 ms of CPU can cost
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m41s
standards workspaces build + test / aac-rs (pull_request) Successful in 34s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 34s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 36s
standards workspaces build + test / map-rs (pull_request) Successful in 32s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m53s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m16s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m10s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m52s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m42s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m12s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m16s
standards workspaces build + test / sense-rs (pull_request) Successful in 3m17s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m48s
f4847eafce
The process-energy burn test asked for user-interactive QoS so its burn
would run on the fast cores, and it asserted at least 0.2 J. The QoS call
returns 0 but changes nothing: the kernel caps a process with no app role,
such as a test binary, at default QoS. rusage books all of the burn as
default QoS (measured in jcp-energy's matching test, 22e5a6ad).

The burn already counts 200 ms of the thread's own CPU time, not wall time,
but the energy those 200 ms cost still depends on which core class ran
them, and at what clock. On an M5 Max at default QoS it read 0.18-1.24 J,
so a 0.2 J floor sits at the bottom of the real range.

This drops the ineffective QoS call and its declarations, records why in
the test's doc, and lowers the floor to 0.1 J: half the lowest reading,
and the sleeping test's ceiling. A counter that reads wall time at a fixed
power still cannot pass both tests, and a counter reading 0 still fails.

On this M5 Max, 4/4 pass: the burn read 0.504 J, and 500 ms asleep 21 µJ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The S5 executor's two CPU meters broke the rule sandbox-meter's RAPL probe
follows since 7cf1f036, and each sealed what it read as `HwShunt`.

`RaplMeter` read one file, `intel-rapl:0/energy_uj`, through `at(path)` or
`intel_package0()`, the constructor `prove_microvm` uses. It never read the
zone's `name`, so nothing confirmed `intel-rapl:0` is a package, and on a
two-socket host the second socket's energy was left out of a figure sealed
as the CPU's. It had no wrap handling of its own: a wrap read as a fall,
which the executor's `a >= b` happened to turn into an estimate. And nothing
checked that the counter was live: one that held, or stepped only in a batch
refresh, was sealed as a reading of the run. `MsrRaplMeter` read
`MSR_PKG_ENERGY_STATUS` through `/dev/cpu/0/msr` alone, package 0's counter,
the same one-socket figure on a multi-socket host, also with no liveness
check.

`RaplMeter` is now sandbox-meter's own RAPL probe rather than a second copy
of it. sandbox-meter gains `RaplPackages`, its `RaplProbe` behind the
liveness gate with no ungated constructor: `host()` opens the sysfs tree only
when the cached `rapl_status()` is `Live` (and is `Unavailable` off Linux),
`live_in(root, tick)` runs the same liveness run over another tree and opens
only if it is `Live`, and `cumulative_uj()` is the probe's snapshot. So the
figure is the sum of every top-level zone whose `name` is `package-<digits>`
and nothing else, every package or none, each wrap-corrected at its own
`max_energy_range_uj`; a package with no readable range does not open, and a
fall from above a package's range breaks the count for good. `RaplMeter::host()`
replaces `intel_package0()` and `at()`, which are gone; an unopened meter
samples `None` and says why (`unavailable()`), and `prove_microvm` uses it.
To make that possible on every platform, `rapl` and `liveness` are now built
everywhere, not only on Linux and under test: both take their tree or their
sampler as an argument, and only Linux opens sysfs.

`MsrRaplMeter` now reads every package, and every die of a multi-die
package, since the register is per die there. It groups every CPU under
`/sys/devices/system/cpu` by `topology/physical_package_id` and
`topology/die_id`, reads each domain through the first of its CPUs whose
`msr` device reads both registers, scales each by its own energy unit, and
sums them. It does not open when a domain reads through none of its CPUs,
when a domain's unit is zero, or when a CPU has no readable topology: an
offline CPU's package is unknown and could be one no online CPU covers, so
the sum could not be shown to be the whole CPU's. That makes a host with
offline CPUs (`nosmt` included) estimated, which is the safe side. Each
domain's counter is 32 bits, so a fall is a wrap at 2^32 counts, its
architectural range; the reserved upper half is ignored. It opens only once
every domain's raw counter has kept advancing under the same liveness rule,
through a new public `sandbox_meter::probe::counter_liveness(open, tick)`
(with `counter_liveness_tick()`, the production CPU-burning tick), probed
once per process for the host. `MsrRaplMeter::host()` replaces `package0()`
and returns why it did not open; `boot_microvm` prints it.

Tests, on fake trees in the temp directory, on macOS here and on Linux in
CI. The MSR tree is a sysfs CPU directory beside fake `msr` devices holding
the two registers at their offsets.
- `the_rapl_meter_sums_every_package_zone_and_no_other`: two sockets with
  `psys`, a top-level `dram`, subzones and the MMIO zone, opened live; with
  every zone rising, a difference is the packages' 8,000 µJ alone; package 1
  wraps at its own 65,532,610,987 µJ range (11,487 µJ); a run through
  `MicroVmExecutor` whose VMM raises both packages and `psys` seals
  `Some(3000)`, `HwShunt`.
- `the_rapl_meter_reads_nothing_unless_every_package_is_live`: package 1
  holding beside a live `psys`, `dram`, MMIO zone and package 0 leaves the
  meter unopened, `None`, and the executor seals `ModelBased`; a `psys`-only
  tree and a package with no range never open and are never ticked.
- `a_fall_from_above_a_package_range_stops_the_rapl_meter_for_good`.
- `the_msr_meter_reads_every_package_and_die`: three domains, one read
  through cpu3 because cpu2's device is absent; 256, 512 and 16,384 counts at
  ESU 14 sum to 1,046,875 µJ with package 0's reserved bits changing; a run
  through the executor seals a joule, `HwShunt`.
- `the_msr_meter_wraps_each_domain_at_32_bits`: 512 counts across the wrap.
- `the_msr_meter_declines_what_it_cannot_cover`: a domain holding beside
  live ones; an offline CPU; no `die_id`; a domain none of whose CPUs read; a
  zero unit; no CPU; the unopened ones never ticked.
- sandbox-meter: `the_public_package_counter_opens_only_over_live_packages`
  and `counter_liveness_holds_a_callers_counter_to_the_every_rail_rule`.

Falsified, each restored from a saved copy before the next:
- `RaplMeter` reading `intel-rapl:0/energy_uj` alone again (the liveness
  gate kept) fails two: "not the two packages' rise alone, left: 3000,
  right: 8000", and "a fall from above the range gave a figure, left:
  Some(10)";
- the MSR meter keeping package 0 only fails three, "not one CPU per package
  and die" among them, and the holding-domain case opens;
- the MSR wrap as a saturating difference fails "512 counts across the wrap,
  left: 0, right: 31250";
- the MSR gate opening on a Frozen status fails the declines test ("opened
  [...] with package 1 die 1 holding");
- an offline CPU skipped instead of declining fails it too.

sandbox-exec-microvm (18) and the whole sandbox-rs workspace (308 tests)
pass on macOS (M5 Max, macOS 27.0.1), as does the sandbox-conformance binary
("All classes passed with no skips"); jcp-gateway, jcp-action-receipt,
joule-code-remediate, joule-ui-interaction and joule-ui-serve pass, and
joule-ui's `gen_vectors` regenerated into a scratch directory is
byte-identical to `conformance/v1/`. `cargo clippy --all-targets -p
sandbox-meter -p sandbox-exec-microvm` is clean for aarch64-apple-darwin and
x86_64-unknown-linux-gnu; sandbox-proof is clean on the host, and its Linux
cross-check needs a cross C compiler for zstd-sys, which this machine lacks.
Not run on a Linux host with a real RAPL tree or MSRs here.

Not done here: the executor seals a zero CPU delta between two reads of a
live meter as a measured `HwShunt` zero (`a >= b`), where sandbox-meter's
span estimates a CPU counter that held.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
joule-code-remediate: a test's scratch crate builds in its own target directory, whatever CARGO_TARGET_DIR says
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Has been cancelled
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Has been cancelled
a16e36cf81
With `CARGO_TARGET_DIR` exported, four to eight of the crate's 13 tests
failed. The tests build scratch crates in the temp directory and run the
harness over them, and the harness's `cargo` runs inherit the test process's
environment. Every scratch crate is a package named `scratch` at 0.1.0, and
cargo names its artifacts without the package's path, so under one exported
target directory the concurrent tests' crates built over each other's
artifacts and fingerprints: after one run the shared directory held three
`scratch-<hash>` builds for all thirteen tests' crates. Reproduced here at
7cf1f036 by running the test binary with `CARGO_TARGET_DIR` set to a scratch
directory: 9 passed, 4 failed (`l3_fix_passes_the_same_gates`,
`l3_garbage_is_caught_by_the_gate_and_reverted`,
`shipped_l3_fix_induces_a_skill_that_closes_the_recurrence_without_a_model`,
`failed_gate_reverts_and_never_ships`).

The fix is in test builds only. Under `cfg(test)`, `Harness` carries the
workspace's own `target/`, and every `cargo` run it makes (`clippy`, `clippy
--fix`, `test`, `build`) is handed `--target-dir <workspace>/target` after
the subcommand, which outranks an inherited `CARGO_TARGET_DIR`: each scratch
crate builds where it does with no `CARGO_TARGET_DIR` at all. Production
builds have neither the field nor the flag; `joule-fix` passes cargo its
arguments as before, and the user's environment, `CARGO_TARGET_DIR`
included, decides where it builds.

`verified_fix_end_to_end_ships_with_receipt` now checks both halves: the
arguments a test harness hands cargo carry the flag, and after the run the
scratch crate's own `target/debug` exists.

13/13, three runs each, with and without `CARGO_TARGET_DIR` exported to a
scratch directory, which stayed empty; and `CARGO_TARGET_DIR` exported to
the workspace's own target directory around `cargo test -p
joule-code-remediate`, 13/13. Falsified: compiling the flag out again fails
five with `CARGO_TARGET_DIR` exported (the four above and the end-to-end
test) and one without it (the end-to-end test's argument check). `cargo
clippy --all-targets -p joule-code-remediate` is clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
sandbox-exec-microvm: a CPU counter that held across a run is not a measured zero
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m38s
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 32s
standards workspaces build + test / mesh-rs (pull_request) Successful in 24s
standards workspaces build + test / openpay (pull_request) Successful in 1m21s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m28s
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 1m16s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m12s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m12s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m35s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m16s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 4m37s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m47s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m40s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m41s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m54s
3d70348a0d
MicroVmExecutor::cpu_energy sealed any `after >= before` as a HwShunt
reading. So a run whose vCPUs did work while the host's CPU energy counter
held sealed `measured_uj: Some(0)`, HwShunt: a zero dressed as a
measurement. That happens when a counter is frozen, or between the lumps
of a lump-refreshed one. sandbox-meter's Span::stop already refuses this
(a span whose CPU counter held is estimator-class), and the family rule is
that work that ran is never a measured zero.

The CPU figure is now a reading only if the counter advanced (`after >
before`). Otherwise it is the model estimate (vCPU-seconds x
uj_per_vcpu_second, ModelBased). The accelerator path is unchanged: a zero
accelerator delta can be an idle device. This matches sandbox-meter, which
keeps a GPU/NPU zero beside a CPU counter that advanced.

Test: a_cpu_counter_that_held_across_a_run_is_not_a_measured_zero. Held ->
ModelBased, measured None, 48,000 µJ estimated. Advanced -> HwShunt 3,000.
Restoring `a >= b` fails it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
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
8aa25541b8
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]>
Each of the five meter crates (and the S5 executor's power meters) gets a
test binary, cfg(target_os = "linux"), that reads /sys/class/powercap
itself -- every zone's name, energy_uj and max_energy_range_uj -- and
applies the sustained-advance rule to the top-level package-<n> zones on
its own. The crate's RAPL status must agree with that verdict; where live,
the meter's figure over a CPU burn must sit inside the oracle's
package-only bracket (outer and inner reads around the meter's), below
package+psys and package+core wherever the bracket can resolve them, and be
graded as the crate's hardware grade with honest coverage. Where the
counters cannot be read the status must say so; the CI container's view
(Docker masks /sys/devices/virtual/powercap) is replayed where the crate
takes a root.

Run on jetson-hub (i9-13900H: intel-rapl:0 package-0, :0:0 core,
:1 psys, intel-rapl-mmio:0 root-only): all live, all inside the bracket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
On a Linux host whose RAPL is live (jetson-hub, i9-13900H) the ladder
selects RAPL and never asks NVML or the per-process counter, so with the
nvml feature the pin's 'NVML probe ran once' failed there: 'the NVML
liveness probe ran 0 times' (left 0, right 1). The expectation now follows
the detection's own record of which statuses were asked. Run on
jetson-hub: 30 lib tests pass with and without nvml (29 + this one failed
before).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-meter (cuda), sandbox-meter, jcp-energy (nvml) and jouleclaw-energy
(nvml) each get a test binary that opens libnvidia-ml itself (dlopen and
its own symbols, not the crate's bindings), measures how often the
total-energy counter is republished (about every 100 ms on jetson-hub's
RTX 4050 Laptop, driver 595.84), samples it at the crate's own tick by the
sustained-advance rule, and holds it to its board through nvidia-smi: no
faster than 1.5x the maximum power limit, the board's draw within that
bound too, and no faster than twice the draw plus 5 W. The status must
agree with that verdict; a live counter's figure must be graded as the
family's rule says for a GPU-only figure.

On jetson-hub all four fail as committed: the counter advances in every
interval, so each crate calls it Live and grades it as hardware, but polled
at the probes' 100-150 ms ticks it runs at 58-305 W while the board draws
5.5-13.6 W (max limit 65 W). The fixes follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
On jetson-hub's RTX 4050 Laptop GPU (driver 595.84) the total-energy
counter is republished every ~100 ms and advances in every probe interval,
so the probe called it Live and every span carried a Gpu record graded
HwShunt. But the counter does not count the board's energy while it is
polled: at the probe's 150 ms tick it ran at 44-103 W while the board drew
10-14 W against a 65 W maximum limit (304 W at a 100 ms tick; another
process polling every 10 ms drove it to 415 W in an unpolled reader's
span), and a 1 s span with the meter's two reads came out at 22-31 W.

The probe now reads each GPU's power draw with every sample and its
maximum power limit once, and a live run whose counter ran past 1.5x the
limit, past twice the board's mean draw plus 5 W (NVML's stated draw
accuracy), or whose draw reading itself was past 1.5x the limit (590 W for
the first second after the GPU wakes) is CounterStatus::Implausible: the
NVML probe is not built, and the meter has no GPU reading. A bound whose
reading is missing is not applied.

Before: tests/nvml_hardware.rs failed on jetson-hub, status=Live against an
oracle verdict of Implausible (57.8 W counter, 13.1-13.6 W draw). After:
status=Implausible, sources [rapl-powercap]; 104 lib tests (the new rule and
run tests among them), the NVML and RAPL hardware tests and
sandbox-exec-microvm's pass there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
On jetson-hub's RTX 4050 Laptop GPU (driver 595.84) the total-energy
counter advances in every 100 ms probe interval, so nvml_status() was Live
and the NVML rung would grade its spans HwShunt (Partial). But the counter
does not count the board's energy while it is polled: at the probe's 100 ms
wait it ran at 72-136 W while the board drew 12-14 W against a 65 W maximum
limit.

The probe now reads the board's power draw with every sample and its
maximum power limit once, and a live counter that ran past 1.5x the limit,
past twice the board's mean draw plus 5 W, or beside a draw reading itself
past 1.5x the limit (590 W in the first second after the GPU wakes) is
CounterStatus::Implausible, which no rung admits. A bound whose reading is
missing is not applied.

Before: tests/nvml_hardware.rs failed on jetson-hub, status=Live against an
oracle verdict of Implausible (135.8 W counter, 13.5 W draw). After:
status=Implausible; 32 lib tests (the rule's among them), the NVML and RAPL
hardware tests pass there with --features nvml.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
On jetson-hub's RTX 4050 Laptop GPU (driver 595.84) the cumulative-energy
counter advances in every 100 ms probe interval, so the counter was Live
and every reading HwShunt over Gpu. But it does not count the board's
energy while it is polled: at the probe's 100 ms tick it ran at 127-305 W
while the board drew 5.5-13.4 W against a 65 W maximum limit, and a 1 s
span read 44.8 W of HwShunt.

The probe now reads every GPU's power draw with each sample and its
maximum power limit once (liveness::GpuWatch), and a live counter one of
whose GPUs ran past 1.5x its limit, past twice its board's mean draw plus
5 W, or beside a draw reading itself past 1.5x the limit, is
CounterLiveness::Implausible: the counter falls to the power-draw integral,
graded ModelBased, as for a counter that is not live. A bound whose reading
is missing is not applied. The module doc's 50 ms republication period is
corrected to the ~100 ms measured.

Before: tests/nvml_hardware.rs failed on jetson-hub, liveness=Live against
an oracle verdict of Implausible (304.5 W counter, 5.5-7.7 W draw). After:
liveness=Implausible, grade ModelBased; 56 lib tests (the rule's among
them) and the NVML, RAPL and conformance tests pass there with
--features rapl,nvml.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Two defects of eoc's NVML path (feature cuda), both seen on jetson-hub's
RTX 4050 Laptop GPU (driver 595.84).

detect() ranked NVML first, so on a host with a live NVML counter every
consumer (eoc-cascade, eoc-local, eoc-streaming, eoc-agent) was handed the
GPU's counter and graded its advance Measured, with no coverage field to
say the CPU's share of the work went unread: on jetson-hub detect()
returned nvml over a live RAPL. By the family's rule a figure that does not
cover the work it is booked to is an estimate in a format without coverage.
detect() now has no NVML rung, and NvmlCounter is no longer a JouleCounter
(read_gpu_microjoules() reads it as GPU telemetry), so no consumer can grade
it Measured. JouleSource and the README say so.

And the counter does not count the board's energy while it is polled: at
the probe's 100 ms wait it ran at 81-136 W while the board drew 13-15 W
against a 65 W maximum limit. The probe now reads the board's draw with
each sample and its maximum power limit once (gpu_plausibility), and a
live counter past 1.5x the limit, past twice the mean draw plus 5 W, or
beside a draw reading past 1.5x the limit, is CounterStatus::Implausible
and NvmlCounter is not constructed.

Before: tests/nvml_hardware.rs failed on jetson-hub (status=Live against an
oracle verdict of Implausible, 119.4 W counter, 13-13.5 W draw;
detect()=nvml). After: status=Implausible, constructed=false,
detect()=rapl; 58 lib tests with cuda (57 without), the NVML and RAPL
hardware tests pass there with and without cuda.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A mutation run on jetson-hub while its CI jobs held the package near its
power limit (load average 11) read 0 µJ a call through both windows: the
busy window drew no more than the idle one, which the crate documents as a
bracket's 'inside the idle noise', not a defect. The test asserted a
non-zero figure; it now logs the load and keeps the assertions that hold
whatever the load: the calls' total within the oracle's package-only
energy around the measurement, the HwShunt grade and the package domain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
jouleclaw-energy: the GPU plausibility rule is dead code without nvml, and says so
Some checks failed
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m28s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m58s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Failing after 38s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / aac-rs (pull_request) Successful in 33s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 36s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 36s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m50s
standards workspaces build + test / map-rs (pull_request) Successful in 31s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m19s
standards workspaces build + test / openpay (pull_request) Successful in 1m26s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m15s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m5s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 4m23s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 4m12s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m36s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m30s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 4m31s
c7e2d345b3
A build without the nvml feature (the workspace's own cargo check) warned
that GpuWatch, plausible_gpu_counter and its constants are never used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
energy meters: document the hardware tests' helper functions
All checks were successful
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m36s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m22s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 59s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / aac-rs (pull_request) Successful in 1m30s
standards workspaces build + test / arl-rs (pull_request) Successful in 4m17s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m51s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 41s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 31s
standards workspaces build + test / map-rs (pull_request) Successful in 34s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m20s
standards workspaces build + test / mesh-rs (pull_request) Successful in 29s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m13s
standards workspaces build + test / openpay (pull_request) Successful in 1m37s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 4m3s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m12s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m53s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m54s
standards workspaces build + test / sense-rs (pull_request) Successful in 4m26s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 3m36s
219a779085
jouleclaw's public-surface audit failed #129 on seven helpers in
jouleclaw-energy's rapl_hardware.rs that had no doc comment: the
bracket's zone sums, the masked view, and the watch summary. Document
them, and the same helpers in the other crates' copies of the module, so
the copies stay alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
dcharlot force-pushed energy/meters-on-real-linux-hardware from 219a779085
All checks were successful
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m36s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m22s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 59s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / aac-rs (pull_request) Successful in 1m30s
standards workspaces build + test / arl-rs (pull_request) Successful in 4m17s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m51s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 41s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 31s
standards workspaces build + test / map-rs (pull_request) Successful in 34s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m20s
standards workspaces build + test / mesh-rs (pull_request) Successful in 29s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m13s
standards workspaces build + test / openpay (pull_request) Successful in 1m37s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 4m3s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m12s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m53s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m54s
standards workspaces build + test / sense-rs (pull_request) Successful in 4m26s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 3m36s
to 1d85821aa8
Some checks failed
WAI energy receipts / energy receipts · linux (pull_request) Successful in 39s
wai formal verification (integer core) / kani (pull_request) Successful in 3m56s
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Successful in 53s
WAI neural decoders end-to-end / synthetic fixtures → Rust decode → reference (±4) (pull_request) Successful in 1m4s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 25s
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Successful in 2m33s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 21m57s
standards workspaces build + test / aac-rs (pull_request) Successful in 8m24s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 4m4s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 2m34s
standards workspaces build + test / map-rs (pull_request) Successful in 4m15s
standards workspaces build + test / mesh-rs (pull_request) Successful in 2m34s
standards workspaces build + test / openpay (pull_request) Successful in 15m42s
wai cross-architecture byte-equality / linux-aarch64 (pull_request) Successful in 1m25s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 5m21s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 1m2s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 2m58s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m19s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 5m44s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 7m23s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 9m0s
standards workspaces build + test / sense-rs (pull_request) Successful in 6m7s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 7m49s
wai cross-architecture byte-equality / macos-arm64 (reference) (pull_request) Successful in 1m0s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 4m51s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 56s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 2m22s
wai cross-architecture byte-equality / windows-x86_64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · windows (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · windows (pull_request) Has been cancelled
2026-10-01 12:34:17 -04:00
Compare
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!129
No description provided.