open-standards/joule-ui/joule-ui-rs/crates/joule-ui-interaction/tests/span_meter.rs
Transaction Science 7539c62e19
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
joule-ui: a span with no CPU reading is sealed as an estimate of the interaction, never as HwShunt
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]>
2026-09-30 20:47:34 -04:00

86 lines
4 KiB
Rust

//! The examples' `SpanMeter` (`examples/support/span_meter.rs`), driven over
//! scripted spans through the real `run_interaction`: each interaction is
//! sealed with its own span's figure and grade, and a span with no reading of
//! the CPU is never sealed as a hardware reading of the interaction.
#[path = "../examples/support/span_meter.rs"]
#[allow(dead_code)]
mod span_meter;
use joule_ui_interaction::{ContentId, InteractionLog, Provenance, run_interaction, verify_chain};
use sandbox_meter::{DeviceEnergy, Measurement, Provenance as Grade};
use sandbox_schema::DeviceClass;
use span_meter::{SpanMeter, Spans};
use std::collections::VecDeque;
use std::time::Duration;
/// A meter whose spans replay a script, one per close, and whose estimate is
/// 20 W, the Sandbox estimator's default.
struct Script(VecDeque<Measurement>);
impl Spans for Script {
type Open = ();
fn open(&mut self) {}
fn close(&mut self, _span: ()) -> Measurement {
self.0.pop_front().expect("script exhausted")
}
fn estimate(&self, wall: Duration) -> Measurement {
let uj = (wall.as_secs_f64() * 20e6) as u64;
Measurement { devices: vec![record(DeviceClass::Cpu, Grade::Estimator, uj)], wall, source: "estimator" }
}
}
fn record(class: DeviceClass, provenance: Grade, uj: u64) -> DeviceEnergy {
DeviceEnergy { class, measured_uj: (provenance == Grade::HwShunt).then_some(uj), estimated_uj: uj, provenance }
}
/// An 8 ms span of these records.
fn span_of(devices: Vec<DeviceEnergy>, source: &'static str) -> Measurement {
Measurement { devices, wall: Duration::from_millis(8), source }
}
/// Seal `n` interactions over the scripted spans — the first interaction's
/// own span, then for each later one the idle gap before it and its own span
/// — through the real `run_interaction`, returning the log.
fn seal(script: Vec<Measurement>, n: u8) -> InteractionLog {
assert_eq!(script.len(), 2 * usize::from(n) - 1, "setup: one span for the first, two for each later one");
let meter = SpanMeter::new(Script(script.into()));
let surface = ContentId::of_bytes(b"<Form><Slider action='apply'/></Form>");
let mut log = InteractionLog::new(ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]));
for i in 1..=n {
log.record(run_interaction(&surface, "apply", "did:key:z6MkTestActor", &[i], &meter, None, || vec![i]));
}
log
}
/// A GPU-only span — NVML's reading, no CPU record, as on a Linux host whose
/// only live counter is the GPU's — is not a reading of the interaction,
/// which ran on the CPU. It is sealed as the meter's estimate for the span's
/// 8 ms (160 000 µJ at 20 W), `Estimator`: not the GPU's 9 000 µJ as
/// `HwShunt`, and not 0. The span that reads the CPU beside the GPU (RAPL and
/// NVML) is a reading, sealed as one; and one that reads only the CPU is too.
#[test]
fn a_gpu_only_span_is_sealed_as_an_estimate_never_as_a_reading() {
let gpu_only = span_of(vec![record(DeviceClass::Gpu, Grade::HwShunt, 9_000)], "nvml-total-energy");
assert!(gpu_only.fully_measured() && !gpu_only.reads_cpu(), "setup: a GPU reading with no CPU record");
let gap = || span_of(vec![record(DeviceClass::Cpu, Grade::Estimator, 999_999)], "estimator");
let both = span_of(
vec![record(DeviceClass::Cpu, Grade::HwShunt, 1_500), record(DeviceClass::Gpu, Grade::HwShunt, 9_000)],
"composite",
);
let cpu = span_of(vec![record(DeviceClass::Cpu, Grade::HwShunt, 2_000)], "rapl-powercap");
let log = seal(vec![gpu_only, gap(), both, gap(), cpu], 3);
assert_eq!(verify_chain(log.entries()), Ok(3));
let sealed: Vec<_> = log.entries().iter().map(|e| e.interaction.energy).collect();
let got: Vec<_> = sealed.iter().map(|e| (e.provenance, e.measured_uj, e.estimated_uj)).collect();
assert_eq!(
got,
[
(Provenance::Estimator, None, 160_000),
(Provenance::HwShunt, Some(10_500), 10_500),
(Provenance::HwShunt, Some(2_000), 2_000),
],
"{sealed:?}"
);
}