sandbox-meter: a CPU counter that does not keep moving is never a measurement, and no consumer relabels an estimate #114

Merged
dcharlot merged 20 commits from sandbox-meter/cpu-rail-liveness into main 2026-10-01 05:45:12 -04:00
43 changed files with 7875 additions and 526 deletions

1
jcp/jcp-rs/Cargo.lock generated
View file

@ -630,6 +630,7 @@ dependencies = [
"jcp-runtime",
"jcp-schema",
"sandbox-meter",
"sandbox-schema",
"serde_json",
]

View file

@ -19,7 +19,7 @@ what an audit actually asks for, and **none of them binds to the others**:
|---|---|---|
| `jcp-receipt` | authority | grant chain, verdict, budget drawn |
| `sandbox-proof` | environment | attestation, isolation tier, substrate |
| JCR-1 | work | input hash, measured joules, energy provenance |
| JCR-1 | work | input hash, joules, and how they were obtained (energy provenance) |
| *(nothing)* | **effect** | what changed, and whether it can be undone |
So no single artifact answers: *under what authority did this agent do that,
@ -73,7 +73,7 @@ ActionReceipt {
// binding 3 — work
work_receipt: ContentId
input_hash: Hash
joules_uj: u64
joules_uj: u64 // the work's cost, graded by energy_provenance
energy_provenance: "HwShunt" | "ModelBased" | "Estimator"
// binding 4 — effect (new; nothing else records this)
@ -114,6 +114,10 @@ them into a single undifferentiated number. A measured figure and an estimated
one are not interchangeable, and a receipt that treats them as such is lying by
arithmetic.
`joules_uj` is the work's cost however it was obtained. It is a hardware
reading only when `energy_provenance` is `HwShunt`, and a verifier or display
MUST NOT present it as a measurement otherwise.
## 3. The three rules
These MUST be enforced at verification, re-derived from the wire projection —
@ -201,8 +205,10 @@ not prove those artifacts are themselves sound — that is their own verifiers'
job, and a complete audit runs all of them.
**The energy cross-check is a consistency check, not a proof.** An attestation
asserts; joules are measured. Binding both means a forged attestation must also
maintain a plausible energy profile across an independently sampled counter, and
that the failure is visible rather than silent. An adversary with physical
asserts; joules are metered, and where `energy_provenance` is `HwShunt` they are
read off an independently sampled counter. Binding both means a forged
attestation must also maintain a plausible energy profile across that counter,
and that the failure is visible rather than silent. An `Estimator` figure offers
no such cross-check, which is why it is labelled. An adversary with physical
access who can forge a quote can also influence measurement. Claiming more than
this would be exactly the overreach the design exists to avoid.

View file

@ -15,8 +15,10 @@
//! is really refused;
//! - the authorisation is a **real `decide` call** against a real budget, and
//! the receipt records the verdict that call returned;
//! - the joules are **measured**, and the meter's provenance is carried through
//! verbatim. On a host with no counter it reports `Estimator` and this example
//! - the joules are **metered**, and the span's provenance is carried through
//! verbatim: `HwShunt` only when every record the meter returned is a
//! hardware reading. Where the host has no live counter — or the span's CPU
//! counter held — the meter returns its `Estimator` record, and this example
//! says so rather than inventing `HwShunt`;
//! - the tier is stamped from what is **actually running**. This is in-process
//! with no isolation boundary crossed, so it is `S0`. Claiming `S5` without a
@ -136,13 +138,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
std::hint::black_box(acc);
let joules_uj = measurement.total_uj();
let provenance = if is_hw { "HwShunt" } else { "Estimator" };
// The span's own provenance, the weakest of its records. A hardware meter
// still returns its estimator record for a span whose CPU counter held,
// so `is_hardware()` says nothing about this span.
let provenance = {
use sandbox_meter::Provenance as P;
let records = &measurement.devices;
if records.is_empty() || records.iter().any(|d| d.provenance == P::Estimator) {
"Estimator"
} else if records.iter().all(|d| d.provenance == P::HwShunt && d.measured_uj.is_some()) {
"HwShunt"
} else {
"ModelBased"
}
};
println!(
"3. work metered — {joules_uj} µJ [{provenance}] over {:.2}s via {sources}",
measurement.wall.as_secs_f64()
);
if !is_hw {
println!(" note: no hardware counter here; provenance is Estimator, not HwShunt");
if provenance != "HwShunt" {
println!(
" note: this span was not a hardware reading; provenance is {provenance}, not HwShunt ({})",
if is_hw { "its CPU counter held" } else { "no live energy counter here" }
);
}
// Build the REAL work receipt from the devices the meter actually reported.

View file

@ -9,7 +9,7 @@
//! |---|---|---|
//! | `jcp-receipt` | authority | grant chain, verdict, budget drawn |
//! | `sandbox-proof` | environment | attestation, isolation tier, substrate |
//! | JCR-1 | work | input hash, measured joules, energy provenance |
//! | JCR-1 | work | input hash, joules, and how they were obtained (energy provenance) |
//! | *(nothing)* | **effect** | what changed, and whether it can be undone |
//!
//! AR-1 binds the first three **by content-id** and supplies the fourth. It is
@ -201,7 +201,10 @@ pub struct ActionReceipt {
pub work_receipt: ContentId,
/// Hash of the input the work consumed.
pub input_hash: String,
/// Measured cost in microjoules.
/// The work's energy cost in microjoules. Whether it is a hardware
/// reading, a model or an estimate is not this field's to say: it is
/// graded by [`energy_provenance`](Self::energy_provenance), and is a
/// reading only when that says `HwShunt`.
pub joules_uj: u64,
/// How that figure was obtained. Never collapsed into a single number:
/// `HwShunt` and `Estimator` are not the same claim.

View file

@ -1,6 +1,6 @@
[package]
name = "jcp-gateway"
description = "The energy-native MCP gateway: a JCP enforcement point in front of an MCP server. Every tools/call is gated by a capability grant, the upstream call is metered on real hardware, the MEASURED joules are debited from the grant's budget, and the decision seals into a hash-chained receipt log. The chokepoint position the AI-gateway field occupies — denominated in joules, with a tamper-evident audit, not dollars and a span."
description = "The energy-native MCP gateway: a JCP enforcement point in front of an MCP server. Every tools/call is gated by a capability grant, the upstream call is metered (a hardware reading where the host exposes a live energy counter, an estimate where it does not, each sealed as what it is), the metered joules are debited from the grant's budget, and the decision seals into a hash-chained receipt log. The chokepoint position the AI-gateway field occupies — denominated in joules, with a tamper-evident audit, not dollars and a span."
version.workspace = true
edition.workspace = true
license.workspace = true
@ -15,6 +15,9 @@ jcp-receipt = { path = "../jcp-receipt" }
jcp-bridge = { path = "../jcp-bridge" }
jcp-flow = { path = "../jcp-flow" }
sandbox-meter = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-meter" }
# The meter's device classes: a hardware span with no CPU record is a reading
# of part of the call, and is sealed as one.
sandbox-schema = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-schema" }
ed25519-dalek = { workspace = true }
serde_json = { workspace = true }

View file

@ -17,6 +17,18 @@ use jcp_gateway::{Gateway, Relayed, Upstream};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
/// The energy grade a verified receipt seals: its `energy_provenance`, plus
/// `energy_coverage` when that is `Partial`. `None` if it does not verify.
fn sealed_grade(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Option<String> {
let core = jcp_receipt::jcr1::verify_cose_sign1(envelope, steward, pubkey).ok()?;
let v: serde_json::Value = jcp_schema::from_canonical_cbor(&core).ok()?;
let provenance = v.get("energy_provenance")?.as_str()?;
Some(match v.get("energy_coverage").and_then(serde_json::Value::as_str) {
Some(coverage) => format!("{provenance}, {coverage}"),
None => provenance.to_string(),
})
}
/// The upstream MCP server as a child process speaking JSON-RPC over stdio.
struct ChildServer {
_child: Child,
@ -80,6 +92,8 @@ fn main() -> std::io::Result<()> {
let grant_refs: Vec<(&str, u64)> = grants.iter().map(|(t, b)| (t.as_str(), *b)).collect();
let steward_sk = SigningKey::from_bytes(&[0x5e; 32]);
let steward_pk = steward_sk.verifying_key().to_bytes();
let steward = format!("did:jcp:gateway:{server}");
let mut gw = Gateway::new(&server, &grant_refs, steward_sk);
eprintln!(
"jcp-gateway: server={server} tools={:?} metering={}",
@ -100,7 +114,14 @@ fn main() -> std::io::Result<()> {
}
match gw.relay(&line, &mut up)? {
Relayed::Allowed { response, measured_uj, budget_remaining_uj } => {
eprintln!("jcp-gateway: allowed, {measured_uj} µJ measured, {budget_remaining_uj} µJ left");
// How the joules were obtained, read back from the receipt the
// gateway just sealed, so this line cannot disagree with it.
let grade = gw
.receipts()
.last()
.and_then(|r| sealed_grade(r, &steward, &steward_pk))
.unwrap_or_else(|| "unverified".into());
eprintln!("jcp-gateway: allowed, {measured_uj} µJ [{grade}], {budget_remaining_uj} µJ left");
if !response.is_empty() {
writeln!(stdout, "{response}")?;
stdout.flush()?;

View file

@ -8,9 +8,12 @@
//! `(server, tool)`, so a grant for `read_file` cannot authorize `shell`.
//! 2. The call is authorized *before* it runs ([`Runtime::authorize`]) — but
//! nothing is debited yet, because the cost isn't known until it runs.
//! 3. The upstream call is **metered on real hardware**
//! ([`sandbox_meter::PowerMeter`]) — the measured joules, not an estimate.
//! 4. The measured cost is debited from the grant ([`Runtime::charge`]); when
//! 3. The upstream call is **metered** ([`sandbox_meter::PowerMeter`]): a
//! hardware reading where this host exposes a live energy counter, and the
//! meter's wall-clock estimate where it does not. The figure keeps the
//! provenance of the span it came from — `HwShunt` only for a reading,
//! `Estimator` for an estimate — in the ledger and in the receipt.
//! 4. The metered cost is debited from the grant ([`Runtime::charge`]); when
//! the budget is exhausted the next call is denied.
//! 5. Every decision — allow and deny — seals into a **hash-chained receipt
//! log** ([`jcp_receipt::ReceiptLog`]): a tamper-evident, non-repudiable
@ -36,7 +39,8 @@ use jcp_receipt::{FlowSeal, ReceiptLog};
use jcp_runtime::Runtime;
use jcp_schema::flow::{FlowCeiling, Readers};
use jcp_schema::{Capability, Caveat, ContentId, Did, Energy, FlowFacts, Grant, SignedGrant};
use sandbox_meter::PowerMeter;
use sandbox_meter::{Measurement, PowerMeter};
use sandbox_schema::DeviceClass;
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
@ -53,6 +57,9 @@ pub enum Relayed {
/// A non-`tools/call` message forwarded verbatim; here is the response.
PassedThrough(String),
/// A `tools/call` the gateway authorized, metered, and forwarded.
/// `measured_uj` is the figure debited from the grant: a hardware reading
/// or the meter's estimate. The sealed receipt's `energy_provenance`, and
/// the same label beside the figure in the response's `_meta`, say which.
Allowed { response: String, measured_uj: u64, budget_remaining_uj: u64 },
/// A `tools/call` the gateway refused; the upstream was never contacted.
/// `response` is a JSON-RPC error to hand back to the client.
@ -61,7 +68,7 @@ pub enum Relayed {
Notified,
}
/// The gateway: a JCP runtime, the tool grants, a hardware meter, and a
/// The gateway: a JCP runtime, the tool grants, an energy meter, and a
/// hash-chained receipt log, in front of one MCP server.
pub struct Gateway {
server: String,
@ -168,7 +175,10 @@ impl Gateway {
self.log.entries()
}
/// Whether the active meter reads real hardware (vs. the estimator).
/// Whether the active meter has a hardware energy source (vs. the
/// estimator alone). This says what the meter can read, not what a call
/// did: a span whose CPU counter held is estimated even on a hardware
/// meter, and each call's receipt carries its own span's provenance.
pub fn metering_is_hardware(&self) -> bool {
self.meter.is_hardware()
}
@ -225,7 +235,10 @@ impl Gateway {
label: witness.label.clone(),
witness_id: witness.id(),
eval_uj: eval.total_uj(),
eval_prov: eval_provenance(&self.meter),
// The span's own provenance: over a scan this short the CPU
// counter can hold, and the span is then an estimate even on a
// hardware meter.
eval_prov: span_provenance(&eval),
};
let seal = FlowSeal { facts: Some(facts.clone()), ceiling: ceiling_digest(&grant) };
// Keep the witness discoverable for a later audit of this verdict.
@ -243,7 +256,7 @@ impl Gateway {
});
}
// Phase 2: run the upstream call, metered on real hardware.
// Phase 2: run the upstream call, metered.
let (response, measurement) = {
let mut response = String::new();
let (res, m) = self.meter.measure(|| up.call(line));
@ -252,11 +265,17 @@ impl Gateway {
}
(response, m)
};
let measured_uj = measurement.total_uj();
// The call's cost as what it is: a hardware reading, or the meter's
// estimate when this host exposes no live counter or the span's CPU
// counter held. Either way it is debited, so a runaway is enforced.
let cost = energy_of(&measurement);
let measured_uj = cost.uj();
// Phase 3: debit the measured joules (and the tainted sub-budget, if the
// Phase 3: debit the metered joules (and the tainted sub-budget, if the
// call was tainted); seal the accounting decision as jcp/3 when tainted.
let decision = self.runtime.charge_flow(&grant, Energy::measured(measured_uj), &facts);
// The receipt seals `cost`'s own provenance: an estimate never enters
// the ledger, or the evidence, as a reading.
let decision = self.runtime.charge_flow(&grant, cost, &facts);
self.log.append_flow(&frame, &grant, &decision, &seal);
// Phase 4: the result becomes a labelled source. Its frame is Trust::Data
@ -278,7 +297,7 @@ impl Gateway {
// receives evidence for its own request rather than having to trust
// that the gateway logged something somewhere.
let response = match self.log.entries().last() {
Some(r) => attach_receipt(&response, r, measured_uj, decision.budget_remaining_uj),
Some(r) => attach_receipt(&response, r, &cost, decision.budget_remaining_uj),
None => response,
};
@ -345,7 +364,17 @@ fn jcp_meta(params: &Value) -> Option<&Value> {
/// be presented, disputed, or forwarded, and that is most of what a receipt is
/// for. Returns the response unchanged if it is not a JSON-RPC result object —
/// this must never mangle an upstream payload it does not understand.
fn attach_receipt(response: &str, receipt: &[u8], measured_uj: u64, remaining_uj: u64) -> String {
///
/// The plaintext figure is graded beside itself. `measured_uj` keeps its name
/// for the clients already reading it, but it is the call's COST, which is a
/// hardware reading only where the span was one; on a host with no live energy
/// counter it is the meter's estimate. So `energy_provenance` (`HwShunt`,
/// `ModelBased` or `Estimator`) rides next to it, with `energy_coverage:
/// "Partial"` when the figure reads only part of the call, the same two labels
/// the sealed receipt carries. A client that reads only the plaintext therefore
/// never takes an estimate for a reading, and never has to decode the receipt
/// to find out which it got.
fn attach_receipt(response: &str, receipt: &[u8], cost: &Energy, remaining_uj: u64) -> String {
let Ok(mut v) = serde_json::from_str::<Value>(response) else {
return response.to_string();
};
@ -357,14 +386,16 @@ fn attach_receipt(response: &str, receipt: &[u8], measured_uj: u64, remaining_uj
.or_insert_with(|| json!({}))
.as_object_mut();
let Some(meta) = meta else { return response.to_string() };
meta.insert(
JCP_META_NS.to_string(),
json!({
"receipt_b64": b64(receipt),
"measured_uj": measured_uj,
"budget_remaining_uj": remaining_uj,
}),
);
let mut jcp = json!({
"receipt_b64": b64(receipt),
"measured_uj": cost.uj(),
"energy_provenance": cost.provenance.jcr1_label(),
"budget_remaining_uj": remaining_uj,
});
if !cost.is_whole() {
jcp["energy_coverage"] = json!(cost.coverage.label());
}
meta.insert(JCP_META_NS.to_string(), jcp);
v.to_string()
}
@ -426,12 +457,40 @@ fn declared_witness_ids(params: &Value) -> BTreeSet<ContentId> {
.unwrap_or_default()
}
/// The provenance label to seal for the monitor's own metered evaluation.
fn eval_provenance(meter: &PowerMeter) -> jcp_schema::Provenance {
if meter.is_hardware() {
/// The provenance of one metered span: the weakest of its records, because a
/// sum is only as good as its weakest part. The meter returns its estimator
/// record when the host exposes no live counter, and also when the span's CPU
/// counter held — even on a hardware meter. So a span is labelled by its own
/// records, never by [`PowerMeter::is_hardware`].
fn span_provenance(m: &Measurement) -> jcp_schema::Provenance {
use sandbox_meter::Provenance as Grade;
if m.devices.is_empty() || m.devices.iter().any(|d| d.provenance == Grade::Estimator) {
jcp_schema::Provenance::Estimator
} else if m
.devices
.iter()
.all(|d| d.provenance == Grade::HwShunt && d.measured_uj.is_some())
{
jcp_schema::Provenance::HwShunt
} else {
jcp_schema::Provenance::Estimator
jcp_schema::Provenance::ModelBased
}
}
/// The energy a metered span enters the ledger and the receipt as, built with
/// jcp-schema's own constructors: an estimate as an estimate, a reading as a
/// reading. A hardware reading with no CPU record — a GPU counter alone, as on
/// a host that exposes NVML but not RAPL — reads only part of the call, so it
/// is sealed `Partial` instead of claiming the whole call's energy.
fn energy_of(m: &Measurement) -> Energy {
let uj = m.total_uj();
match span_provenance(m) {
jcp_schema::Provenance::HwShunt if m.devices.iter().any(|d| d.class == DeviceClass::Cpu) => {
Energy::measured(uj)
}
jcp_schema::Provenance::HwShunt => Energy::measured_partial(uj),
jcp_schema::Provenance::ModelBased => Energy::modeled(uj),
jcp_schema::Provenance::Estimator => Energy::estimator(uj),
}
}
@ -509,10 +568,48 @@ mod tests {
let b64_returned = meta["receipt_b64"].as_str().expect("receipt_b64");
assert_eq!(b64_returned, b64(g.receipts().last().unwrap()));
// The plaintext figure carries its grade, and it is the grade the
// receipt sealed: a client reading `measured_uj` without decoding the
// receipt is told whether it is a reading or an estimate.
let steward_pub = SigningKey::from_bytes(&[7u8; 32]).verifying_key().to_bytes();
let sealed = jcr1_payload(g.receipts().last().unwrap(), "did:jcp:gateway:files", &steward_pub);
let grade = meta["energy_provenance"].as_str().expect("energy_provenance beside measured_uj");
assert_eq!(Some(grade), sealed.get("energy_provenance").and_then(Value::as_str), "{meta} vs {sealed}");
assert_eq!(meta.get("energy_coverage"), sealed.get("energy_coverage"), "{meta} vs {sealed}");
eprintln!("gateway _meta: hardware={} {meta}", g.metering_is_hardware());
if !g.metering_is_hardware() {
assert_eq!(grade, "Estimator", "an estimate labelled as something else: {meta}");
}
// And the upstream's own payload survived untouched beside it.
assert_eq!(v["result"]["ok"].as_bool(), Some(true));
}
/// `measured_uj` keeps its name for compatibility but is the call's cost,
/// so its grade rides beside it in plaintext for every shape the cost
/// takes: `energy_provenance` always, `energy_coverage` only when the
/// figure covers part of the call (whole is the default and is omitted,
/// as in the sealed receipt).
#[test]
fn the_plaintext_figure_carries_its_grade() {
let ok = r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#;
let ptr = format!("/result/_meta/{}", JCP_META_NS.replace('/', "~1"));
for (cost, provenance, coverage) in [
(Energy::estimator(60_000), "Estimator", None),
(Energy::measured(1_300), "HwShunt", None),
(Energy::measured_partial(3_000), "HwShunt", Some("Partial")),
(Energy::modeled(700), "ModelBased", None),
] {
let v: Value = serde_json::from_str(&attach_receipt(ok, b"r", &cost, 9)).unwrap();
let meta = v.pointer(&ptr).expect("the jcp _meta block");
assert_eq!(meta["measured_uj"].as_u64(), Some(cost.uj()), "{meta}");
assert_eq!(meta["energy_provenance"].as_str(), Some(provenance), "{meta}");
assert_eq!(meta.get("energy_coverage").and_then(Value::as_str), coverage, "{meta}");
assert_eq!(meta["budget_remaining_uj"].as_u64(), Some(9));
assert_eq!(v["result"]["ok"].as_bool(), Some(true), "the upstream payload is untouched");
}
}
/// A refusal the caller cannot see is an enforcement nobody can audit, so a
/// denial carries its sealed record too.
#[test]
@ -554,10 +651,10 @@ mod tests {
#[test]
fn a_response_the_gateway_cannot_parse_is_returned_untouched() {
let junk = "not json at all";
assert_eq!(attach_receipt(junk, b"r", 1, 2), junk);
assert_eq!(attach_receipt(junk, b"r", &Energy::estimator(1), 2), junk);
// Valid JSON, but no `result` object to extend.
let err = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"x"}}"#;
assert_eq!(attach_receipt(err, b"r", 1, 2), err);
assert_eq!(attach_receipt(err, b"r", &Energy::estimator(1), 2), err);
}
/// Base64 is RFC 4648 with padding — pinned against known vectors, because a
@ -573,6 +670,9 @@ mod tests {
assert_eq!(b64(b"foobar"), "Zm9vYmFy");
}
/// A call that ran costs joules and is debited on every host: read off a
/// live counter, or estimated where there is none. An estimate is still
/// debited, so the budget binds on a host that exposes no reading.
#[test]
fn granted_tool_is_allowed_metered_and_debited() {
let mut g = gw();
@ -581,10 +681,8 @@ mod tests {
match r {
Relayed::Allowed { measured_uj, budget_remaining_uj, .. } => {
assert_eq!(up.calls, 1, "upstream was contacted");
if g.metering_is_hardware() {
assert!(measured_uj > 0, "a real span costs joules");
assert!(budget_remaining_uj < 50_000_000, "budget was debited");
}
assert!(measured_uj > 0, "a real span costs joules, measured or estimated");
assert_eq!(budget_remaining_uj, 50_000_000 - measured_uj, "budget was debited by the cost");
}
other => panic!("expected Allowed, got {other:?}"),
}
@ -616,7 +714,8 @@ mod tests {
#[test]
fn budget_exhaustion_denies_further_calls_and_the_log_verifies() {
// A tiny budget so a couple of measured calls exhaust it.
// A tiny budget so a couple of metered calls exhaust it — on every
// host: an estimate is debited like a reading.
let mut g = Gateway::new("files", &[("read_file", 500)], SigningKey::from_bytes(&[7u8; 32]));
let mut up = MockServer { calls: 0 };
let steward_pub = SigningKey::from_bytes(&[7u8; 32]).verifying_key().to_bytes();
@ -633,11 +732,9 @@ mod tests {
_ => unreachable!(),
}
}
if g.metering_is_hardware() {
assert!(allowed >= 1, "at least one call fit the budget");
assert!(denied >= 1, "the budget eventually exhausts");
assert_eq!(up.calls, allowed, "denied calls never reached upstream");
}
assert!(allowed >= 1, "at least one call fit the budget");
assert!(denied >= 1, "the budget eventually exhausts");
assert_eq!(up.calls, allowed, "denied calls never reached upstream");
// The full decision log — allows and denies — is hash-chain intact.
assert_eq!(
jcp_receipt::verify_log(g.receipts(), "did:jcp:gateway:files", &steward_pub),
@ -645,6 +742,90 @@ mod tests {
);
}
// ── a call's energy is sealed as what it is ──────────────────────────────
use sandbox_meter::{DeviceEnergy, Provenance as Grade};
use std::time::Duration;
fn rec(class: DeviceClass, measured_uj: Option<u64>, estimated_uj: u64, provenance: Grade) -> DeviceEnergy {
DeviceEnergy { class, measured_uj, estimated_uj, provenance }
}
fn span(devices: Vec<DeviceEnergy>) -> Measurement {
Measurement { devices, wall: Duration::from_millis(3), source: "test" }
}
/// Every shape a metered span takes, and the energy it enters the ledger
/// and the receipt as. The first is what every macOS 27.0.1 host checked
/// now gets; main sealed it as `Energy::measured`, an estimate presented
/// as a reading.
#[test]
fn a_metered_span_enters_the_ledger_as_what_it_is() {
use jcp_schema::{Coverage, Provenance};
// The meter's estimator record — no live counter, or a CPU counter
// that held across the span: wall × TDP, debited as an estimate.
let est = span(vec![rec(DeviceClass::Cpu, None, 60_000, Grade::Estimator)]);
assert_eq!(energy_of(&est), Energy::estimator(60_000));
assert_eq!(span_provenance(&est), Provenance::Estimator);
// A live CPU counter with the NPU and GPU beside it: a whole-call reading.
let live = span(vec![
rec(DeviceClass::Cpu, Some(1_234), 1_234, Grade::HwShunt),
rec(DeviceClass::Npu, Some(0), 0, Grade::HwShunt),
rec(DeviceClass::Gpu, Some(66), 66, Grade::HwShunt),
]);
assert_eq!(energy_of(&live), Energy::measured(1_300));
assert_eq!(span_provenance(&live), Provenance::HwShunt);
// A GPU counter alone (NVML without RAPL): a reading of part of the call.
let gpu = span(vec![rec(DeviceClass::Gpu, Some(3_000), 3_000, Grade::HwShunt)]);
assert_eq!(energy_of(&gpu), Energy::measured_partial(3_000));
assert_eq!(energy_of(&gpu).coverage, Coverage::Partial);
// A sum is only as good as its weakest record.
let mixed = span(vec![
rec(DeviceClass::Cpu, Some(10), 10, Grade::HwShunt),
rec(DeviceClass::Gpu, None, 500, Grade::Estimator),
]);
assert_eq!(energy_of(&mixed), Energy::estimator(510));
let modeled = span(vec![rec(DeviceClass::Cpu, None, 700, Grade::ModelBased)]);
assert_eq!(energy_of(&modeled), Energy::modeled(700));
assert_eq!(span_provenance(&modeled), Provenance::ModelBased);
for m in [&est, &live, &gpu, &mixed, &modeled] {
let e = energy_of(m);
e.check_provenance().unwrap();
assert_eq!(e.uj(), m.total_uj(), "the debit is the span's own figure");
}
}
/// End to end on this host: the receipt a call gets back says how its
/// joules were obtained, as the meter's span said it. Where the meter can
/// only estimate — every macOS 27.0.1 host checked, whose CPU energy rail
/// is frozen — the sealed provenance is `Estimator`, never `HwShunt`.
#[test]
fn a_calls_receipt_says_how_its_joules_were_obtained() {
let steward_pub = SigningKey::from_bytes(&[7u8; 32]).verifying_key().to_bytes();
let mut g = gw();
let mut up = MockServer { calls: 0 };
let Relayed::Allowed { measured_uj, .. } = g.relay(&call("read_file", 1), &mut up).unwrap() else {
panic!("expected Allowed")
};
let payload = jcr1_payload(g.receipts().last().unwrap(), "did:jcp:gateway:files", &steward_pub);
let sealed = payload.get("energy_provenance").and_then(Value::as_str).expect("energy_provenance");
eprintln!("gateway call: hardware={} sealed={sealed} cost_uj={measured_uj}", g.metering_is_hardware());
assert_eq!(payload.get("cost_uj").and_then(Value::as_u64), Some(measured_uj));
assert!(measured_uj > 0, "a call that ran costs joules, measured or estimated");
if g.metering_is_hardware() {
// A hardware meter reads the span, or estimates it if its CPU counter held.
assert!(matches!(sealed, "HwShunt" | "Estimator"), "{payload}");
} else {
// No hardware source: every span is the estimator's, and says so.
assert_eq!(sealed, "Estimator", "an estimate sealed as a reading: {payload}");
assert!(payload.get("energy_coverage").is_none(), "{payload}");
}
}
// ── the end-to-end attack the whole feature exists to stop ───────────────
use jcp_schema::flow::{Integrity, Label, Origins};
@ -723,6 +904,13 @@ mod tests {
assert_eq!(payload.get("jcp_receipt").and_then(Value::as_str), Some("jcp/3"));
let flow = payload.get("flow").expect("a flow block");
assert_eq!(flow.get("verdict").and_then(Value::as_str), Some("above"));
// The monitor's own energy is sealed with its own span's provenance.
let flow_prov = flow.get("flow_energy_provenance").and_then(Value::as_str);
if g.metering_is_hardware() {
assert!(matches!(flow_prov, Some("HwShunt" | "Estimator")), "{flow}");
} else {
assert_eq!(flow_prov, Some("Estimator"), "an estimated scan sealed as a reading: {flow}");
}
// And the sealed witness is disclosable and re-verifies offline.
let witness_hex = flow.get("witness").and_then(Value::as_str).unwrap();

View file

@ -1,6 +1,6 @@
[package]
name = "joule-code-remediate"
description = "The reference verified-fix harness: scan a Rust workspace, remediate at the cheapest sufficient cascade tier (L1 lawful codemods first), gate every fix behind a verification pipeline whose stages ARE the contract obligations, and emit a JouleContract receipt with hardware-measured joules sealed by a sandbox meter attestation. A fix that fails its gate is reverted, never shipped."
description = "The reference verified-fix harness: scan a Rust workspace, remediate at the cheapest sufficient cascade tier (L1 lawful codemods first), gate every fix behind a verification pipeline whose stages ARE the contract obligations, and emit a JouleContract receipt whose joules are graded by how they were obtained: a hardware reading apportioned to the run and anchored to a sealed sandbox meter envelope only where the host exposes a live CPU energy counter, an estimate sealed as one everywhere else. A fix that fails its gate is reverted, never shipped."
version.workspace = true
edition.workspace = true
license.workspace = true
@ -26,7 +26,10 @@ thiserror = { workspace = true }
# fix's formal obligations through the joulecontract→proof vericoding bridge over
# a real `z3`, and a refuted/unproved obligation reverts the fix like any other
# failed gate. Decision-procedure ladder only — a formal gate must never fall
# through to a model stand-in (see `examples/formal_fix.rs`).
# through to a model stand-in (see `examples/formal_fix.rs`). Without `z3` on
# PATH an SMT obligation is Unproved and the gate fails closed; the formal-tier
# tests assert that branch on a solverless host, and `JOULE_FIX_EXPECT_Z3=1`
# makes a missing `z3` fail them instead (set it wherever z3 is installed).
joulecontract-proof = { path = "../../../../joulecontract/joulecontract-rs/crates/joulecontract-proof" }
proof-cascade = { path = "../../../../proof/proof-rs/crates/proof-cascade" }
proof-solvers = { path = "../../../../proof/proof-rs/crates/proof-solvers" }

View file

@ -26,20 +26,37 @@ fn main() {
for g in &report.gates {
println!(" [{}] {} — {}", if g.passed { "✓" } else { "✗" }, g.gate, g.detail);
}
println!(
"\nenergy: {:.3} J attributed of {:.3} J machine-wide SoC-CPU over {:.2}s ({})",
report.attributed_uj as f64 / 1e6,
report.soc_cpu_uj as f64 / 1e6,
report.wall.as_secs_f64(),
report.meter_source,
);
if report.attributed {
println!(
"\nenergy: {:.3} J attributed of {:.3} J machine-wide SoC-CPU over {:.2}s ({})",
report.attributed_uj as f64 / 1e6,
report.soc_cpu_uj as f64 / 1e6,
report.wall.as_secs_f64(),
report.meter_source,
);
} else {
println!(
"\nenergy: {:.3} J estimated, not attributed, over {:.2}s ({})",
report.total_uj() as f64 / 1e6,
report.wall.as_secs_f64(),
report.meter_source,
);
}
if let Some(uj) = report.uj_per_verified_fix() {
println!("energy per verified fix: {:.3} J (attributed)", uj as f64 / 1e6);
println!(
"energy per verified fix: {:.3} J ({})",
uj as f64 / 1e6,
if report.attributed { "attributed" } else { "estimated" }
);
}
if let Some(f) = &report.formal {
println!(
"\nformal proof: {} proved, {} refuted, {} unproved — {:.3} J (real z3, ModelBased)",
f.proved, f.refuted, f.unproved, f.proof_uj as f64 / 1e6,
"\nformal proof: {} proved, {} refuted, {} unproved — {:.3} J ({}, ModelBased)",
f.proved,
f.refuted,
f.unproved,
f.proof_uj as f64 / 1e6,
if proof_solvers::z3_available() { "real z3" } else { "no z3 on PATH: SMT goals left undecided" },
);
for e in &f.entries {
print!(" [{}] {}", if e.verdict == "proved" { "✓" } else { "✗" }, e.obligation);

View file

@ -6,16 +6,18 @@
//! JouleContract whose `pre`/`post`/`inv` clauses are the pipeline stages
//! (baseline capture, build, no-new-test-failures, lint-delta, frame
//! confinement, diff guard), discharges each one with the stage's actual
//! outcome, and seals the result with hardware-measured energy. Three
//! consequences, by construction:
//! outcome, and seals the result with its energy, graded by how that energy
//! was obtained. Three consequences, by construction:
//!
//! 1. **A fix that fails its gate is reverted and never ships** — but the
//! refusal still produces a receipt: an undischarged obligation on the
//! record beats a silently dropped fix.
//! 2. **The energy figure is measured, not estimated**, whenever the host has
//! a hardware counter ([`sandbox_meter::PowerMeter`]); the meter
//! attestation inside the JouleContract receipt is a sealed sandbox JCR-1
//! envelope — one standard's receipt is the other's evidence.
//! 2. **The energy figure rests on a hardware reading** whenever the host
//! exposes a live CPU energy counter ([`sandbox_meter::PowerMeter`]), and
//! is then anchored to a sealed sandbox JCR-1 meter envelope — one
//! standard's receipt is the other's evidence. Where it does not, the
//! figure is an estimate and is sealed as one (JCR-1 `Estimator`,
//! JouleContract `Unaccounted`), never as a reading.
//! 3. **The receipt grades honestly**: receipt-local validation, JR-2 at
//! most, generator and verifier self-declared and labeled as such. Higher
//! rigor is a deployment's verifier-of-record work, exactly per the
@ -36,7 +38,7 @@ use joulecontract_schema::{
Ceiling, ContentAddress, Contract, EnergyClass, GovernanceClass, ObligationKind, Profile,
Rigor,
};
use sandbox_meter::{run_attributed, PowerMeter};
use sandbox_meter::{run_attributed, Attribution, PowerMeter};
use serde::{Deserialize, Serialize};
// The formally-verified fix tier. `DischargeStatus`/`Formalization`/
// `ContractDischarge` come from the proof bridge; the bridge's own
@ -57,12 +59,87 @@ use std::time::{Duration, Instant};
/// run unmetered.
struct MeterAcc {
meter: PowerMeter,
/// Σ machine-wide SoC-CPU energy over the metered windows (the upper bound).
/// This run's metered windows, reset at the start of each run.
tally: EnergyTally,
}
/// The energy of one run's metered cargo windows, and the figure and grades
/// the receipts carry for it.
///
/// A window is attributed when a hardware CPU reading that advanced was
/// apportioned to the child's process tree (`ModelBased`). Otherwise its
/// figure is not a reading of this workload. It is the meter's wall × TDP
/// estimate where the host exposes no live CPU counter (every macOS 27.0.1
/// host checked), or where the counter held; or it is a machine-wide figure
/// the meter could not apportion. The run is `ModelBased` only when every
/// window was attributed, because a sum is only as good as its weakest part.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct EnergyTally {
/// Σ machine-wide SoC-CPU figure over the windows (the upper bound).
soc_uj: u64,
/// Σ energy attributed to this run's process trees (ModelBased apportion).
/// Σ figure apportioned to this run's process trees.
attr_uj: u64,
/// True once at least one window rested on real per-process accounting.
attributed: bool,
windows: u64,
attributed_windows: u64,
}
impl EnergyTally {
fn add(&mut self, a: &Attribution) {
self.soc_uj = self.soc_uj.saturating_add(a.soc_cpu_uj);
self.attr_uj = self.attr_uj.saturating_add(a.attributed_uj);
self.windows += 1;
if a.attributed {
self.attributed_windows += 1;
}
}
/// True when every metered window rested on real per-process accounting
/// of a hardware CPU reading.
fn attributed(&self) -> bool {
self.windows > 0 && self.attributed_windows == self.windows
}
/// The figure the receipts carry. When attributed, it is the apportioned
/// reading. Otherwise it is the unapportioned total over the windows, as
/// an estimate. An apportioned estimate is no better than an estimate,
/// and the apportioning share is 0 whenever the machine's tick counters
/// (which update in bursts up to ~1 s apart) missed a window, which would
/// put 0 J on the receipt for work that ran.
fn figure_uj(&self) -> u64 {
if self.attributed() {
self.attr_uj
} else {
self.soc_uj
}
}
/// The sandbox JCR-1 meter record for the figure. It is `ModelBased` for
/// an attribution, and otherwise the `Estimator` grade with no measured
/// figure.
fn meter_record(&self) -> sandbox_meter::DeviceEnergy {
sandbox_meter::DeviceEnergy {
class: sandbox_schema::DeviceClass::Cpu,
measured_uj: None,
estimated_uj: self.figure_uj(),
provenance: if self.attributed() {
sandbox_meter::Provenance::ModelBased
} else {
sandbox_meter::Provenance::Estimator
},
}
}
/// The JouleContract energy class for the figure. It is `ModelBased`,
/// anchored to the sealed meter receipt, for an attribution. Otherwise it
/// is `Unaccounted`, JouleContract's grade for energy estimated where the
/// substrate exposes no reading.
fn energy_class(&self) -> EnergyClass {
if self.attributed() {
EnergyClass::ModelBased
} else {
EnergyClass::Unaccounted
}
}
}
#[derive(Debug, thiserror::Error)]
@ -75,6 +152,10 @@ pub enum HarnessError {
Spawn(String),
#[error("contract error: {0}")]
Contract(String),
/// Every gate passed but the fix could not be committed, so nothing
/// shipped. The message says where the verified change was left.
#[error("the fix passed every gate but could not be committed: {0}")]
Ship(String),
}
/// One pipeline stage's outcome — the discharge evidence for one obligation.
@ -116,7 +197,8 @@ pub struct FormalEntry {
pub closed_by: Option<String>,
/// The counter-model witness (when refuted by a decision procedure).
pub counter_model: Option<String>,
/// Measured joules for this obligation's proof attempts.
/// Joules for this obligation's proof attempts, as the proof cascade
/// accounted them (`ModelBased`, in-process).
pub uj: u64,
}
@ -126,8 +208,8 @@ pub struct FormalOutcome {
pub proved: usize,
pub refuted: usize,
pub unproved: usize,
/// Total measured joules across all proof attempts (`ModelBased`, in-process
/// — distinct from the cargo-attributed gate energy).
/// Total joules across all proof attempts (`ModelBased`, in-process —
/// distinct from the cargo-attributed gate energy).
pub proof_uj: u64,
/// True iff at least one formalization was supplied and **every** one
/// proved. This is the gate: any refutation or unproven goal fails it, and
@ -149,14 +231,17 @@ pub struct RunReport {
pub shipped: bool,
/// The branch carrying the fix, when shipped.
pub branch: Option<String>,
/// Energy attributed to this run's process trees (`ModelBased`
/// apportionment of a real SoC measurement), microjoules — the receipt's
/// figure, and the honest per-workload cost.
/// Energy apportioned to this run's process trees, microjoules. When
/// `attributed`, it is a `ModelBased` apportionment of a real SoC reading:
/// the receipts' figure, and the honest per-workload cost. Otherwise it is
/// at best an estimate, and is context only.
pub attributed_uj: u64,
/// Machine-wide SoC-CPU energy over the metered windows, microjoules —
/// the upper bound, for context.
/// Machine-wide SoC-CPU figure over the metered windows, microjoules — the
/// upper bound. When not `attributed`, this is the receipts' figure, as an
/// estimate.
pub soc_cpu_uj: u64,
/// True when the figure rests on real per-process accounting.
/// True when every metered window rested on real per-process accounting
/// of a hardware CPU reading. False means the receipts carry an estimate.
pub attributed: bool,
pub wall: Duration,
pub meter_source: String,
@ -176,16 +261,24 @@ pub struct RunReport {
}
impl RunReport {
/// Microjoules for the whole verified run (measure → fix → gate): the
/// energy ATTRIBUTED to this run's process trees, not the machine-wide
/// total. `soc_cpu_uj` carries the machine-wide upper bound.
/// Microjoules for the whole verified run (measure → fix → gate), the
/// figure the receipts carry. When `attributed`, it is the energy
/// ATTRIBUTED to this run's process trees, not the machine-wide total.
/// Otherwise it is the machine-wide figure over the metered windows, which
/// is then an estimate and is sealed as one (`Estimator`, `Unaccounted`).
/// It is never an apportioned zero for work that ran.
pub fn total_uj(&self) -> u64 {
self.attributed_uj
if self.attributed {
self.attributed_uj
} else {
self.soc_cpu_uj
}
}
/// The headline metric nobody else publishes: microjoules per verified,
/// shipped fix. `None` when nothing shipped (energy was still spent and
/// is still on the receipt — refusals aren't free, and saying so is the
/// shipped fix. It is attributed when `attributed` and an estimate
/// otherwise. `None` when nothing shipped (energy was still spent and is
/// still on the receipt — refusals aren't free, and saying so is the
/// point).
pub fn uj_per_verified_fix(&self) -> Option<u64> {
if !self.shipped {
@ -282,6 +375,17 @@ pub struct Harness {
/// Per-process energy attribution, accumulated across cargo subprocesses.
/// `RefCell` because `run()` takes `&self` and feeds it call by call.
meter: RefCell<MeterAcc>,
/// Test builds only: the target directory every `cargo` run is handed
/// (`--target-dir`), the workspace's own `target/`, so a test's scratch
/// crate builds where it does with no `CARGO_TARGET_DIR`, whatever the
/// test process inherited. Every scratch crate is a package named
/// `scratch` at 0.1.0, and cargo names its artifacts without the path, so
/// under one exported `CARGO_TARGET_DIR` the concurrent tests' crates
/// built over each other's artifacts and fingerprints. Not in production
/// builds: `joule-fix` hands cargo no such flag, and the user's
/// environment, `CARGO_TARGET_DIR` included, decides where it builds.
#[cfg(test)]
cargo_target_dir: PathBuf,
}
impl Harness {
@ -298,6 +402,8 @@ impl Harness {
if !inside {
return Err(HarnessError::NotGit(workspace));
}
#[cfg(test)]
let cargo_target_dir = workspace.join("target");
Ok(Harness {
workspace,
max_diff_lines: 400,
@ -314,10 +420,10 @@ impl Harness {
}),
meter: RefCell::new(MeterAcc {
meter: PowerMeter::detect(),
soc_uj: 0,
attr_uj: 0,
attributed: false,
tally: EnergyTally::default(),
}),
#[cfg(test)]
cargo_target_dir,
})
}
@ -331,13 +437,12 @@ impl Harness {
// The real compute is `cargo`; meter and attribute it. Other programs
// (git, rustfmt) are negligible harness overhead and run unmetered.
if program == "cargo" {
let args = self.cargo_args(args);
let mut acc = self.meter.borrow_mut();
let MeterAcc { meter, soc_uj, attr_uj, attributed } = &mut *acc;
let (ok, text, a) = run_attributed(meter, program, args, &self.workspace)
let MeterAcc { meter, tally } = &mut *acc;
let (ok, text, a) = run_attributed(meter, program, &args, &self.workspace)
.map_err(|e| HarnessError::Spawn(format!("{program}: {e}")))?;
*soc_uj = soc_uj.saturating_add(a.soc_cpu_uj);
*attr_uj = attr_uj.saturating_add(a.attributed_uj);
*attributed |= a.attributed;
tally.add(&a);
return Ok((ok, text));
}
let out = Command::new(program)
@ -350,6 +455,19 @@ impl Harness {
Ok((out.status.success(), text))
}
/// The arguments a `cargo` run is given: `args`, as passed. In test
/// builds only, `--target-dir <workspace>/target` follows the subcommand
/// (every cargo run here has one: `clippy`, `test`, `build`), and the
/// flag outranks an inherited `CARGO_TARGET_DIR`.
fn cargo_args<'a>(&'a self, args: &[&'a str]) -> Vec<&'a str> {
#[cfg(test)]
if let Some((sub, rest)) = args.split_first() {
let dir = self.cargo_target_dir.to_str().expect("a test workspace path is UTF-8");
return [*sub, "--target-dir", dir].into_iter().chain(rest.iter().copied()).collect();
}
args.to_vec()
}
fn git_head(&self) -> Result<String, HarnessError> {
let (ok, out) = self.run("git", &["rev-parse", "HEAD"])?;
if !ok {
@ -422,12 +540,12 @@ impl Harness {
let prior_rev = self.git_head()?;
let prior = ContentId::of_bytes(prior_rev.as_bytes());
// Reset the per-run attribution accumulator; cargo calls feed it.
{
let mut acc = self.meter.borrow_mut();
acc.soc_uj = 0;
acc.attr_uj = 0;
}
// Reset the per-run tally, all of it; this run's cargo calls feed it.
// An earlier run's windows are not this run's energy: without the
// reset, a second run on this Harness would seal the first run's
// cargo figure into its own receipts (on every host, estimated or
// not), and an attributed earlier window could grade this one.
self.meter.borrow_mut().tally = EnergyTally::default();
let wall_start = Instant::now();
// ---- Stage 0: baseline (the `pre` obligation's evidence).
@ -582,11 +700,18 @@ impl Harness {
// The rung above build+test, and the one the market has not reached.
let formal = formal_spec.as_ref().map(|s| self.discharge_formal(s));
if let Some(f) = &formal {
// The detail rides into a failed gate's receipt entry, so it names
// the solver only when there was one to decide the goals.
let solver = if proof_solvers::z3_available() {
"real z3"
} else {
"no z3 on PATH: SMT goals left undecided"
};
gates.push(GateOutcome {
gate: "formal-proof".into(),
passed: f.all_proved,
detail: format!(
"{} proved, {} refuted, {} unproved ({:.3} J, real z3)",
"{} proved, {} refuted, {} unproved ({:.3} J, {solver})",
f.proved,
f.refuted,
f.unproved,
@ -601,14 +726,37 @@ impl Harness {
let branch = if shipped {
let name = format!("joule-fix/{}", &prior_rev[..12.min(prior_rev.len())]);
let _ = self.run("git", &["checkout", "-b", &name]);
// Shipped means committed. Every step is checked: an unchecked
// `checkout -b` that fails (the branch exists) would let the
// commit land on whatever branch is current, and an unchecked
// `commit` that fails (no git identity, a rejecting hook) would
// report a fix as shipped while it sits uncommitted in the tree.
let (ok, out) = self.run("git", &["checkout", "-b", &name])?;
if !ok {
return Err(HarnessError::Ship(format!(
"git checkout -b {name}: {} (the verified change is left uncommitted in the work tree)",
out.trim()
)));
}
// -u: stage only tracked modifications — the harness owns the
// tracked diff; untracked artifacts (target/) are not its fix.
let _ = self.run("git", &["add", "-u", "--", "."]);
let _ = self.run(
let (ok, out) = self.run("git", &["add", "-u", "--", "."])?;
if !ok {
return Err(HarnessError::Ship(format!(
"git add on {name}: {} (the verified change is left uncommitted on {name})",
out.trim()
)));
}
let (ok, out) = self.run(
"git",
&["commit", "-m", &format!("joule-fix: {} lint findings remediated, all gates green", findings_before.saturating_sub(findings_after))],
);
)?;
if !ok {
return Err(HarnessError::Ship(format!(
"git commit on {name}: {} (the verified change is left staged on {name})",
out.trim()
)));
}
// A gate-verified stochastic fix is a lesson worth keeping:
// compile it into a deterministic skill so the next recurrence
// never pays the model's joules again.
@ -632,33 +780,36 @@ impl Harness {
None
};
// ---- Energy: read the per-process attribution accumulated across the
// run's cargo subprocesses. The receipt carries the ATTRIBUTED figure
// (ModelBased — an apportionment of a real SoC measurement), not the
// machine-wide total; the SoC total rides along as honest context.
let (soc_cpu_uj, attributed_uj, attributed) = {
// ---- Energy: the per-window accounting accumulated across the run's
// cargo subprocesses. When every window was attributed, the receipts
// carry the ATTRIBUTED figure (ModelBased — an apportionment of a real
// SoC reading), not the machine-wide total. Otherwise they carry an
// estimate and grade it as one — never a figure signed as a reading.
let (tally, meter_sources) = {
let acc = self.meter.borrow();
(acc.soc_uj, acc.attr_uj, acc.attributed && acc.meter.is_hardware())
(acc.tally, acc.meter.sources())
};
let (soc_cpu_uj, attributed_uj, attributed) = (tally.soc_uj, tally.attr_uj, tally.attributed());
let wall = wall_start.elapsed();
let meter_source = if attributed {
"attributed(ModelBased over IOReport HwShunt)".to_string()
} else if meter_sources.is_empty() {
"estimated(Unaccounted): wall-clock × TDP, no live energy counter on this host".to_string()
} else {
"estimator".to_string()
format!(
"estimated(Unaccounted): not every window attributed; machine-wide figure over the \
metered windows ({})",
meter_sources.join("+")
)
};
// The sandbox meter receipt records the attributed figure as a
// ModelBased CPU reading; the JouleContract receipt anchors to it.
let device = sandbox_meter::DeviceEnergy {
class: sandbox_schema::DeviceClass::Cpu,
measured_uj: None,
estimated_uj: attributed_uj,
provenance: sandbox_meter::Provenance::ModelBased,
};
// The sandbox meter receipt seals the receipts' figure with its grade:
// ModelBased for an attribution, the Estimator grade for an estimate.
// The JouleContract receipt anchors to it only when attributed.
let meter_receipt = sandbox_meter::Receipt::build(
sandbox_schema::ContentId::of_bytes(prior_rev.as_bytes()),
sandbox_schema::Tier::S0,
vec![device],
vec![tally.meter_record()],
1,
[3u8; 8],
)
@ -670,8 +821,7 @@ impl Harness {
let receipt = self.receipt(
&contract,
&op,
attributed_uj,
attributed,
&tally,
&meter_envelope,
&gates,
findings_before,
@ -1072,8 +1222,7 @@ impl Harness {
&self,
contract: &Contract,
op: &CodingOp,
attributed_uj: u64,
attributed: bool,
energy: &EnergyTally,
meter_envelope: &[u8],
gates: &[GateOutcome],
before: usize,
@ -1136,18 +1285,15 @@ impl Harness {
})
.collect();
// The figure is an apportionment of a real SoC measurement: ModelBased,
// never Metered (a process is not a shunt). A ModelBased receipt is
// anchored to the sealed sandbox meter receipt that carries the
// measurement the apportionment rests on; without hardware it is
// honestly Unaccounted.
let provenance = if attributed {
EnergyClass::ModelBased
} else {
EnergyClass::Unaccounted
};
// An attributed figure is an apportionment of a real SoC reading:
// ModelBased, never Metered (a process is not a shunt), and anchored
// to the sealed sandbox meter receipt that carries it. Unless every
// window was attributed, the figure is an estimate, and Unaccounted
// (energy estimated where the substrate exposes no reading) is the
// honest class.
let provenance = energy.energy_class();
let energy_anchor =
attributed.then(|| ContentAddress::of_bytes(meter_envelope));
energy.attributed().then(|| ContentAddress::of_bytes(meter_envelope));
// No MeterBinding: that attests a Metered (direct-shunt) figure, which
// this is not. The energy_anchor is the ModelBased evidence path.
let meter: Option<MeterBinding> = None;
@ -1169,7 +1315,7 @@ impl Harness {
},
discharge,
execution: vec![tier_addr],
joules_spent: attributed_uj as f64 / 1e6,
joules_spent: energy.figure_uj() as f64 / 1e6,
meter,
energy_anchor,
joules_allowed: contract.ceiling.enpi_joules,
@ -1222,24 +1368,77 @@ fn answers() { assert_eq!(answer(), 42); }
"#,
)
.unwrap();
sh(&dir, "git init -q && git add -A && git -c user.email=t@t -c user.name=t commit -qm init");
// A repo-local identity: the harness's own ship commit must work in
// any environment, including a CI container with no global git
// identity (where it used to fail silently and leave the tree dirty).
sh(&dir, "git init -q && git config user.email t@t && git config user.name t && git add -A && git commit -qm init");
dir
}
fn git_out(dir: &std::path::Path, args: &[&str]) -> String {
let o = Command::new("git").args(args).current_dir(dir).output().unwrap();
assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
#[test]
fn verified_fix_end_to_end_ships_with_receipt() {
let dir = scratch_crate("ship");
let before = git_out(&dir, &["rev-parse", "HEAD"]);
let mut h = Harness::new(&dir).unwrap();
h.skill_store = dir.join("skills.json");
// A test's cargo runs build in the scratch crate's own `target/`:
// `--target-dir` follows the subcommand and outranks an inherited
// `CARGO_TARGET_DIR`, under which every scratch crate (each named
// `scratch`) would share one target directory with the other tests'.
let own_target = dir.join("target");
assert_eq!(
h.cargo_args(&["test", "--workspace"]),
["test", "--target-dir", own_target.to_str().unwrap(), "--workspace"]
);
let report = h.run_once().unwrap();
assert!(own_target.join("debug").is_dir(), "the scratch crate did not build in its own target directory");
assert!(report.findings_before > 0, "scratch crate has findings");
assert!(report.shipped, "gates: {:?}", report.gates);
assert!(report.gates.iter().all(|g| g.passed));
assert_eq!(report.findings_after, Some(0));
assert!(report.branch.is_some());
assert!(report.total_uj() > 0);
assert!(report.uj_per_verified_fix().is_some());
// Shipped means committed: the fix is a commit on the reported branch,
// on top of where the run started, and nothing is left uncommitted.
let branch = report.branch.clone().expect("a shipped fix names its branch");
assert_eq!(git_out(&dir, &["rev-parse", "--abbrev-ref", "HEAD"]), branch);
assert_eq!(git_out(&dir, &["rev-parse", "HEAD~1"]), before, "one commit on top of the start");
assert!(git_out(&dir, &["status", "--porcelain", "--untracked-files=no"]).is_empty(), "shipped, yet the tree is dirty");
// Work ran, so the figure is never zero — attributed or estimated.
assert!(report.total_uj() > 0, "{} µJ ({})", report.total_uj(), report.meter_source);
assert!(report.uj_per_verified_fix().is_some_and(|uj| uj > 0));
// Both receipts grade the figure by how it was obtained.
eprintln!(
"joule-fix run: attributed={} total_uj={} soc_cpu_uj={} attributed_uj={} ({})",
report.attributed, report.total_uj(), report.soc_cpu_uj, report.attributed_uj, report.meter_source
);
assert_eq!(report.receipt.joules_spent, report.total_uj() as f64 / 1e6);
let sealed = sandbox_meter::jcr1::open(&report.meter_envelope).expect("meter envelope").payload;
let seals = |grade: &str| sealed.windows(grade.len()).any(|w| w == grade.as_bytes());
if report.attributed {
assert_eq!(report.receipt.provenance, EnergyClass::ModelBased);
assert!(seals("ModelBased") && !seals("Estimator"), "meter envelope grade");
} else {
assert_eq!(report.receipt.provenance, EnergyClass::Unaccounted);
assert!(report.receipt.energy_anchor.is_none());
assert!(
seals("Estimator") && !seals("ModelBased"),
"an estimate sealed in the meter envelope as ModelBased"
);
}
// Where the meter cannot read CPU energy at all, nothing is attributed.
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
if sandbox_meter::ioreport_status() != sandbox_meter::IoReportStatus::Live {
assert!(!report.attributed, "attributed with no live CPU rail ({:?})", sandbox_meter::ioreport_status());
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
assert!(!report.attributed, "the unattributed fallback never attributes");
// The receipt validates locally and tells the truth about itself:
// self-verified ⇒ independence false ⇒ JR-1 ceiling, stated.
@ -1250,6 +1449,145 @@ fn answers() { assert_eq!(answer(), 42); }
let _ = std::fs::remove_dir_all(&dir);
}
/// A fix that passes every gate but cannot be committed did not ship.
/// The harness used to discard `git commit`'s result and report
/// `shipped: true` with a branch name while the fix sat uncommitted.
/// That is what a CI container with no git identity produced.
#[test]
fn a_fix_whose_commit_is_rejected_is_never_reported_shipped() {
let dir = scratch_crate("commit-rejected");
sh(&dir, "printf '#!/bin/sh\\nexit 1\\n' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit");
let before = git_out(&dir, &["rev-parse", "HEAD"]);
let mut h = Harness::new(&dir).unwrap();
h.skill_store = dir.join("skills.json");
match h.run_once() {
Err(HarnessError::Ship(msg)) => assert!(msg.contains("git commit"), "{msg}"),
other => panic!("a rejected commit must not ship: {:?}", other.map(|r| (r.shipped, r.branch))),
}
assert_eq!(git_out(&dir, &["rev-parse", "HEAD"]), before, "nothing was committed");
let _ = std::fs::remove_dir_all(&dir);
}
/// When the fix's branch already exists, the harness must not commit the
/// fix onto whatever branch happens to be checked out.
#[test]
fn a_fix_never_lands_on_the_current_branch_when_its_own_branch_exists() {
let dir = scratch_crate("branch-exists");
let before = git_out(&dir, &["rev-parse", "HEAD"]);
let current = git_out(&dir, &["rev-parse", "--abbrev-ref", "HEAD"]);
git_out(&dir, &["branch", &format!("joule-fix/{}", &before[..12])]);
let mut h = Harness::new(&dir).unwrap();
h.skill_store = dir.join("skills.json");
match h.run_once() {
Err(HarnessError::Ship(msg)) => assert!(msg.contains("checkout -b"), "{msg}"),
other => panic!("an existing branch must stop the ship: {:?}", other.map(|r| (r.shipped, r.branch))),
}
assert_eq!(git_out(&dir, &["rev-parse", "--abbrev-ref", "HEAD"]), current);
assert_eq!(git_out(&dir, &["rev-parse", "HEAD"]), before, "the fix was committed onto {current}");
let _ = std::fs::remove_dir_all(&dir);
}
/// One metered cargo window, as `run_attributed` reports it.
fn window(soc_cpu_uj: u64, attributed_uj: u64, attributed: bool) -> Attribution {
Attribution {
soc_cpu_uj,
attributed_uj,
proc_cpu_secs: 1.0,
busy_cpu_secs: if attributed_uj > 0 { 4.0 } else { 0.0 },
share: if attributed_uj > 0 { 0.25 } else { 0.0 },
wall: Duration::from_secs(1),
attributed,
}
}
/// A run whose windows were estimated is sealed as an estimate, and never
/// as an apportioned zero. This is every macOS 27.0.1 host checked, where
/// the meter has no live CPU counter. main sealed 0 J `ModelBased` there,
/// and the core fix alone still sealed the apportioned estimate
/// `ModelBased` in the meter envelope, and 0 J whenever the tick counters
/// missed every window.
#[test]
fn an_unattributed_run_is_sealed_as_an_estimate_never_an_apportioned_zero() {
let mut t = EnergyTally::default();
// The machine tick counters missed this window: share 0.
t.add(&window(20_000_000, 0, false));
t.add(&window(40_000_000, 10_000_000, false));
assert!(!t.attributed());
assert_eq!(t.figure_uj(), 60_000_000, "the unapportioned estimate");
assert_eq!(t.energy_class(), EnergyClass::Unaccounted);
let r = t.meter_record();
assert_eq!(r.provenance, sandbox_meter::Provenance::Estimator);
assert_eq!((r.measured_uj, r.estimated_uj), (None, 60_000_000));
r.check_provenance().unwrap();
// Even when every window's share was lost, the run is not free.
let mut missed = EnergyTally::default();
missed.add(&window(20_000_000, 0, false));
assert_eq!(missed.figure_uj(), 20_000_000);
}
/// Each run on a Harness seals its own cargo windows only. The tally a
/// run leaves behind (here with a stale window added on top, so the
/// assertion does not depend on run 1's figure) never reaches the next
/// run's figure, grade or receipts.
#[test]
fn a_second_run_seals_only_its_own_cargo_windows() {
const STALE_UJ: u64 = 1_000_000_000_000_000; // 1 GJ: no scratch run comes near it
let dir = scratch_crate("rerun");
let mut h = Harness::new(&dir).unwrap();
h.skill_store = dir.join("skills.json");
let first = h.run_once().unwrap();
assert!(first.shipped, "gates: {:?}", first.gates);
{
// Run 1's windows are still in the tally when run 1 returns.
let acc = h.meter.borrow();
assert!(acc.tally.windows > 0, "run 1 metered its cargo calls");
assert_eq!(acc.tally.soc_uj, first.soc_cpu_uj);
}
h.meter.borrow_mut().tally.add(&window(STALE_UJ, STALE_UJ, true));
let second = h.run_once().unwrap();
eprintln!(
"joule-fix rerun: first soc_cpu_uj={} second soc_cpu_uj={} attributed={} ({})",
first.soc_cpu_uj, second.soc_cpu_uj, second.attributed, second.meter_source
);
assert!(second.soc_cpu_uj < STALE_UJ, "run 2 sealed an earlier run's windows: {} µJ", second.soc_cpu_uj);
assert!(second.attributed_uj < STALE_UJ, "{}", second.attributed_uj);
assert!(second.total_uj() > 0, "run 2's own cargo calls ran");
assert!(second.receipt.joules_spent < STALE_UJ as f64 / 1e6);
assert_eq!(second.receipt.joules_spent, second.total_uj() as f64 / 1e6);
let acc = h.meter.borrow();
assert_eq!(acc.tally.soc_uj, second.soc_cpu_uj, "the tally holds run 2's windows alone");
assert_eq!(acc.tally.attributed(), second.attributed);
drop(acc);
let _ = std::fs::remove_dir_all(&dir);
}
/// `ModelBased` only when every window was attributed: one estimated
/// window makes the sum an estimate. main labelled the run attributed if
/// any window was, and never reset that between runs.
#[test]
fn a_run_is_attributed_only_when_every_window_was() {
let mut all = EnergyTally::default();
all.add(&window(10_000_000, 2_500_000, true));
all.add(&window(30_000_000, 7_500_000, true));
assert!(all.attributed());
assert_eq!(all.figure_uj(), 10_000_000, "the apportioned reading");
assert_eq!(all.energy_class(), EnergyClass::ModelBased);
assert_eq!(all.meter_record().provenance, sandbox_meter::Provenance::ModelBased);
all.meter_record().check_provenance().unwrap();
let mut mixed = all;
mixed.add(&window(20_000_000, 0, false));
assert!(!mixed.attributed());
assert_eq!(mixed.figure_uj(), 60_000_000, "the unapportioned estimate");
assert_eq!(mixed.energy_class(), EnergyClass::Unaccounted);
assert_eq!(mixed.meter_record().provenance, sandbox_meter::Provenance::Estimator);
// Nothing metered is not an attribution either.
assert!(!EnergyTally::default().attributed());
}
#[test]
fn l3_fix_passes_the_same_gates() {
// A finding clippy --fix can NOT auto-fix (approx_constant): L1
@ -1469,52 +1807,103 @@ fn pins_source_text() {
std::fs::write(dir.join(".joule-fix/formal.json"), spec.to_string()).unwrap();
}
/// Whether the formal-tier tests run against a real `z3`.
///
/// They follow this repo's z3 convention (proof-solvers, joulecontract-
/// proof, and the standards-build workflow's "Install z3" step): a suite
/// passes with or without a solver, and asserts something either way.
/// With `z3` the SMT rung decides the obligation and its verdict is
/// asserted. Without it the obligation is Unproved, never a fabricated
/// proof, and the formal gate fails closed, so the fix is reverted — that
/// is asserted instead. Neither branch returns without asserting, and each
/// logs which one ran.
///
/// Where a solver is supposed to be installed (a CI job that installs z3,
/// a dev box), set `JOULE_FIX_EXPECT_Z3=1`: a missing `z3` then FAILS
/// these tests rather than quietly taking the solverless branch.
fn z3_for_formal_test(test: &str) -> bool {
let present = proof_solvers::z3_available();
let expected = std::env::var("JOULE_FIX_EXPECT_Z3").as_deref() == Ok("1");
assert!(present || !expected, "{test}: JOULE_FIX_EXPECT_Z3=1, but there is no working `z3` on PATH");
eprintln!(
"{test}: z3 {}",
if present { "present, asserting its verdict" } else { "absent, asserting the formal gate fails closed" }
);
present
}
/// Without a solver nothing decides the obligation: it is Unproved (not
/// proved, and not refuted either: there is no counter-model without a
/// solver), the formal gate fails and says why, and the fix is reverted
/// although every other gate passed.
fn assert_the_formal_gate_failed_closed(dir: &std::path::Path, report: &RunReport) {
let formal = report.formal.as_ref().expect("formal gate ran");
assert_eq!((formal.proved, formal.refuted, formal.unproved), (0, 0, 1), "{:?}", formal.entries);
assert!(!formal.all_proved);
let g = report.gates.iter().find(|g| g.gate == "formal-proof").unwrap();
assert!(!g.passed);
assert!(g.detail.contains("no z3 on PATH") && !g.detail.contains("real z3"), "{}", g.detail);
assert!(
report.gates.iter().filter(|g| g.gate != "formal-proof").all(|g| g.passed),
"only the undecided proof blocks this fix: {:?}",
report.gates
);
assert!(!report.shipped, "an undecided formal obligation must block shipping");
let src = std::fs::read_to_string(dir.join("src/main.rs")).unwrap();
assert!(src.contains("return 42;"), "the operational fix was reverted");
assert!(dir.join(".joule-fix/formal-discharge.json").exists(), "the discharge evidence is written either way");
}
#[test]
fn a_proven_formal_obligation_lets_the_fix_ship() {
if !proof_solvers::z3_available() {
return; // no solver: the formal gate can't run — covered below
}
let z3 = z3_for_formal_test("a_proven_formal_obligation_lets_the_fix_ship");
let dir = scratch_crate("formal-ok");
write_formal_spec(&dir, "(- debits credits)"); // the correct postcondition
let mut h = Harness::new(&dir).unwrap();
h.skill_store = dir.join("skills.json");
let report = h.run_once().unwrap();
let formal = report.formal.as_ref().expect("formal gate ran");
assert!(formal.all_proved, "entries: {:?}", formal.entries);
assert_eq!((formal.proved, formal.refuted, formal.unproved), (1, 0, 0));
assert!(formal.proof_uj > 0, "real z3 burned measured joules");
let g = report.gates.iter().find(|g| g.gate == "formal-proof").unwrap();
assert!(g.passed);
assert!(report.shipped, "all gates incl. formal green: {:?}", report.gates);
// the discharge evidence was written for an auditor
assert!(dir.join(".joule-fix/formal-discharge.json").exists());
if z3 {
let formal = report.formal.as_ref().expect("formal gate ran");
assert!(formal.all_proved, "entries: {:?}", formal.entries);
assert_eq!((formal.proved, formal.refuted, formal.unproved), (1, 0, 0));
assert!(formal.proof_uj > 0, "the proof attempt was accounted joules");
let g = report.gates.iter().find(|g| g.gate == "formal-proof").unwrap();
assert!(g.passed);
assert!(report.shipped, "all gates incl. formal green: {:?}", report.gates);
// the discharge evidence was written for an auditor
assert!(dir.join(".joule-fix/formal-discharge.json").exists());
} else {
assert_the_formal_gate_failed_closed(&dir, &report);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_refuted_formal_obligation_reverts_the_fix() {
if !proof_solvers::z3_available() {
return;
}
let z3 = z3_for_formal_test("a_refuted_formal_obligation_reverts_the_fix");
let dir = scratch_crate("formal-refute");
write_formal_spec(&dir, "(+ debits credits)"); // the BUG: z3 finds a counter-model
let mut h = Harness::new(&dir).unwrap();
h.skill_store = dir.join("skills.json");
let report = h.run_once().unwrap();
let formal = report.formal.as_ref().expect("formal gate ran");
assert_eq!(formal.refuted, 1, "z3 refuted the false postcondition");
assert!(!formal.all_proved);
// the witness rode through from the cascade
assert!(formal.entries.iter().any(|e| e.verdict == "refuted"));
let g = report.gates.iter().find(|g| g.gate == "formal-proof").unwrap();
assert!(!g.passed);
// a refuted formal obligation reverts the fix exactly like a failed test
assert!(!report.shipped, "a refuted proof must block shipping");
let src = std::fs::read_to_string(dir.join("src/main.rs")).unwrap();
assert!(src.contains("return 42;"), "the operational fix was reverted");
if z3 {
let formal = report.formal.as_ref().expect("formal gate ran");
assert_eq!(formal.refuted, 1, "z3 refuted the false postcondition");
assert!(!formal.all_proved);
// the witness rode through from the cascade
assert!(formal.entries.iter().any(|e| e.verdict == "refuted"));
let g = report.gates.iter().find(|g| g.gate == "formal-proof").unwrap();
assert!(!g.passed);
// a refuted formal obligation reverts the fix exactly like a failed test
assert!(!report.shipped, "a refuted proof must block shipping");
let src = std::fs::read_to_string(dir.join("src/main.rs")).unwrap();
assert!(src.contains("return 42;"), "the operational fix was reverted");
} else {
assert_the_formal_gate_failed_closed(&dir, &report);
}
let _ = std::fs::remove_dir_all(&dir);
}

View file

@ -28,7 +28,8 @@ renderer is product surface, owned by the consumer.
an agent (the protocol draws no distinction; both are traced).
`run_interaction` authorizes it against a capability *before it runs*
(a denied action's handler never executes), meters it in joules,
charges the measured cost, and an `InteractionLog` seals it as the next
charges its cost (a reading or an estimate, and which), and an
`InteractionLog` seals it as the next
link of a **hash-chained** receipt. The actor is bound into the signed
core, so an interaction cannot be re-attributed without breaking the
chain. A joule-ui session becomes a tamper-evident ledger of *who fired

View file

@ -62,17 +62,44 @@ provides.
## §5 Energy and provenance
Every interaction carries a two-part energy figure in integer **microjoules**:
`measured_uj` (present only from a hardware interface) and `estimated_uj`
(always present), tagged with a **Provenance** — `HwShunt` (a hardware energy
reading), `ModelBased` (a calibrated apportionment), or `Estimator` (a coarse
constant). This is the family's shared energy model; a figure is reported with
the honesty of how it was obtained, never promoted.
`measured_uj` (present only for a hardware reading) and `estimated_uj` (always
present), tagged with a **Provenance** — `HwShunt` (a hardware energy reading),
`ModelBased` (a calibrated apportionment), or `Estimator` (a coarse estimate).
This is the family's shared energy model; a figure is reported with the honesty
of how it was obtained, never promoted.
Measurement itself is a **recognized edge**, not part of this standard: a
`Meter` is any source of a cumulative microjoule counter, and the sibling
**Sandbox** standard's `PowerMeter` (real RAPL/IOReport, and CPU-time
apportionment) is the reference one. joule-ui defines what an interaction
records; the host wires the meter it trusts.
`Meter` is any source of a cumulative microjoule counter, and it declares the
provenance of its readings. A meter's figures are `Estimator` unless the meter
declares otherwise: a meter opts in to `ModelBased` for a calibrated model, and
to `HwShunt` only for readings of a live hardware counter (below). A meter
MUST NOT declare `HwShunt` for figures that are not such readings — a
deterministic stub, a model, a replayed counter. (The conformance pack's
fixture meter, §8, is the one exception: it declares `HwShunt` only so the
vectors exercise the reading path of the signed core, and like the pack's
published steward key it attests nothing.) The sibling
**Sandbox** standard's `PowerMeter` is the reference meter: it reads vendor
energy counters (RAPL, NVML, Apple IOReport) while they are live, apportions a
reading to a process tree by CPU time (`ModelBased`), and falls back to a
wall-clock estimate, labelled `Estimator`, where no counter is live. joule-ui
defines what an interaction records; the host wires the meter it trusts.
**Vendor energy counters.** A reading from a vendor energy counter (RAPL,
NVML, Apple IOReport, ...) 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 Apple IOReport: the CPU energy rail itself advancing 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
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`. joule-ui's energy record has no coverage field, so a figure from a
counter that does not read the domain the work ran on — a GPU counter alone,
for a handler that ran on the CPU — MUST NOT be graded `HwShunt` for the
interaction: the reference adapters seal such a span as the meter's
`Estimator` figure for its wall time.
## §6 The interaction lifecycle
@ -84,11 +111,22 @@ records; the host wires the meter it trusts.
two-phase `(authorize, charge)` gate, and the sibling **JCP** standard's
runtime — gating on a signed, joule-budgeted grant — is the reference one.
2. **Meter the handler.** The handler runs once; the meter is sampled before
and after; the difference is the interaction's energy. With no hardware
meter the figure is honestly `Estimator`.
3. **Charge the measured cost.** The measured joules are debited from the
capability's remaining budget — so the budget that stops a runaway UI is
energy *actually spent*, settled after the fact, not a token estimate.
and after; the difference is the interaction's energy, graded with the
meter's declared provenance (§5). When the meter gives no reading — either
sample is absent — the handler's wall-clock duration is charged at the
meter's declared estimator power (20 W unless the meter declares its own),
rounded up to whole microjoules, and the figure is `Estimator`. A handler
that ran MUST NOT be recorded at 0 µJ for want of a reading: work that ran
is not free, and a figure of zero would let a budget never bind on a host
without a counter. A denied action's handler never ran, and its record
carries 0 µJ `Estimator`.
3. **Charge the cost, with its provenance.** The interaction's figure — a
reading or an estimate — is debited from the capability's remaining budget,
and the authorizer is handed the figure's provenance with it. So the budget
binds on every host, settled after the fact against the energy the work
cost as nearly as the host can know it; and an authorizer that keeps its own
record of the debit MUST record it with that provenance, never an estimate
as a reading.
The result is an **Interaction**: the surface's content address (what the user
was looking at), the action id, the content addresses of the inputs and the
@ -135,7 +173,8 @@ edge, not required.)
the exhaustive error list (empty for a valid tree).
- **C2 — interaction metering.** Given a surface, action, actor, inputs, a stub
meter, and a handler, reproduce the Interaction: verdict, energy, content
addresses, and the sealed actor.
addresses, and the sealed actor. The stub meter declares `HwShunt` as a
fixture grade, so the recorded energy carries `measured_uj` (§5).
- **C3 — authorization.** A denied action produces a deny Interaction whose
outputs are the genesis sentinel and whose handler did not run; the actor is
still named on the denial.
@ -145,11 +184,13 @@ edge, not required.)
## §9 Recognized edges
joule-ui is self-contained at its core and composes outward at named edges:
the **Sandbox** standard supplies the hardware `Meter`; **JCP** supplies the
budgeted-grant `Authorizer`; **JouleContract** / in-toto+DSSE supply an optional
cross-family receipt envelope; and any transport (MCP, REST, SSE, gRPC, Web
MoQ) carries the validated surface and the interaction stream. None are required
to use the protocol; each is the reference answer where a host needs that edge.
the **Sandbox** standard supplies the reference `Meter` (a hardware reading
while a vendor counter is live, its labelled estimate otherwise); **JCP**
supplies the budgeted-grant `Authorizer`; **JouleContract** / in-toto+DSSE
supply an optional cross-family receipt envelope; and any transport (MCP, REST,
SSE, gRPC, Web MoQ) carries the validated surface and the interaction stream.
None are required to use the protocol; each is the reference answer where a
host needs that edge.
## §10 Non-goals

View file

@ -27,7 +27,11 @@ them. There is no renderer vector (the renderer is the host's), no transport
vector (the consumer's — MCP/SSE/Web MoQ), and no widget-library vector (product
surface). The metering and capability mechanisms are **recognized edges** (SPEC
§9): C2/C3/C4 use a deterministic stub meter and a stub budget so the vectors are
reproducible, while a deployment wires the Sandbox `PowerMeter` (real hardware)
and a JCP grant. What conformance guarantees is the load-bearing claim — that an
reproducible, while a deployment wires the Sandbox `PowerMeter` (a hardware
reading while a vendor counter is live, its labelled estimate otherwise) and a
JCP grant. The stub meter declares `HwShunt` explicitly, so C2 and C4 exercise
the reading path of the signed core (`measured_uj` present); like the steward
key, that grade is a fixture and attests nothing. Outside the pack, a meter that
declares no grade is `Estimator` (SPEC §5). What conformance guarantees is the load-bearing claim — that an
interaction is validated, authorized-before-running, metered, and sealed into a
tamper-evident chain, identically across implementations.

View file

@ -341,6 +341,7 @@ dependencies = [
"jcp-schema",
"joule-ui-core",
"sandbox-meter",
"sandbox-schema",
"serde",
"serde_json",
"thiserror",
@ -371,6 +372,7 @@ dependencies = [
"joule-ui-interaction",
"joule-ui-render",
"sandbox-meter",
"sandbox-schema",
"serde",
"serde_json",
]

View file

@ -19,7 +19,8 @@ use joule_ui_core::{
WidgetSchema, validate,
};
use joule_ui_interaction::{
Authorizer, ContentId, Interaction, InteractionLog, Meter, SealedInteraction, run_interaction,
Authorizer, ContentId, Energy, Interaction, InteractionLog, Meter, Provenance, SealedInteraction,
run_interaction,
};
use std::cell::Cell;
@ -30,6 +31,13 @@ pub const STEWARD_SEED: [u8; 32] = [0x17; 32];
/// A deterministic meter: each sample advances by `step` µJ, so a before/after
/// difference is fixed.
///
/// It declares `HwShunt` explicitly. The published C2 and C4 vectors were
/// sealed with this fixture standing in for a hardware counter, so they carry
/// `measured_uj` and exercise the reading path of the signed core (SPEC §7.1);
/// keeping that grade keeps every vector byte-identical. It is a fixture grade,
/// like the published steward key: it attests nothing, and outside the pack a
/// stub meter that declares nothing is `Estimator` (SPEC §5).
pub struct StepMeter {
counter: Cell<u64>,
step: u64,
@ -45,6 +53,9 @@ impl Meter for StepMeter {
self.counter.set(v + self.step);
Some(v)
}
fn provenance(&self) -> Provenance {
Provenance::HwShunt
}
}
/// A joule budget — the JCP edge in miniature, deterministic.
@ -59,8 +70,8 @@ impl Authorizer for Budget {
Err("budget-exceeded".into())
}
}
fn charge(&mut self, _action: &str, measured_uj: u64) -> u64 {
self.remaining = self.remaining.saturating_sub(measured_uj);
fn charge(&mut self, _action: &str, cost: &Energy) -> u64 {
self.remaining = self.remaining.saturating_sub(cost.uj());
self.remaining
}
fn grant_id(&self) -> Option<String> {
@ -194,7 +205,7 @@ mod tests {
let it = c2_interaction();
assert!(it.verdict.is_allow());
assert_eq!(it.energy.uj(), 4200);
assert_eq!(it.energy.provenance, joule_ui_interaction::Provenance::HwShunt);
assert_eq!(it.energy.provenance, Provenance::HwShunt);
assert_ne!(it.outputs, ContentId::genesis());
}

View file

@ -17,10 +17,14 @@ ed25519-dalek = { workspace = true }
[dev-dependencies]
joule-ui-core = { path = "../joule-ui-core" }
# The Sandbox standard's real hardware meter — the reference Meter edge
# (SPEC §5). A dev-dependency keeps the core decoupled while proving the edge
# composes against real RAPL/IOReport.
# The Sandbox standard's meter — the reference Meter edge (SPEC §5): a hardware
# reading where the host exposes a live energy counter (RAPL, NVML, or Apple
# IOReport while its CPU rail is live), its labelled estimate otherwise. A
# dev-dependency keeps the core decoupled while proving the edge composes.
sandbox-meter = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-meter" }
# Device classes for the scripted spans `tests/span_meter.rs` replays through
# the examples' meter adapter.
sandbox-schema = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-schema" }
# The JCP standard's runtime + grant — the reference Authorizer edge (SPEC §6).
# Dev-deps: the core stays decoupled; the example proves a real signed,
# joule-budgeted grant gates a joule-ui interaction.

View file

@ -1,27 +1,37 @@
//! The full "metered, attested UI" with **both recognized edges real at once**:
//! a joule-ui interaction gated by an actual signed **JCP grant** (the
//! Authorizer edge, SPEC §6) and metered by the actual **Sandbox PowerMeter**
//! (the Meter edge, SPEC §5). Real hardware joules are debited from a real
//! capability's budget until the grant runs out, and then the **real JCP
//! runtime** — not a stub — denies the next interaction. Each one seals into
//! the chain.
//! (the Meter edge, SPEC §5). Each interaction's figure is debited from the
//! grant's budget, and once the budget is spent the **real JCP runtime** — not
//! a stub — denies the next interaction. Each one seals into the chain.
//!
//! Each interaction is metered as one `PowerMeter` span and sealed with that
//! span's own figure and provenance (the `SpanMeter` in `support/`, the
//! adapter `joule-ui-serve` uses), and the output says which it is. With a
//! live hardware counter whose CPU rail advanced, it is `HwShunt`: the
//! counters' machine-wide delta over the interaction. With none (every Apple
//! Silicon host checked on macOS 27.0.1) it is the meter's `Estimator` figure,
//! wall-clock × TDP for the interaction's span — never 0 µJ for work that ran.
//!
//! The grant is debited with the figure AND its provenance: `Authorizer::charge`
//! is handed the interaction's sealed figure with its grade, and the authorizer
//! here hands the JCP runtime an estimate as an estimate, never wrapped as a
//! measurement. JCP debits a budget by the reading if there is one, else by the
//! estimate, so estimated interactions spend the grant too.
//!
//! ```bash
//! cargo run -p joule-ui-interaction --example jcp_metered --release
//! ```
use joule_ui_interaction::{Authorizer, ContentId, InteractionLog, Meter, run_interaction, verify_chain};
use joule_ui_interaction::{
Authorizer, ContentId, Energy, InteractionLog, Provenance, run_interaction, verify_chain,
};
use sandbox_meter::PowerMeter;
use std::cell::RefCell;
use std::time::{Duration, Instant};
/// The Sandbox `PowerMeter` as a joule-ui `Meter` (SPEC §5 edge).
struct SandboxMeter(RefCell<PowerMeter>);
impl Meter for SandboxMeter {
fn sample_uj(&self) -> Option<u64> {
self.0.borrow_mut().sample_uj()
}
}
#[path = "support/span_meter.rs"]
mod span_meter;
use span_meter::SpanMeter;
/// The JCP runtime as a joule-ui `Authorizer` (SPEC §6 edge): a real signed,
/// joule-budgeted grant, enforced by the real two-phase JCP runtime.
@ -52,16 +62,35 @@ impl Authorizer for JcpAuthorizer {
jcp_schema::Verdict::Deny(r) => Err(r.label().to_string()),
}
}
fn charge(&mut self, _action: &str, measured_uj: u64) -> u64 {
self.runtime
.charge(&self.grant, jcp_schema::Energy::measured(measured_uj))
.budget_remaining_uj
fn charge(&mut self, _action: &str, cost: &Energy) -> u64 {
// The figure's own provenance, as the interaction is sealed with it: a
// measurement is charged as one, an estimate as an estimate.
let uj = cost.uj();
let cost = match cost.provenance {
Provenance::HwShunt => jcp_schema::Energy::measured(uj),
Provenance::ModelBased => jcp_schema::Energy::modeled(uj),
Provenance::Estimator => jcp_schema::Energy::estimator(uj),
};
self.runtime.charge(&self.grant, cost).budget_remaining_uj
}
fn grant_id(&self) -> Option<String> {
Some(self.grant.id().to_hex())
}
}
/// What an interaction's figure is, read from its own record: a 0 for work
/// that ran is no figure, and only a hardware record is called a reading.
fn figure(e: &Energy) -> String {
match (e.uj(), e.provenance) {
(0, p) => format!("no figure (0 µJ [{}]: the meter gave no reading for this work)", p.label()),
(uj, Provenance::HwShunt) => format!("{uj} µJ [HwShunt]"),
(uj, p) => format!("{uj} µJ [{}, an estimate]", p.label()),
}
}
/// The grant's budget, as minted below.
const BUDGET_UJ: u64 = 15_000_000;
fn main() {
// A real signed JCP grant for the `apply` action, with a joule budget that
// a couple of real interactions will exhaust.
@ -74,7 +103,7 @@ fn main() {
jcp_schema::ContentId::of_bytes(b"apply"),
["apply"],
),
budget_uj: 15_000_000, // 15 J
budget_uj: BUDGET_UJ, // 15 J
// Joules-only grant: no money ceiling, no caveats. An empty caveat list is
// omitted from the canonical encoding, so this stays byte-identical to the
// grant this example minted before JCP gained dual ceilings.
@ -86,20 +115,38 @@ fn main() {
nonce: [0; 16],
};
let sg = jcp_grant::issue(&issuer, grant.clone()).expect("issue grant");
let mut auth = JcpAuthorizer { runtime: jcp_runtime::Runtime::new(), sg, grant, caller: did };
let pm = PowerMeter::detect();
println!(
"edges: JCP grant (15 J budget, real signed) + {} meter",
if pm.is_hardware() { "Sandbox hardware" } else { "estimator" }
"edges: JCP grant (15 J budget, real signed) + {}",
if pm.is_hardware() {
format!("Sandbox hardware meter ({})", pm.sources().join(", "))
} else {
let why = match sandbox_meter::ioreport_status() {
// Apple Silicon: say why the SoC counters were declined.
sandbox_meter::IoReportStatus::Unavailable => String::new(),
status => format!(" ({status})"),
};
format!(
"no hardware probe{why} — each interaction below is charged and sealed with the \
estimator's wall-clock × TDP figure for its own span"
)
}
);
let meter = SandboxMeter(RefCell::new(pm));
let meter = SpanMeter::new(pm);
let mut auth = JcpAuthorizer {
runtime: jcp_runtime::Runtime::new(),
sg,
grant,
caller: did,
};
let surface = ContentId::of_bytes(b"<Form><Slider action='apply'/></Form>");
let mut log = InteractionLog::new(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]));
// Fire interactions doing ~150ms of real work each until the real grant's
// real joules run out and the real JCP runtime denies the next one.
// Fire interactions doing ~150ms of real work each. Each one's figure is
// debited from the grant; if the figures spend it, the real JCP runtime
// denies the next one.
for n in 1..=6u64 {
let it = run_interaction(&surface, "apply", "did:key:z6MkDemoUser", &n.to_le_bytes(), &meter, Some(&mut auth), || {
let t = Instant::now();
@ -111,21 +158,49 @@ fn main() {
}
std::hint::black_box(x).to_le_bytes().to_vec()
});
let charged = if it.verdict.is_allow() { figure(&it.energy) } else { "not run".into() };
println!(
" apply #{n}: {} · {} µJ [{}] · grant {} µJ left",
" apply #{n}: {} · {charged} · grant {} µJ left",
it.verdict.tag(),
it.energy.uj(),
it.energy.provenance.label(),
it.budget_remaining_uj.map(|b| b.to_string()).unwrap_or_else(|| "—".into()),
);
log.record(it);
}
let verified = verify_chain(log.entries()).expect("chain verifies");
let allowed = log.entries().iter().filter(|e| e.interaction.verdict.is_allow()).count();
let allowed: Vec<&Energy> = log
.entries()
.iter()
.filter(|e| e.interaction.verdict.is_allow())
.map(|e| &e.interaction.energy)
.collect();
let denied = verified - allowed.len();
let sealed: u64 = allowed.iter().map(|e| e.uj()).sum();
// What the grant was actually debited: the runtime never debits past the
// budget, so the last figure may have been only partly charged.
let left = log
.entries()
.iter()
.rev()
.filter(|e| e.interaction.verdict.is_allow())
.find_map(|e| e.interaction.budget_remaining_uj)
.unwrap_or(BUDGET_UJ);
let debited = BUDGET_UJ - left;
let unmetered = allowed.iter().filter(|e| e.uj() == 0).count();
// Label the figures by the sealed records, not by the meter.
let what = if unmetered > 0 {
format!("{unmetered} had no figure, 0 µJ for work that ran")
} else if allowed.iter().all(|e| e.provenance == Provenance::HwShunt) {
format!("{sealed} µJ sealed, machine-wide hardware counter deltas (HwShunt)")
} else if allowed.iter().all(|e| e.provenance == Provenance::Estimator) {
format!("{sealed} µJ sealed, estimated (wall-clock × TDP over each interaction's span), not measured")
} else {
format!("{sealed} µJ sealed, partly estimated, not measured")
};
let what = format!("{what}; {debited} of the {BUDGET_UJ} µJ budget debited");
println!(
"\n{verified} interactions sealed: {allowed} allowed (real joules, debited from a real grant), \
{} denied by the real JCP runtime when the budget ran out.",
verified - allowed
"\n{verified} interactions sealed: {} allowed; {what}; {denied} denied by the real JCP runtime{}.",
allowed.len(),
if denied > 0 { " once the budget was spent" } else { ", as the budget was never spent" }
);
}

View file

@ -13,13 +13,14 @@ use joule_ui_core::{
AllowedChildren, PropSchema, PropType, PropValue, Registry, Widget, WidgetSchema, validate,
};
use joule_ui_interaction::{
Authorizer, ContentId, InteractionLog, Meter, run_interaction, verify_chain,
Authorizer, ContentId, Energy, InteractionLog, Meter, run_interaction, verify_chain,
};
use std::cell::Cell;
/// A wall-clock-free deterministic meter standing in for the Sandbox standard's
/// real `PowerMeter` (which reads RAPL/IOReport). The point of the example is
/// the *protocol*; the hardware figure is the Sandbox edge.
/// `PowerMeter`. The point of the example is the *protocol*. It declares no
/// hardware grade, so its figures are sealed as `Estimator`: a hardware
/// figure is the Sandbox edge's to supply, and only from a live counter.
struct DemoMeter(Cell<u64>);
impl Meter for DemoMeter {
fn sample_uj(&self) -> Option<u64> {
@ -41,8 +42,8 @@ impl Authorizer for Budget {
Err("budget-exceeded".into())
}
}
fn charge(&mut self, _action: &str, measured_uj: u64) -> u64 {
self.remaining = self.remaining.saturating_sub(measured_uj);
fn charge(&mut self, _action: &str, cost: &Energy) -> u64 {
self.remaining = self.remaining.saturating_sub(cost.uj());
self.remaining
}
fn grant_id(&self) -> Option<String> {

View file

@ -1,26 +1,38 @@
//! A joule-ui interaction metered by the **real Sandbox `PowerMeter`** — the
//! reference Meter edge (SPEC §5), reading actual hardware energy (Apple
//! Silicon IOReport / Intel-AMD RAPL) rather than a stub. Proves the
//! recognized-edge design composes end to end: a fired Action's joules are a
//! hardware measurement, sealed into the chain.
//! reference Meter edge (SPEC §5) — rather than a stub. Proves the
//! recognized-edge design composes end to end: a fired Action's energy
//! record is sealed into the chain.
//!
//! What that record is depends on the host, and the output says which. Each
//! interaction is metered as one `PowerMeter` span and sealed with that
//! span's own figure and provenance (the `SpanMeter` in `support/`, the
//! adapter `joule-ui-serve` uses). Where the meter has a live hardware counter
//! (Intel/AMD RAPL; Apple Silicon IOReport only when its CPU rail is live) and
//! the span's CPU counter advanced, the figure is `HwShunt`: the counters'
//! machine-wide delta over the interaction. Otherwise (every Apple Silicon
//! host checked on macOS 27.0.1) it is the meter's `Estimator` figure,
//! wall-clock × TDP for the interaction's own span: an estimate, labelled as
//! one, and never 0 µJ for work that ran.
//!
//! ```bash
//! cargo run -p joule-ui-interaction --example real_meter --release
//! ```
use joule_ui_interaction::{ContentId, InteractionLog, Meter, run_interaction, verify_chain};
use joule_ui_interaction::{ContentId, Energy, InteractionLog, Provenance, run_interaction, verify_chain};
use sandbox_meter::PowerMeter;
use std::cell::RefCell;
use std::time::{Duration, Instant};
/// Adapts the Sandbox `PowerMeter` (whose cumulative `sample_uj` needs `&mut`)
/// to joule-ui's `Meter` (which samples through `&self`) via interior
/// mutability. This tiny shim is the whole "Sandbox is the reference Meter
/// edge" claim, made real.
struct SandboxMeter(RefCell<PowerMeter>);
impl Meter for SandboxMeter {
fn sample_uj(&self) -> Option<u64> {
self.0.borrow_mut().sample_uj()
#[path = "support/span_meter.rs"]
mod span_meter;
use span_meter::SpanMeter;
/// What an interaction's figure is, read from its own record: a 0 for work
/// that ran is no figure, and only a hardware record is called a reading.
fn figure(e: &Energy) -> String {
match (e.uj(), e.provenance) {
(0, p) => format!("no figure (0 µJ [{}]: the meter gave no reading for this work)", p.label()),
(uj, Provenance::HwShunt) => format!("{uj} µJ [HwShunt]"),
(uj, p) => format!("{uj} µJ [{}, an estimate]", p.label()),
}
}
@ -28,18 +40,29 @@ fn main() {
let pm = PowerMeter::detect();
let hardware = pm.is_hardware();
println!(
"meter: {} ({})",
if hardware { "Sandbox PowerMeter — hardware" } else { "no hardware probe — estimator" },
pm.sources().join(", ")
"meter: {}",
if hardware {
format!("Sandbox PowerMeter — hardware ({})", pm.sources().join(", "))
} else {
let why = match sandbox_meter::ioreport_status() {
// Apple Silicon: say why the SoC counters were declined.
sandbox_meter::IoReportStatus::Unavailable => String::new(),
status => format!(" ({status})"),
};
format!(
"no hardware probe{why} — each interaction below is sealed with the estimator's \
wall-clock × TDP figure for its own span"
)
}
);
let meter = SandboxMeter(RefCell::new(pm));
let meter = SpanMeter::new(pm);
let surface = ContentId::of_bytes(b"<Form><Slider action='render'/></Form>");
let signing = ed25519_dalek::SigningKey::from_bytes(&[5u8; 32]);
let mut log = InteractionLog::new(signing);
// Three real interactions: each handler does ~150ms of genuine CPU work
// (the "computation behind the UI"), metered on real hardware.
// (the "computation behind the UI"), metered by whatever the meter can read.
for n in 1..=3u64 {
let it = run_interaction(&surface, "render", "did:key:z6MkDemoUser", &n.to_le_bytes(), &meter, None, || {
let t = Instant::now();
@ -51,19 +74,26 @@ fn main() {
}
std::hint::black_box(x).to_le_bytes().to_vec()
});
println!(
" render #{n}: {} · {} µJ [{}]",
it.verdict.tag(),
it.energy.uj(),
it.energy.provenance.label(),
);
println!(" render #{n}: {} · {}", it.verdict.tag(), figure(&it.energy));
log.record(it);
}
let verified = verify_chain(log.entries()).expect("chain verifies");
println!(
"\nsealed chain: {verified} links verified, {} µJ total{}",
log.total_uj(),
if hardware { " — measured on real hardware (HwShunt)" } else { "" },
);
// Label the total by the sealed records, not by the meter: "measured"
// only when every record is a hardware reading that moved.
let records: Vec<&Energy> = log.entries().iter().map(|e| &e.interaction.energy).collect();
let unmetered = records.iter().filter(|e| e.uj() == 0).count();
let label = if unmetered > 0 {
format!(
" — {unmetered} of {} interaction(s) have no figure (0 µJ for work that ran): not a measurement",
records.len()
)
} else if records.iter().all(|e| e.provenance == Provenance::HwShunt) {
" — hardware counter deltas (HwShunt), machine-wide over each interaction".to_string()
} else if records.iter().all(|e| e.provenance == Provenance::Estimator) {
" — estimated (wall-clock × TDP over each interaction's span), not measured".to_string()
} else {
" — partly estimated, not measured".to_string()
};
println!("\nsealed chain: {verified} links verified, {} µJ total{label}", log.total_uj());
}

View file

@ -0,0 +1,115 @@
//! The Sandbox `PowerMeter` as a joule-ui `Meter` (SPEC §5 edge) over managed
//! spans, shared by the `real_meter` and `jcp_metered` examples. It is the
//! adapter `joule-ui-serve` seals its served interactions with.
//!
//! Every `sample_uj` closes the span the previous sample opened, adds that
//! span's figure to a running counter, and opens the next. So the difference
//! of two consecutive samples is exactly one span's figure, and `provenance`
//! is that span's own, which `run_interaction` asks for right after its second
//! sample. A span is `HwShunt` only when it was a hardware reading of the CPU
//! the handler ran on, whose CPU counter advanced. Otherwise it is the meter's
//! `Estimator` figure (wall-clock × TDP, never 0 for work that ran): on a host
//! with no live energy counter — every macOS 27.0.1 host checked, whose CPU
//! energy rail is frozen — when the span's CPU counter held while another
//! class moved, and when the span has no CPU reading at all (an NVML-only
//! host reads the GPU alone: see [`interaction_figure`]).
//!
//! The examples' old adapter sampled `PowerMeter::sample_uj`, a sum of raw
//! counters under the trait's default `HwShunt`. Where no hardware probe
//! existed that was `None`, and each interaction that ran was sealed at
//! `0 µJ [Estimator]`.
//!
//! `tests/span_meter.rs` includes this file too, and drives the adapter over
//! scripted spans.
use joule_ui_interaction::{Meter, Provenance};
use sandbox_meter::probe::Span;
use sandbox_meter::{Measurement, PowerMeter};
use std::cell::RefCell;
use std::time::Duration;
/// A meter that measures managed spans: the Sandbox `PowerMeter` does, and a
/// test can script one.
pub trait Spans {
type Open;
fn open(&mut self) -> Self::Open;
fn close(&mut self, span: Self::Open) -> Measurement;
/// The meter's estimate for `wall` of work, with no reading.
fn estimate(&self, wall: Duration) -> Measurement;
}
impl Spans for PowerMeter {
type Open = Span;
fn open(&mut self) -> Span {
self.start()
}
fn close(&mut self, span: Span) -> Measurement {
span.stop(self)
}
fn estimate(&self, wall: Duration) -> Measurement {
PowerMeter::estimate(self, wall)
}
}
/// `RefCell` because sampling mutates the meter while the `Meter` trait
/// samples through `&self`. Each example drives one interaction at a time, so
/// a span is exclusive to one interaction.
pub struct SpanMeter<M: Spans = PowerMeter>(RefCell<State<M>>);
struct State<M: Spans> {
meter: M,
open: Option<M::Open>,
counter_uj: u64,
last: Provenance,
}
impl<M: Spans> SpanMeter<M> {
pub fn new(meter: M) -> Self {
SpanMeter(RefCell::new(State { meter, open: None, counter_uj: 0, last: Provenance::Estimator }))
}
}
impl<M: Spans> Meter for SpanMeter<M> {
fn sample_uj(&self) -> Option<u64> {
let mut s = self.0.borrow_mut();
if let Some(span) = s.open.take() {
let m = s.meter.close(span);
let m = interaction_figure(m, |wall| s.meter.estimate(wall));
s.counter_uj = s.counter_uj.saturating_add(m.total_uj());
s.last = span_provenance(&m);
}
let next = s.meter.open();
s.open = Some(next);
Some(s.counter_uj)
}
/// The provenance of the span the last sample closed.
fn provenance(&self) -> Provenance {
self.0.borrow().last
}
}
/// The figure an interaction is sealed with, from its span. joule-ui seals
/// one figure per interaction, with no field to say that figure covers part
/// of the work, so a span with no hardware reading of the CPU is not a
/// reading of the interaction: an NVML-only probe set reads the GPU alone,
/// and its figure would be sealed `HwShunt` for work that ran on the CPU. Such
/// a span is sealed as the meter's estimate for its wall time (`Estimator`,
/// never 0 for work that ran); a span that reads the CPU passes through,
/// with any GPU record beside it.
fn interaction_figure(m: Measurement, estimate: impl FnOnce(Duration) -> Measurement) -> Measurement {
if m.reads_cpu() { m } else { estimate(m.wall) }
}
/// A span's provenance: the weakest of its records, because a sum is only as
/// good as its weakest part.
fn span_provenance(m: &Measurement) -> Provenance {
use sandbox_meter::Provenance as Grade;
if m.devices.is_empty() || m.devices.iter().any(|d| d.provenance == Grade::Estimator) {
Provenance::Estimator
} else if m.devices.iter().all(|d| d.provenance == Grade::HwShunt && d.measured_uj.is_some()) {
Provenance::HwShunt
} else {
Provenance::ModelBased
}
}

View file

@ -14,7 +14,8 @@
//! The two heavy mechanisms — measuring energy and gating on a budget — are
//! **recognized edges**, not hard dependencies. A [`Meter`] is any source of a
//! cumulative microjoule counter (the sibling Sandbox standard's `PowerMeter`
//! is one, and reads real hardware); an [`Authorizer`] is any two-phase
//! is one: a hardware reading where the host exposes a live energy counter,
//! an estimate where it does not); an [`Authorizer`] is any two-phase
//! capability check (the sibling JCP standard's runtime is one, gating on a
//! signed joule budget). joule-ui supplies the receipt, the chain, and the
//! lifecycle; the host wires whichever edge it trusts.
@ -23,10 +24,11 @@
//!
//! [`run_interaction`] is the whole protocol in one function: hash the inputs,
//! **authorize before running** (a denied action never executes its handler),
//! meter the handler, **charge the measured cost**, hash the outputs, and
//! return an [`Interaction`]. An [`InteractionLog`] then seals it as the next
//! link in a chain — allows and denies alike, because a denial excised from
//! the record is exactly the audit a malicious host would want to drop.
//! meter the handler, **charge its cost with the cost's provenance**, hash the
//! outputs, and return an [`Interaction`]. An [`InteractionLog`] then seals it
//! as the next link in a chain — allows and denies alike, because a denial
//! excised from the record is exactly the audit a malicious host would want to
//! drop.
use serde::{Deserialize, Serialize};
@ -87,28 +89,48 @@ impl Energy {
}
}
/// The power, in milliwatts, the estimator of last resort charges a handler's
/// wall-clock time at when its meter gives no reading (SPEC §6): 20 W.
/// Deliberately coarse, and a meter that knows its host's power envelope
/// declares its own through [`Meter::estimator_milliwatts`]. The figure it
/// produces is always graded `Estimator`.
pub const DEFAULT_ESTIMATOR_MILLIWATTS: u64 = 20_000;
/// A cumulative microjoule counter — the metering edge. The Sandbox standard's
/// `PowerMeter` is one implementation (real RAPL/IOReport). `sample_uj` returns
/// `PowerMeter` is one implementation: a hardware reading where the host
/// exposes a live energy counter, its estimator otherwise. `sample_uj` returns
/// the running counter; the lifecycle takes a before/after difference. `None`
/// means no reading, and the interaction falls to `Estimator`.
/// means no reading, and the lifecycle estimates the handler's energy from its
/// wall-clock time at [`estimator_milliwatts`](Meter::estimator_milliwatts),
/// graded `Estimator` (SPEC §6).
///
/// A meter MUST also declare the **provenance** of its readings, so the receipt
/// is honest about *how the number was obtained*. This is load-bearing: a
/// deterministic stub or a calibrated model returns a number too, and stamping
/// it `HwShunt` (a hardware measurement) is exactly the dishonesty this standard
/// exists to forbid — a figure is reported with the honesty of how it was
/// obtained, never promoted. Real hardware samplers keep the `HwShunt` default;
/// a model declares `ModelBased`; a coarse constant declares `Estimator`.
/// obtained, never promoted. So the grade is `Estimator` unless a meter says
/// otherwise: a model declares `ModelBased`, and a meter declares `HwShunt`
/// only for a reading of a hardware counter it has observed to be live for the
/// work it covers (SPEC §5). A counter that is present but frozen, or
/// republished in lumps, is not live, and its figures are not `HwShunt`.
pub trait Meter {
fn sample_uj(&self) -> Option<u64>;
/// How honest this meter's readings are. Defaults to `HwShunt` — implement
/// only on a real hardware interface; override for a model or estimator.
/// How honest this meter's readings are. Defaults to `Estimator`: a meter
/// opts in to a stronger grade, and only for readings that earn it.
fn provenance(&self) -> Provenance {
Provenance::HwShunt
Provenance::Estimator
}
/// The power the lifecycle charges a handler's wall-clock time at when
/// this meter gives no reading, in milliwatts. Defaults to
/// [`DEFAULT_ESTIMATOR_MILLIWATTS`].
fn estimator_milliwatts(&self) -> u64 {
DEFAULT_ESTIMATOR_MILLIWATTS
}
}
/// A meter with no interface — always `None` (interactions fall to `Estimator`).
/// A meter with no interface — always `None`, so every interaction that runs
/// is estimated from its wall-clock time and graded `Estimator`.
pub struct NoMeter;
impl Meter for NoMeter {
fn sample_uj(&self) -> Option<u64> {
@ -119,17 +141,30 @@ impl Meter for NoMeter {
}
}
/// The estimator of last resort: `wall` at `milliwatts`, in microjoules,
/// rounded up and never 0 — this is only asked for work that ran, and work
/// that ran is not free. (1 mW for 1 ns is 1e-6 µJ, so µJ = mW × ns / 1e6.)
pub fn estimate_uj(wall: std::time::Duration, milliwatts: u64) -> u64 {
let nuj = wall.as_nanos().saturating_mul(u128::from(milliwatts));
u64::try_from(nuj.div_ceil(1_000_000)).unwrap_or(u64::MAX).max(1)
}
/// The capability edge: two-phase, because an interaction's true cost is
/// unknown until its handler runs. [`authorize`](Authorizer::authorize) gates
/// the action *before* it executes (on any remaining budget); [`charge`] debits
/// the measured cost *after*. The JCP standard's runtime implements exactly
/// this shape over a signed joule-budgeted grant.
/// the action *before* it executes (on any remaining budget);
/// [`charge`](Authorizer::charge) debits its cost *after*. The JCP standard's
/// runtime implements exactly this shape over a signed joule-budgeted grant.
pub trait Authorizer {
/// May `action` fire? `Ok(budget_remaining_uj)` to allow, `Err(reason)` to
/// deny. Called before the handler runs and debits nothing.
fn authorize(&mut self, action: &str) -> Result<u64, String>;
/// Debit the measured cost after the handler ran; returns budget left.
fn charge(&mut self, action: &str, measured_uj: u64) -> u64;
/// Debit the interaction's cost after the handler ran; returns budget
/// left. `cost` is the figure the interaction is sealed with, and carries
/// its provenance: a hardware reading, a model, or an estimate. Debit
/// [`cost.uj()`](Energy::uj) whichever it is, so the budget binds on a
/// host with no reading; an authorizer that seals its own record of the
/// debit seals it with `cost.provenance`, never as a reading it was not.
fn charge(&mut self, action: &str, cost: &Energy) -> u64;
/// The grant identity this authorizer enforces, for the receipt.
fn grant_id(&self) -> Option<String> {
None
@ -185,8 +220,14 @@ pub struct Interaction {
/// Run one interaction end to end (SPEC §6). Hashes the inputs, authorizes
/// before running (a denied action's handler never executes), meters the
/// handler, charges the measured cost, hashes the outputs. The handler returns
/// the output bytes (a serialized widget tree or `UiDelta`).
/// handler, charges its cost with the cost's provenance, hashes the outputs.
/// The handler returns the output bytes (a serialized widget tree or
/// `UiDelta`).
///
/// A handler that ran is never costed at 0 µJ for want of a reading: when the
/// meter gives none, its wall-clock time is estimated at the meter's
/// [`estimator_milliwatts`](Meter::estimator_milliwatts) and graded
/// `Estimator`, so a budget binds on every host.
pub fn run_interaction<O, F>(
surface: &ContentId,
action: &str,
@ -222,10 +263,13 @@ where
// Phase 2: meter the handler. The reading is tagged with the meter's
// DECLARED provenance — a number from a model or a stub is never promoted
// to `HwShunt`. `measured_uj` is carried only for a hardware shunt; a
// model/estimator figure rides in `estimated_uj` (SPEC §5).
// to `HwShunt`, and a meter that declares nothing is `Estimator`.
// `measured_uj` is carried only for a hardware shunt; a model/estimator
// figure rides in `estimated_uj` (SPEC §5).
let before = meter.sample_uj();
let started = std::time::Instant::now();
let output = handler();
let wall = started.elapsed();
let after = meter.sample_uj();
let energy = match (before, after) {
(Some(a), Some(b)) => {
@ -244,11 +288,15 @@ where
},
}
}
_ => Energy::estimator(0),
// No reading for work that ran: estimate it from the handler's own
// wall-clock time rather than seal 0 µJ, which would cost it nothing
// and let a budget never bind on a host without a counter (SPEC §6).
_ => Energy::estimator(estimate_uj(wall, meter.estimator_milliwatts())),
};
// Phase 3: charge the measured cost.
let budget_remaining_uj = authorizer.map(|a| a.charge(action, energy.uj()));
// Phase 3: charge the cost, with its provenance — an estimate is debited
// as an estimate, never handed on as a reading.
let budget_remaining_uj = authorizer.map(|a| a.charge(action, &energy));
Interaction {
surface: surface.clone(),
@ -409,7 +457,8 @@ mod tests {
use super::*;
/// A test meter whose counter advances by a fixed step each sample, so a
/// before/after difference is deterministic.
/// before/after difference is deterministic. It declares no grade, so its
/// figures are the trait default's: `Estimator`.
struct StepMeter(std::cell::Cell<u64>, u64);
impl Meter for StepMeter {
fn sample_uj(&self) -> Option<u64> {
@ -419,11 +468,32 @@ mod tests {
}
}
/// The same counter, opted in to the hardware grade — standing in for a
/// meter reading a live hardware counter, to exercise the `HwShunt` path.
struct HwStepMeter(StepMeter);
impl Meter for HwStepMeter {
fn sample_uj(&self) -> Option<u64> {
self.0.sample_uj()
}
fn provenance(&self) -> Provenance {
Provenance::HwShunt
}
}
fn hw_step(step: u64) -> HwStepMeter {
HwStepMeter(StepMeter(std::cell::Cell::new(0), step))
}
/// A budget authorizer — the JCP edge in miniature: allow while budget
/// remains, deny when exhausted, debit the measured cost.
/// remains, deny when exhausted, debit the cost. It records every figure
/// it was charged, so a test can see what it was handed.
struct Budget {
remaining: u64,
id: String,
charged: Vec<Energy>,
}
fn budget(remaining: u64, id: &str) -> Budget {
Budget { remaining, id: id.into(), charged: Vec::new() }
}
impl Authorizer for Budget {
fn authorize(&mut self, _action: &str) -> Result<u64, String> {
@ -433,8 +503,9 @@ mod tests {
Err("budget-exceeded".into())
}
}
fn charge(&mut self, _action: &str, measured_uj: u64) -> u64 {
self.remaining = self.remaining.saturating_sub(measured_uj);
fn charge(&mut self, _action: &str, cost: &Energy) -> u64 {
self.charged.push(*cost);
self.remaining = self.remaining.saturating_sub(cost.uj());
self.remaining
}
fn grant_id(&self) -> Option<String> {
@ -442,13 +513,23 @@ mod tests {
}
}
/// Work a handler does for at least `ms` of wall time.
fn busy(ms: u64) -> Vec<u8> {
let t = std::time::Instant::now();
let mut x = 0u64;
while t.elapsed() < std::time::Duration::from_millis(ms) {
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
}
std::hint::black_box(x).to_le_bytes().to_vec()
}
fn surface() -> ContentId {
ContentId::of_bytes(b"<Form><Slider action='apply'/></Form>")
}
#[test]
fn an_interaction_is_metered_and_sealed() {
let meter = StepMeter(std::cell::Cell::new(0), 1500);
let meter = hw_step(1500);
let it = run_interaction(
&surface(),
"apply",
@ -459,8 +540,7 @@ mod tests {
|| br#"{"op":"replace","path":"/0","widget":{"name":"Image"}}"#.to_vec(),
);
assert!(it.verdict.is_allow());
assert_eq!(it.energy.provenance, Provenance::HwShunt);
assert_eq!(it.energy.uj(), 1500); // 1500 → 3000 delta
assert_eq!(it.energy, Energy::measured(1500)); // a declared hardware reading, 0 → 1500
assert_ne!(it.outputs, ContentId::genesis());
let mut log = InteractionLog::new(SigningKey::from_bytes(&[3u8; 32]));
@ -472,7 +552,7 @@ mod tests {
#[test]
fn a_denied_action_never_runs_its_handler_and_is_still_sealed() {
let meter = NoMeter;
let mut budget = Budget { remaining: 0, id: "grant-xyz".into() };
let mut budget = budget(0, "grant-xyz");
let mut ran = false;
let it = run_interaction(
&surface(),
@ -490,6 +570,9 @@ mod tests {
assert_eq!(it.verdict, Verdict::Deny("budget-exceeded".into()));
assert_eq!(it.outputs, ContentId::genesis());
assert_eq!(it.grant_id.as_deref(), Some("grant-xyz"));
// Nothing ran, so nothing is costed and nothing is charged.
assert_eq!(it.energy, Energy::estimator(0));
assert!(budget.charged.is_empty(), "a denial debits nothing");
// The denial still seals — and chains.
let mut log = InteractionLog::new(SigningKey::from_bytes(&[3u8; 32]));
@ -553,11 +636,11 @@ mod tests {
#[test]
fn budget_is_debited_across_interactions() {
let mut budget = Budget { remaining: 5000, id: "g".into() };
let mut budget = budget(5000, "g");
let mut log = InteractionLog::new(SigningKey::from_bytes(&[9u8; 32]));
// Three interactions of 1500 µJ each: third leaves 500.
for _ in 0..3 {
let meter = StepMeter(std::cell::Cell::new(0), 1500);
let meter = hw_step(1500);
let it = run_interaction(&surface(), "apply", "did:key:z6MkTestActor", b"x", &meter, Some(&mut budget), || b"y".to_vec());
log.record(it);
}
@ -571,4 +654,115 @@ mod tests {
log.record(it);
assert_eq!(verify_chain(log.entries()), Ok(4));
}
// ── a figure is graded by what it is, and work that ran is never free ──
/// A meter that declares nothing is an estimator: a stub's number is not a
/// hardware reading until the meter says it is one.
#[test]
fn a_meter_is_an_estimator_unless_it_opts_in() {
struct Silent;
impl Meter for Silent {
fn sample_uj(&self) -> Option<u64> {
Some(0)
}
}
assert_eq!(Silent.provenance(), Provenance::Estimator);
assert_eq!(NoMeter.provenance(), Provenance::Estimator);
assert_eq!(Silent.estimator_milliwatts(), DEFAULT_ESTIMATOR_MILLIWATTS);
let meter = StepMeter(std::cell::Cell::new(0), 1500);
let it = run_interaction(&surface(), "apply", "did:key:z6MkTestActor", b"x", &meter, None, || b"y".to_vec());
assert_eq!(it.energy, Energy::estimator(1500), "an undeclared counter's delta is an estimate");
assert_eq!(it.energy.measured_uj, None);
let it = run_interaction(&surface(), "apply", "did:key:z6MkTestActor", b"x", &hw_step(1500), None, || b"y".to_vec());
assert_eq!(it.energy, Energy::measured(1500), "a meter that opts in is sealed as a reading");
}
/// With no reading, the handler's wall-clock time is estimated at the
/// meter's declared power — never 0 µJ for work that ran — and graded
/// `Estimator`.
#[test]
fn work_that_ran_without_a_reading_is_estimated_never_zero() {
let it = run_interaction(&surface(), "apply", "did:key:z6MkTestActor", b"x", &NoMeter, None, || busy(20));
assert!(it.verdict.is_allow());
assert_eq!((it.energy.provenance, it.energy.measured_uj), (Provenance::Estimator, None));
// At least 20 ms at 20 W = 400_000 µJ.
assert!(it.energy.estimated_uj >= 400_000, "{:?}", it.energy);
// A meter that knows its power envelope declares it, and the estimate
// scales with it: 20 ms at 1 W is at least 20_000 µJ, and well under
// what the same work costs at 20 W.
struct OneWatt;
impl Meter for OneWatt {
fn sample_uj(&self) -> Option<u64> {
None
}
fn estimator_milliwatts(&self) -> u64 {
1_000
}
}
let low = run_interaction(&surface(), "apply", "did:key:z6MkTestActor", b"x", &OneWatt, None, || busy(20));
assert_eq!(low.energy.provenance, Provenance::Estimator);
assert!((20_000..400_000).contains(&low.energy.estimated_uj), "{:?}", low.energy);
// Even an instant handler is not free.
let instant = run_interaction(&surface(), "apply", "did:key:z6MkTestActor", b"x", &NoMeter, None, || b"y".to_vec());
assert!(instant.energy.uj() >= 1, "{:?}", instant.energy);
}
#[test]
fn the_estimate_is_wall_time_at_the_declared_power_rounded_up() {
use std::time::Duration;
assert_eq!(estimate_uj(Duration::from_millis(1), 20_000), 20_000); // 1 ms at 20 W = 20 mJ
assert_eq!(estimate_uj(Duration::from_secs(2), 1_500), 3_000_000); // 2 s at 1.5 W = 3 J
assert_eq!(estimate_uj(Duration::from_nanos(1), 20_000), 1); // 0.02 µJ rounds up
assert_eq!(estimate_uj(Duration::ZERO, 20_000), 1); // work that ran is never free
assert_eq!(estimate_uj(Duration::from_secs(u64::MAX), u64::MAX), u64::MAX); // saturates
}
/// On a host with no reading, a budget still binds: each interaction that
/// ran spends its estimate. Sealed at 0 µJ, as before, the budget never
/// moved and a runaway UI was never stopped.
#[test]
fn a_budget_binds_on_a_host_with_no_reading() {
let mut budget = budget(1_000_000, "g"); // 1 J: 50 ms of work at 20 W
let mut log = InteractionLog::new(SigningKey::from_bytes(&[9u8; 32]));
let mut ran = 0;
for _ in 0..10 {
let it = run_interaction(&surface(), "apply", "did:key:z6MkTestActor", b"x", &NoMeter, Some(&mut budget), || {
ran += 1;
busy(20)
});
let allowed = it.verdict.is_allow();
log.record(it);
if !allowed {
break;
}
}
assert!(ran <= 3, "a 1 J budget stopped ~20 ms interactions at 20 W within three; {ran} ran");
assert_eq!(budget.remaining, 0);
assert!(!log.entries().last().unwrap().interaction.verdict.is_allow(), "the budget denied the next one");
assert!(budget.charged.iter().all(|e| e.provenance == Provenance::Estimator), "{:?}", budget.charged);
assert_eq!(verify_chain(log.entries()), Ok(log.entries().len()));
}
/// The authorizer is handed the figure the interaction is sealed with, and
/// its provenance: a reading as a reading, an estimate as an estimate.
#[test]
fn charge_is_handed_the_sealed_figure_and_its_provenance() {
let mut budget = budget(u64::MAX, "g");
let hw = hw_step(1500);
let est = StepMeter(std::cell::Cell::new(0), 700);
let mut sealed = Vec::new();
for meter in [&hw as &dyn Meter, &est, &NoMeter] {
let it = run_interaction(&surface(), "apply", "did:key:z6MkTestActor", b"x", meter, Some(&mut budget), || busy(1));
sealed.push(it.energy);
}
assert_eq!(budget.charged, sealed, "charge sees exactly what is sealed");
let grades: Vec<Provenance> = budget.charged.iter().map(|e| e.provenance).collect();
assert_eq!(grades, [Provenance::HwShunt, Provenance::Estimator, Provenance::Estimator]);
assert_eq!((budget.charged[0].measured_uj, budget.charged[1].measured_uj), (Some(1500), None));
}
}

View file

@ -0,0 +1,86 @@
//! 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:?}"
);
}

View file

@ -183,7 +183,7 @@ mod tests {
Ok(1_000)
}
}
fn charge(&mut self, _action: &str, _measured_uj: u64) -> u64 {
fn charge(&mut self, _action: &str, _cost: &joule_ui_interaction::Energy) -> u64 {
1_000
}
}
@ -202,7 +202,9 @@ mod tests {
let it = meter_mcp_action(&action("submit"), &meter, None, || b"<next-ui/>".to_vec());
assert!(matches!(it.verdict, Verdict::Allow));
assert_eq!(it.actor, "did:key:z6MkActor", "the actor is sealed in");
assert_eq!(it.energy.uj(), 1_500, "the action carries its measured cost");
assert_eq!(it.energy.uj(), 1_500, "the action carries its metered cost");
// A stub meter declares no hardware grade, so its figure is an estimate.
assert_eq!(it.energy.provenance, joule_ui_interaction::Provenance::Estimator);
// the component became the surface
assert_eq!(it.surface, action("submit").surface());
}

View file

@ -18,11 +18,19 @@ ed25519-dalek = { workspace = true }
# Render the host's own verifying key as a `did:key:z…` — the default actor
# attributed to interactions fired against an unauthenticated served surface.
bs58 = "0.5"
# The reference Meter edge (SPEC §5): the live demo meters on REAL hardware
# (Apple Silicon IOReport / RAPL), so every receipt's HwShunt is true and the
# joules vary with the work — not a stub mislabelled as a measurement.
# The reference Meter edge (SPEC §5): the live demo meters each interaction as
# a Sandbox PowerMeter span. A receipt is HwShunt only when its span was a
# hardware reading of the CPU (a live RAPL or IOReport counter, with any live
# NVML GPU reading beside it), and Estimator otherwise, with the meter's
# wall-clock estimate as its figure: a GPU reading alone does not cover work
# that ran on the CPU. The joules vary with the work either way, and no
# estimate is sealed as a measurement.
sandbox-meter = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-meter" }
[dev-dependencies]
# Device classes for the scripted spans the binary's tests replay.
sandbox-schema = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-schema" }
[[bin]]
name = "joule-ui-serve"
path = "src/bin/joule_ui_serve.rs"

View file

@ -15,9 +15,10 @@
//! position always lands, each landing a real sealed, metered interaction.
use joule_ui_core::Widget;
use joule_ui_interaction::Meter;
use joule_ui_interaction::{Meter, Provenance};
use joule_ui_serve::{Host, parse_request};
use sandbox_meter::PowerMeter;
use sandbox_meter::probe::Span;
use sandbox_meter::{Measurement, PowerMeter};
use std::cell::RefCell;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
@ -26,20 +27,122 @@ use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
/// The **real** Sandbox `PowerMeter` as a joule-ui `Meter` (SPEC §5 edge): it
/// reads actual hardware energy (Apple Silicon IOReport / Intel-AMD RAPL), so
/// the receipt's `HwShunt` is true and the joules are a measurement, not a
/// constant. `RefCell` because `sample_uj` mutates the probe and the `Meter`
/// trait samples through `&self`; the host mutex serialises access across the
/// worker pool, so the metered window is exclusive — required for an honest
/// before/after delta. Where no hardware probe exists, `sample_uj` returns
/// `None` and the interaction is honestly `Estimator`, never a faked figure.
struct HardwareMeter(RefCell<PowerMeter>);
impl Meter for HardwareMeter {
/// A meter that measures managed spans: the Sandbox `PowerMeter` does, and a
/// test can script one.
trait Spans {
type Open;
fn open(&mut self) -> Self::Open;
fn close(&mut self, span: Self::Open) -> Measurement;
/// The meter's estimate for `wall` of work, with no reading.
fn estimate(&self, wall: Duration) -> Measurement;
}
impl Spans for PowerMeter {
type Open = Span;
fn open(&mut self) -> Span {
self.start()
}
fn close(&mut self, span: Span) -> Measurement {
span.stop(self)
}
fn estimate(&self, wall: Duration) -> Measurement {
PowerMeter::estimate(self, wall)
}
}
/// The Sandbox `PowerMeter` as a joule-ui `Meter` (SPEC §5 edge), sealing each
/// interaction with the provenance of the span it was metered over.
///
/// Every `sample_uj` closes the span the previous sample opened, adds that
/// span's figure to a running counter, and opens the next. So the difference
/// of two consecutive samples is exactly one span's figure, and `provenance`
/// is that span's own, which `run_interaction` asks for right after its second
/// sample. A span is `HwShunt` only when it was a hardware reading of the CPU
/// the handler ran on. It is the meter's `Estimator` figure (wall-clock × TDP,
/// never 0 for work that ran) when the host exposes no live energy counter —
/// every macOS 27.0.1 host checked, whose CPU energy rail is frozen — when the
/// span's CPU counter held while another class, a GPU still compositing,
/// moved, and when the span has no CPU reading at all: an NVML-only host reads
/// the GPU alone, and joule-ui seals one figure per interaction with no field
/// to say it covers part of the work ([`interaction_figure`]). The old sampler
/// summed raw counters under the trait's default `HwShunt`, so it could seal
/// such GPU energy as the energy of this CPU work, and it sealed 0 J wherever
/// no hardware probe existed.
///
/// `RefCell` because sampling mutates the probe while the `Meter` trait samples
/// through `&self`. The host mutex serialises access across the worker pool,
/// so a span is exclusive to one interaction.
struct SpanMeter<M: Spans>(RefCell<SpanState<M>>);
struct SpanState<M: Spans> {
meter: M,
open: Option<M::Open>,
counter_uj: u64,
last: Provenance,
}
impl<M: Spans> SpanMeter<M> {
fn new(meter: M) -> Self {
SpanMeter(RefCell::new(SpanState {
meter,
open: None,
counter_uj: 0,
last: Provenance::Estimator,
}))
}
}
impl<M: Spans> Meter for SpanMeter<M> {
fn sample_uj(&self) -> Option<u64> {
self.0.borrow_mut().sample_uj()
let mut s = self.0.borrow_mut();
if let Some(span) = s.open.take() {
let m = s.meter.close(span);
let m = interaction_figure(m, |wall| s.meter.estimate(wall));
s.counter_uj = s.counter_uj.saturating_add(m.total_uj());
s.last = span_provenance(&m);
}
let next = s.meter.open();
s.open = Some(next);
Some(s.counter_uj)
}
fn provenance(&self) -> Provenance {
self.0.borrow().last
}
}
/// The meter the server seals interactions with.
fn served_meter(meter: PowerMeter) -> Box<dyn Meter + Send> {
Box::new(SpanMeter::new(meter))
}
/// The figure an interaction is sealed with, from its span. joule-ui seals
/// one figure per interaction, with no field to say that figure covers part
/// of the work, so a span with no hardware reading of the CPU is not a
/// reading of the interaction: an NVML-only probe set reads the GPU alone,
/// and its figure would be sealed `HwShunt` for work that ran on the CPU. Such
/// a span is sealed as the meter's estimate for its wall time (`Estimator`,
/// never 0 for work that ran); a span that reads the CPU passes through,
/// with any GPU record beside it.
fn interaction_figure(m: Measurement, estimate: impl FnOnce(Duration) -> Measurement) -> Measurement {
if m.reads_cpu() { m } else { estimate(m.wall) }
}
/// A span's provenance: the weakest of its records, because a sum is only as
/// good as its weakest part.
fn span_provenance(m: &Measurement) -> Provenance {
use sandbox_meter::Provenance as Grade;
if m.devices.is_empty() || m.devices.iter().any(|d| d.provenance == Grade::Estimator) {
Provenance::Estimator
} else if m
.devices
.iter()
.all(|d| d.provenance == Grade::HwShunt && d.measured_uj.is_some())
{
Provenance::HwShunt
} else {
Provenance::ModelBased
}
// provenance() defaults to HwShunt — correct, because this is real hardware.
}
/// The output the demo computes: a "processed" image whose intensity is the
@ -49,9 +152,10 @@ fn output(intensity: f64) -> Widget {
Widget::leaf("Image").prop("intensity", intensity)
}
/// The demo's "compute": a real, bounded CPU workload scaled by intensity, so
/// the metered joules genuinely reflect work done — more intensity, more
/// compute, more joules — rather than a flat number. ~8 ms per intensity step.
/// The demo's "compute": a real, bounded CPU workload scaled by intensity,
/// ~8 ms per intensity step. More intensity is more compute and more joules,
/// not a flat number, whether the joules are read off a live counter or
/// estimated from the span's wall time.
fn process(intensity: f64) -> Widget {
let budget = Duration::from_millis((intensity.clamp(1.0, 10.0) * 8.0) as u64);
let t = Instant::now();
@ -75,7 +179,7 @@ fn main() -> std::io::Result<()> {
let host = Host::new(
"joule-ui — metered surface",
output(1.0),
Box::new(HardwareMeter(RefCell::new(PowerMeter::detect()))),
served_meter(PowerMeter::detect()),
Box::new(|_action, inputs| {
let intensity: f64 = serde_json::from_slice(inputs).unwrap_or(1.0);
process(intensity)
@ -232,3 +336,135 @@ input[type=range]{width:100%}\
.jui-placeholder{display:grid;place-items:center;height:140px;background:#11151a;border:1px dashed #2a2f36;color:#9aa4af;font-size:1.2rem}\
.jui-note{margin-top:1.25rem;font-size:.85rem;color:#9aa4af;font-family:ui-monospace,monospace}\
a{color:#2d6cdf}";
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use joule_ui_interaction::verify_chain;
use sandbox_meter::{DeviceEnergy, Provenance as Grade};
use sandbox_schema::DeviceClass;
use std::collections::VecDeque;
/// 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 }
}
fn span(provenance: Grade, uj: u64) -> Measurement {
span_of(vec![record(DeviceClass::Cpu, provenance, uj)], "script")
}
fn host(meter: Box<dyn Meter + Send>) -> Host {
Host::new(
"t",
output(1.0),
meter,
Box::new(|_action, inputs| process(serde_json::from_slice(inputs).unwrap_or(1.0))),
SigningKey::from_bytes(&[12u8; 32]),
)
}
fn fire(h: &mut Host) -> serde_json::Value {
serde_json::from_str(&h.handle("POST", "/action", br#"{"action":"apply","value":3}"#).body).unwrap()
}
/// Through the real host, each fired action is sealed with its own span's
/// provenance and figure: a hardware reading as `HwShunt`, the meter's
/// estimate as `Estimator` with that estimate — not `HwShunt`, and not 0.
#[test]
fn each_served_interaction_carries_its_own_spans_provenance() {
let script = Script(VecDeque::from([
span(Grade::HwShunt, 1_500), // interaction 1: a hardware reading
span(Grade::Estimator, 999_999), // the idle gap between the two
span(Grade::Estimator, 160_000), // interaction 2: the estimator's figure
]));
let mut h = host(Box::new(SpanMeter::new(script)));
let first = fire(&mut h);
assert_eq!((first["provenance"].as_str(), first["measured_uj"].as_u64()), (Some("HwShunt"), Some(1_500)));
let second = fire(&mut h);
assert_eq!((second["provenance"].as_str(), second["measured_uj"].as_u64()), (Some("Estimator"), Some(160_000)));
let chain = h.entries();
assert_eq!(verify_chain(chain), Ok(2));
let (a, b) = (chain[0].interaction.energy, chain[1].interaction.energy);
assert_eq!((a.provenance, a.measured_uj, a.estimated_uj), (Provenance::HwShunt, Some(1_500), 1_500));
assert_eq!((b.provenance, b.measured_uj, b.estimated_uj), (Provenance::Estimator, None, 160_000));
}
/// 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. A span that reads the CPU beside the
/// GPU (RAPL and NVML) is a reading, and is sealed as one.
#[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 both = span_of(
vec![record(DeviceClass::Cpu, Grade::HwShunt, 1_500), record(DeviceClass::Gpu, Grade::HwShunt, 9_000)],
"composite",
);
let script = Script(VecDeque::from([gpu_only, span(Grade::Estimator, 999_999), both]));
let mut h = host(Box::new(SpanMeter::new(script)));
let first = fire(&mut h);
assert_eq!((first["provenance"].as_str(), first["measured_uj"].as_u64()), (Some("Estimator"), Some(160_000)));
let second = fire(&mut h);
assert_eq!((second["provenance"].as_str(), second["measured_uj"].as_u64()), (Some("HwShunt"), Some(10_500)));
let chain = h.entries();
assert_eq!(verify_chain(chain), Ok(2));
let (a, b) = (chain[0].interaction.energy, chain[1].interaction.energy);
assert_eq!((a.provenance, a.measured_uj, a.estimated_uj), (Provenance::Estimator, None, 160_000));
assert_eq!((b.provenance, b.measured_uj, b.estimated_uj), (Provenance::HwShunt, Some(10_500), 10_500));
}
/// The served meter on this host, end to end: an interaction that ran is
/// never sealed at 0 J, and is `HwShunt` only from a hardware meter. Every
/// macOS 27.0.1 host checked has no hardware source, so there each
/// interaction is sealed as the estimator's figure for its work.
#[test]
fn the_served_meter_never_seals_zero_or_an_estimate_as_a_reading() {
let pm = PowerMeter::detect();
let hardware = pm.is_hardware();
let mut h = host(served_meter(pm));
for _ in 0..2 {
fire(&mut h);
}
for sealed in h.entries() {
let e = sealed.interaction.energy;
eprintln!("served interaction: hardware={hardware} {e:?}");
assert!(e.uj() > 0, "~24 ms of work sealed at 0 J: {e:?}");
match e.provenance {
Provenance::HwShunt => {
assert!(hardware, "a reading from a meter with no hardware source: {e:?}");
assert_eq!(e.measured_uj, Some(e.estimated_uj));
}
Provenance::Estimator => assert_eq!(e.measured_uj, None),
Provenance::ModelBased => panic!("this meter never apportions: {e:?}"),
}
}
assert_eq!(verify_chain(h.entries()), Ok(2));
}
}

View file

@ -190,6 +190,8 @@ impl Host {
"html": html,
"verdict": it.verdict.tag(),
"reason": match &it.verdict { joule_ui_interaction::Verdict::Deny(r) => Some(r.clone()), _ => None },
// The figure, under the key the page already reads; it is a
// reading only when the grade beside it says `HwShunt`.
"measured_uj": it.energy.uj(),
"provenance": it.energy.provenance.label(),
"budget_remaining_uj": it.budget_remaining_uj,
@ -217,6 +219,8 @@ mod tests {
use joule_ui_core::{PropValue, Widget};
use joule_ui_interaction::verify_chain;
/// A deterministic stub. It declares no grade, so what it reads is served
/// as what it is: an estimate.
struct StepMeter(Cell<u64>);
impl Meter for StepMeter {
fn sample_uj(&self) -> Option<u64> {
@ -261,8 +265,11 @@ mod tests {
assert_eq!(r.status, 200);
let j: Value = serde_json::from_str(&r.body).unwrap();
assert_eq!(j["verdict"], "allow");
// `measured_uj` keeps its name for the page reading it, and is the
// figure whatever its grade; the grade rides beside it. A stub's
// figure is never served as a reading.
assert_eq!(j["measured_uj"], 2500);
assert_eq!(j["provenance"], "HwShunt");
assert_eq!(j["provenance"], "Estimator");
assert!(j["html"].as_str().unwrap().contains("intensity 7"));
assert_eq!(j["seq"], 0);

View file

@ -6,7 +6,7 @@ Sandbox is the **isolated-execution substrate** of the Transaction Science open
Every other sandbox in the field isolates a **CPU process** — a container, a microVM, a Linux process pinned to a core. The accelerator, where present, is an opaque passthrough that nobody meters and nobody attests. But agentic and AI work does not run on a CPU. It runs across a **fabric** — CPU, GPU, NPU, TPU, photonic, quantum. So in this standard the unit of isolation is not a process; it is a **heterogeneous unit of work**, and the accelerator is a first-class citizen of the isolation boundary: a capability you grant, a resource you meter in joules, and a line item you attest.
A Sandbox run carries exactly four guarantees: **isolation** (the unit runs at the lowest tier of an eight-rung ladder, S0–S7, that satisfies its needs, and cannot reach past it), **capability** (every resource the unit touches — each device class included — was explicitly granted; ungranted access is denied), **metering** (the run emits a signed energy receipt with per-device-class joule attribution and an honest provenance tag on every reading — and the reference meter reads real hardware counters: Apple Silicon's energy model via IOReport, Intel/AMD RAPL via powercap, NVIDIA's total-energy counter via NVML, each tagged `HwShunt`; where no counter exists the reading degrades to a labeled estimator, never a fabricated measurement; and because a hardware counter reports the whole SoC, the meter can **attribute** it to a single process tree by CPU-time apportionment — tagged `ModelBased`, since the reading is a measurement but the split is a model), and **attestation** (a signed statement of *what image ran, at what tier, under what grants, on what hardware, for what cost*, with a deterministic-replay journal hash where one was produced).
A Sandbox run carries exactly four guarantees: **isolation** (the unit runs at the lowest tier of an eight-rung ladder, S0–S7, that satisfies its needs, and cannot reach past it), **capability** (every resource the unit touches — each device class included — was explicitly granted; ungranted access is denied), **metering** (the run emits a signed energy receipt with per-device-class joule attribution and an honest provenance tag on every reading — and the reference meter reads vendor energy counters: Apple Silicon's energy model via IOReport, Intel/AMD RAPL via powercap, NVIDIA's total-energy counter via NVML. A reading is tagged `HwShunt` only while its counter is demonstrably live: each vendor counter is used only if every rail of it that the meter sums into one figure keeps advancing while the meter's probe burns CPU — in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms — every IOReport CPU energy rail (the rail itself, one per die on a multi-die part), every RAPL package and every NVML GPU; and a span across which the CPU counter held is never a reading. A counter that is absent, or present but not live — frozen, or republished in lumps, as the IOReport CPU rails are on macOS 27 — yields no hardware-graded figure: the span degrades to the labeled `Estimator` (wall-clock × TDP), never a fabricated measurement, and `ioreport_status()`, `rapl_status()` and `nvml_status()` say why a host is estimated. Because a live counter reports the whole SoC, the meter can **attribute** it to a single process tree by CPU-time apportionment — tagged `ModelBased`, since the reading is a measurement but the split is a model), and **attestation** (a signed statement of *what image ran, at what tier, under what grants, on what hardware, for what cost*, with a deterministic-replay journal hash where one was produced).
The substrate is intentionally **ownerless**: no protocol-level token, permissive licensing on spec text and reference code, and conformance vectors so any implementer can verify canonicity without a trust relationship with the authors. A conforming host runs a unit, enforces its grants, meters its energy, and seals its attestation **without contacting any single steward's servers as a condition of operation**. Transaction Science is one steward: it publishes the spec, ships a reference implementation, and operates commercial services around the substrate — not its proprietor.
@ -30,7 +30,7 @@ Three consequences, normative in [`SPEC.md`](SPEC.md):
## Heterogeneity is normative, not an extension
The clause that distinguishes this standard from a process sandbox is in [`SPEC.md`](SPEC.md) §2.3 and §6.2: where a unit is granted an accelerator capability, a conforming host **MUST** attribute energy to that device class separately in the receipt, **MUST** record the device identity (class, model, driver) in the attestation, and at tiers S5 and above **MUST** report the measurement provenance honestly (`HwShunt` where the device exposes an energy interface — NVML, RAPL, ROCm SMI, Level Zero — `ModelBased` or `Estimator` otherwise). A host that runs accelerated work but reports a single undifferentiated CPU energy figure is **not conformant**.
The clause that distinguishes this standard from a process sandbox is in [`SPEC.md`](SPEC.md) §2.3 and §6.2: where a unit is granted an accelerator capability, a conforming host **MUST** attribute energy to that device class separately in the receipt, **MUST** record the device identity (class, model, driver) in the attestation, and at tiers S5 and above **MUST** report the measurement provenance honestly (`HwShunt` where the device exposes a live energy interface — NVML, RAPL, ROCm SMI, Level Zero — `ModelBased` or `Estimator` otherwise). A host that runs accelerated work but reports a single undifferentiated CPU energy figure is **not conformant**.
## Lineage

View file

@ -13,8 +13,10 @@
//! It spawns `firecracker --api-sock <sock>`, drives `PUT /boot-source`,
//! `/drives/rootfs`, `/machine-config`, and the `InstanceStart` action through
//! our unix-socket HTTP/1.1 client, lets the guest run briefly, and reads the
//! package energy counter before and after. A genuine `HwShunt` figure where
//! the MSR is readable; an honest `ModelBased` vCPU-seconds estimate otherwise.
//! package energy counters (every package's, and every die's) before and
//! after. A genuine `HwShunt` figure where the MSRs read and every package's
//! counter proved live; an honest `ModelBased` vCPU-seconds estimate
//! otherwise.
use sandbox_exec_microvm::firecracker::{FirecrackerConfig, FirecrackerVmm, VmState, Vmm};
use sandbox_exec_microvm::power::PowerMeter;
@ -49,10 +51,17 @@ fn main() {
}
// The meter: real RAPL via MSR where available (root on Nitro metal),
// else None → an honest vCPU-seconds ModelBased estimate.
// every package's and every die's, once each has proved live; else None
// → an honest vCPU-seconds ModelBased estimate.
#[cfg(target_os = "linux")]
let meter: Option<sandbox_exec_microvm::power::MsrRaplMeter> =
sandbox_exec_microvm::power::MsrRaplMeter::package0();
match sandbox_exec_microvm::power::MsrRaplMeter::host() {
Ok(m) => Some(m),
Err(why) => {
println!("no MSR meter: {why}");
None
}
};
#[cfg(not(target_os = "linux"))]
let meter: Option<sandbox_exec_microvm::power::NoMeter> = None;
@ -92,7 +101,7 @@ fn main() {
let device = match (before, after) {
(Some(a), Some(b)) => {
let uj = b.saturating_sub(a);
println!("CPU energy (RAPL pkg-0, MSR): {:.3} J over the window", uj as f64 / 1e6);
println!("CPU energy (RAPL, every package, MSR): {:.3} J over the window", uj as f64 / 1e6);
DeviceEnergy {
class: DeviceClass::Cpu,
measured_uj: Some(uj),

View file

@ -92,8 +92,12 @@ impl MicroVmExecutor {
fn cpu_energy(&self, before: Option<u64>, after: Option<u64>, vcpu_seconds: u64) -> DeviceEnergy {
match (before, after) {
// S5 with a hardware interface: HwShunt measured (SPEC §6.3).
(Some(b), Some(a)) if a >= b => DeviceEnergy {
// S5 with a hardware interface: HwShunt measured (SPEC §6.3) - but
// only if the counter advanced. vCPU work ran, so a CPU counter that
// held across it is not a reading of that work (a frozen or
// lump-refreshed counter); it falls to the model estimate below,
// never a measured zero. (Same rule as sandbox-meter's `Span::stop`.)
(Some(b), Some(a)) if a > b => DeviceEnergy {
class: DeviceClass::Cpu,
measured_uj: Some(a - b),
estimated_uj: a - b,
@ -270,6 +274,26 @@ mod tests {
assert_eq!(cpu.estimated_uj, 4 * 12_000);
}
/// vCPU work ran, so a CPU counter that held across it is not a reading
/// of that work: the figure is the model estimate, never a measured zero.
/// A counter that advanced is the reading.
#[test]
fn a_cpu_counter_that_held_across_a_run_is_not_a_measured_zero() {
let exec = MicroVmExecutor::new(
Box::new(MockVmm { state: VmState::Completed, vcpu_seconds: 4 }),
template(),
Box::new(NoMeter),
);
let held = exec.cpu_energy(Some(5_000), Some(5_000), 4);
assert_eq!(held.provenance, Provenance::ModelBased, "a held counter sealed as {held:?}");
assert_eq!(held.measured_uj, None);
assert_eq!(held.estimated_uj, 4 * 12_000);
let advanced = exec.cpu_energy(Some(5_000), Some(8_000), 4);
assert_eq!(advanced.provenance, Provenance::HwShunt);
assert_eq!(advanced.measured_uj, Some(3_000));
}
#[test]
fn failed_vm_is_terminated_as_tamper() {
// A VMM that reports a failed guest must surface Outcome::Terminated with

View file

@ -5,6 +5,26 @@
//! (SPEC §6.3). A [`PowerMeter`] abstracts that interface so the executor can
//! prefer a hardware reading and fall back to an honest `ModelBased` estimate
//! where none exists — tagging the provenance accordingly.
//!
//! **A CPU figure is every package's, and only a live counter's.** Both CPU
//! meters here follow the rule sandbox-meter's RAPL probe implements. The
//! figure sums every CPU package — every socket, and on a part whose RAPL
//! counter is per die, every die — and nothing that is not one: never one
//! socket's energy as the whole CPU's, never `psys` (whose count already
//! includes the packages') or `dram`. Every package or none: a package that
//! cannot be read leaves the meter unopened rather than out of the sum. Each
//! package's counter is wrap-corrected at its own range, and a counter whose
//! delta cannot be known gives no figure. And the meter reads nothing until
//! every package's counter has kept advancing under the sustained-advance
//! liveness rule (each one in at least five sample intervals, four in five of
//! those observed, over at least 300 ms, while the probing thread burned CPU):
//! a counter that held, or stepped only in a burst, is not a reading of a
//! run. A meter that did not open, or whose count broke, samples `None`, and
//! the executor estimates.
use sandbox_meter::probe::{ProbeError, RaplPackages};
use std::path::Path;
use std::sync::Mutex;
/// A source of cumulative energy readings for one device, in microjoules.
/// `sample()` returns the running counter; the executor takes a before/after
@ -15,86 +35,324 @@ pub trait PowerMeter: Send + Sync {
fn sample_uj(&self) -> Option<u64>;
}
/// The Linux RAPL / powercap CPU energy interface
/// (`/sys/class/powercap/intel-rapl:0/energy_uj`), or AMD's equivalent. Reads
/// the cumulative microjoule counter. Available only where the sysfs node
/// exists and is readable; elsewhere `sample_uj` returns `None`.
/// Linux RAPL CPU energy through the powercap interface
/// (`/sys/class/powercap/intel-rapl:<n>/energy_uj`, Intel's or AMD's): the
/// sum of every top-level zone whose `name` is `package-<n>`, each
/// wrap-corrected at its own `max_energy_range_uj`, as a cumulative counter
/// from when the meter opened. It is sandbox-meter's own RAPL probe
/// ([`RaplPackages`]): not `psys`, `dram`, a package's subzones or the
/// `intel-rapl-mmio` interface to the same packages; every package or none (a
/// package whose counter or range does not read, or a top-level zone whose
/// `name` does not, leaves the meter unopened); a package with no readable,
/// non-zero range never opens, and a fall from above a package's range breaks
/// the count for good, so that read and every later one sample `None`. It
/// opens only once every package counter has proved live.
pub struct RaplMeter {
path: String,
/// The package counters, or why there are none.
packages: Result<Mutex<RaplPackages>, String>,
}
impl RaplMeter {
/// The standard Intel RAPL package-0 energy node.
pub fn intel_package0() -> Self {
RaplMeter {
path: "/sys/class/powercap/intel-rapl:0/energy_uj".into(),
}
/// This host's package zones, read only when sandbox-meter's cached
/// [`rapl_status`](sandbox_meter::rapl_status) is `Live` (probed once per
/// process: about a second of CPU on the first caller's thread where
/// powercap reads). Otherwise a meter that samples `None` and says why
/// ([`unavailable`](Self::unavailable)); always so off Linux.
pub fn host() -> RaplMeter {
Self::opened(RaplPackages::host())
}
/// A RAPL/powercap node at an explicit sysfs path.
pub fn at(path: impl Into<String>) -> Self {
RaplMeter { path: path.into() }
/// The package zones of the powercap tree at `root`, read only when every
/// one proves live under the same rule, with `tick` driving the load
/// between liveness samples. For a tree other than the host's sysfs: a
/// test's fake tree.
pub fn live_in(root: &Path, tick: impl FnMut()) -> RaplMeter {
Self::opened(RaplPackages::live_in(root, tick))
}
fn opened(packages: Result<RaplPackages, ProbeError>) -> RaplMeter {
RaplMeter { packages: packages.map(Mutex::new).map_err(|e| e.to_string()) }
}
/// Why this meter reads nothing, if it did not open.
pub fn unavailable(&self) -> Option<&str> {
self.packages.as_ref().err().map(String::as_str)
}
}
impl PowerMeter for RaplMeter {
/// The packages' cumulative energy since the meter opened; `None` if it
/// did not open, if a package did not read, and from the read that broke
/// the count on.
fn sample_uj(&self) -> Option<u64> {
std::fs::read_to_string(&self.path)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
let packages = self.packages.as_ref().ok()?;
let mut packages = packages.lock().ok()?;
packages.cumulative_uj().ok()
}
}
/// RAPL package energy read **directly from the MSR** (`MSR_PKG_ENERGY_STATUS`,
/// `0x611`), scaled by the energy unit in `MSR_RAPL_POWER_UNIT` (`0x606`). This
/// is the same physical counter `RaplMeter` reads through powercap — but some
/// hosts expose the MSR while omitting the `intel_rapl` sysfs driver entirely.
/// AWS Nitro bare-metal is exactly this case: `/dev/cpu/N/msr` yields a live
/// energy counter on a kernel (`*-aws`) that ships no powercap node. Reading the
/// MSR needs `CAP_SYS_RAWIO` (root) and the `msr` module; where unavailable,
/// construction returns `None` and the executor falls back honestly.
///
/// `sample_uj` returns the package-0 cumulative microjoules. The raw MSR is a
/// 32-bit counter that wraps about every ~40 min at ~100 W; a before/after
/// difference over a short metered window is correct, which is the executor's
/// only use of it.
#[cfg(target_os = "linux")]
pub struct MsrRaplMeter {
/// Energy-status unit exponent (`ESU`): one tick is `1 / 2^esu` joules.
esu: u32,
}
pub use msr::MsrRaplMeter;
#[cfg(target_os = "linux")]
impl MsrRaplMeter {
/// Open the package-0 RAPL MSRs. `None` if `/dev/cpu/0/msr` is unreadable
/// (no `msr` module, or not root) or the registers don't respond.
pub fn package0() -> Option<MsrRaplMeter> {
let unit = read_msr(0, 0x606)?;
let esu = ((unit >> 8) & 0x1f) as u32;
if esu == 0 {
return None; // implausible unit — treat as unavailable
}
read_msr(0, 0x611)?; // confirm the energy counter responds
Some(MsrRaplMeter { esu })
}
}
/// Read one 64-bit MSR via `/dev/cpu/<cpu>/msr` at the register offset.
#[cfg(target_os = "linux")]
fn read_msr(cpu: u32, reg: u64) -> Option<u64> {
/// RAPL package energy read **directly from the MSRs**, for hosts that expose
/// `/dev/cpu/<n>/msr` but ship no powercap driver. Built on Linux, and under
/// test on every Unix, where it reads fake trees.
#[cfg(any(target_os = "linux", all(test, unix)))]
mod msr {
use super::PowerMeter;
use sandbox_meter::probe::{counter_liveness, CounterStatus};
use std::collections::BTreeMap;
use std::os::unix::fs::FileExt;
let f = std::fs::File::open(format!("/dev/cpu/{cpu}/msr")).ok()?;
let mut buf = [0u8; 8];
f.read_exact_at(&mut buf, reg).ok()?;
Some(u64::from_le_bytes(buf))
}
use std::path::{Path, PathBuf};
use std::sync::Mutex;
#[cfg(target_os = "linux")]
impl PowerMeter for MsrRaplMeter {
fn sample_uj(&self) -> Option<u64> {
let raw = read_msr(0, 0x611)? & 0xffff_ffff;
// ticks × (1e6 µJ/J) / 2^esu
Some(((raw as u128 * 1_000_000u128) >> self.esu) as u64)
/// `MSR_RAPL_POWER_UNIT`: bits 12:8 are the energy status unit (ESU).
const MSR_RAPL_POWER_UNIT: u64 = 0x606;
/// `MSR_PKG_ENERGY_STATUS`: bits 31:0 count the domain's energy in units
/// of `1 / 2^ESU` J; bits 63:32 are reserved.
const MSR_PKG_ENERGY_STATUS: u64 = 0x611;
/// RAPL package energy read directly from the MSRs
/// (`MSR_PKG_ENERGY_STATUS`, `0x611`), each domain scaled by the energy
/// unit in its own `MSR_RAPL_POWER_UNIT` (`0x606`). This is the counter
/// [`RaplMeter`](super::RaplMeter) reads through powercap, for hosts that
/// expose the MSR but omit the `intel_rapl` sysfs driver. AWS Nitro
/// bare-metal is exactly this case: `/dev/cpu/N/msr` yields a live energy
/// counter on a kernel (`*-aws`) that ships no powercap node. Reading the
/// MSR needs `CAP_SYS_RAWIO` (root) and the `msr` module; where
/// unavailable, construction fails, saying why, and the executor falls
/// back honestly.
///
/// **Every package.** The register is per package, and per die on a part
/// with more than one die in a package, so one CPU's read is one domain's
/// energy, not the machine's. The meter groups every CPU under
/// `/sys/devices/system/cpu` by its `topology/physical_package_id` and
/// `topology/die_id`, reads each domain through the first of its CPUs
/// whose `msr` device reads, and sums them. It does not open when a
/// domain cannot be read through any of its CPUs, 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 meter cannot show that the sum is
/// the whole CPU's. A host with offline CPUs (`nosmt` included) is
/// estimated.
///
/// **Wrap.** Each domain's counter is 32 bits: a fall is a wrap at 2^32
/// counts, its architectural range, so every delta between two reads is
/// known. More than one wrap between two reads cannot be seen: 2^32
/// counts at the common unit (2^-14 J) is about 262 kJ, some 44 minutes at
/// 100 W per package.
///
/// **Only when live.** It opens only once every domain's raw counter has
/// kept advancing under sandbox-meter's liveness rule
/// ([`counter_liveness`]), probed once per process for the host.
pub struct MsrRaplMeter {
domains: Mutex<MsrDomains>,
}
impl MsrRaplMeter {
/// This host's RAPL domains through `/dev/cpu/<n>/msr`, only when
/// every one proved live while the probing thread burned CPU: probed
/// once per process and cached, about a second of CPU on the first
/// caller's thread where the MSRs read. Otherwise `Err`, saying why.
#[cfg(target_os = "linux")]
pub fn host() -> Result<MsrRaplMeter, String> {
static STATUS: std::sync::OnceLock<CounterStatus> = std::sync::OnceLock::new();
let root = Path::new("/");
let status = *STATUS.get_or_init(|| status_over(root, sandbox_meter::probe::counter_liveness_tick));
Self::given(root, status)
}
/// The RAPL domains of the tree at `root` (its `sys/devices/system/cpu`
/// and `dev/cpu`), only when every one proves live, with `tick`
/// driving the load between liveness samples. For a tree other than
/// the host's: a test's fake tree. Not cached.
pub fn live_in(root: &Path, tick: impl FnMut()) -> Result<MsrRaplMeter, String> {
Self::given(root, status_over(root, tick))
}
/// The meter over the tree at `root`, for a liveness `status` of its
/// domains: opened only when it is `Live`.
fn given(root: &Path, status: CounterStatus) -> Result<MsrRaplMeter, String> {
if status != CounterStatus::Live {
let why = match MsrDomains::open_in(root) {
Err(why) => why,
Ok(_) => status.to_string(),
};
return Err(format!("RAPL MSR: {why}"));
}
let domains = MsrDomains::open_in(root).map_err(|why| format!("RAPL MSR: {why}"))?;
Ok(MsrRaplMeter { domains: Mutex::new(domains) })
}
/// The domains summed, `package-<p>-die-<d>`, each with the `msr`
/// device it is read through, in order.
pub fn domains(&self) -> Vec<(String, PathBuf)> {
match self.domains.lock() {
Ok(d) => d.0.iter().map(|d| (d.name.clone(), d.msr.clone())).collect(),
Err(_) => Vec::new(),
}
}
}
impl PowerMeter for MsrRaplMeter {
/// Every domain's cumulative energy since the meter opened, summed;
/// `None` if a domain did not read (nothing is updated then).
fn sample_uj(&self) -> Option<u64> {
self.domains.lock().ok()?.cumulative_uj()
}
}
/// The liveness run over the domains of the tree at `root`: never ticked
/// if they do not open.
fn status_over(root: &Path, tick: impl FnMut()) -> CounterStatus {
counter_liveness(|| MsrDomains::open_in(root).ok().map(|d| move || d.raw_sample()), tick)
}
/// One RAPL energy domain: a die of a package (the package itself on a
/// single-die part).
struct MsrDomain {
/// `package-<p>-die-<d>`: its rail name for the liveness rule.
name: String,
/// The `msr` device of the CPU it is read through.
msr: PathBuf,
/// Its energy status unit: one count is `1 / 2^esu` J. Non-zero.
esu: u32,
/// The counter's last read.
last_raw: u32,
/// Counts since the meter opened, wrap-corrected at 2^32.
counts: u64,
}
struct MsrDomains(Vec<MsrDomain>);
impl MsrDomains {
/// Every (package, die) of the CPUs under `root/sys/devices/system/cpu`,
/// each read through the first of its CPUs whose
/// `root/dev/cpu/<n>/msr` reads both registers. `Err` when there is no
/// CPU, when a CPU's package or die cannot be read, when a domain
/// cannot be read through any of its CPUs, or when a domain's energy
/// unit is zero.
fn open_in(root: &Path) -> Result<MsrDomains, String> {
let cpu_dir = root.join("sys/devices/system/cpu");
let entries = std::fs::read_dir(&cpu_dir).map_err(|e| format!("{}: {e}", cpu_dir.display()))?;
let mut by_domain: BTreeMap<(u32, u32), Vec<u32>> = BTreeMap::new();
for entry in entries.flatten() {
let Some(cpu) = cpu_number(&entry.file_name().to_string_lossy()) else {
continue; // cpufreq, cpuidle, online, ...
};
let topology = entry.path().join("topology");
// An offline CPU has no topology: its package is unknown, and
// could be one no online CPU covers.
let id = |file: &str| {
read_u32(&topology.join(file)).map_err(|e| {
format!("cpu{cpu} has no readable {file} ({e}): its package is unknown, and could be one no other CPU covers")
})
};
let key = (id("physical_package_id")?, id("die_id")?);
by_domain.entry(key).or_default().push(cpu);
}
if by_domain.is_empty() {
return Err(format!("no CPU under {}", cpu_dir.display()));
}
let mut domains = Vec::with_capacity(by_domain.len());
for ((package, die), mut cpus) in by_domain {
cpus.sort_unstable();
let name = format!("package-{package}-die-{die}");
let read = cpus.iter().find_map(|cpu| {
let msr = root.join(format!("dev/cpu/{cpu}/msr"));
let unit = read_msr(&msr, MSR_RAPL_POWER_UNIT)?;
let raw = read_msr(&msr, MSR_PKG_ENERGY_STATUS)?;
Some((msr, unit, raw))
});
let Some((msr, unit, raw)) = read else {
return Err(format!("{name}: the RAPL MSRs of none of its CPUs {cpus:?} read"));
};
let esu = ((unit >> 8) & 0x1f) as u32;
if esu == 0 {
return Err(format!("{name}: an implausible energy unit (ESU 0)"));
}
domains.push(MsrDomain { name, msr, esu, last_raw: low32(raw), counts: 0 });
}
Ok(MsrDomains(domains))
}
/// One absolute sample for the liveness rule: every domain's name and
/// raw 32-bit count, in domain order, as read (a wrap reads as a
/// fall, which is not an advance). `None` if any domain did not read.
fn raw_sample(&self) -> Option<Vec<(String, i64)>> {
self.0
.iter()
.map(|d| read_msr(&d.msr, MSR_PKG_ENERGY_STATUS).map(|raw| (d.name.clone(), i64::from(low32(raw)))))
.collect()
}
/// Every domain's energy since open, summed, in microjoules: each
/// domain's counts, wrap-corrected at 2^32, at its own unit. Every
/// domain is read before any is updated: a domain that does not read
/// gives `None` and changes nothing.
fn cumulative_uj(&mut self) -> Option<u64> {
let raws = self
.0
.iter()
.map(|d| read_msr(&d.msr, MSR_PKG_ENERGY_STATUS).map(low32))
.collect::<Option<Vec<_>>>()?;
let mut total = 0u64;
for (d, raw) in self.0.iter_mut().zip(raws) {
d.counts = d.counts.saturating_add(u64::from(raw.wrapping_sub(d.last_raw)));
d.last_raw = raw;
total = total.saturating_add(counts_to_uj(d.counts, d.esu));
}
Some(total)
}
}
/// `cpu<n>` → `n`; `None` for anything else under the cpu directory.
fn cpu_number(dir_name: &str) -> Option<u32> {
let n = dir_name.strip_prefix("cpu")?;
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
n.parse().ok()
}
/// The energy counter's 32 bits; the reserved upper half is ignored.
fn low32(raw: u64) -> u32 {
(raw & 0xffff_ffff) as u32
}
/// `counts` at `1 / 2^esu` J each, in microjoules, rounded down.
fn counts_to_uj(counts: u64, esu: u32) -> u64 {
u64::try_from((u128::from(counts) * 1_000_000) >> esu).unwrap_or(u64::MAX)
}
fn read_u32(p: &Path) -> Result<u32, String> {
let s = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
s.trim().parse::<u32>().map_err(|e| format!("{:?}: {e}", s.trim()))
}
/// Read one 64-bit MSR through an `msr` device at the register offset.
fn read_msr(dev: &Path, reg: u64) -> Option<u64> {
let f = std::fs::File::open(dev).ok()?;
let mut buf = [0u8; 8];
f.read_exact_at(&mut buf, reg).ok()?;
Some(u64::from_le_bytes(buf))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_unit_conversion_and_the_cpu_names() {
assert_eq!(counts_to_uj(16_384, 14), 1_000_000, "2^14 counts at 2^-14 J is a joule");
assert_eq!(counts_to_uj(1, 14), 61, "one count is 61.035 µJ, rounded down");
assert_eq!(counts_to_uj(256, 14), 15_625);
assert_eq!(counts_to_uj(u64::MAX, 1), u64::MAX, "saturates");
assert_eq!(low32(0xabcd_ef01_0000_0102), 0x0000_0102, "the reserved half is ignored");
assert_eq!((cpu_number("cpu0"), cpu_number("cpu17")), (Some(0), Some(17)));
for other in ["cpu", "cpufreq", "cpuidle", "cpu1a", "online", "possible"] {
assert_eq!(cpu_number(other), None, "{other}");
}
}
}
}
@ -122,18 +380,21 @@ impl PowerMeter for FixedMeter {
#[cfg(test)]
mod tests {
use super::*;
use crate::firecracker::{FirecrackerConfig, VmOutcome, VmState, Vmm, VmmError};
use crate::MicroVmExecutor;
use sandbox_capability::{ComputeGrant, Grants};
use sandbox_meter::{DeviceEnergy, Provenance};
use sandbox_runtime::{Executor, Unit};
use sandbox_schema::DeviceClass;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst};
use std::time::Duration;
#[test]
fn nometer_is_none() {
assert_eq!(NoMeter.sample_uj(), None);
}
#[test]
fn missing_rapl_node_is_none() {
let m = RaplMeter::at("/nonexistent/sandbox/rapl/energy_uj");
assert_eq!(m.sample_uj(), None);
}
#[test]
fn fixed_meter_advances() {
let m = FixedMeter(std::sync::atomic::AtomicU64::new(0));
@ -141,4 +402,405 @@ mod tests {
let b = m.sample_uj().unwrap();
assert!(b > a);
}
/// A fake tree in the OS temp directory, removed when dropped.
struct FakeTree {
root: PathBuf,
}
impl FakeTree {
fn new(label: &str) -> FakeTree {
static N: AtomicUsize = AtomicUsize::new(0);
let root = std::env::temp_dir().join(format!(
"sandbox-microvm-{label}-{}-{}",
std::process::id(),
N.fetch_add(1, SeqCst)
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create a fake tree");
FakeTree { root }
}
}
impl Drop for FakeTree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// Write `bytes` to `root/rel` atomically (a reader sees the old file or
/// the new one), creating its directory.
fn put(root: &Path, rel: &str, bytes: &[u8]) {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).unwrap();
std::fs::rename(&tmp, &path).unwrap();
}
/// Run `f` while another thread calls `write` with a rising step every
/// 2 ms; the thread stops when `f` returns or panics.
fn advancing<T>(write: impl Fn(u64) + Sync, f: impl FnOnce() -> T) -> T {
struct StopOnDrop<'a>(&'a AtomicBool);
impl Drop for StopOnDrop<'_> {
fn drop(&mut self) {
self.0.store(true, SeqCst);
}
}
let stop = AtomicBool::new(false);
std::thread::scope(|s| {
s.spawn(|| {
let mut step = 0;
while !stop.load(SeqCst) {
step += 1;
write(step);
std::thread::sleep(Duration::from_millis(2));
}
});
let _stop = StopOnDrop(&stop);
f()
})
}
/// The liveness tick the tests drive: a sleep, while a writer thread
/// advances the counters.
fn sleep_tick() {
std::thread::sleep(Duration::from_millis(25));
}
/// A VMM whose "run" is a script over the fake tree.
struct ScriptedVmm(Box<dyn Fn()>);
impl Vmm for ScriptedVmm {
fn run(&self, _cfg: &FirecrackerConfig) -> Result<VmOutcome, VmmError> {
(self.0)();
Ok(VmOutcome { state: VmState::Completed, vcpu_seconds: 2 })
}
}
/// The CPU record of one executor run over `meter`, the VMM's run being
/// `during`.
fn cpu_record_of(meter: Box<dyn PowerMeter>, during: impl Fn() + 'static) -> DeviceEnergy {
let template = FirecrackerConfig {
api_socket: "/run/fc.sock".into(),
kernel_image_path: "/img/vmlinux".into(),
rootfs_path: "/img/rootfs.ext4".into(),
boot_args: "console=ttyS0".into(),
vcpus: 2,
mem_mib: 256,
};
let exec = MicroVmExecutor::new(Box::new(ScriptedVmm(Box::new(during))), template, meter);
let grants = Grants { compute: ComputeGrant::cpu_only(1_000_000, 1000, 256 * 1024 * 1024), ..Grants::default() };
let report = exec.run(&Unit::new(b"vm-image", &grants)).unwrap();
assert_eq!(report.device_energy.len(), 1);
let cpu = report.device_energy[0];
assert_eq!(cpu.class, DeviceClass::Cpu);
cpu
}
// ---- RAPL through powercap.
/// A powercap zone: its directory, `name`, `energy_uj` and
/// `max_energy_range_uj` (`None`: no such file).
type Zone<'a> = (&'a str, &'a str, u64, Option<u64>);
fn powercap(zones: &[Zone<'_>]) -> FakeTree {
let tree = FakeTree::new("powercap");
for (dir, name, uj, range) in zones {
put(&tree.root, &format!("{dir}/name"), format!("{name}\n").as_bytes());
if let Some(range) = range {
put(&tree.root, &format!("{dir}/max_energy_range_uj"), format!("{range}\n").as_bytes());
}
set_uj(&tree.root, dir, *uj);
}
tree
}
fn set_uj(root: &Path, dir: &str, uj: u64) {
put(root, &format!("{dir}/energy_uj"), format!("{uj}\n").as_bytes());
}
fn uj_of(root: &Path, dir: &str) -> u64 {
std::fs::read_to_string(root.join(dir).join("energy_uj")).unwrap().trim().parse().unwrap()
}
/// Two sockets with a platform zone: packages `intel-rapl:0` and
/// `intel-rapl:2` (each wrapping at a range of its own), `psys` at
/// `intel-rapl:1`, a top-level `dram` zone at `intel-rapl:3`, subzones,
/// and the MMIO interface to package 0.
fn two_sockets() -> FakeTree {
powercap(&[
("intel-rapl:0", "package-0", 1_000, Some(262_143_328_850)),
("intel-rapl:1", "psys", 90_000, Some(262_143_328_850)),
("intel-rapl:2", "package-1", 2_000, Some(65_532_610_987)),
("intel-rapl:3", "dram", 30_000, Some(65_532_610_987)),
("intel-rapl:0:0", "core", 700, Some(262_143_328_850)),
("intel-rapl:0:1", "dram", 800, Some(65_532_610_987)),
("intel-rapl-mmio:0", "package-0", 1_000, Some(262_143_328_850)),
])
}
/// `RaplMeter::live_in` over `tree` while the zones in `advancing_zones`
/// rise by 4 mJ every 2 ms and the rest hold.
fn rapl_live_in(tree: &FakeTree, advancing_zones: &[&str]) -> RaplMeter {
advancing(
|step| {
for dir in advancing_zones {
set_uj(&tree.root, dir, 100_000 + 4_000 * step);
}
},
|| RaplMeter::live_in(&tree.root, sleep_tick),
)
}
/// The CPU figure is every package zone's and no other zone's: over two
/// live sockets with `psys`, `dram`, subzones and the MMIO interface all
/// rising too, a difference is the two packages' rise alone; package 1
/// wraps at its own range, not package 0's; and a run through the
/// executor seals the packages' rise as `HwShunt`.
#[test]
fn the_rapl_meter_sums_every_package_zone_and_no_other() {
let tree = two_sockets();
let meter = rapl_live_in(&tree, &["intel-rapl:0", "intel-rapl:2"]);
assert_eq!(meter.unavailable(), None, "both packages live, yet unopened");
let rises = [
("intel-rapl:0", 3_000),
("intel-rapl:1", 50_000),
("intel-rapl:2", 5_000),
("intel-rapl:3", 9_000),
("intel-rapl:0:0", 2_000),
("intel-rapl:0:1", 1_000),
("intel-rapl-mmio:0", 3_000),
];
let a = meter.sample_uj().expect("a live meter reads");
for (dir, by) in rises {
set_uj(&tree.root, dir, uj_of(&tree.root, dir) + by);
}
let b = meter.sample_uj().expect("a live meter reads");
assert_eq!(b - a, 8_000, "not the two packages' rise alone");
// Package 1 wraps at ITS range, 65,532,610,987 µJ: 65,532,600,000 ->
// 500 is 11,487 µJ. At package 0's range it would be ~1.97e11.
set_uj(&tree.root, "intel-rapl:2", 65_532_600_000);
let a = meter.sample_uj().unwrap();
set_uj(&tree.root, "intel-rapl:2", 500);
assert_eq!(meter.sample_uj().unwrap() - a, 11_487, "package 1 did not wrap at its own range");
// Through the executor: the run raises both packages and psys.
let root = tree.root.clone();
let cpu = cpu_record_of(Box::new(meter), move || {
for (dir, by) in [("intel-rapl:0", 1_000), ("intel-rapl:1", 70_000), ("intel-rapl:2", 2_000)] {
set_uj(&root, dir, uj_of(&root, dir) + by);
}
});
assert_eq!((cpu.measured_uj, cpu.provenance), (Some(3_000), Provenance::HwShunt));
}
/// The meter reads nothing, and the executor estimates, unless every
/// package counter is live and every package can be read and corrected:
/// package 1 holding beside a live `psys`, `dram`, MMIO zone and package
/// 0; a tree with no package zone; a package with no wrap range. The
/// last two never open, so they are never probed.
#[test]
fn the_rapl_meter_reads_nothing_unless_every_package_is_live() {
let tree = two_sockets();
let meter = rapl_live_in(&tree, &["intel-rapl:0", "intel-rapl:1", "intel-rapl:3", "intel-rapl-mmio:0"]);
let why = meter.unavailable().expect("package 1 held, yet the meter opened").to_string();
assert!(why.contains("RAPL"), "{why}");
assert_eq!(meter.sample_uj(), None);
let cpu = cpu_record_of(Box::new(meter), || {});
assert_eq!((cpu.measured_uj, cpu.provenance), (None, Provenance::ModelBased));
for (tree, expect) in [
(powercap(&[("intel-rapl:0", "psys", 90_000, Some(262_143_328_850))]), "no RAPL package zone"),
(powercap(&[("intel-rapl:0", "package-0", 1_000, None)]), "wrap range"),
] {
let ticks = AtomicUsize::new(0);
let meter = RaplMeter::live_in(&tree.root, || {
ticks.fetch_add(1, SeqCst);
});
let why = meter.unavailable().expect("opened").to_string();
assert!(why.contains("RAPL"), "{why}");
assert_eq!((meter.sample_uj(), ticks.load(SeqCst)), (None, 0), "{expect}");
}
}
/// A fall from above a package's range is a delta nobody knows: that read
/// and every later one give no figure, even once the counter is back up,
/// so a run after it is estimated, never measured short of the energy
/// that went uncounted.
#[test]
fn a_fall_from_above_a_package_range_stops_the_rapl_meter_for_good() {
let tree = powercap(&[("intel-rapl:0", "package-0", 1_000, Some(1_000_000_000))]);
let meter = rapl_live_in(&tree, &["intel-rapl:0"]);
assert_eq!(meter.unavailable(), None);
set_uj(&tree.root, "intel-rapl:0", 1_500_000_000);
assert!(meter.sample_uj().is_some(), "a rise reads");
set_uj(&tree.root, "intel-rapl:0", 10);
assert_eq!(meter.sample_uj(), None, "a fall from above the range gave a figure");
set_uj(&tree.root, "intel-rapl:0", 2_000_000_000);
assert_eq!(meter.sample_uj(), None, "a broken count read again");
let root = tree.root.clone();
let cpu = cpu_record_of(Box::new(meter), move || set_uj(&root, "intel-rapl:0", 2_100_000_000));
assert_eq!((cpu.measured_uj, cpu.provenance), (None, Provenance::ModelBased));
}
// ---- RAPL through the MSRs: Unix only, where the meter is built under test.
#[cfg(unix)]
mod msr_meter {
use super::*;
/// `MSR_RAPL_POWER_UNIT` with an energy status unit of `esu` (and the
/// usual power and time units around it).
fn unit_raw(esu: u64) -> u64 {
0x000a_0003 | (esu << 8)
}
/// Write CPU `cpu`'s fake `msr` device: the unit register and the energy
/// status register at their offsets, the rest zero.
fn set_msr(root: &Path, cpu: u32, unit: u64, status: u64) {
let mut bytes = vec![0u8; 0x611 + 8];
bytes[0x606..0x606 + 8].copy_from_slice(&unit.to_le_bytes());
bytes[0x611..0x611 + 8].copy_from_slice(&status.to_le_bytes());
put(root, &format!("dev/cpu/{cpu}/msr"), &bytes);
}
/// A CPU's topology: `None` for no topology directory (offline).
fn set_cpu(root: &Path, cpu: u32, package_die: Option<(u32, u32)>) {
let dir = format!("sys/devices/system/cpu/cpu{cpu}");
std::fs::create_dir_all(root.join(&dir)).unwrap();
if let Some((package, die)) = package_die {
put(root, &format!("{dir}/topology/physical_package_id"), format!("{package}\n").as_bytes());
put(root, &format!("{dir}/topology/die_id"), format!("{die}\n").as_bytes());
}
}
/// A two-socket host whose package 1 has two dies, with ESU 14: cpu0,
/// cpu1 and cpu5 on package 0; cpu2 and cpu3 on package 1 die 0, cpu2's
/// `msr` device absent (read through cpu3); cpu4 on package 1 die 1. The
/// `cpufreq` directory beside them is not a CPU.
fn three_domains() -> FakeTree {
let tree = FakeTree::new("msr");
for (cpu, pd) in [(0, (0, 0)), (1, (0, 0)), (5, (0, 0)), (2, (1, 0)), (3, (1, 0)), (4, (1, 1))] {
set_cpu(&tree.root, cpu, Some(pd));
}
std::fs::create_dir_all(tree.root.join("sys/devices/system/cpu/cpufreq")).unwrap();
for cpu in [0, 1, 3, 4, 5] {
set_msr(&tree.root, cpu, unit_raw(14), 1_000_192);
}
tree
}
/// `MsrRaplMeter::live_in` over `tree` while the CPUs in `advancing_cpus`
/// count up by 4,096 every 2 ms (a multiple of 256, so every figure here
/// is a whole number of microjoules at ESU 14) and the rest hold.
fn msr_live_in(tree: &FakeTree, advancing_cpus: &[u32]) -> Result<msr::MsrRaplMeter, String> {
advancing(
|step| {
for cpu in advancing_cpus {
set_msr(&tree.root, *cpu, unit_raw(14), 1_000_192 + 4_096 * step);
}
},
|| msr::MsrRaplMeter::live_in(&tree.root, sleep_tick),
)
}
fn msr_status_of(root: &Path, cpu: u32) -> u64 {
let bytes = std::fs::read(root.join(format!("dev/cpu/{cpu}/msr"))).unwrap();
u64::from_le_bytes(bytes[0x611..0x611 + 8].try_into().unwrap())
}
/// The MSR meter reads every package, and every die of a multi-die
/// package, each through one CPU whose `msr` device reads, and sums
/// them at each domain's own unit; a run through the executor seals
/// that sum as `HwShunt`.
#[test]
fn the_msr_meter_reads_every_package_and_die() {
let tree = three_domains();
let meter = msr_live_in(&tree, &[0, 3, 4]).expect("every domain live");
let held: Vec<(String, PathBuf)> = meter.domains();
let cpu_msr = |cpu: u32| tree.root.join(format!("dev/cpu/{cpu}/msr"));
assert_eq!(
held,
[
("package-0-die-0".to_string(), cpu_msr(0)),
("package-1-die-0".to_string(), cpu_msr(3)),
("package-1-die-1".to_string(), cpu_msr(4)),
],
"not one CPU per package and die"
);
// 256, 512 and 16,384 counts at ESU 14: 15,625 + 31,250 +
// 1,000,000 µJ. The reserved upper half of package 0's register
// changes too, and is ignored.
let a = meter.sample_uj().expect("a live meter reads");
set_msr(&tree.root, 0, unit_raw(14), (0xdead_u64 << 32) | (msr_status_of(&tree.root, 0) + 256));
set_msr(&tree.root, 3, unit_raw(14), msr_status_of(&tree.root, 3) + 512);
set_msr(&tree.root, 4, unit_raw(14), msr_status_of(&tree.root, 4) + 16_384);
let b = meter.sample_uj().expect("a live meter reads");
assert_eq!(b - a, 1_046_875, "not every package's and die's energy");
// Through the executor: the run raises package 1 die 1 by a joule.
let root = tree.root.clone();
let cpu = cpu_record_of(Box::new(meter), move || {
set_msr(&root, 4, unit_raw(14), msr_status_of(&root, 4) + 16_384);
});
assert_eq!((cpu.measured_uj, cpu.provenance), (Some(1_000_000), Provenance::HwShunt));
}
/// Each domain's 32-bit counter wraps at 2^32 counts, on its own: a fall
/// from near the top to near the bottom is the counts across the wrap.
#[test]
fn the_msr_meter_wraps_each_domain_at_32_bits() {
let tree = three_domains();
let meter = msr_live_in(&tree, &[0, 3, 4]).expect("every domain live");
set_msr(&tree.root, 3, unit_raw(14), 0xffff_ff00);
let a = meter.sample_uj().unwrap();
set_msr(&tree.root, 3, unit_raw(14), 0x0000_0100);
assert_eq!(meter.sample_uj().unwrap() - a, 31_250, "512 counts across the wrap");
}
/// The MSR meter does not open, and the executor estimates, when it
/// cannot show that its sum is the whole CPU's or that every domain is
/// live: a domain holding beside live ones; an offline CPU (no
/// topology), whose package is unknown; a CPU with no `die_id`; a domain
/// none of whose CPUs' MSRs read; a zero energy unit; no CPU at all. The
/// ones that never open are never probed.
#[test]
fn the_msr_meter_declines_what_it_cannot_cover() {
let tree = three_domains();
let why = match msr_live_in(&tree, &[0, 3]) {
Err(why) => why,
Ok(m) => panic!("opened {:?} with package 1 die 1 holding", m.domains()),
};
assert!(why.contains("RAPL MSR") && why.contains("kept advancing"), "{why}");
let unopened = |tree: &FakeTree, expect: &str| {
let ticks = AtomicUsize::new(0);
match msr::MsrRaplMeter::live_in(&tree.root, || {
ticks.fetch_add(1, SeqCst);
}) {
Err(why) => assert!(why.contains(expect), "{expect}: {why}"),
Ok(m) => panic!("{expect}: opened {:?}", m.domains()),
}
assert_eq!(ticks.load(SeqCst), 0, "{expect}: an unopened meter was probed");
};
let tree = three_domains();
set_cpu(&tree.root, 6, None);
unopened(&tree, "cpu6 has no readable physical_package_id");
let tree = three_domains();
std::fs::remove_file(tree.root.join("sys/devices/system/cpu/cpu5/topology/die_id")).unwrap();
unopened(&tree, "cpu5 has no readable die_id");
let tree = three_domains();
std::fs::remove_file(tree.root.join("dev/cpu/3/msr")).unwrap();
unopened(&tree, "package-1-die-0: the RAPL MSRs of none of its CPUs [2, 3] read");
let tree = three_domains();
set_msr(&tree.root, 4, unit_raw(0), 1_000_192);
unopened(&tree, "package-1-die-1: an implausible energy unit");
let tree = FakeTree::new("msr-empty");
std::fs::create_dir_all(tree.root.join("sys/devices/system/cpu")).unwrap();
unopened(&tree, "no CPU");
}
}
}

View file

@ -16,7 +16,9 @@ thiserror.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
libloading = "0.8"
[target.'cfg(target_os = "macos")'.dependencies]
# 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]]

View file

@ -1,46 +1,97 @@
//! `meter-cmd` — a `time`-like energy bracket: measure the machine's energy
//! over the execution of an external command, with an idle baseline of equal
//! length so the workload's marginal draw is honest.
//! `meter-cmd` — a `time`-like energy bracket: the machine's energy over the
//! execution of an external command, labelled by how it was obtained.
//!
//! Counters are machine-wide (not process-scoped) — that caveat is printed,
//! not hidden. On a quiet machine, `work − idle` approximates the workload.
//! Where the meter reads hardware counters, the span is a measurement, and an
//! idle baseline of equal length gives the workload's marginal draw. The
//! counters are machine-wide (not process-scoped) — that caveat is printed,
//! not hidden: on a quiet machine, `work − idle` approximates the workload.
//! Where the meter could only estimate (no live hardware counter — on Apple
//! Silicon, see `ioreport_status()`), the span is wall-clock × TDP, printed
//! with its provenance. A `work − idle` difference of two such estimates is
//! not the workload's share of anything, so none is printed.
//!
//! Run: `cargo run --release --example meter-cmd -- <program> [args…]`
//! Run: `cargo run --release --example meter_cmd -- <program> [args…]`
use sandbox_meter::PowerMeter;
use sandbox_meter::{IoReportStatus, Measurement, PowerMeter};
use std::process::Command;
/// How a span's figure was obtained, from its own records.
fn obtained(m: &Measurement) -> &'static str {
if m.fully_measured() {
"hardware-measured"
} else if m.devices.iter().all(|d| d.measured_uj.is_none()) {
"estimated"
} else {
"partly estimated"
}
}
/// Each record's provenance, e.g. `Cpu Estimator`.
fn grades(m: &Measurement) -> String {
m.devices
.iter()
.map(|d| format!("{:?} {}", d.class, d.provenance.label()))
.collect::<Vec<_>>()
.join(", ")
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let (program, rest) = args.split_first().expect("usage: meter-cmd <program> [args…]");
let mut meter = PowerMeter::detect();
let ioreport = sandbox_meter::ioreport_status();
eprintln!(
"meter-cmd: probes [{}]",
if meter.is_hardware() { meter.sources().join(", ") } else { "estimator".into() }
if meter.is_hardware() {
meter.sources().join(", ")
} else if ioreport != IoReportStatus::Unavailable {
// Apple Silicon: say why the SoC counters were declined.
format!("none — estimator fallback ({ioreport})")
} else {
"none — estimator fallback".into()
}
);
// measure the command (the workload inherits this process's environment)
let (status, work) = meter.measure(|| {
Command::new(program).args(rest).status().expect("spawn command")
});
// an idle baseline of the same wall length, for the marginal figure
let dur = work.wall;
let ((), idle) = meter.measure(|| std::thread::sleep(dur));
let work_j = work.total_uj() as f64 / 1e6;
let idle_j = idle.total_uj() as f64 / 1e6;
let marginal = (work_j - idle_j).max(0.0);
println!("\n== meter-cmd result ==");
println!("command exited: {status}");
println!("wall: {:.1} s", work.wall.as_secs_f64());
println!("machine (work): {work_j:.3} J ({:.2} W avg)", work.avg_watts());
println!("machine (idle): {idle_j:.3} J ({:.2} W avg)", idle.avg_watts());
println!("marginal: {marginal:.3} J (work − idle, the workload's honest share)");
println!(
"source: {} ({})",
work.source,
if work.fully_measured() { "hardware-measured" } else { "partly estimated" }
);
println!("machine (work): {work_j:.3} J ({:.2} W avg) [{}]", work.avg_watts(), grades(&work));
if work.fully_measured() {
// An idle baseline of the same wall length, for the marginal figure.
let dur = work.wall;
let ((), idle) = meter.measure(|| std::thread::sleep(dur));
let idle_j = idle.total_uj() as f64 / 1e6;
println!("machine (idle): {idle_j:.3} J ({:.2} W avg) [{}]", idle.avg_watts(), grades(&idle));
if !idle.fully_measured() {
println!("marginal: none — the idle baseline was {}, not a reading", obtained(&idle));
} else if work_j > idle_j {
println!(
"marginal: {:.3} J (work − idle over machine-wide counters: the workload's \
draw, on a quiet machine)",
work_j - idle_j
);
} else {
// Never a floored 0 for work that ran: the counters did not
// resolve this workload above the idle draw.
println!(
"marginal: not resolved — work ({work_j:.3} J) did not exceed idle ({idle_j:.3} J) \
on machine-wide counters"
);
}
} else {
println!(
"estimate: {work_j:.3} J ({}) — wall-clock × TDP, not a reading; no work − idle \
marginal, since two such estimates differ only by duration",
obtained(&work)
);
}
println!("source: {} ({})", work.source, obtained(&work));
}

View file

@ -5,7 +5,7 @@
//! Usage: `joule-meter [seconds]` (default 2).
use ed25519_dalek::SigningKey;
use sandbox_meter::{PowerMeter, Receipt};
use sandbox_meter::{IoReportStatus, PowerMeter, Receipt};
use sandbox_schema::{ContentId, Tier};
use std::time::{Duration, Instant};
@ -37,10 +37,15 @@ fn main() {
.unwrap_or(2.0);
let mut meter = PowerMeter::detect();
let ioreport = sandbox_meter::ioreport_status();
println!(
"probes: {}",
if meter.is_hardware() {
meter.sources().join(", ")
} else if ioreport != IoReportStatus::Unavailable {
// Apple Silicon: say why the SoC counters were declined, so an
// estimated host is not misread as a missing or broken probe.
format!("none — estimator fallback ({ioreport})")
} else {
"none — estimator fallback".into()
}
@ -74,8 +79,13 @@ fn main() {
}
println!(" total {:.3} J avg {:.2} W", work.total_uj() as f64 / 1e6, work.avg_watts());
println!(
" source: {} (machine-wide counters; subtract idle for the workload's marginal draw)",
work.source
" source: {} ({})",
work.source,
if work.fully_measured() {
"machine-wide counters; subtract idle for the workload's marginal draw"
} else {
"wall-clock × TDP estimate, not a reading: idle and busy differ only by duration"
}
);
// Seal it: the reading becomes a signed, verifiable receipt.

View file

@ -15,7 +15,10 @@ pub mod jcr1;
pub mod probe;
pub use contention::{StorageActivity, StorageMeterError};
pub use probe::{run_attributed, Attribution, Measurement, PowerMeter};
pub use probe::{
ioreport_status, nvml_status, rapl_status, run_attributed, Attribution, CounterStatus,
IoReportStatus, Measurement, PowerMeter, CPU_RAIL_LIVENESS_BUDGET,
};
/// The receipt's JCR-1 profile tag (SPEC §6.4).
pub const PROFILE: &str = "sandbox/1";

View file

@ -15,6 +15,14 @@
//! side — and downstream the attributed joules grade one rung below a direct
//! shunt, which is exactly right.
//!
//! An attribution rests on a measurement or it is not made. `attributed` is
//! true only when the window's CPU-class figure is an `HwShunt` reading that
//! advanced, and the child and the machine both logged CPU time. When the
//! meter could only estimate the window — no hardware probe, as on an Apple
//! Silicon host whose CPU energy rail is frozen, or a CPU counter that held
//! across the window — `attributed` is false and `soc_cpu_uj` is the
//! estimator's figure: carried as context, never an apportioned reading.
//!
//! macOS (Apple Silicon) is implemented natively here. On other platforms the
//! attributed runner falls back to running the command unattributed and
//! reporting `None` — a caller then keeps the machine-wide figure, honestly
@ -25,9 +33,13 @@ use std::time::Duration;
/// One attributed run of a child process.
#[derive(Clone, Debug)]
pub struct Attribution {
/// SoC CPU energy over the window (the `HwShunt` total), microjoules.
/// SoC CPU energy over the window, microjoules. A hardware (`HwShunt`)
/// reading only when `attributed` is true; otherwise it may be the
/// estimator's wall × TDP figure, and a caller must label it estimated.
pub soc_cpu_uj: u64,
/// Energy attributed to the child tree: `soc_cpu_uj · share`. `ModelBased`.
/// Energy attributed to the child tree: `soc_cpu_uj · share`. `ModelBased`
/// when `attributed` is true; otherwise it apportions an estimate and is
/// no better than one.
pub attributed_uj: u64,
/// The child tree's CPU-seconds (user + system, descendants folded in).
pub proc_cpu_secs: f64,
@ -36,8 +48,11 @@ pub struct Attribution {
/// `proc_cpu_secs / busy_cpu_secs`, clamped to `[0, 1]`.
pub share: f64,
pub wall: Duration,
/// True when the attribution rests on real per-process accounting (vs. the
/// unattributed fallback on unsupported platforms).
/// True when the attribution rests on real per-process accounting of a
/// hardware CPU reading that advanced over the window. False for the
/// unattributed fallback on unsupported platforms, and whenever the
/// window's CPU figure is not such a reading (an estimate, or zero while
/// the machine was busy).
pub attributed: bool,
}
@ -45,6 +60,7 @@ pub struct Attribution {
mod imp {
use super::*;
use crate::probe::PowerMeter;
use crate::{DeviceEnergy, Provenance};
use sandbox_schema::DeviceClass;
use std::ffi::CString;
use std::time::Instant;
@ -178,12 +194,23 @@ mod imp {
};
// SoC CPU energy this window (sum the CPU-class device records).
let soc_cpu_uj: u64 = measurement
let cpu_records: Vec<&DeviceEnergy> = measurement
.devices
.iter()
.filter(|d| d.class == DeviceClass::Cpu)
.collect();
let soc_cpu_uj: u64 = cpu_records
.iter()
.map(|d| d.measured_uj.unwrap_or(d.estimated_uj))
.sum();
// Only a hardware reading can be apportioned as one. The estimator's
// wall × TDP figure (no probe, or a CPU counter that held across the
// window) is not a measurement, and neither is a zero while the
// machine was busy.
let cpu_is_a_reading = !cpu_records.is_empty()
&& cpu_records
.iter()
.all(|d| d.provenance == Provenance::HwShunt && d.measured_uj.is_some());
let (share, attributed_ok) = if busy_cpu_secs > 0.0 && proc_cpu_secs > 0.0 {
((proc_cpu_secs / busy_cpu_secs).clamp(0.0, 1.0), true)
@ -202,7 +229,9 @@ mod imp {
busy_cpu_secs,
share,
wall,
attributed: attributed_ok && meter.is_hardware(),
// `attributed_ok` already requires the machine to have been
// busy, so a zero reading here is a counter that held.
attributed: attributed_ok && cpu_is_a_reading && soc_cpu_uj > 0,
},
))
}
@ -274,29 +303,116 @@ mod tests {
use super::*;
use crate::probe::PowerMeter;
/// 2–3 s of one core, bounded by the clock — POSIX `srand()` returns the
/// previous seed, i.e. the time in seconds of the previous call — and by
/// an iteration cap, so an awk that differs can only end early, never
/// hang. Long on purpose: the machine tick counters behind `busy_cpu_secs`
/// update in bursts up to ~1 s apart (measured on an M5 Max and an M3
/// Ultra, macOS 27.0.1), so a shorter window can see no machine busy time
/// at all and decline an attribution for that reason instead of the one
/// under test. It also outlasts the liveness probe's budget, so a CPU rail
/// the probe saw keep moving must move across this window too.
const BUSY_CHILD: &str = "awk 'BEGIN { srand(); t0 = srand(); \
while (srand() - t0 < 3 && n++ < 2000) for (i = 0; i < 100000; i++) x += i; print x }'";
fn run_busy_child(meter: &mut PowerMeter) -> Attribution {
let (ok, _out, a) =
run_attributed(meter, "sh", &["-c", BUSY_CHILD], std::path::Path::new("."))
.expect("spawn");
assert!(ok, "the child exits cleanly");
// Attribution never exceeds the SoC total it apportions.
assert!(a.attributed_uj <= a.soc_cpu_uj, "attributed ≤ SoC total: {a:?}");
assert!(a.share >= 0.0 && a.share <= 1.0, "share in [0,1]: {a:?}");
a
}
/// On Apple Silicon both CPU-time inputs registered the busy child, so an
/// attribution that is declined is declined for the rule under test.
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
fn assert_cpu_time_registered(a: &Attribution) {
assert!(a.proc_cpu_secs > 0.0, "setup: the child's rusage shows no CPU time: {a:?}");
assert!(a.busy_cpu_secs > 0.0, "setup: the machine tick counters did not move: {a:?}");
}
/// An attribution rests on a hardware CPU reading that advanced, or it is
/// not made. The platform block pins which outcome this host must give,
/// so neither branch passes without asserting.
#[test]
fn attributed_run_captures_a_busy_child() {
let mut meter = PowerMeter::detect();
// A child that burns ~0.4s of one core.
let (ok, _out, a) = run_attributed(
&mut meter,
"sh",
&["-c", "awk 'BEGIN{x=0;for(i=0;i<40000000;i++)x+=i; print x}'"],
std::path::Path::new("."),
)
.expect("spawn");
assert!(ok, "the child exits cleanly");
// The child genuinely consumed CPU time.
let a = run_busy_child(&mut meter);
if a.attributed {
assert!(meter.is_hardware(), "attributed without a hardware probe: {a:?}");
assert!(a.soc_cpu_uj > 0, "attributed a CPU reading that did not advance: {a:?}");
assert!(a.proc_cpu_secs > 0.0, "the child used CPU");
assert!(a.busy_cpu_secs >= a.proc_cpu_secs, "busy ≥ this one process");
assert!(a.share > 0.0 && a.share <= 1.0, "share in (0,1]");
// Attribution never exceeds the SoC total it apportions.
assert!(a.attributed_uj <= a.soc_cpu_uj, "attributed ≤ SoC total");
if meter.is_hardware() {
assert!(a.soc_cpu_uj > 0, "a busy window costs SoC energy");
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
use crate::probe::{ioreport_status, IoReportStatus};
let status = ioreport_status();
eprintln!(
"attribution: status={status:?} hardware={} attributed={} soc_cpu_uj={} \
attributed_uj={}",
meter.is_hardware(),
a.attributed,
a.soc_cpu_uj,
a.attributed_uj
);
assert_cpu_time_registered(&a);
if status == IoReportStatus::Live {
assert!(a.attributed, "a live CPU rail over a busy child was not attributed: {a:?}");
} else {
// Declined rung: the window was estimated, and an estimate is
// never apportioned as a reading.
assert!(!meter.is_hardware(), "IOReport declined ({status:?}) yet hardware");
assert!(!a.attributed, "attributed an estimate ({status:?}): {a:?}");
assert!(a.soc_cpu_uj > 0, "the estimator still accounts for the window");
}
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
assert!(!a.attributed, "the unattributed fallback never attributes");
}
/// The attribution rule on its own, deterministic on every host. The
/// M5 Max shape on macOS 27.0.1 — CPU counter held, GPU advanced — seen
/// through a hardware probe: the window is estimated, and an estimate is
/// never apportioned as a reading. (main attributed it.)
#[test]
fn a_window_whose_cpu_counter_held_is_never_attributed() {
use crate::probe::tests::{meter_of, scripted};
use sandbox_schema::DeviceClass::{Cpu, Gpu};
let mut meter = meter_of(vec![scripted(
"ioreport-energy-model",
&[(Cpu, 480_083_160_000), (Gpu, 97_187_370_334)],
&[(Cpu, 480_083_160_000), (Gpu, 97_188_052_877)],
)]);
let a = run_busy_child(&mut meter);
assert!(meter.is_hardware(), "the scripted probe stands in for a hardware probe");
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
assert_cpu_time_registered(&a);
assert!(!a.attributed, "a window whose CPU counter held was attributed: {a:?}");
}
/// The positive path, which no host running macOS 27.0.1 can reach for
/// real: a CPU counter that advanced across a busy child is attributed.
#[test]
fn a_window_whose_cpu_counter_advanced_is_attributed() {
use crate::probe::tests::{meter_of, scripted};
use sandbox_schema::DeviceClass::Cpu;
let mut meter =
meter_of(vec![scripted("ioreport-energy-model", &[(Cpu, 1_000_000)], &[(Cpu, 3_000_000)])]);
let a = run_busy_child(&mut meter);
assert_eq!(a.soc_cpu_uj, 2_000_000, "the window's CPU reading");
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
assert_cpu_time_registered(&a);
assert!(a.attributed, "a CPU reading that advanced over a busy child: {a:?}");
assert!(a.share > 0.0 && a.attributed_uj > 0, "{a:?}");
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
assert!(!a.attributed, "the unattributed fallback never attributes");
}
#[test]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,16 +7,27 @@
//!
//! - **Apple Silicon (macOS/aarch64):** the IOReport "Energy Model" counters
//! (CPU/GPU/ANE energy, the same source `powermetrics` reads, sudo-free) —
//! `HwShunt`.
//! `HwShunt` — **only when every CPU energy rail keeps advancing** (one
//! per die on a multi-die part, all summed into the `Cpu` figure)
//! ([`ioreport_status`]).
//! On macOS 27.0.1 an unprivileged reader sees that rail frozen on both an
//! M5 Max and an M3 Ultra, so those hosts get the estimator below, and
//! [`ioreport_status`] says why.
//! - **Linux:** Intel/AMD RAPL via powercap (`energy_uj`, wrap-corrected) —
//! `HwShunt`; NVIDIA GPUs via NVML's total-energy counter when
//! `libnvidia-ml` is present at runtime — `HwShunt`.
//! `HwShunt` — summing the zones whose `name` is `package-<n>` (never
//! `psys`, which already includes them, or `dram`), **only when every
//! package counter keeps advancing** ([`rapl_status`]); NVIDIA GPUs via NVML's total-energy counter when
//! `libnvidia-ml` is present at runtime — `HwShunt` — **only when every
//! GPU's counter keeps advancing** ([`nvml_status`]). The rule is
//! IOReport's: every rail a figure sums must keep advancing.
//! - **Anywhere else (or unreadable counters):** wall-clock × configured TDP —
//! `Estimator`, with `measured_uj` absent, exactly as SPEC §6.3 demands.
//!
//! 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.
//! measurement. The same holds when the CPU energy counter did not advance
//! across a span: this process ran during it, so a zero there is not a
//! reading of the span, and the whole span comes back `Estimator`.
use crate::{DeviceEnergy, Provenance};
use sandbox_schema::DeviceClass;
@ -24,10 +35,18 @@ use std::time::{Duration, Instant};
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
mod ioreport;
// The vendor-counter liveness rule, apart from any FFI: used by `ioreport`,
// `rapl` and `nvml`, and through [`counter_liveness`] by a caller that reads a
// counter of its own; built and tested on every platform.
mod liveness;
#[cfg(target_os = "linux")]
mod nvml;
#[cfg(target_os = "linux")]
// Built everywhere: its zone listing, reads and liveness run take the
// powercap root as a path, so a caller's [`RaplPackages::live_in`] and the
// tests run them on fake powercap trees on any platform. Only Linux opens the
// sysfs tree.
mod rapl;
pub use rapl::RaplPackages;
pub mod attribute;
pub use attribute::{run_attributed, Attribution};
@ -37,6 +56,247 @@ pub use attribute::{run_attributed, Attribution};
/// provenance is that nobody mistakes this for a measurement.
const DEFAULT_TDP_WATTS: f64 = 20.0;
/// Why the Apple Silicon IOReport probe was or was not used on this host —
/// so a caller, a CLI or a receipt can say *why* an Apple Silicon host is
/// estimated instead of misdiagnosing it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IoReportStatus {
/// No IOReport Energy Model subscription could be opened, or fewer than
/// two of its samples could be read before the probe's cap: not Apple
/// Silicon macOS (always the case on other platforms), or the surface
/// changed.
Unavailable,
/// A subscription opened, but no `CPU Energy` rail (nor `DIE_<n>_CPU
/// Energy`) exists in the Energy Model group — an unfamiliar channel
/// naming. Never used.
CpuRailAbsent,
/// A CPU energy rail exists, but not every one kept advancing while this
/// process burned CPU: at least one rail (on a multi-die part, one die's)
/// did not advance in at least five sample intervals, four in five of
/// those observed, over at least 300 ms. It held, or stepped in a burst —
/// a batch refresh of energy spent long before, in one step or a few, not
/// a reading of the work. The platform is not exposing live energy for
/// the whole CPU to this process, so every span would report GPU-only
/// energy, zero, a stale refresh, or some dies' energy as the whole
/// CPU's, as a measurement of CPU work. Never used. Observed on macOS
/// 27.0.1 on both an M5 Max and an M3 Ultra.
CpuRailFrozen,
/// Every CPU energy rail kept advancing under load, each one on its own:
/// in at least five sample intervals, at least four in five of those
/// observed, over at least 300 ms of wall time; the probe is used.
Live,
}
impl std::fmt::Display for IoReportStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
IoReportStatus::Unavailable => {
"no IOReport Energy Model subscription could be opened or sampled"
}
IoReportStatus::CpuRailAbsent => {
"the IOReport Energy Model group has no `CPU Energy` rail"
}
IoReportStatus::CpuRailFrozen => {
"not every IOReport `CPU Energy` rail (one per die on a multi-die part) kept \
advancing while this process burned CPU (one held, or stepped only in a batch \
refresh), so it cannot meter CPU work"
}
IoReportStatus::Live => {
"every IOReport `CPU Energy` rail keeps advancing under CPU load"
}
})
}
}
/// How long (wall time) the liveness probe watches the CPU rail, burning CPU
/// on its thread and sampling after every 25 ms of that thread's CPU time,
/// before it may conclude the rail is frozen. A live rail must advance in at
/// least five intervals, four in five of those observed, over at least
/// 300 ms, and a live Energy Model rail moves many times a second (the M5
/// Max's `GPU Energy` about every 28 ms), so at ~31 ms an interval a live
/// rail proves itself in about 310 ms and a second is generous; a slower
/// live rail fails safe — estimated, never over-claimed. If a second holds
/// fewer than five sample intervals, the probe samples on past it, never
/// beyond five budgets. Paid once per process, on the thread that first needs
/// it; see [`ioreport_status`].
pub const CPU_RAIL_LIVENESS_BUDGET: Duration = Duration::from_millis(1000);
/// This host's IOReport status, probed once per process and cached.
///
/// [`PowerMeter::detect`] uses the IOReport probe only when this is
/// [`IoReportStatus::Live`]: every CPU energy rail — `CPU Energy`, or each
/// die's `DIE_<n>_CPU Energy` on multi-die parts — must keep advancing, each
/// one on its own, while the probing thread burns CPU: every rail, because
/// the probe sums them all into one `Cpu` figure, and a rail is present if it
/// is in any sample the probe read. Each must advance in at least five
/// sample intervals, those being at least four in five of the intervals
/// observed, over at least 300 ms of wall time from the first sample. It is
/// decided on each rail's raw integer between consecutive samples, after
/// every interval, and never on a summed total. A step, or a few steps close together, is a batch refresh, not
/// liveness: on macOS 27.0.1 one refresh was seen to land in two steps 64 ms
/// apart. The probe samples a subscription to the Energy Model group alone
/// (a few hundred channels, ~6–12 ms of this thread's CPU a sample; its wall
/// time can run far longer while another process samples every channel at
/// once) after every 25 ms of this thread's CPU time burned, however loaded
/// the machine (a tick's wall time is capped at 500 ms), until the rails
/// prove live or [`CPU_RAIL_LIVENESS_BUDGET`] has passed with at least five
/// intervals seen (never past five budgets).
/// Always [`IoReportStatus::Unavailable`] off Apple Silicon macOS.
pub fn ioreport_status() -> IoReportStatus {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
static STATUS: std::sync::OnceLock<IoReportStatus> = std::sync::OnceLock::new();
*STATUS.get_or_init(ioreport::host_status)
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
{
IoReportStatus::Unavailable
}
}
/// Whether a vendor energy counter the meter sums into one figure per class —
/// Linux RAPL's package domains ([`rapl_status`]), NVML's GPUs
/// ([`nvml_status`]) — is used on this host, and if not, why.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CounterStatus {
/// The counter could not be opened, or not read twice before the probe's
/// cap: not this platform, no driver, no permission (RAPL's `energy_uj` is
/// root-only on many kernels), or no device that supports it (for RAPL, no
/// zone named `package-<n>`).
Unavailable,
/// The counter opened and read, but not every rail it sums kept advancing
/// while this process burned CPU: one held, or stepped only in a burst.
/// A figure summed over it would claim a domain it does not read, or a
/// stale refresh as a reading. Never used.
Frozen,
/// Every rail it sums kept advancing: in at least five sample intervals,
/// at least four in five of those observed, over at least 300 ms of wall
/// time, while the probing thread burned CPU. The probe is used.
Live,
}
impl std::fmt::Display for CounterStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
CounterStatus::Unavailable => "the counter could not be opened or read twice",
CounterStatus::Frozen => {
"not every rail of the counter kept advancing while this process burned CPU (one \
held, or stepped only in a burst), so it cannot meter the work"
}
CounterStatus::Live => "every rail of the counter keeps advancing under load",
})
}
}
/// This host's Linux RAPL status, probed once per process and cached.
///
/// [`PowerMeter::detect`] uses the RAPL probe only when this is
/// [`CounterStatus::Live`]: the `energy_uj` of every top-level zone whose
/// `name` is `package-<n>` must keep advancing while the probing thread burns
/// CPU — the IOReport rule, [`CPU_RAIL_LIVENESS_BUDGET`] and its ~25 ms tick,
/// applied to every package, since the probe sums them into one `Cpu` figure.
/// Other zones (`psys`, `dram`) are neither summed nor probed. A host where
/// powercap is absent or unreadable, or has no package zone, is `Unavailable`
/// at once, with no burn. Always `Unavailable` off Linux.
pub fn rapl_status() -> CounterStatus {
#[cfg(target_os = "linux")]
{
static STATUS: std::sync::OnceLock<CounterStatus> = std::sync::OnceLock::new();
*STATUS.get_or_init(rapl::host_status)
}
#[cfg(not(target_os = "linux"))]
{
CounterStatus::Unavailable
}
}
/// The liveness rule [`rapl_status`] and [`nvml_status`] apply, run over a
/// vendor energy counter this crate does not open itself (the S5 executor's
/// per-socket RAPL MSRs), so such a counter is held to the same rule before
/// its figure is called a hardware reading. `open` opens the counter and
/// hands back its sampler (`None`: it did not open); the sampler reads every
/// rail the caller sums into one figure — its name and raw cumulative
/// integer, before any unit conversion or wrap correction, in a stable order
/// — from one absolute sample (`None`: this read failed). `tick` drives the
/// load between samples: production passes [`counter_liveness_tick`].
///
/// `Live` only if every rail advanced in at least five sample intervals, at
/// least four in five of those observed, over at least 300 ms of wall time,
/// each interval read against the sample before it, within
/// [`CPU_RAIL_LIVENESS_BUDGET`] (never past five of them). `Frozen` if one
/// held or stepped only in a burst; `Unavailable` if the counter did not open
/// (never sampled or ticked), could not be read twice, or has no rail. The
/// caller caches the status as it sees fit; nothing here is cached.
pub fn counter_liveness<R>(open: impl FnOnce() -> Option<R>, tick: impl FnMut()) -> CounterStatus
where
R: FnMut() -> Option<Vec<(String, i64)>>,
{
let epoch = Instant::now();
liveness::counter_status_of(
liveness::counter_run(open, tick, move || epoch.elapsed(), CPU_RAIL_LIVENESS_BUDGET).verdict(),
)
}
/// The tick the production counter probes burn between two liveness samples:
/// 25 ms of the calling thread's CPU time, capped at 500 ms of wall time —
/// the `tick` to hand [`counter_liveness`] for a real counter.
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub fn counter_liveness_tick() {
liveness::counter_tick(liveness::PROBE_TICK);
}
/// This host's NVML status, probed once per process and cached.
///
/// [`PowerMeter::detect`] uses the NVML probe only when this is
/// [`CounterStatus::Live`]: every GPU's total-energy counter must keep
/// advancing — the same rule, at a 150 ms tick, since a GPU energy counter is
/// published more coarsely than a CPU rail — while the probing thread burns
/// CPU. A host with no `libnvidia-ml`, or no GPU that supports the counter, is
/// `Unavailable` at once, with no burn. Always `Unavailable` off Linux.
pub fn nvml_status() -> CounterStatus {
#[cfg(target_os = "linux")]
{
static STATUS: std::sync::OnceLock<CounterStatus> = std::sync::OnceLock::new();
*STATUS.get_or_init(nvml::host_status)
}
#[cfg(not(target_os = "linux"))]
{
CounterStatus::Unavailable
}
}
/// How many times this process has run the real RAPL and NVML liveness
/// probes, `(rapl, nvml)`: for the test that pins their once-per-process
/// caches from a process of its own. Always `(0, 0)` off Linux.
#[doc(hidden)]
pub fn counter_probe_runs() -> (usize, usize) {
#[cfg(target_os = "linux")]
{
use std::sync::atomic::Ordering::SeqCst;
(rapl::REAL_RUNS.load(SeqCst), nvml::REAL_RUNS.load(SeqCst))
}
#[cfg(not(target_os = "linux"))]
{
(0, 0)
}
}
/// How many times this process has run the real IOReport liveness probe:
/// for the test that pins [`ioreport_status`]'s once-per-process cache from
/// a process of its own. Always 0 off Apple Silicon macOS, which never
/// probes.
#[doc(hidden)]
pub fn ioreport_probe_runs() -> usize {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
ioreport::REAL_RUNS.load(std::sync::atomic::Ordering::SeqCst)
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
{
0
}
}
/// One measured span of work.
#[derive(Clone, Debug)]
pub struct Measurement {
@ -62,6 +322,20 @@ impl Measurement {
self.devices.iter().all(|d| d.measured_uj.is_some())
}
/// True when the span has a hardware reading of the CPU: a `Cpu` record
/// that is `HwShunt` with a measured figure. Per class, a span's records
/// are honest as they stand; this is for a caller that grades a whole unit
/// of work by ONE figure and has no field to say the figure covers part of
/// it. A span with no CPU reading — an NVML-only probe set, which reads
/// the GPU alone — is not a reading of work that ran on the CPU, and such
/// a caller must grade it as an estimate ([`PowerMeter::estimate`]), never
/// as a hardware reading of the whole.
pub fn reads_cpu(&self) -> bool {
self.devices
.iter()
.any(|d| d.class == DeviceClass::Cpu && d.provenance == Provenance::HwShunt && d.measured_uj.is_some())
}
/// Average power over the span, in watts.
pub fn avg_watts(&self) -> f64 {
let secs = self.wall.as_secs_f64();
@ -89,6 +363,30 @@ pub enum ProbeError {
Read(String),
}
/// The statuses the meter's probe selection is gated on: one per vendor
/// counter, each from its own cached probe in production.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Statuses {
ioreport: IoReportStatus,
rapl: CounterStatus,
nvml: CounterStatus,
}
impl Statuses {
/// This host's: each counter probed once per process and cached.
fn host() -> Statuses {
Statuses { ioreport: ioreport_status(), rapl: rapl_status(), nvml: nvml_status() }
}
/// No counter usable: the base a test sets one status on.
#[cfg(test)]
const NONE: Statuses = Statuses {
ioreport: IoReportStatus::Unavailable,
rapl: CounterStatus::Unavailable,
nvml: CounterStatus::Unavailable,
};
}
/// The platform power meter. Detect once, measure many spans.
pub struct PowerMeter {
probes: Vec<Box<dyn Probe + Send>>,
@ -98,22 +396,40 @@ pub struct PowerMeter {
impl PowerMeter {
/// Detect the best available energy sources for this platform. Always
/// succeeds: with no hardware source, the meter is an honest estimator.
/// Each vendor counter is included only when its cached status is live:
/// IOReport on Apple Silicon ([`ioreport_status`]), RAPL and NVML on
/// Linux ([`rapl_status`], [`nvml_status`]). The first call in a process
/// pays each probe that applies on this thread: about
/// [`CPU_RAIL_LIVENESS_BUDGET`] of wall time or less, most of it burning
/// CPU, and never much more than five times it; a counter that cannot be
/// opened costs nothing.
pub fn detect() -> PowerMeter {
Self::detect_given(Statuses::host())
}
/// [`detect`](Self::detect) for given counter statuses, which production
/// takes from the cached status functions and nowhere else: each probe is
/// built only if its gate ([`admits`](liveness::admits),
/// [`counter_admits`](liveness::counter_admits)) passes its status.
/// Separate so the gates are tested for every status without probing.
fn detect_given(statuses: Statuses) -> PowerMeter {
let mut probes: Vec<Box<dyn Probe + Send>> = Vec::new();
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
if let Ok(p) = ioreport::IoReportProbe::new() {
if let Ok(p) = ioreport::IoReportProbe::given(statuses.ioreport) {
probes.push(Box::new(p));
}
#[cfg(target_os = "linux")]
{
if let Ok(p) = rapl::RaplProbe::new() {
if let Ok(p) = rapl::RaplProbe::given(statuses.rapl) {
probes.push(Box::new(p));
}
if let Ok(p) = nvml::NvmlProbe::new() {
if let Ok(p) = nvml::NvmlProbe::given(statuses.nvml) {
probes.push(Box::new(p));
}
}
// A platform without a given probe has nothing to gate.
let _ = statuses;
let tdp_watts = std::env::var("SANDBOX_METER_TDP_W")
.ok()
@ -129,7 +445,11 @@ impl PowerMeter {
self.probes.iter().map(|p| p.name()).collect()
}
/// True when at least one hardware energy source is live.
/// True when at least one hardware energy source is live. This says what
/// the meter *can* read, not what a given span *did*: a span whose CPU
/// counter held comes back `Estimator` even from a hardware meter, so
/// label a span by its own records ([`Measurement::fully_measured`], each
/// record's provenance), never by this.
pub fn is_hardware(&self) -> bool {
!self.probes.is_empty()
}
@ -137,9 +457,15 @@ impl PowerMeter {
/// The current cumulative SoC energy in microjoules, summed across every
/// live hardware probe and device class — a monotonic counter for callers
/// that want before/after *sampling* rather than a managed [`Span`] (e.g.
/// the joule-ui `Meter` edge). `None` when no hardware probe is active: the
/// estimator path has no cumulative counter, and a sampler must say so
/// rather than fabricate one.
/// the joule-ui `Meter` edge). `None` when no hardware probe is active
/// (on Apple Silicon that includes a host whose CPU energy rail is not
/// live — see [`ioreport_status`]): the estimator path has no cumulative
/// counter, and a sampler must say so rather than fabricate one.
///
/// Being a sum over every class, a before/after difference of this value
/// cannot tell a CPU counter that held from one that advanced; a span that
/// must be a measurement of CPU work should use [`measure`](Self::measure)
/// or [`start`](Self::start), which apply that rule.
pub fn sample_uj(&mut self) -> Option<u64> {
if self.probes.is_empty() {
return None;
@ -174,6 +500,20 @@ impl PowerMeter {
}
}
/// The estimator's figure for `wall` of work: the single `Estimator` CPU
/// record (wall-clock × this meter's TDP, never a measured figure) that a
/// span with no usable hardware reading comes back as. For a caller that
/// must replace a span with no CPU reading by an estimate of the whole
/// ([`Measurement::reads_cpu`]): the figure is the one [`Span::stop`]
/// itself would give.
pub fn estimate(&self, wall: Duration) -> Measurement {
Measurement {
devices: vec![self.estimator_record(wall)],
wall,
source: "estimator",
}
}
fn read_all(&mut self) -> Vec<(usize, Vec<(DeviceClass, u64)>)> {
let mut out = Vec::new();
for (i, p) in self.probes.iter_mut().enumerate() {
@ -228,7 +568,17 @@ impl Span {
}
}
if totals.is_empty() {
// A CPU energy counter that did not advance across a span in which
// this process ran is not a measurement of that span — a frozen rail,
// or one too coarse to resolve it. Then the span has no CPU reading,
// and whatever another class did (a GPU rail that kept compositing)
// is not the energy of this work. The whole span is estimated,
// exactly as when every probe vanished. A probe set with no CPU class
// (NVML alone) is not subject to this.
let cpu_counter_held = totals
.iter()
.any(|(class, uj)| *class == DeviceClass::Cpu && *uj == 0);
if totals.is_empty() || cpu_counter_held {
return Measurement {
devices: vec![meter.estimator_record(wall)],
wall,
@ -277,26 +627,299 @@ mod tests {
reading.devices[0].check_provenance().unwrap();
}
fn busy(d: Duration) {
let t = Instant::now();
let mut x = 0u64;
while t.elapsed() < d {
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
}
std::hint::black_box(x);
}
/// The span is the single estimator record — no measured figure anywhere.
fn assert_estimated(r: &Measurement) {
assert_eq!(r.source, "estimator", "{:?}", r.devices);
assert_eq!(r.devices.len(), 1, "one estimator record: {:?}", r.devices);
let d = r.devices[0];
assert_eq!(d.class, DeviceClass::Cpu);
assert_eq!(d.provenance, Provenance::Estimator);
assert_eq!(d.measured_uj, None);
assert!(!r.fully_measured());
d.check_provenance().unwrap();
}
/// Whatever the host: `detect()` succeeds, and a span is either a
/// hardware measurement whose CPU record advanced or the single estimator
/// record — never a zero CPU reading sealed as a measurement, and never a
/// total that advanced only because another class (a GPU still
/// compositing) did. Asserts the CPU record, not the summed total.
#[test]
fn detect_never_fails_and_records_validate() {
let mut meter = PowerMeter::detect();
let ((), reading) = meter.measure(|| {
// A short busy spin so hardware counters actually advance.
let t = Instant::now();
let mut x = 0u64;
while t.elapsed() < Duration::from_millis(120) {
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
}
std::hint::black_box(x);
});
let ((), reading) = meter.measure(|| busy(Duration::from_millis(120)));
assert!(!reading.devices.is_empty());
for d in &reading.devices {
d.check_provenance().unwrap();
}
// On Apple Silicon / Linux-with-RAPL this is a real measurement.
if meter.is_hardware() {
assert!(reading.fully_measured(), "hardware probe must measure");
assert!(reading.total_uj() > 0, "a busy span consumes energy");
if reading.fully_measured() {
assert!(meter.is_hardware(), "only a hardware probe produces a measured record");
for d in &reading.devices {
assert_eq!(d.provenance, Provenance::HwShunt, "{:?}", reading.devices);
}
if let Some(cpu) = reading.devices.iter().find(|d| d.class == DeviceClass::Cpu) {
assert!(
cpu.measured_uj.is_some_and(|uj| uj > 0),
"a measured CPU record read zero: {cpu:?}"
);
}
} else {
assert_estimated(&reading);
}
if !meter.is_hardware() {
assert_estimated(&reading);
}
// On Apple Silicon the IOReport probe exists exactly when its CPU rail
// is live (the probe's own tests pin that against an oracle).
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
assert_eq!(
meter.is_hardware(),
ioreport_status() == IoReportStatus::Live,
"hardware={} status={:?}",
meter.is_hardware(),
ioreport_status()
);
}
/// A probe that replays scripted cumulative snapshots: the rail behaviour
/// measured on real hosts, deterministic on every platform.
struct Scripted {
name: &'static str,
snaps: std::collections::VecDeque<Vec<(DeviceClass, u64)>>,
}
impl Probe for Scripted {
fn name(&self) -> &'static str {
self.name
}
fn snapshot(&mut self) -> Result<Vec<(DeviceClass, u64)>, ProbeError> {
self.snaps
.pop_front()
.ok_or_else(|| ProbeError::Read("script exhausted".into()))
}
}
pub(super) fn scripted(
name: &'static str,
before: &[(DeviceClass, u64)],
after: &[(DeviceClass, u64)],
) -> Box<dyn Probe + Send> {
Box::new(Scripted {
name,
snaps: [before.to_vec(), after.to_vec()].into(),
})
}
pub(super) fn meter_of(probes: Vec<Box<dyn Probe + Send>>) -> PowerMeter {
PowerMeter {
probes,
tdp_watts: 10.0,
}
}
use DeviceClass::{Cpu, Gpu, Npu};
/// The M5 Max on macOS 27.0.1, from a channel dump over 3 s of burn:
/// `CPU Energy` and `ANE0` held, `GPU Energy` advanced. main sealed this as
/// Cpu 0 µJ, Npu 0 µJ and Gpu 682 543 µJ, all `HwShunt` — GPU energy passed
/// off as the energy of the work.
#[test]
fn a_span_whose_cpu_counter_held_is_estimated_even_though_the_gpu_advanced() {
let before = [(Cpu, 480_083_160_000), (Npu, 191_479_000), (Gpu, 97_187_370_334)];
let after = [(Cpu, 480_083_160_000), (Npu, 191_479_000), (Gpu, 97_188_052_877)];
let mut m = meter_of(vec![scripted("ioreport-energy-model", &before, &after)]);
let ((), r) = m.measure(|| ());
assert_estimated(&r);
}
/// The M3 Ultra on macOS 27.0.1: every rail held, both dies' CPU rails
/// included. main sealed three measured zeros.
#[test]
fn a_span_where_every_counter_held_is_estimated() {
let snap = [(Cpu, 18_849_831_000), (Npu, 21_704_000), (Gpu, 221_414_992)];
let mut m = meter_of(vec![scripted("ioreport-energy-model", &snap, &snap)]);
let ((), r) = m.measure(|| ());
assert_estimated(&r);
}
/// The rule is about the CPU counter, not about IOReport: a RAPL package
/// counter that held is no measurement either, and it takes a GPU that
/// advanced on another probe down with it — the span has no CPU reading.
#[test]
fn a_held_cpu_counter_estimates_the_whole_composite_span() {
let rapl = scripted("rapl-powercap", &[(Cpu, 5_000)], &[(Cpu, 5_000)]);
let nvml = scripted("nvml-total-energy", &[(Gpu, 9_000)], &[(Gpu, 12_000)]);
let mut m = meter_of(vec![rapl, nvml]);
let ((), r) = m.measure(|| ());
assert_estimated(&r);
}
/// A CPU counter that advanced is a measurement, per class. A class that
/// held beside it is left as it is (see the rule's scope in `Span::stop`).
#[test]
fn a_span_whose_cpu_counter_advanced_is_measured() {
let before = [(Cpu, 1_000), (Gpu, 7_000)];
let after = [(Cpu, 2_234), (Gpu, 7_000)];
let mut m = meter_of(vec![scripted("ioreport-energy-model", &before, &after)]);
let ((), r) = m.measure(|| ());
assert!(r.fully_measured(), "{:?}", r.devices);
assert_eq!(r.source, "ioreport-energy-model");
let cpu = r.devices.iter().find(|d| d.class == Cpu).unwrap();
assert_eq!(cpu.provenance, Provenance::HwShunt);
assert_eq!(cpu.measured_uj, Some(1_234));
for d in &r.devices {
d.check_provenance().unwrap();
}
}
/// A probe set with no CPU class (NVML alone) measures its GPU as before.
#[test]
fn a_probe_set_without_a_cpu_class_is_not_subject_to_the_cpu_rule() {
let nvml = scripted("nvml-total-energy", &[(Gpu, 9_000)], &[(Gpu, 12_000)]);
let mut m = meter_of(vec![nvml]);
let ((), r) = m.measure(|| ());
assert!(r.fully_measured(), "{:?}", r.devices);
assert_eq!(r.devices.len(), 1);
assert_eq!(r.devices[0].class, Gpu);
assert_eq!(r.devices[0].measured_uj, Some(3_000));
}
/// `reads_cpu` is true only for a span with a hardware CPU reading. An
/// NVML-only span (a GPU reading, no CPU record) is not one, and neither
/// is the estimator's record. `estimate` is the very record `Span::stop`
/// gives a span with no reading.
#[test]
fn a_span_reads_the_cpu_only_with_a_hardware_cpu_record() {
let nvml = scripted("nvml-total-energy", &[(Gpu, 9_000)], &[(Gpu, 12_000)]);
let mut m = meter_of(vec![nvml]);
let ((), gpu_only) = m.measure(|| ());
assert!(gpu_only.fully_measured(), "the GPU record stands as a reading: {:?}", gpu_only.devices);
assert!(!gpu_only.reads_cpu(), "a GPU-only span read the CPU: {:?}", gpu_only.devices);
let mut m = meter_of(vec![scripted("rapl-powercap", &[(Cpu, 1_000)], &[(Cpu, 2_000)])]);
let ((), cpu) = m.measure(|| ());
assert!(cpu.reads_cpu(), "{:?}", cpu.devices);
let mut none = meter_of(Vec::new());
let ((), est) = none.measure(|| ());
assert!(!est.reads_cpu(), "the estimator's record is no reading: {:?}", est.devices);
let again = none.estimate(est.wall);
assert_eq!(again.devices, est.devices, "estimate() is the record Span::stop gives");
assert_eq!(again.source, "estimator");
let e = none.estimate(Duration::from_millis(8));
assert_estimated(&e);
assert_eq!(e.devices[0].estimated_uj, 80_000, "8 ms at 10 W");
}
/// Selection goes through the one gate: an injected status that is not
/// `Live` never selects the IOReport probe, and `Live` does wherever its
/// subscription opens with a CPU rail (both Apple Silicon hosts here).
/// Nothing here probes: the status is given.
#[test]
fn detect_selects_the_ioreport_probe_only_for_a_live_status() {
let selects = |status| {
PowerMeter::detect_given(Statuses { ioreport: status, ..Statuses::NONE })
.sources()
.contains(&"ioreport-energy-model")
};
for status in [IoReportStatus::Unavailable, IoReportStatus::CpuRailAbsent, IoReportStatus::CpuRailFrozen] {
assert!(!selects(status), "{status:?} selected the IOReport probe");
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
assert_eq!(
selects(IoReportStatus::Live),
ioreport::energy_model_has_a_cpu_rail(),
"a Live status must select the probe wherever its subscription opens with a CPU rail"
);
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
assert!(!selects(IoReportStatus::Live), "no IOReport probe exists off Apple Silicon macOS");
}
/// RAPL and NVML each go through their own gate: a status that is not
/// `Live` never selects its probe, wherever the counter exists, and one
/// live counter does not admit the other. `Live` selects each wherever it
/// opens — on Linux; neither exists elsewhere. Nothing here probes: the
/// statuses are given.
#[test]
fn detect_selects_rapl_and_nvml_only_for_a_live_status() {
use CounterStatus::{Frozen, Live, Unavailable};
let sources = |rapl, nvml| PowerMeter::detect_given(Statuses { rapl, nvml, ..Statuses::NONE }).sources();
for status in [Unavailable, Frozen] {
let s = sources(status, status);
assert!(!s.contains(&"rapl-powercap") && !s.contains(&"nvml-total-energy"), "{status:?} selected {s:?}");
let s = sources(Live, status);
assert!(!s.contains(&"nvml-total-energy"), "NVML {status:?} selected: {s:?}");
let s = sources(status, Live);
assert!(!s.contains(&"rapl-powercap"), "RAPL {status:?} selected: {s:?}");
}
let live = sources(Live, Live);
#[cfg(target_os = "linux")]
{
assert_eq!(live.contains(&"rapl-powercap"), rapl::RaplProbe::open().is_ok(), "{live:?}");
assert_eq!(live.contains(&"nvml-total-energy"), nvml::NvmlProbe::open().is_ok(), "{live:?}");
}
#[cfg(not(target_os = "linux"))]
assert!(live.is_empty(), "no RAPL or NVML probe exists off Linux: {live:?}");
}
/// The counter statuses agree with themselves, and off Linux there is no
/// RAPL or NVML to probe.
#[test]
fn the_counter_statuses_explain_themselves() {
assert_eq!(rapl_status(), rapl_status());
assert_eq!(nvml_status(), nvml_status());
#[cfg(not(target_os = "linux"))]
{
assert_eq!((rapl_status(), nvml_status()), (CounterStatus::Unavailable, CounterStatus::Unavailable));
assert_eq!(counter_probe_runs(), (0, 0), "no RAPL or NVML probe runs off Linux");
}
assert!(CounterStatus::Frozen.to_string().contains("kept advancing"));
}
#[test]
fn the_ioreport_status_explains_itself() {
// Off Apple Silicon macOS there is no IOReport to probe.
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
assert_eq!(ioreport_status(), IoReportStatus::Unavailable);
// It agrees with itself. That the probe behind it runs once per
// process is pinned by `the_liveness_probe_runs_once_per_process`,
// which counts the real probe's runs.
assert_eq!(ioreport_status(), ioreport_status());
assert!(IoReportStatus::CpuRailFrozen.to_string().contains("CPU Energy"));
assert!(IoReportStatus::CpuRailAbsent.to_string().contains("CPU Energy"));
}
/// [`counter_liveness`] holds a caller's own counter to the rule, on
/// every rail: two rails advancing at every sample are `Live`; one rail
/// holding beside one advancing is `Frozen`; a counter that does not
/// open is `Unavailable` and never ticked.
#[test]
fn counter_liveness_holds_a_callers_counter_to_the_every_rail_rule() {
let tick = || std::thread::sleep(Duration::from_millis(25));
let n = std::cell::Cell::new(0i64);
let both = || {
n.set(n.get() + 1);
Some(vec![("package-0".to_string(), n.get()), ("package-1".to_string(), 3 * n.get())])
};
assert_eq!(counter_liveness(|| Some(both), tick), CounterStatus::Live);
let m = std::cell::Cell::new(0i64);
let one_held = || {
m.set(m.get() + 1);
Some(vec![("package-0".to_string(), m.get()), ("package-1".to_string(), 7)])
};
assert_eq!(counter_liveness(|| Some(one_held), tick), CounterStatus::Frozen);
let ticks = std::cell::Cell::new(0);
type Sampler = fn() -> Option<Vec<(String, i64)>>;
let none = counter_liveness(|| None::<Sampler>, || ticks.set(ticks.get() + 1));
assert_eq!((none, ticks.get()), (CounterStatus::Unavailable, 0));
}
}

View file

@ -2,11 +2,33 @@
//! (`nvmlDeviceGetTotalEnergyConsumption`, millijoules since driver load),
//! loaded at runtime from `libnvidia-ml.so.1` — no link-time dependency, the
//! probe simply doesn't exist on machines without the driver. `HwShunt`.
//!
//! **Used only when every GPU's counter is live.** A counter that answers is
//! not yet a reading of the work: one that holds, or is republished in rare
//! lumps, would seal zero or a stale refresh as a span's GPU energy. So
//! [`NvmlProbe::given`] declines unless [`nvml_status`](super::nvml_status) is
//! `Live` — every supported GPU's raw counter 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 — and the meter has no GPU
//! reading. Every GPU must be live, not one: the probe sums them into one
//! `Gpu` figure. The probe samples at [`NVML_PROBE_TICK`] (150 ms), because a
//! GPU energy counter is published more coarsely than a CPU rail. The rule
//! and the run live in [`liveness`](super::liveness) ([`counter_run`]); this
//! module only opens the devices and reads their raw integers.
//!
//! A GPU-only figure is the GPU's energy, not the work's: the meter's span
//! rule (a CPU counter that held estimates the span) does not apply to a
//! probe set with no CPU class, so a caller that grades a whole unit of work
//! by one figure must not grade a span with no CPU reading as a reading of
//! that work ([`Measurement::reads_cpu`](super::Measurement::reads_cpu)).
use super::{Probe, ProbeError};
use super::liveness::{counter_run, counter_status_of, counter_tick, gated, RailWatch, Sample, NVML_PROBE_TICK};
use super::{CounterStatus, Probe, ProbeError, CPU_RAIL_LIVENESS_BUDGET};
use libloading::{Library, Symbol};
use sandbox_schema::DeviceClass;
use std::ffi::c_void;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
type NvmlReturn = i32;
type Device = *mut c_void;
@ -21,8 +43,48 @@ pub struct NvmlProbe {
// the probe is used from one thread at a time by the meter.
unsafe impl Send for NvmlProbe {}
/// How many times this process ran the real NVML liveness probe: bumped by
/// [`host_status`] before anything else it does, read through
/// [`counter_probe_runs`](super::counter_probe_runs).
pub(super) static REAL_RUNS: AtomicUsize = AtomicUsize::new(0);
/// This host's NVML status from one real probe run: the value
/// [`nvml_status`](super::nvml_status) caches, and its only source. One
/// composition with no branch of its own (count the run, run the real probe,
/// map its verdict). Call [`nvml_status`](super::nvml_status), not this.
pub(super) fn host_status() -> CounterStatus {
REAL_RUNS.fetch_add(1, Ordering::SeqCst);
counter_status_of(real_run().verdict())
}
/// The real probe run: [`counter_run`] over every supported GPU's raw
/// counter, a tick that burns [`NVML_PROBE_TICK`] of this thread's CPU time
/// and a monotonic clock, for [`CPU_RAIL_LIVENESS_BUDGET`]. A host with no
/// NVML, or no GPU that supports the counter, does not open, and is never
/// ticked.
fn real_run() -> RailWatch {
let epoch = Instant::now();
counter_run(
|| NvmlProbe::open().ok().map(|p| move || p.raw_sample()),
|| counter_tick(NVML_PROBE_TICK),
move || epoch.elapsed(),
CPU_RAIL_LIVENESS_BUDGET,
)
}
impl NvmlProbe {
pub fn new() -> Result<NvmlProbe, ProbeError> {
/// The probe the meter uses, built for this host's NVML `status`, which
/// production passes from the cached [`nvml_status`](super::nvml_status)
/// and nowhere else: **only when every GPU's counter is live**
/// ([`gated`]). Otherwise `Unavailable`, naming why.
pub fn given(status: CounterStatus) -> Result<NvmlProbe, ProbeError> {
gated("NVML", status, Self::open)
}
/// Load NVML and every GPU that supports the energy counter, with no
/// liveness gate. Used by the liveness probe itself and by
/// [`given`](Self::given) once the gate passed.
pub(super) fn open() -> Result<NvmlProbe, ProbeError> {
unsafe {
let lib = Library::new("libnvidia-ml.so.1")
.map_err(|e| ProbeError::Unavailable(format!("no NVML: {e}")))?;
@ -71,6 +133,23 @@ impl NvmlProbe {
})
}
}
/// One absolute sample for the liveness rule: every supported GPU's raw
/// counter (mJ), named by its position, in device order, as read — never
/// summed. `None` if any device could not be read.
fn raw_sample(&self) -> Option<Sample> {
self.devices
.iter()
.enumerate()
.map(|(i, d)| {
let mut mj = 0u64;
// SAFETY: `d` is a handle NVML returned for the library this
// probe keeps loaded, and `mj` is writable for the call.
let rc = unsafe { (self.get_energy)(*d, &mut mj) };
(rc == 0).then(|| (format!("nvml:{i}"), i64::try_from(mj).unwrap_or(i64::MAX)))
})
.collect()
}
}
impl Probe for NvmlProbe {
@ -91,3 +170,52 @@ impl Probe for NvmlProbe {
Ok(vec![(DeviceClass::Gpu, total_mj.saturating_mul(1_000))])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::probe::nvml_status;
/// The gate declines every status but `Live`, without loading NVML, and
/// `Live` builds the probe wherever NVML opens with a supported GPU.
#[test]
fn only_a_live_status_builds_the_nvml_probe() {
for status in [CounterStatus::Unavailable, CounterStatus::Frozen] {
match NvmlProbe::given(status) {
Err(ProbeError::Unavailable(why)) => assert!(why.contains("NVML"), "{why}"),
Err(e) => panic!("{status:?}: {e}"),
Ok(_) => panic!("{status:?} built the NVML probe"),
}
}
assert_eq!(NvmlProbe::given(CounterStatus::Live).is_ok(), NvmlProbe::open().is_ok());
}
/// Against an independent oracle that reads every GPU's ABSOLUTE counter
/// before and after three budgets, with no rule of its own: the NVML rung
/// is never selected over a GPU whose counter did not move, and a selected
/// rung's GPUs all move (an idle GPU still draws power). A host with no
/// NVML must report `Unavailable`. It logs what it observed.
#[test]
fn the_nvml_rung_is_never_selected_over_a_counter_that_held() {
let status = nvml_status();
let selected = crate::probe::PowerMeter::detect().sources().contains(&"nvml-total-energy");
let Ok(p) = NvmlProbe::open() else {
eprintln!("nvml: status={status:?} oracle=no NVML device selected={selected}");
assert_eq!(status, CounterStatus::Unavailable);
assert!(!selected);
return;
};
let before = p.raw_sample().expect("oracle read");
std::thread::sleep(CPU_RAIL_LIVENESS_BUDGET * 3);
let after = p.raw_sample().expect("oracle read");
let every_moved = before.iter().zip(&after).all(|((_, b), (_, a))| a != b);
eprintln!("nvml: status={status:?} oracle every_moved={every_moved} selected={selected} {before:?} -> {after:?}");
assert_eq!(selected, status == CounterStatus::Live, "selection disagrees with the status");
if !every_moved {
assert_ne!(status, CounterStatus::Live, "NVML live although a GPU counter held");
}
if status == CounterStatus::Live {
assert!(every_moved, "NVML live, yet the oracle saw a GPU hold");
}
}
}

View file

@ -1,14 +1,66 @@
//! Intel/AMD RAPL via the Linux powercap interface — package-domain
//! cumulative energy from `/sys/class/powercap/intel-rapl:N/energy_uj`,
//! wrap-corrected against `max_energy_range_uj`. `HwShunt` provenance.
//! cumulative energy from `/sys/class/powercap/intel-rapl:N/energy_uj`, for
//! every top-level zone whose `name` is `package-<n>`, each wrap-corrected
//! against its own `max_energy_range_uj`. `HwShunt` provenance.
//!
//! **The package zones and no other.** A top-level powercap zone is not
//! necessarily a package. Many Intel hosts expose `psys`, the platform zone,
//! whose count already includes the packages', so summing it beside them
//! counts their energy twice; some expose a top-level `dram` zone, which is not
//! CPU energy at all. The `Cpu` figure is the sum of every zone whose `name`
//! file reads `package-<digits>` — every socket's — and nothing else: not
//! `psys`, not `dram`, not a package's subzones (`intel-rapl:0:0`, its `core`,
//! `uncore` or `dram`), and not the second interface to the same packages
//! (`intel-rapl-mmio:N`). Every package or none: a package zone whose counter
//! or wrap range cannot be read, or a top-level zone whose `name` cannot be
//! read (it may be a package), does not open the probe, rather than leave a
//! package out of a figure that claims the CPU.
//!
//! **Used only when every package counter is live.** A readable `energy_uj`
//! is not yet a reading of the work: a counter that holds, or that is
//! republished in rare lumps, would seal zero or a stale refresh as the energy
//! of a span. So [`RaplProbe::given`] declines unless
//! [`rapl_status`](super::rapl_status) is `Live` — every package zone's raw
//! `energy_uj` 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 — and the meter falls back to its estimator. Every package must
//! be live, not one: the probe sums them into one `Cpu` figure, and a package
//! whose counter held is a domain that figure would claim without reading it.
//! Only the package zones are probed, as only they are summed: a live `psys`
//! beside a held package does not make the counter live. The rule and the run
//! live in [`liveness`](super::liveness) ([`counter_run`]); this module only
//! opens the package zones and reads their raw integers.
//!
//! Built everywhere: the zone listing, the reads and the run take the
//! powercap root as a path, so the tests, and a caller's
//! [`RaplPackages::live_in`], run them on fake trees on any platform. Only
//! Linux opens the sysfs tree ([`RaplPackages::host`], the meter's probe).
//! [`RaplPackages`] is this probe for a caller outside the crate that takes
//! its own before/after differences.
use super::{Probe, ProbeError};
use super::liveness::{counter_run, counter_status_of, gated, RailWatch, Sample};
#[cfg(target_os = "linux")]
use super::liveness::{counter_tick, PROBE_TICK};
#[cfg(target_os = "linux")]
use super::CounterStatus;
use super::{Probe, ProbeError, CPU_RAIL_LIVENESS_BUDGET};
use sandbox_schema::DeviceClass;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
#[cfg(target_os = "linux")]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
/// The powercap tree the production probe reads.
#[cfg(target_os = "linux")]
const POWERCAP: &str = "/sys/class/powercap";
struct Domain {
/// The package zone's directory name, `intel-rapl:N`: its rail name for
/// the liveness rule.
name: String,
energy_path: PathBuf,
/// The value this zone's counter wraps at, read at open: non-zero.
max_range_uj: u64,
last_raw: u64,
/// Accumulated energy since probe creation, wrap-corrected.
@ -17,28 +69,116 @@ struct Domain {
pub struct RaplProbe {
domains: Vec<Domain>,
/// Why the cumulative count can no longer be trusted, once a package's
/// delta could not be known: every snapshot from then on fails, so every
/// span is estimated rather than one missing that energy.
broken: Option<String>,
}
/// How many times this process ran the real RAPL liveness probe: bumped by
/// [`host_status`] before anything else it does, read through
/// [`counter_probe_runs`](super::counter_probe_runs).
#[cfg(target_os = "linux")]
pub(super) static REAL_RUNS: AtomicUsize = AtomicUsize::new(0);
/// This host's RAPL status from one real probe run: the value
/// [`rapl_status`](super::rapl_status) caches, and its only source. One
/// composition with no branch of its own (count the run, run the real probe,
/// map its verdict). Call [`rapl_status`](super::rapl_status), not this.
#[cfg(target_os = "linux")]
pub(super) fn host_status() -> CounterStatus {
REAL_RUNS.fetch_add(1, Ordering::SeqCst);
counter_status_of(real_run().verdict())
}
/// The real probe run: [`run_over`] the host's powercap tree with a tick that
/// burns [`PROBE_TICK`] of this thread's CPU time.
#[cfg(target_os = "linux")]
fn real_run() -> RailWatch {
run_over(Path::new(POWERCAP), || counter_tick(PROBE_TICK))
}
/// [`counter_run`] over the package zones' raw counters under `root`, the
/// given `tick` and a monotonic clock, for [`CPU_RAIL_LIVENESS_BUDGET`]. A
/// tree that does not open (absent, unreadable, no package zone) is never
/// ticked.
fn run_over(root: &Path, tick: impl FnMut()) -> RailWatch {
let epoch = Instant::now();
counter_run(
|| RaplProbe::open_in(root).ok().map(|p| move || p.raw_sample()),
tick,
move || epoch.elapsed(),
CPU_RAIL_LIVENESS_BUDGET,
)
}
/// Whether a powercap zone's `name` file names a CPU package: `package-<n>`,
/// `<n>` one or more ASCII digits, surrounding whitespace allowed. Not `psys`,
/// `dram`, `core`, `uncore`, or anything else.
fn is_package_name(name: &str) -> bool {
name.trim()
.strip_prefix("package-")
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
/// Whether a powercap directory is a top-level RAPL zone, `intel-rapl:<n>`:
/// not the `intel-rapl` control type itself, a subzone (`intel-rapl:0:0`), or
/// another interface (`intel-rapl-mmio:0`).
fn is_top_level_zone(dir_name: &str) -> bool {
dir_name
.strip_prefix("intel-rapl:")
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
impl RaplProbe {
pub fn new() -> Result<RaplProbe, ProbeError> {
let base = PathBuf::from("/sys/class/powercap");
let entries = fs::read_dir(&base)
.map_err(|e| ProbeError::Unavailable(format!("no powercap: {e}")))?;
/// The probe the meter uses, built for this host's RAPL `status`, which
/// production passes from the cached [`rapl_status`](super::rapl_status)
/// and nowhere else: **only when every package counter is live**
/// ([`gated`]). Otherwise `Unavailable`, naming why, and the
/// meter estimates instead.
#[cfg(target_os = "linux")]
pub fn given(status: CounterStatus) -> Result<RaplProbe, ProbeError> {
gated("RAPL", status, Self::open)
}
/// Open every package zone of the host's powercap tree, with no liveness
/// gate. Used by the liveness probe itself and by [`given`](Self::given)
/// once the gate passed.
#[cfg(target_os = "linux")]
pub(super) fn open() -> Result<RaplProbe, ProbeError> {
Self::open_in(Path::new(POWERCAP))
}
/// Open every package zone of the powercap tree at `root`: each top-level
/// `intel-rapl:<n>` zone whose `name` is `package-<n>`, with its readable
/// `energy_uj` and non-zero `max_energy_range_uj`. `Unavailable` when
/// there is no such zone, and when a package zone, or a top-level zone
/// that may be one, cannot be read whole.
fn open_in(root: &Path) -> Result<RaplProbe, ProbeError> {
let entries = fs::read_dir(root).map_err(|e| ProbeError::Unavailable(format!("no powercap: {e}")))?;
let mut domains = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
// Top-level packages only: "intel-rapl:0", "intel-rapl:1", …
// (sub-domains like :0:0 double-count the package figure).
if !name.starts_with("intel-rapl:") || name.matches(':').count() != 1 {
if !is_top_level_zone(&name) {
continue;
}
let dir = entry.path();
// A zone that cannot say what it is may be a package.
let zone = fs::read_to_string(dir.join("name"))
.map_err(|e| ProbeError::Unavailable(format!("RAPL zone {name} has no readable name: {e}")))?;
if !is_package_name(&zone) {
continue; // psys, a top-level dram zone, ...: not the packages' energy
}
let energy_path = dir.join("energy_uj");
let Ok(raw) = read_u64(&energy_path) else {
continue; // unreadable without privileges on some kernels
};
let max_range_uj = read_u64(&dir.join("max_energy_range_uj")).unwrap_or(u64::MAX);
// Unreadable without privileges on many kernels; a package left out
// would leave its energy out of a figure that claims the CPU.
let raw = read_u64(&energy_path).map_err(|e| ProbeError::Unavailable(format!("RAPL package {name}: {e}")))?;
let max_range_uj = read_u64(&dir.join("max_energy_range_uj"))
.ok()
.filter(|&r| r > 0)
.ok_or_else(|| ProbeError::Unavailable(format!("RAPL package {name}: no readable wrap range")))?;
domains.push(Domain {
name,
energy_path,
max_range_uj,
last_raw: raw,
@ -46,10 +186,103 @@ impl RaplProbe {
});
}
if domains.is_empty() {
return Err(ProbeError::Unavailable("no readable RAPL package domains".into()));
return Err(ProbeError::Unavailable("no RAPL package zone".into()));
}
Ok(RaplProbe { domains })
// A stable rail order, so consecutive samples pair domain by domain.
domains.sort_by(|a, b| a.name.cmp(&b.name));
Ok(RaplProbe { domains, broken: None })
}
/// One absolute sample for the liveness rule: every package domain's name
/// and raw `energy_uj`, in domain order, as read — never wrap-corrected or
/// summed (a wrap reads as a fall, which is not an advance). `None` if any
/// domain could not be read.
fn raw_sample(&self) -> Option<Sample> {
self.domains
.iter()
.map(|d| read_u64(&d.energy_path).ok().map(|raw| (d.name.clone(), raw_i64(raw))))
.collect()
}
}
/// The RAPL package counters as one cumulative CPU energy counter, for a
/// caller that takes its own before/after differences rather than metering
/// spans through [`PowerMeter`](super::PowerMeter) — the S5 executor's
/// `RaplMeter`. It is the probe the meter itself uses, under the same rules:
/// every top-level zone whose `name` is `package-<n>` and nothing else (not
/// `psys`, `dram`, subzones or `intel-rapl-mmio`), every package or none,
/// each wrap-corrected at its own `max_energy_range_uj`, a package with no
/// readable range never opened, and a fall from above a package's range
/// breaking the count for good. There is no ungated constructor: it opens only
/// once every package counter has proved live.
pub struct RaplPackages {
probe: RaplProbe,
}
impl RaplPackages {
/// This host's package zones (`/sys/class/powercap`), only when the
/// cached [`rapl_status`](super::rapl_status) is `Live`: every package
/// counter kept advancing while the probing thread burned CPU. Otherwise
/// `Unavailable`, naming why; always so off Linux.
pub fn host() -> Result<RaplPackages, ProbeError> {
#[cfg(target_os = "linux")]
{
RaplProbe::given(super::rapl_status()).map(|probe| RaplPackages { probe })
}
#[cfg(not(target_os = "linux"))]
{
Err(ProbeError::Unavailable("RAPL: no powercap off Linux".into()))
}
}
/// The package zones of the powercap tree at `root`, only when every one
/// proves live: the liveness run [`rapl_status`](super::rapl_status) makes
/// over the host's tree, made here over `root`, with `tick` driving the
/// load between samples (production's tick burns CPU,
/// [`counter_liveness_tick`](super::counter_liveness_tick)). For a tree
/// other than the host's sysfs: a test's fake tree. Not cached; the run
/// takes about 300 ms over live counters and the whole
/// [`CPU_RAIL_LIVENESS_BUDGET`] over a held one.
pub fn live_in(root: &Path, tick: impl FnMut()) -> Result<RaplPackages, ProbeError> {
let status = counter_status_of(run_over(root, tick).verdict());
gated("RAPL", status, || RaplProbe::open_in(root)).map(|probe| RaplPackages { probe })
}
/// The package zones summed, by directory name (`intel-rapl:<n>`), in
/// order.
pub fn zones(&self) -> Vec<&str> {
self.probe.domains.iter().map(|d| d.name.as_str()).collect()
}
/// The packages' cumulative energy since this opened, in microjoules:
/// each package's deltas, wrap-corrected at its own range, summed. Every
/// package is read before any is updated: a package whose counter does
/// not read fails the read and changes nothing. Once a package's delta
/// could not be known (a fall from above its range), this read and every
/// later one fail: a difference taken across that read would come back
/// short of the energy that went uncounted.
pub fn cumulative_uj(&mut self) -> Result<u64, ProbeError> {
Ok(self.probe.snapshot()?.iter().map(|(_, uj)| *uj).sum())
}
}
/// One package's energy between two reads, modulo its own wrap: a rise is the
/// rise, and a fall wrapped at `max_range_uj`, the energy left to the wrap
/// plus what it counted since. `None` for a fall from above the range: the
/// counter contradicts its declared range, and its delta is unknown. Cannot
/// see more than one wrap between reads — minutes at package power.
fn zone_delta(last_raw: u64, raw: u64, max_range_uj: u64) -> Option<u64> {
if raw >= last_raw {
return Some(raw - last_raw);
}
max_range_uj.checked_sub(last_raw)?.checked_add(raw)
}
/// A raw counter as the rule's signed integer, saturating: `energy_uj` never
/// approaches `i64::MAX` (its range is far below it), and a value that did
/// would read as held, never as an advance.
fn raw_i64(raw: u64) -> i64 {
i64::try_from(raw).unwrap_or(i64::MAX)
}
fn read_u64(p: &std::path::Path) -> Result<u64, ProbeError> {
@ -65,15 +298,26 @@ impl Probe for RaplProbe {
"rapl-powercap"
}
/// The packages' cumulative energy since the probe opened: each package's
/// deltas, wrap-corrected at its own range, summed. A package whose delta
/// cannot be known breaks the count for good — this read and every later
/// one fail, and the meter estimates — because a span bracketing that
/// read from both sides would otherwise come back measured, short of the
/// energy that went uncounted.
fn snapshot(&mut self) -> Result<Vec<(DeviceClass, u64)>, ProbeError> {
if let Some(why) = &self.broken {
return Err(ProbeError::Read(why.clone()));
}
let raws = self.domains.iter().map(|d| read_u64(&d.energy_path)).collect::<Result<Vec<_>, _>>()?;
let mut total = 0u64;
for d in &mut self.domains {
let raw = read_u64(&d.energy_path)?;
let delta = if raw >= d.last_raw {
raw - d.last_raw
} else {
// Counter wrapped within its declared range.
d.max_range_uj.saturating_sub(d.last_raw).saturating_add(raw)
for (d, raw) in self.domains.iter_mut().zip(raws) {
let Some(delta) = zone_delta(d.last_raw, raw, d.max_range_uj) else {
let why = format!(
"RAPL package {} fell from {} to {} µJ, above its {} µJ wrap range",
d.name, d.last_raw, raw, d.max_range_uj
);
self.broken = Some(why.clone());
return Err(ProbeError::Read(why));
};
d.accumulated_uj = d.accumulated_uj.saturating_add(delta);
d.last_raw = raw;
@ -82,3 +326,397 @@ impl Probe for RaplProbe {
Ok(vec![(DeviceClass::Cpu, total)])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::probe::liveness::counter_status_of;
#[cfg(target_os = "linux")]
use crate::probe::rapl_status;
use crate::probe::{CounterStatus, PowerMeter};
use crate::Provenance;
use std::time::Duration;
/// The gate declines every status but `Live`, without touching powercap,
/// and `Live` builds the probe wherever powercap opens.
#[cfg(target_os = "linux")]
#[test]
fn only_a_live_status_builds_the_rapl_probe() {
for status in [CounterStatus::Unavailable, CounterStatus::Frozen] {
match RaplProbe::given(status) {
Err(ProbeError::Unavailable(why)) => assert!(why.contains("RAPL"), "{why}"),
Err(e) => panic!("{status:?}: {e}"),
Ok(_) => panic!("{status:?} built the RAPL probe"),
}
}
assert_eq!(RaplProbe::given(CounterStatus::Live).is_ok(), RaplProbe::open().is_ok());
}
/// A raw sample is every domain's own integer, named, in a stable order.
#[test]
fn a_raw_sample_is_each_domains_integer() {
assert_eq!(raw_i64(262_143_328_850), 262_143_328_850);
assert_eq!(raw_i64(u64::MAX), i64::MAX, "saturates, never wraps negative");
#[cfg(target_os = "linux")]
if let Ok(p) = RaplProbe::open() {
let s = p.raw_sample().expect("a readable domain reads");
assert_eq!(s.len(), p.domains.len());
assert!(s.windows(2).all(|w| w[0].0 < w[1].0), "domain order: {s:?}");
}
}
#[cfg(target_os = "linux")]
fn burn(d: Duration) {
let t = Instant::now();
let mut x = 0u64;
while t.elapsed() < d {
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
}
std::hint::black_box(x);
}
/// The property that matters, against an independent oracle that reads
/// every package's ABSOLUTE `energy_uj` before and after three budgets of
/// burn, with no rule of its own: the RAPL rung is never selected over a
/// package whose counter did not move, and a selected rung's packages all
/// move. A host with no readable powercap must report `Unavailable`. It
/// logs what it observed, so a CI log shows which branch was asserted.
#[cfg(target_os = "linux")]
#[test]
fn the_rapl_rung_is_never_selected_over_a_counter_that_held() {
let status = rapl_status();
let selected = crate::probe::PowerMeter::detect().sources().contains(&"rapl-powercap");
let Ok(p) = RaplProbe::open() else {
eprintln!("rapl: status={status:?} oracle=no readable powercap selected={selected}");
assert_eq!(status, CounterStatus::Unavailable);
assert!(!selected);
return;
};
let before = p.raw_sample().expect("oracle read");
burn(CPU_RAIL_LIVENESS_BUDGET * 3);
let after = p.raw_sample().expect("oracle read");
let every_moved = before.iter().zip(&after).all(|((_, b), (_, a))| a != b);
eprintln!("rapl: status={status:?} oracle every_moved={every_moved} selected={selected} {before:?} -> {after:?}");
assert_eq!(selected, status == CounterStatus::Live, "selection disagrees with the status");
if !every_moved {
assert_ne!(status, CounterStatus::Live, "RAPL live although a package counter held");
}
if status == CounterStatus::Live {
assert!(every_moved, "RAPL live, yet the oracle saw a package hold");
}
}
/// A fake powercap tree in the OS temp directory, removed when dropped:
/// zone directories each with a `name`, an `energy_uj` and a
/// `max_energy_range_uj`, as sysfs lays them out.
struct FakePowercap {
root: PathBuf,
}
/// One zone of a [`FakePowercap`]: its directory, its `name` file (`None`:
/// no such file), its `energy_uj`, and its `max_energy_range_uj` (`None`:
/// no such file).
type FakeZone<'a> = (&'a str, Option<&'a str>, &'a str, Option<&'a str>);
impl FakePowercap {
fn with_zones(zones: &[FakeZone<'_>]) -> Self {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let root = std::env::temp_dir().join(format!("sandbox-meter-powercap-{}-{n}", std::process::id()));
let fake = FakePowercap { root };
fs::create_dir_all(&fake.root).expect("create a fake powercap tree");
for (dir, name, value, range) in zones {
let zone = fake.root.join(dir);
fs::create_dir_all(&zone).expect("create a fake zone");
if let Some(name) = name {
fs::write(zone.join("name"), format!("{name}\n")).unwrap();
}
if let Some(range) = range {
fs::write(zone.join("max_energy_range_uj"), format!("{range}\n")).unwrap();
}
fake.write(dir, value);
}
fake
}
/// Replace zone `dir`'s `energy_uj`, atomically, so a reader sees the
/// old value or the new one.
fn write(&self, dir: &str, value: &str) {
let dir = self.root.join(dir);
fs::write(dir.join("energy_uj.tmp"), format!("{value}\n")).unwrap();
fs::rename(dir.join("energy_uj.tmp"), dir.join("energy_uj")).unwrap();
}
}
impl Drop for FakePowercap {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
/// A tree laid out as a two-socket Intel host with a platform zone: two
/// packages (`intel-rapl:0`, `intel-rapl:2`), `psys` (`intel-rapl:1`), a
/// top-level `dram` zone (`intel-rapl:3`), each package's `core` and
/// `dram` subzones, and the MMIO interface to package 0. Each package
/// wraps at a range of its own.
fn two_sockets_with_psys_and_dram() -> FakePowercap {
FakePowercap::with_zones(&[
("intel-rapl:0", Some("package-0"), "1000", Some("262143328850")),
("intel-rapl:1", Some("psys"), "90000", Some("262143328850")),
("intel-rapl:2", Some("package-1"), "2000", Some("65532610987")),
("intel-rapl:3", Some("dram"), "30000", Some("65532610987")),
("intel-rapl:0:0", Some("core"), "700", Some("262143328850")),
("intel-rapl:0:1", Some("dram"), "800", Some("65532610987")),
("intel-rapl:2:0", Some("core"), "900", Some("262143328850")),
("intel-rapl-mmio:0", Some("package-0"), "1000", Some("262143328850")),
])
}
/// The package zones by their `name` file, and no other: `package-<n>`;
/// the top-level zones by their directory: `intel-rapl:<n>`.
#[test]
fn a_package_is_a_top_level_zone_named_package_n() {
for name in ["package-0", "package-1\n", "package-12", " package-3 \n"] {
assert!(is_package_name(name), "{name:?}");
}
for name in ["psys", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", "package0", ""] {
assert!(!is_package_name(name), "{name:?}");
}
for dir in ["intel-rapl:0", "intel-rapl:12"] {
assert!(is_top_level_zone(dir), "{dir:?}");
}
for dir in ["intel-rapl", "intel-rapl:", "intel-rapl:0:0", "intel-rapl:1:2", "intel-rapl-mmio:0", "intel-rapl:x"] {
assert!(!is_top_level_zone(dir), "{dir:?}");
}
}
/// The probe opens, samples and sums the package zones and nothing else.
/// On a tree with two packages, `psys`, a top-level `dram` zone, subzones
/// and an MMIO interface: the probe holds the two packages, each with its
/// own range; the liveness sample names those two; a span over which every
/// zone rose is metered, through the meter itself, as the two packages'
/// rise alone; a package wrapping at its own range is corrected at that
/// range, while `psys` and `dram` fall too.
#[test]
fn the_probe_sums_and_samples_the_package_zones_only() {
let tree = two_sockets_with_psys_and_dram();
let p = RaplProbe::open_in(&tree.root).expect("a tree with two package zones opens");
let held: Vec<(&str, u64)> = p.domains.iter().map(|d| (d.name.as_str(), d.max_range_uj)).collect();
assert_eq!(
held,
[("intel-rapl:0", 262_143_328_850), ("intel-rapl:2", 65_532_610_987)],
"the probe did not hold exactly the two package zones"
);
let sample = p.raw_sample().expect("the packages read");
assert_eq!(sample, [("intel-rapl:0".to_string(), 1_000), ("intel-rapl:2".to_string(), 2_000)], "the probe sampled other zones");
let mut meter = PowerMeter { probes: vec![Box::new(p)], tdp_watts: 10.0 };
let ((), m) = meter.measure(|| {
tree.write("intel-rapl:0", "4000");
tree.write("intel-rapl:1", "190000");
tree.write("intel-rapl:2", "7000");
tree.write("intel-rapl:3", "80000");
tree.write("intel-rapl:0:0", "2700");
tree.write("intel-rapl:0:1", "1800");
tree.write("intel-rapl-mmio:0", "4000");
});
let cpu: Vec<_> = m.devices.iter().map(|d| (d.class, d.measured_uj, d.provenance)).collect();
assert_eq!(cpu, [(DeviceClass::Cpu, Some(8_000), Provenance::HwShunt)], "not the packages' rise alone");
// Package 1 wraps at its own range, 65,532,610,987 µJ, as psys and
// dram fall too: 65,532,600,000 -> 500 is 11,487 µJ, plus package 0's
// 1,000.
tree.write("intel-rapl:2", "65532600000");
let _ = meter.sample_uj();
let ((), m) = meter.measure(|| {
tree.write("intel-rapl:0", "5000");
tree.write("intel-rapl:2", "500");
tree.write("intel-rapl:1", "10");
tree.write("intel-rapl:3", "10");
});
let cpu: Vec<_> = m.devices.iter().map(|d| (d.class, d.measured_uj, d.provenance)).collect();
assert_eq!(cpu, [(DeviceClass::Cpu, Some(12_487), Provenance::HwShunt)], "{:?}", m.devices);
}
/// The liveness run — the one `rapl_status` runs on Linux, here over a
/// fake tree with a sleeping tick — watches the package zones only: Live
/// while both packages advance with `psys` and `dram` holding; Frozen
/// while `psys`, `dram`, the MMIO zone and package 0 advance and package 1
/// holds; and a tree with no package zone is never ticked.
#[test]
fn the_liveness_run_watches_the_package_zones_only() {
/// The run over `tree` while another thread advances the zones in
/// `advancing` by 4 mJ every 2 ms.
fn run_advancing(tree: &FakePowercap, advancing: &[&str]) -> CounterStatus {
let stop = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|s| {
s.spawn(|| {
let mut uj: u64 = 100_000;
while !stop.load(std::sync::atomic::Ordering::SeqCst) {
uj += 4_000;
for dir in advancing {
tree.write(dir, &uj.to_string());
}
std::thread::sleep(Duration::from_millis(2));
}
});
let watch = run_over(&tree.root, || std::thread::sleep(Duration::from_millis(25)));
let status = counter_status_of(watch.verdict());
eprintln!("rapl run: zones {advancing:?} advancing, the rest holding -> {status:?}");
stop.store(true, std::sync::atomic::Ordering::SeqCst);
status
})
}
let tree = two_sockets_with_psys_and_dram();
assert_eq!(
run_advancing(&tree, &["intel-rapl:0", "intel-rapl:2"]),
CounterStatus::Live,
"both packages advancing, psys and dram holding, was not live"
);
let tree = two_sockets_with_psys_and_dram();
assert_eq!(
run_advancing(&tree, &["intel-rapl:0", "intel-rapl:1", "intel-rapl:3", "intel-rapl-mmio:0"]),
CounterStatus::Frozen,
"package 1 holding beside a live psys, dram and package 0 was not declined"
);
let psys_only = FakePowercap::with_zones(&[("intel-rapl:0", Some("psys"), "90000", Some("262143328850"))]);
let ticks = std::cell::Cell::new(0);
let watch = run_over(&psys_only.root, || ticks.set(ticks.get() + 1));
assert_eq!((counter_status_of(watch.verdict()), ticks.get()), (CounterStatus::Unavailable, 0));
}
/// No package zone, no probe: a host exposing only `psys` and `dram` (a
/// `package-0` subzone does not count) does not open. Nor does a tree
/// that would leave a package out, or could not correct its wrap: a
/// package whose `energy_uj` does not read beside one that does, a
/// top-level zone whose `name` does not read (it may be a package), a
/// package with no `max_energy_range_uj`, or a zero one.
#[test]
fn the_probe_never_leaves_a_package_out_or_takes_another_zone() {
let unavailable = |zones: &[FakeZone<'_>]| {
let tree = FakePowercap::with_zones(zones);
match RaplProbe::open_in(&tree.root) {
Err(ProbeError::Unavailable(why)) => why,
Err(e) => panic!("{zones:?}: {e}"),
Ok(p) => panic!("{zones:?} opened {:?}", p.domains.iter().map(|d| &d.name).collect::<Vec<_>>()),
}
};
let why = unavailable(&[
("intel-rapl:0", Some("psys"), "90000", Some("262143328850")),
("intel-rapl:1", Some("dram"), "30000", Some("65532610987")),
("intel-rapl:0:0", Some("package-0"), "1000", Some("262143328850")),
]);
assert!(why.contains("no RAPL package zone"), "{why}");
let why = unavailable(&[
("intel-rapl:0", Some("package-0"), "1000", Some("262143328850")),
("intel-rapl:1", Some("package-1"), "not a number", Some("262143328850")),
]);
assert!(why.contains("intel-rapl:1"), "{why}");
let why = unavailable(&[
("intel-rapl:0", Some("package-0"), "1000", Some("262143328850")),
("intel-rapl:1", None, "2000", Some("262143328850")),
]);
assert!(why.contains("no readable name"), "{why}");
for range in [None, Some("0")] {
let why = unavailable(&[("intel-rapl:0", Some("package-0"), "1000", range)]);
assert!(why.contains("wrap range"), "{range:?}: {why}");
}
let gone = FakePowercap::with_zones(&[]);
assert!(matches!(RaplProbe::open_in(&gone.root.join("absent")), Err(ProbeError::Unavailable(_))));
}
/// One package's delta at its own range; a fall from above the range is
/// unknown, and breaks the probe's count for good: that read and every
/// later one fail — even once the counter is back above where it was — so
/// a span that brackets the unknown delta from both sides is estimated,
/// never measured short of the energy that went uncounted.
#[test]
fn a_package_wraps_at_its_own_range_and_a_contradiction_breaks_the_count() {
assert_eq!(zone_delta(100, 400, 1_000), Some(300));
assert_eq!(zone_delta(7, 7, 1_000), Some(0));
assert_eq!(zone_delta(900, 50, 1_000), Some(150));
assert_eq!(zone_delta(65_532_600_000, 500, 65_532_610_987), Some(11_487));
assert_eq!(zone_delta(2_000, 50, 1_000), None, "a fall from above the range gave a figure");
let tree = FakePowercap::with_zones(&[("intel-rapl:0", Some("package-0"), "900", Some("1000"))]);
let mut p = RaplProbe::open_in(&tree.root).unwrap();
tree.write("intel-rapl:0", "950");
assert_eq!(p.snapshot().unwrap(), [(DeviceClass::Cpu, 50)]);
tree.write("intel-rapl:0", "20");
assert_eq!(p.snapshot().unwrap(), [(DeviceClass::Cpu, 120)], "a wrap at 1,000: 50 + 20 more");
tree.write("intel-rapl:0", "5000");
assert_eq!(p.snapshot().unwrap(), [(DeviceClass::Cpu, 5_100)]);
// A span opens at 5,000; mid-span a sampler reads the counter at 10,
// below where it was and above nothing it could have wrapped from;
// the span closes with the counter back up at 6,000.
let mut meter = PowerMeter { probes: vec![Box::new(p)], tdp_watts: 10.0 };
let span = meter.start();
tree.write("intel-rapl:0", "10");
assert_eq!(meter.sample_uj(), None, "the contradictory read gave a count");
tree.write("intel-rapl:0", "6000");
let m = span.stop(&mut meter);
assert_eq!(m.source, "estimator", "a span over an unknown delta was measured: {:?}", m.devices);
assert!(m.devices.iter().all(|d| d.provenance == Provenance::Estimator && d.measured_uj.is_none()), "{:?}", m.devices);
assert_eq!(meter.sample_uj(), None, "a broken count read again");
let ((), m) = meter.measure(|| tree.write("intel-rapl:0", "7000"));
assert_eq!(m.source, "estimator", "{:?}", m.devices);
let tree = FakePowercap::with_zones(&[("intel-rapl:0", Some("package-0"), "5000", Some("1000"))]);
let mut p = RaplProbe::open_in(&tree.root).unwrap();
tree.write("intel-rapl:0", "10");
assert!(matches!(p.snapshot(), Err(ProbeError::Read(why)) if why.contains("above its 1000")));
}
/// The public counter is the meter's probe behind the liveness gate:
/// over a tree whose two packages advance (with `psys` and `dram`
/// holding) it opens holding exactly the package zones, and its
/// cumulative figure is their rise alone; with package 1 holding beside
/// a live `psys`, `dram` and package 0 it does not open, naming RAPL; a
/// broken count stays broken.
#[test]
fn the_public_package_counter_opens_only_over_live_packages() {
/// `RaplPackages::live_in(tree)` while another thread advances the
/// zones in `advancing` by 4 mJ every 2 ms.
fn live_in_advancing(tree: &FakePowercap, advancing: &[&str]) -> Result<RaplPackages, ProbeError> {
let stop = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|s| {
s.spawn(|| {
let mut uj: u64 = 100_000;
while !stop.load(std::sync::atomic::Ordering::SeqCst) {
uj += 4_000;
for dir in advancing {
tree.write(dir, &uj.to_string());
}
std::thread::sleep(Duration::from_millis(2));
}
});
let opened = RaplPackages::live_in(&tree.root, || std::thread::sleep(Duration::from_millis(25)));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
opened
})
}
let tree = two_sockets_with_psys_and_dram();
let mut packages = live_in_advancing(&tree, &["intel-rapl:0", "intel-rapl:2"]).expect("both packages live");
assert_eq!(packages.zones(), ["intel-rapl:0", "intel-rapl:2"]);
let before = packages.cumulative_uj().unwrap();
for (dir, by) in [("intel-rapl:0", 3_000), ("intel-rapl:1", 50_000), ("intel-rapl:2", 5_000), ("intel-rapl:3", 9_000)] {
let now = read_u64(&tree.root.join(dir).join("energy_uj")).unwrap();
tree.write(dir, &(now + by).to_string());
}
assert_eq!(packages.cumulative_uj().unwrap() - before, 8_000, "not the packages' rise alone");
// Package 1 reads above its 65,532,610,987 µJ range, then falls.
tree.write("intel-rapl:2", "70000000000");
packages.cumulative_uj().expect("a rise reads");
tree.write("intel-rapl:2", "10");
assert!(packages.cumulative_uj().is_err(), "a fall from above the range gave a figure");
tree.write("intel-rapl:2", "20");
assert!(packages.cumulative_uj().is_err(), "a broken count read again");
let tree = two_sockets_with_psys_and_dram();
match live_in_advancing(&tree, &["intel-rapl:0", "intel-rapl:1", "intel-rapl:3", "intel-rapl-mmio:0"]) {
Err(ProbeError::Unavailable(why)) => assert!(why.contains("RAPL"), "{why}"),
Err(e) => panic!("{e}"),
Ok(p) => panic!("opened {:?} with package 1 holding", p.zones()),
}
}
}

View file

@ -0,0 +1,52 @@
//! The RAPL and NVML liveness probes run once per PROCESS. This file holds
//! this one test, so cargo runs it as a process of its own and its threads'
//! first calls are the process's first use of the caches.
use sandbox_meter::probe::{counter_probe_runs, nvml_status, rapl_status, CounterStatus};
use sandbox_meter::PowerMeter;
use std::sync::{Arc, Barrier};
/// However many threads ask at once, and whichever they ask first (a status,
/// or a meter whose selection is gated on it), each real probe runs once, and
/// every caller gets its one status; selection agrees with it.
#[test]
fn the_counter_liveness_probes_run_once_per_process() {
assert_eq!(counter_probe_runs(), (0, 0), "a probe ran before this test's first use of the caches");
const THREADS: usize = 8;
let start = Arc::new(Barrier::new(THREADS));
let askers: Vec<_> = (0..THREADS)
.map(|i| {
let start = Arc::clone(&start);
std::thread::spawn(move || {
let selects = || {
let s = PowerMeter::detect().sources();
(s.contains(&"rapl-powercap"), s.contains(&"nvml-total-energy"))
};
start.wait();
if i % 2 == 0 {
let status = (rapl_status(), nvml_status());
(status, selects())
} else {
let selected = selects();
((rapl_status(), nvml_status()), selected)
}
})
})
.collect();
let seen: Vec<_> = askers.into_iter().map(|t| t.join().expect("a thread that asked")).collect();
let first = seen[0].0;
for (status, (rapl, nvml)) in &seen {
assert_eq!(*status, first, "threads saw different statuses: {seen:?}");
assert_eq!(*rapl, first.0 == CounterStatus::Live, "RAPL selection disagrees with its status: {seen:?}");
assert_eq!(*nvml, first.1 == CounterStatus::Live, "NVML selection disagrees with its status: {seen:?}");
}
let runs = counter_probe_runs();
eprintln!("counters once per process: {THREADS} threads, (rapl, nvml)={first:?}, real probe runs={runs:?}");
#[cfg(target_os = "linux")]
assert_eq!(runs, (1, 1), "each real probe runs once in this process ({THREADS} threads asked at once)");
#[cfg(not(target_os = "linux"))]
{
assert_eq!(runs, (0, 0), "no RAPL or NVML probe runs off Linux");
assert_eq!(first, (CounterStatus::Unavailable, CounterStatus::Unavailable));
}
}

View file

@ -0,0 +1,77 @@
//! The IOReport liveness probe runs once per PROCESS. This file holds this one
//! test, so cargo runs it as a process of its own and its threads' first
//! calls are the process's first use of the cache: the case a per-thread
//! cache, or one that checks and then sets, gets wrong.
use sandbox_meter::probe::ioreport_probe_runs;
use sandbox_meter::{ioreport_status, IoReportStatus, PowerMeter};
use std::sync::{Arc, Barrier};
/// However many threads ask at once, and whichever they ask first (the
/// status, or a meter whose selection is gated on it), the real probe runs
/// once, and every caller gets its one status.
#[test]
fn the_liveness_probe_runs_once_per_process() {
assert_eq!(
ioreport_probe_runs(),
0,
"the probe ran before this test's first use of the cache"
);
const THREADS: usize = 8;
let start = Arc::new(Barrier::new(THREADS));
let askers: Vec<_> = (0..THREADS)
.map(|i| {
let start = Arc::clone(&start);
std::thread::spawn(move || {
let selects = || {
PowerMeter::detect()
.sources()
.contains(&"ioreport-energy-model")
};
start.wait();
// Half the threads ask for the status first and half build a
// meter first, so the first use is both paths at once.
if i % 2 == 0 {
let status = ioreport_status();
(status, selects(), ioreport_status())
} else {
let selected = selects();
let status = ioreport_status();
(status, selected, ioreport_status())
}
})
})
.collect();
let seen: Vec<_> = askers
.into_iter()
.map(|t| t.join().expect("a thread that asked"))
.collect();
let first = seen[0].0;
for (status, selected, again) in &seen {
assert_eq!(
(*status, *again),
(first, first),
"threads saw different statuses: {seen:?}"
);
assert_eq!(
*selected,
first == IoReportStatus::Live,
"selection disagrees with the status: {seen:?}"
);
}
assert_eq!(ioreport_status(), first);
let runs = ioreport_probe_runs();
eprintln!(
"ioreport once per process: {THREADS} threads, status={first:?}, real probe runs={runs}"
);
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
assert_eq!(
runs, 1,
"the real probe ran {runs} times in this process ({THREADS} threads asked at once): {seen:?}"
);
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
{
assert_eq!(runs, 0, "no IOReport probe runs off Apple Silicon macOS");
assert_eq!(first, IoReportStatus::Unavailable);
}
}

View file

@ -367,7 +367,8 @@ pub fn prove_microvm_with(
/// Prove the **S5 (microVM) substrate** on this host with a real Firecracker
/// boot. Requires `/dev/kvm` and a staged firecracker binary, kernel, and
/// rootfs; writes a Firecracker `--no-api` config, supervises the boot to
/// completion, and seals an S5 cell. CPU energy is read from RAPL.
/// completion, and seals an S5 cell. CPU energy is read from RAPL (every
/// package zone, once every package counter has proved live), else estimated.
pub fn prove_microvm(
inputs: &MicroVmInputs,
cloud: &str,
@ -419,7 +420,7 @@ pub fn prove_microvm(
};
prove_microvm_with(
Box::new(vmm),
Box::new(RaplMeter::intel_package0()),
Box::new(RaplMeter::host()),
template,
cloud,
at_unix_us,
@ -1150,9 +1151,12 @@ fn resolve_weight_hash(
/// the call goes over the wire. Here the silicon is the prover's own, which is
/// the one case where the joules can actually be *measured* — so the device
/// records come straight off [`PowerMeter`] with whatever provenance the
/// hardware honestly supports (`HwShunt` on Apple Silicon IOReport and Linux
/// RAPL/NVML; `Estimator` where no counter exists). The meter can degrade; it
/// does not invent a reading, and this function does not re-tag one.
/// hardware honestly supports: `HwShunt` from a live counter (Linux RAPL/NVML,
/// or Apple Silicon IOReport when its CPU energy rail is live), and
/// `Estimator` where no live counter exists, which on macOS 27.0.1 includes
/// every Apple Silicon host checked. The meter can degrade; it does not invent
/// a reading, and this function does not re-tag one — in what it seals or in
/// what it says.
///
/// Three properties the cell attests, each checked rather than asserted:
///
@ -1163,8 +1167,9 @@ fn resolve_weight_hash(
/// empty outbound allowlist, inbound false — and that emptiness is asserted
/// before the engine is spawned. Deny-by-default is the standard's posture;
/// this makes it a checked precondition of the proof.
/// 3. **The energy is this host's.** Measured over the engine's execution span
/// by the best counter the platform offers.
/// 3. **The energy is this host's.** Metered over the engine's execution span
/// by the best source the platform offers — a live counter, else the
/// estimator — and labelled by the span's own records.
///
/// The engine is supplied by the operator and driven as a subprocess, exactly
/// as [`prove_accelerator`] drives a GPU workload. That keeps the sandbox
@ -1228,7 +1233,6 @@ pub fn prove_local(
// (3) Meter this host's own counters across the engine's execution.
let mut meter = PowerMeter::detect();
let sources = meter.sources().join(",");
let is_hw = meter.is_hardware();
let (status, measurement) = meter.measure(|| {
std::process::Command::new(&inputs.engine)
.args(&inputs.args)
@ -1318,7 +1322,7 @@ pub fn prove_local(
HashSource::Fresh => "hashed",
HashSource::Cached => "cache hit: trusts size+mtime",
},
if is_hw { "measured" } else { "ESTIMATED" },
energy_word(&measurement),
measurement.wall.as_secs_f64(),
measurement.avg_watts(),
sources,
@ -1348,10 +1352,65 @@ pub fn prove_local(
sign(cell, proof_key)
}
/// How [`prove_local`] describes the energy it sealed, decided by the span's
/// own records. The records are sealed verbatim, so this is the receipt's
/// claim in one word: "measured" only when every record is a hardware
/// reading. A hardware meter can still return an estimate — the meter falls
/// back to its estimator for a span whose CPU counter held — so whether the
/// meter *has* a hardware source says nothing about this span.
fn energy_word(m: &sandbox_meter::Measurement) -> &'static str {
use sandbox_meter::Provenance;
let read = !m.devices.is_empty()
&& m
.devices
.iter()
.all(|d| d.provenance == Provenance::HwShunt && d.measured_uj.is_some());
if read {
"measured"
} else {
"ESTIMATED"
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `prove_local` says "measured" only for a span whose every record is a
/// hardware reading — never for the estimator's record, which a hardware
/// meter also returns when the span's CPU counter held. main printed
/// "measured" for every span of a hardware meter, and sealed the same
/// records.
#[test]
fn a_local_proof_says_estimated_for_an_estimated_span() {
use sandbox_meter::{DeviceEnergy, Measurement, Provenance};
let rec = |class, measured_uj, estimated_uj, provenance| DeviceEnergy {
class,
measured_uj,
estimated_uj,
provenance,
};
let span = |devices| Measurement {
devices,
wall: std::time::Duration::from_secs(1),
source: "test",
};
// The meter's estimator record: no live counter, or a CPU counter that held.
let est = span(vec![rec(DeviceClass::Cpu, None, 20_000_000, Provenance::Estimator)]);
assert_eq!(energy_word(&est), "ESTIMATED");
// A live CPU counter, with the GPU and ANE beside it.
let live = span(vec![
rec(DeviceClass::Cpu, Some(9_000_000), 9_000_000, Provenance::HwShunt),
rec(DeviceClass::Npu, Some(0), 0, Provenance::HwShunt),
rec(DeviceClass::Gpu, Some(700_000), 700_000, Provenance::HwShunt),
]);
assert_eq!(energy_word(&live), "measured");
// Anything short of a reading on every record is not a measurement.
let modeled = span(vec![rec(DeviceClass::Cpu, None, 5_000, Provenance::ModelBased)]);
assert_eq!(energy_word(&modeled), "ESTIMATED");
assert_eq!(energy_word(&span(Vec::new())), "ESTIMATED");
}
#[test]
fn prove_here_produces_a_verifiable_cell() {
let key = SigningKey::from_bytes(&[99u8; 32]);