open-standards/sandbox/sandbox-rs/crates/sandbox-meter/Cargo.toml
Transaction Science 6b693aa46f sandbox-meter: RAPL and NVML are hardware readings only while every counter they sum keeps advancing
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]>
2026-09-30 20:47:15 -04:00

26 lines
737 B
TOML

[package]
name = "sandbox-meter"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Sandbox — the per-device energy receipt and JCR-1 sealing (SPEC §6, spec/receipts.md)."
[dependencies]
sandbox-schema.workspace = true
ed25519-dalek.workspace = true
serde.workspace = true
thiserror.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
libloading = "0.8"
# macOS: IOReport and the attributed runner. Both: the thread CPU clock the
# counter liveness probes burn against.
[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies]
libc = "0.2"
[[bin]]
name = "joule-meter"
path = "src/bin/joule_meter.rs"