proof-cascade: never meter CPU work through an IOReport CPU rail that does not keep moving #115
Loading…
Reference in a new issue
No description provided.
Delete branch "proof-cascade/cpu-rail-liveness"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
proof-cascade's
--measurepath read IOReport as a hardware energy interface as soon as a subscription opened. On macOS 27 that gave a GPU-only figure or nothing, and the CLI misdiagnosed both cases.What was wrong: on main
idle_w = 0.345, busy_w = 0.281with every spare core burning. That is the GPU rail alone. The marginal clamped toSome(0), and proof-cli blamed "a loaded/throttled box". Any run where the GPU drew more in the busy window would have printed that difference as a HwShunt "real hardware reading" of a CPU proof.power_overreturned None, and the CLI gave the same misdiagnosis.if let Some, and one checked only an upper bound.What this does
available(),hardware_meter_available(),power_over()andmeasure_marginal_uj()are gated on the family rule.firstand is unchanged.rapl_power_overtook one wrapping difference over the sum of per-zone counters. One zone wrapping gave about 1.8e19 µJ, which proof-cli would label HwShunt. Deltas are now taken per zone, modulo that zone'smax_energy_range_uj, in a pure, tested function.measure_marginal_ujreturnsSome(0)when busy ≤ idle, and the CLI treats that as idle noise. Stale macmon references are removed.Verification of this PR (head
906ed1a6)proof-conformanceprints "all checks passed".simulate_no_energy_model_group()is a public,#[doc(hidden)]function so an integration test can reach it. It can only turn the meter off.Later rounds on this branch
a96c6154: a live vendor counter's reading is HwShunt, and the docs, SPEC §5 and proof-schema say so. The CLI module doc no longer claims T2/T3 stand-ins exist. The measure test now checks the status the CLI actually printed.9334ec0c: RAPL has a liveness probe, and every package counter must keep advancing. It fixes a real double count:psysalready includes the package, but it was summed beside it.4551c5f9: the headline compares like for like. The frontier baseline is charged only for the goals the cascade proved, and the unproved goals are reported separately.0503e69b: IOReport is live only if every CPU rail it sums keeps advancing.Why: what IOReport actually does on macOS 27.0.1
Measured by reading channels directly on the M5 Max (dev) and the M3 Ultra (10.10.10.2), both on macOS 27.0.1:
CPU EnergyandDIE_<n>_CPU Energydo not move under load for seconds to minutes. On the M3 Ultra both dies held for at least 22 minutes, then jumped about 1.3 kJ each.+61,764,485 mJ, then+3,435 mJ64 ms later.A meter that sums rails therefore reports CPU work as GPU-only energy (M5 Max) or 0 (M3 Ultra), labelled as a reading. A liveness check that accepts one advance, or two, gives a false Live whenever its window holds a refresh. After that, each later ~1.3 kJ lump is booked whole to whichever span straddles it.
The rule: identical in all five backends (#110, #114, #115, #116, #117)
CPU Energy, or eachDIE_<n>_CPU Energy), keyed by name and ordinal:namereadspackage-N, on every socket, and each must be live. It never sumspsys, which already includes the packages, nordramor the sub-zones.max_energy_range_uj. A wrap whose range is unknown gives no figure; it never becomes a ~1.8×10¹⁹ µJ reading.Livedeclines the backend, so the crate's existing honest fallback runs. The reason is public:IoReportStatus,ioreport_status(),rapl_status(),nvml_status().admits(status)(Live only).How it was verified
Six rounds of implementation, each checked by independent adversarial verification.
Pure tests on scripted samples and a scripted clock. They cover:
Fault hooks inside the real IOReport opener and sampler. The tests drive the real production status through each script: opener fails, a single lump, a two-step lump, constant values, and a sustained advance. So a bypass written into the glue fails a test.
Hardware tests against an independent oracle. The oracle has its own subscription, reads absolute values, burns on a helper thread, and counts a rail as moving only on a sustained change. The tests check:
Mutation-tested. Each of the following was applied as a temporary edit and failed a test:
admits()that accepts CpuRailAbsent.The counts per crate are below.
Robustness. Each crate passes under artificial CPU load and while another process samples every IOReport channel in a tight loop. The latter is what made an earlier wall-clock pin flaky, so the job now runs on the shared CI host without that flake. Each also passes on the M3 Ultra.
CI: these tests are behind
cfg(target_os = "macos"), and this workspace runs on a linux runner. They reach CI through #102, which merges last. #102 runs every backend's lib and integration tests on macOS, and fails any step whose frozen-rail test did not printioreport liveness:.Merge order: #110, #114, #115, #116 and #117 are independent of each other. #102 goes after all of them.
Open questions, now decided and implemented (per "fix it all")
measured_ujthat carry estimates. Their grade now sits beside them in plaintext: jcp-gateway's_metagetsenergy_provenance, plusenergy_coveragewhen partial. The keys stay, for compatibility.Meterdefaults to Estimator, and the conformance fixture keeps HwShunt explicitly, so the vectors are byte-identical.Authorizer::chargecarries the figure's provenance.ri_energy_nj: unprivileged, live on both Macs, and attributed to the work. It is wired in as stacked PRs #120–#124, only where the work runs in-process. (Privilegedpowermetricsis live too, but machine-wide and root-only, so it is not used.)Still open
joule-ui-servegives it honest grades, and the per-process probe in #121 gives it real readings.🤖 Generated with Claude Code
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]>WIP: proof-cascade: never meter CPU work through an IOReport CPU rail that does not keep movingto proof-cascade: never meter CPU work through an IOReport CPU rail that does not keep movingThe 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 inbedef9b0.) - proof-cli tests/measure.rs.bedef9b0had 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 at906ed1a6, 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]>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]>