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]>