jouleclaw-energy: IOReport only when its CPU rail keeps moving; consumers never zero-fill or relabel #117

Merged
dcharlot merged 23 commits from jouleclaw-energy/cpu-rail-liveness into main 2026-10-01 05:51:08 -04:00
65 changed files with 7887 additions and 698 deletions

View file

@ -501,6 +501,7 @@ dependencies = [
"joule-code-receipt",
"joule-code-schema",
"jouleclaw-energy",
"jouleclaw-fabric",
"jouleclaw-llm",
"jouleclaw-loader-gguf",
"jouleclaw-runtime",

View file

@ -23,4 +23,7 @@ jouleclaw-energy = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-en
# the in-process L3 backend.
jouleclaw-runtime = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime" }
jouleclaw-loader-gguf = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf", features = ["rayon", "fabric"] }
# The grades (`Measured` / `Benchmarked` / `Estimated`) the fabric ledger prices
# a call from, which the L3 stage grades the call's projected energy by.
jouleclaw-fabric = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-fabric" }
serde_json = "1"

View file

@ -39,11 +39,21 @@ JOULE_CODE_L3_ENGINE=llama cargo run --release --bin run -- /path/to/model.gguf
```
The `run` binary generates with the local model, content-addresses the result as
a `CodingOp` (tier L3), and **meters host energy over the inference** with
JouleClaw's energy counter — so the receipt's joule cost is a real local
measurement, honestly tagged with the counter's provenance (`HwShunt` on
RAPL/NVML hosts; `Estimator` on Apple Silicon until IOReport is wired), not a
datacenter token estimate.
a `CodingOp` (tier L3), and puts the **host** energy spent during the inference on
the receipt, tagged with the provenance of whatever metered it:
- **Apple Silicon whose IOReport CPU energy rail is live** — JouleClaw's IOReport
counter, a live vendor energy counter: a real local measurement, tagged
`HwShunt` (with `measured_uj` set).
- **Apple Silicon whose CPU rail is not live** — macOS 27.0.1 for an unprivileged
process, observed on an M5 Max and an M3 Ultra — JouleClaw's load-average
estimator, tagged `Estimator`. Not a measurement.
- **Anywhere else**, Linux and Windows included — the token-count estimate,
tagged `Estimator`. This binding builds `jouleclaw-energy` with the `ioreport`
feature only, so it reads no RAPL or NVML counter even where one exists.
The printout names the meter that ran and the IOReport status, and says whether
the figure was measured or estimated.
Verified live: the llama.cpp path against a local 350M GGUF, and the in-process
from-scratch runtime against `ternary-bonsai-1.7b-q2_0` (qwen3, Q2_0) — real text

View file

@ -7,12 +7,16 @@
//! By default it runs the model **in-process on JouleClaw's own from-scratch GGUF
//! tensor runtime** (`jouleclaw-runtime`); set `JOULE_CODE_L3_ENGINE=llama` to use
//! the `llama.cpp` shell-out backend instead. Either way it content-addresses the
//! result as a `CodingOp` and meters the **host** energy spent during the
//! inference with JouleClaw's energy counter — so the receipt's joule cost is a
//! real local measurement (honestly tagged with the counter's provenance), not a
//! datacenter token estimate.
//! result as a `CodingOp` and puts the **host** energy spent during the inference
//! on the receipt, from JouleClaw's best counter on this host and tagged with that
//! counter's provenance: a measurement where the host exposes a live counter
//! (IOReport on Apple Silicon whose CPU rail advances, `HwShunt`), an estimate
//! from host load where it does not (Apple Silicon whose IOReport CPU rail is
//! frozen, as on macOS 27.0.1), and the token-count estimate where there is no
//! counter at all. The printed receipt says which.
use jouleclaw_energy::{EnergyCounter, Provenance as JcProvenance};
use jouleclaw_energy::meter::EnergyMeter;
use jouleclaw_energy::Provenance as JcProvenance;
use jouleclaw_llm::{LlmBackend, LlmRequest};
use joule_code_cargo::{Anchor, CodingOp, Edit};
@ -23,11 +27,13 @@ use l3_jouleclaw_binding::llama_backend::LlamaCompletionBackend;
use l3_jouleclaw_binding::runtime_backend::JouleclawRuntimeBackend;
/// The host's best energy counter, or None where unavailable (e.g. CI/Linux).
fn host_counter() -> Option<Box<dyn EnergyCounter>> {
fn host_counter() -> Option<Box<dyn EnergyMeter>> {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
// Prefer the real IOReport energy-model counter (ModelBased); the
// load-avg estimator is only a last resort if IOReport will not open.
// The IOReport energy counter (HwShunt, a live vendor counter) where it can
// meter CPU work: it is unavailable unless IOReport opens AND its CPU energy
// rail advances under load (`jouleclaw_energy::ioreport_status()` says why
// not). Otherwise the load-average estimator (Estimator).
let ioreport = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
if ioreport.is_available() {
return Some(Box::new(ioreport));
@ -40,6 +46,39 @@ fn host_counter() -> Option<Box<dyn EnergyCounter>> {
None
}
/// The receipt energy for an inference: the host counter's delta across it, with
/// the counter's provenance, or — without a counter, or without two readings of
/// it — the token-count estimate. `true` when the figure came from the counter.
/// A counter that went backwards, one that registered nothing across the
/// inference (no inference runs on zero energy), or readings that disagree about
/// provenance give no counter figure: never a zero.
fn receipt_energy(
before: Option<(u64, JcProvenance)>,
after: Option<(u64, JcProvenance)>,
input_tokens: u64,
output_tokens: u64,
) -> (Energy, bool) {
if let (Some((b, before_prov)), Some((a, prov))) = (before, after) {
if let Some(uj) = a.checked_sub(b).filter(|uj| *uj > 0 && before_prov == prov) {
let prov = map_provenance(prov);
let measured_uj = (prov == Provenance::HwShunt).then_some(uj);
return (Energy { measured_uj, estimated_uj: uj, provenance: prov }, true);
}
}
(Energy::from_tokens(input_tokens, output_tokens), false)
}
/// How the receipt's energy was obtained, for the printout: by which counter, or
/// from token counts. An estimate is never called metered.
fn energy_note(energy: &Energy, counter: &str, from_counter: bool) -> String {
match (from_counter, energy.provenance) {
(false, _) => "estimated from token counts".to_string(),
(true, Provenance::HwShunt) => format!("measured by {counter}"),
(true, Provenance::ModelBased) => format!("host-metered by {counter} (energy model)"),
(true, Provenance::Estimator) => format!("estimated by {counter} from host load"),
}
}
fn map_provenance(p: JcProvenance) -> Provenance {
match p {
JcProvenance::HwShunt => Provenance::HwShunt,
@ -77,10 +116,11 @@ fn main() {
},
};
let counter = host_counter();
let counter_name = counter.as_ref().map_or("none", |c| c.name());
// Meter host energy across the inference. Prime the counter once so the
// first-sample init delta is excluded from the measured window.
let read = |c: &Option<Box<dyn EnergyCounter>>| {
let read = |c: &Option<Box<dyn EnergyMeter>>| {
c.as_ref()
.and_then(|c| c.read().ok())
.map(|r| (r.uj, r.provenance))
@ -113,27 +153,14 @@ fn main() {
};
let op_id = op.op_id().expect("op_id");
// Receipt energy: real measured host energy if a counter was available,
// else the token-based estimate (honest fallback).
let energy = match (before, after) {
(Some((b, _)), Some((a, prov))) => {
let uj = a.saturating_sub(b);
let prov = map_provenance(prov);
match prov {
Provenance::HwShunt => Energy {
measured_uj: Some(uj),
estimated_uj: uj,
provenance: prov,
},
_ => Energy {
measured_uj: None,
estimated_uj: uj,
provenance: prov,
},
}
}
_ => Energy::from_tokens(response.input_tokens as u64, response.output_tokens as u64),
};
// Receipt energy: the host counter's figure at its provenance, else the
// token-based estimate (honest fallback).
let (energy, from_counter) = receipt_energy(
before,
after,
response.input_tokens as u64,
response.output_tokens as u64,
);
let receipt = Receipt {
op_id: op_id.clone(),
tier: Tier::L3,
@ -151,18 +178,109 @@ fn main() {
println!("op_id : {op_id}");
println!("edits : {} (GENERATED.rs)", op.edits.len());
println!("tier : {}", receipt.tier);
println!(
"meter : {counter_name} (IOReport: {:?})",
jouleclaw_energy::ioreport_status()
);
println!(
"tokens : in={} out={}",
response.input_tokens, response.output_tokens
);
println!(
"energy : {:.3} J ({:?}{})",
"energy : {:.3} J ({:?}, {})",
receipt.energy.estimated_uj as f64 / 1_000_000.0,
receipt.energy.provenance,
if receipt.energy.measured_uj.is_some() {
", measured"
} else {
", host-metered"
}
energy_note(&receipt.energy, counter_name, from_counter)
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_counter_reading_keeps_its_provenance() {
let (e, from_counter) =
receipt_energy(Some((100, JcProvenance::HwShunt)), Some((400, JcProvenance::HwShunt)), 7, 9);
assert!(from_counter);
assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (Some(300), 300, Provenance::HwShunt));
for (jc, p) in [(JcProvenance::ModelBased, Provenance::ModelBased), (JcProvenance::Estimator, Provenance::Estimator)] {
let (e, from_counter) = receipt_energy(Some((100, jc)), Some((400, jc)), 7, 9);
assert!(from_counter);
assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 300, p));
assert!(e.check_provenance().is_ok());
}
}
#[test]
fn without_two_readings_the_receipt_is_the_token_estimate() {
let tokens = Energy::from_tokens(7, 9);
let est = JcProvenance::Estimator;
for (before, after) in [
(None, None),
(None, Some((400, est))),
(Some((100, est)), None),
(Some((400, est)), Some((100, est))), // went backwards
(Some((400, est)), Some((400, est))), // registered nothing
(Some((9, JcProvenance::ModelBased)), Some((9, JcProvenance::ModelBased))), // a held rail
(Some((100, JcProvenance::ModelBased)), Some((400, est))), // disagree
] {
let (e, from_counter) = receipt_energy(before, after, 7, 9);
assert!(!from_counter, "{before:?} → {after:?}");
assert_eq!(e, tokens);
}
}
#[test]
fn an_estimate_is_never_printed_as_metered() {
let est = Energy { measured_uj: None, estimated_uj: 5, provenance: Provenance::Estimator };
assert_eq!(energy_note(&est, "apple-silicon", true), "estimated by apple-silicon from host load");
assert_eq!(energy_note(&est, "none", false), "estimated from token counts");
let modeled = Energy { measured_uj: None, estimated_uj: 5, provenance: Provenance::ModelBased };
assert_eq!(energy_note(&modeled, "apple-ioreport", true), "host-metered by apple-ioreport (energy model)");
let measured = Energy { measured_uj: Some(5), estimated_uj: 5, provenance: Provenance::HwShunt };
assert_eq!(energy_note(&measured, "rapl", true), "measured by rapl");
}
/// On this host: the IOReport counter is used only where its CPU rail is live.
/// Elsewhere on Apple Silicon (macOS 27.0.1 on an M5 Max and an M3 Ultra) the
/// load estimator runs and meters the burn, and the receipt says Estimator; with
/// no counter at all (CI's Linux runner), the token estimate does.
#[test]
fn on_this_host_the_estimator_fallback_runs_unless_ioreport_is_live() {
let status = jouleclaw_energy::ioreport_status();
let counter = host_counter();
let name = counter.as_ref().map_or("none", |c| c.name());
let read = || counter.as_ref().and_then(|c| c.read().ok()).map(|r| (r.uj, r.provenance));
let _ = read(); // prime, as main does
let before = read();
let t = std::time::Instant::now();
let mut x = 1u64;
while t.elapsed() < std::time::Duration::from_millis(20) {
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
}
std::hint::black_box(x);
let after = read();
let (e, from_counter) = receipt_energy(before, after, 7, 9);
let note = energy_note(&e, name, from_counter);
println!("l3 on this host: ioreport={status:?} meter={name} energy={e:?} ({note})");
if status == jouleclaw_energy::IoReportStatus::Live {
assert_eq!(name, "apple-ioreport");
assert_eq!(e.provenance, Provenance::HwShunt, "a live IOReport reading is a hardware reading");
assert!(from_counter && e.measured_uj.is_some_and(|uj| uj > 0), "{e:?}");
} else {
assert_ne!(name, "apple-ioreport", "IOReport is used only when its CPU rail is live");
assert_eq!(e.provenance, Provenance::Estimator);
assert_eq!(e.measured_uj, None);
assert!(note.starts_with("estimated"), "{note}");
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
assert_eq!(name, "apple-silicon");
assert!(from_counter && e.estimated_uj > 0, "the estimator metered 20 ms of burn: {e:?}");
assert_eq!(note, "estimated by apple-silicon from host load");
} else {
assert_eq!((name, from_counter), ("none", false));
assert_eq!(e, Energy::from_tokens(7, 9));
}
}
}
}

View file

@ -180,10 +180,13 @@ impl LlmBackend for HybridBackend {
.with_sampling(sampling);
// Fabric attribution window: GFLOPs per member for
// THIS call, priced by the machine's calibration —
// a calibrated per-operation model (ModelBased), not
// a meter reading; reported only when the fabric is
// on and calibrated.
// THIS call, each priced by the member's loaded matmul
// figure — a projection, not a meter reading; reported
// only when the fabric priced some of it. The L3 stage
// grades it by its weakest input
// (`crate::projection_grade`): ModelBased only when
// every member was priced by a Measured or Benchmarked
// figure.
jouleclaw_loader_gguf::fabric_dispatch::ledger_reset();
let out = match &self.model {
Hybrid::NemotronH(m) => generate(m, &prompt, &params),
@ -201,10 +204,10 @@ impl LlmBackend for HybridBackend {
finish_reason: if stopped { FinishReason::Stop } else { FinishReason::Length },
input_tokens: prompt.len() as u32,
output_tokens: out.len() as u32,
// ModelBased when the fabric ledger priced the call
// against this machine's calibration; honest None
// otherwise (the run binary can still meter host
// energy over the whole call).
// The fabric projection when the ledger priced the
// call (graded by the stage from the ledger's
// inputs); honest None otherwise (the run binary can
// still meter host energy over the whole call).
energy_joules,
})
}

View file

@ -31,16 +31,53 @@ use joule_code_cargo::{Anchor, CodingOp, Edit};
use joule_code_cascade::{FnStage, Request, Resolution, Stage};
use joule_code_receipt::{Energy, Provenance, Receipt};
use joule_code_schema::{ContentId, Tier, ALG_ED25519};
use jouleclaw_fabric::EnergyProvenance;
use jouleclaw_llm::{EchoBackend, LlmBackend, LlmRequest};
use jouleclaw_loader_gguf::fabric_dispatch::{ledger_reset, ledger_snapshot, LedgerEntry};
/// The grade of a per-call energy figure projected from the fabric attribution
/// ledger (`window`: the ledger's entries for that call), by its **weakest
/// input**.
///
/// A projection is GFLOPs × each member's µJ/GFLOP; it is never a reading, so
/// at best it is a calibrated per-operation model, `ModelBased` (joule-code
/// SPEC §6). It is that only when every member that ran was priced by a
/// calibrated figure: `Measured` (a calibration row a live meter read) or
/// `Benchmarked` (a recorded run on that member class). One input priced by an
/// `Estimated` figure — a coarse model, such as the fabric's seeded GPU figures —
/// makes the whole projection an estimate, `Estimator`; so does a member that
/// ran unpriced (its work is missing from the sum), and an empty window (a
/// figure that cannot be traced to any priced input).
pub fn projection_grade(window: &[LedgerEntry]) -> Provenance {
if window.is_empty() {
return Provenance::Estimator;
}
let mut grade = Provenance::ModelBased;
for entry in window {
match entry.projected_grade {
Some(EnergyProvenance::Measured | EnergyProvenance::Benchmarked) => {}
Some(EnergyProvenance::Estimated) | None => grade = Provenance::Estimator,
}
}
grade
}
/// Build an L3 cascade [`Stage`] backed by any JouleClaw [`LlmBackend`]. The
/// stage generates a coding operation from the request intent and reports the
/// backend's joule cost as the receipt energy (tier L3, joules class).
///
/// The stage opens the fabric attribution window around the call (it resets
/// the process-wide ledger first), so the figure a backend reports is graded by
/// the inputs this call was priced from ([`projection_grade`]) and by nothing an
/// earlier call left in the ledger. The ledger is process-wide: work another
/// thread dispatches during the call lands in the same window.
pub fn jouleclaw_l3_stage(backend: Box<dyn LlmBackend>) -> impl Stage {
FnStage::new(Tier::L3, move |req: &Request| {
ledger_reset();
let response = backend
.complete(&LlmRequest::from_prompt(req.intent.clone(), 256))
.ok()?;
let window = ledger_snapshot();
// Reference materialization: the model's output becomes one edit. A
// production binding would parse structured edits from the model.
@ -59,15 +96,15 @@ pub fn jouleclaw_l3_stage(backend: Box<dyn LlmBackend>) -> impl Stage {
};
let op_id = op.op_id().ok()?;
// L3 energy, by best available provenance: when the
// backend reports a per-call figure (the hybrid
// backend's fabric ledger — GFLOPs attributed per
// member, priced by THIS machine's IOReport
// calibration), that is a calibrated per-operation
// model: ModelBased per SPEC §6. Otherwise the
// catalog constant, honestly tagged Estimator.
// L3 energy. When the backend reports a per-call figure
// (the hybrid backend's fabric projection — GFLOPs
// attributed per member, priced by each member's loaded
// matmul figure), it is graded by its weakest input:
// ModelBased only if every member was priced by a
// Measured or Benchmarked figure, Estimator otherwise.
// Without a figure, the catalog constant, Estimator.
let (estimated_uj, provenance) = match response.energy_joules {
Some(j) => ((j * 1_000_000.0) as u64, Provenance::ModelBased),
Some(j) => ((j * 1_000_000.0) as u64, projection_grade(&window)),
None => (
(backend.typical_joules_per_call() * 1_000_000.0) as u64,
Provenance::Estimator,
@ -121,6 +158,98 @@ mod tests {
assert_eq!(res.receipt.energy.provenance, Provenance::Estimator);
}
/// A ledger entry for `member`, priced at `grade` (`None`: unpriced).
fn entry(member: &str, grade: Option<EnergyProvenance>) -> LedgerEntry {
LedgerEntry {
member_id: member.into(),
ops: 3,
gflops: 1.5,
projected_uj: grade.map(|_| 1.5 * 7_000.0),
projected_grade: grade,
}
}
/// The weakest input decides: calibrated figures (Measured, Benchmarked)
/// make a ModelBased projection; one Estimated or unpriced input, or no input
/// at all, makes it an Estimator figure.
#[test]
fn a_projection_is_graded_by_its_weakest_input() {
use EnergyProvenance::*;
let cases: [(&[Option<EnergyProvenance>], Provenance); 8] = [
(&[Some(Measured)], Provenance::ModelBased),
(&[Some(Benchmarked)], Provenance::ModelBased),
(&[Some(Measured), Some(Benchmarked)], Provenance::ModelBased),
(&[Some(Estimated)], Provenance::Estimator),
(&[Some(Benchmarked), Some(Estimated)], Provenance::Estimator),
(&[Some(Estimated), Some(Measured)], Provenance::Estimator),
(&[Some(Measured), None], Provenance::Estimator),
(&[], Provenance::Estimator),
];
for (grades, want) in cases {
let window: Vec<LedgerEntry> =
grades.iter().enumerate().map(|(i, g)| entry(&format!("local/m{i}"), *g)).collect();
assert_eq!(projection_grade(&window), want, "{grades:?}");
}
}
/// A backend that runs one real matmul through the fabric dispatcher (the
/// path the hybrid models take) and reports a per-call figure, as the hybrid
/// backend does from the ledger.
struct DispatchingBackend {
/// Run the matmul (and so leave a ledger entry), or not.
dispatch: bool,
}
impl LlmBackend for DispatchingBackend {
fn model_name(&self) -> &str {
"dispatching"
}
fn complete(&self, request: &LlmRequest) -> Result<jouleclaw_llm::LlmResponse, jouleclaw_llm::LlmError> {
if self.dispatch {
let (m, n, k) = (1usize, 32usize, 64usize);
let a = vec![0.5f32; m * k];
let w = vec![0u8; 2 * n * k];
let mut out = vec![0f32; m * n];
jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_auto(&a, &w, &[], m, n, k, &mut out);
}
Ok(jouleclaw_llm::LlmResponse {
text: format!("fn answer() {{}} // {}", request.prompt),
finish_reason: jouleclaw_llm::FinishReason::Stop,
input_tokens: 4,
output_tokens: 4,
energy_joules: Some(0.25),
})
}
fn typical_joules_per_call(&self) -> f64 {
1.0
}
}
fn resolve(backend: DispatchingBackend) -> Energy {
let stage = jouleclaw_l3_stage(Box::new(backend));
let res = stage
.try_resolve(&Request { prior: "b3:AAAA".into(), intent: "write a hello function".into() })
.expect("L3 resolved");
res.receipt.energy
}
/// A per-call figure is no longer ModelBased by default. Here the fabric is
/// off (no `JOULECLAW_FABRIC=1`), so the member that ran was priced by
/// nothing: the figure is reported, as an estimate. A figure with no ledger
/// input at all is an estimate too.
#[test]
fn a_figure_the_ledger_did_not_price_is_an_estimate() {
for dispatch in [true, false] {
let e = resolve(DispatchingBackend { dispatch });
assert_eq!(
(e.measured_uj, e.estimated_uj, e.provenance),
(None, 250_000, Provenance::Estimator),
"dispatch={dispatch}"
);
assert!(e.check_provenance().is_ok());
}
}
#[test]
fn cascade_falls_through_l0_to_l3() {
// Empty L0 cache in front of the L3 model stage: L0 misses, L3 closes.

View file

@ -0,0 +1,65 @@
//! Shared by the fabric-grade tests: run the L3 stage, with the fabric ON in this
//! test process and priced from a calibration file the test writes (never the
//! machine's own `~/.jouleclaw/fabric-calibration.json`), over a backend that
//! dispatches one real matmul through the fabric and reports the ledger's
//! projection, as the hybrid backend does.
use joule_code_cascade::{Request, Stage};
use joule_code_receipt::Energy;
use jouleclaw_fabric::EnergyProvenance;
use jouleclaw_llm::{FinishReason, LlmBackend, LlmError, LlmRequest, LlmResponse};
use jouleclaw_loader_gguf::fabric_dispatch::{ledger_reset, ledger_snapshot, matmul_bf16_auto, LedgerEntry};
/// One decode-shaped matmul (m = 1, a 4 KiB weight): the fabric places it on the
/// CPU SIMD member, `local/cpu-neon`.
struct OneMatmul;
impl LlmBackend for OneMatmul {
fn model_name(&self) -> &str {
"one-matmul"
}
fn complete(&self, _request: &LlmRequest) -> Result<LlmResponse, LlmError> {
let (m, n, k) = (1usize, 32usize, 64usize);
let a = vec![0.5f32; m * k];
let w = vec![0u8; 2 * n * k];
let mut out = vec![0f32; m * n];
ledger_reset();
matmul_bf16_auto(&a, &w, &[], m, n, k, &mut out);
let projected_j: f64 = ledger_snapshot().iter().filter_map(|e| e.projected_uj).sum::<f64>() / 1e6;
Ok(LlmResponse {
text: "fn hello() {}".into(),
finish_reason: FinishReason::Stop,
input_tokens: 1,
output_tokens: 1,
energy_joules: (projected_j > 0.0).then_some(projected_j),
})
}
fn typical_joules_per_call(&self) -> f64 {
1.0
}
}
/// Write a calibration file pricing `local/cpu-neon`'s matmul at `grade` (or
/// declaring none), switch the fabric on for this process, resolve one L3
/// request, and return its receipt energy with the ledger window it was priced
/// from. Call once per test process: the fabric is discovered once.
pub fn resolve_priced_at(tag: &str, grade: Option<EnergyProvenance>) -> (Energy, Vec<LedgerEntry>) {
let path = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("l3-fabric-grade-{tag}.json"));
let declared = grade.map(|g| format!(r#","provenance":"{g:?}""#)).unwrap_or_default();
// Priced high enough that the one 4.1e-6 GFLOP matmul projects to ~1 mJ, a
// whole number of µJ on the receipt.
let row = format!(
r#"[{{"member_id":"local/cpu-neon","op":"matmul","uj_per_gflop":250000000.0,"method":"test row"{declared}}}]"#
);
std::fs::write(&path, row).expect("write the test calibration file");
std::env::set_var("JOULECLAW_FABRIC_CALIBRATION", &path);
std::env::set_var("JOULECLAW_FABRIC", "1");
let stage = l3_jouleclaw_binding::jouleclaw_l3_stage(Box::new(OneMatmul));
let res = stage
.try_resolve(&Request { prior: "b3:AAAA".into(), intent: "write a hello function".into() })
.expect("L3 resolved");
let window = ledger_snapshot();
let _ = std::fs::remove_file(&path);
(res.receipt.energy, window)
}

View file

@ -0,0 +1,22 @@
//! The L3 stage grades a fabric-priced figure by its weakest input: with every
//! member that ran priced by a calibrated figure (here a `Measured` row), the
//! receipt says ModelBased — a calibrated per-operation model, never a reading.
//! Its own test process, because the fabric is discovered once per process.
mod common;
use joule_code_receipt::Provenance;
use jouleclaw_fabric::EnergyProvenance;
#[test]
fn a_call_priced_by_calibrated_figures_is_model_based() {
let (energy, window) = common::resolve_priced_at("measured", Some(EnergyProvenance::Measured));
eprintln!("window: {window:?}\nenergy: {energy:?}");
assert!(!window.is_empty());
for e in &window {
assert_eq!(e.projected_grade, Some(EnergyProvenance::Measured), "{e:?}");
}
assert!(energy.estimated_uj > 0, "the projection is reported: {energy:?}");
assert_eq!((energy.provenance, energy.measured_uj), (Provenance::ModelBased, None), "{energy:?}");
assert!(energy.check_provenance().is_ok());
}

View file

@ -0,0 +1,19 @@
//! The L3 stage grades a fabric-priced figure by its weakest input: with the
//! member that ran priced by an `Estimated` figure, the receipt says Estimator.
//! Its own test process, because the fabric is discovered once per process.
mod common;
use joule_code_receipt::Provenance;
use jouleclaw_fabric::EnergyProvenance;
#[test]
fn a_call_priced_by_an_estimated_figure_is_an_estimate() {
let (energy, window) = common::resolve_priced_at("estimated", Some(EnergyProvenance::Estimated));
eprintln!("window: {window:?}\nenergy: {energy:?}");
let neon = window.iter().find(|e| e.member_id == "local/cpu-neon").expect("the m=1 matmul ran on cpu-neon");
assert_eq!(neon.projected_grade, Some(EnergyProvenance::Estimated), "the ledger carries the row's grade");
assert!(energy.estimated_uj > 0, "the projection is reported: {energy:?}");
assert_eq!((energy.provenance, energy.measured_uj), (Provenance::Estimator, None), "{energy:?}");
assert!(energy.check_provenance().is_ok());
}

View file

@ -48,13 +48,15 @@ fn hybrid_backend_completes_in_process() {
"smoke answer should mention Paris, got {:?}",
resp.text
);
// Energy contract: with the fabric on and calibrated,
// the call reports a ModelBased figure from the
// attribution ledger; otherwise honest None.
// Energy contract: with the fabric on, the call reports
// the attribution ledger's projection, graded by its
// weakest priced input; otherwise honest None.
match resp.energy_joules {
Some(j) => {
assert!(j > 0.0);
eprintln!(" fabric-priced energy: {j:.1} J (ModelBased)");
let window = jouleclaw_loader_gguf::fabric_dispatch::ledger_snapshot();
let grade = l3_jouleclaw_binding::projection_grade(&window);
eprintln!(" fabric-priced energy: {j:.1} J ({grade:?})");
}
None => eprintln!(" energy: None (fabric off — honest)"),
}

View file

@ -70,10 +70,38 @@ how the value was obtained:
| Provenance | Meaning |
|----------------|--------------------------------------------------------|
| `HwShunt` | Real hardware shunt / coulomb counter (RAPL MSR, NVML cumulative-energy counter, Jetson INA3221) |
| `ModelBased` | Vendor-provided estimate from freq / voltage / utilisation (Apple IOReport, ROCm SMI, NVML `power.draw`) |
| `HwShunt` | Hardware reading: a shunt / coulomb counter (Jetson INA3221), or a vendor energy counter (RAPL MSR, NVML cumulative-energy counter, Apple IOReport) while it is live for the domain it claims (below) |
| `ModelBased` | Vendor-provided estimate from freq / voltage / utilisation (ROCm SMI, NVML `power.draw`) |
| `Estimator` | JouleClaw static cost model from arch × precision × batch tables |
A vendor energy counter's reading is a real reading. What separates
one such reading from another is provenance — which counter, over
which domain, and whether it was live when read — not accuracy. So a
live vendor counter is graded `HwShunt`, and liveness is the
condition:
**Liveness (MUST).** 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.
Apple IOReport's GPU rail is not a partial reading of CPU work. It is
machine-wide — it meters every client of the GPU on the host, display
compositing among them, and moves while nothing of the metered work
runs — so where the CPU rail is frozen and only the GPU rail advances,
the reference implementation declines the counter and estimates the
work rather than reporting the GPU rail as `Partial`: the rail does
not cover part of the work, it covers other work.
The thermodynamic circuit breaker **MUST** enforce at the granularity
of the **worst** counter in the request's span. Implementations
**MUST** surface `resolution_uj()` and `min_window_ns()` so callers
@ -85,7 +113,10 @@ Realistic floors:
- Intel / AMD x86 RAPL: ~1 μJ, ~10 ms window. `HwShunt`.
- Jetson INA3221: ~10 mW (integrate for energy). `HwShunt`.
- NVIDIA discrete (NVML cumulative-energy): ~1 mJ, ~50 ms. `HwShunt`.
- Apple Silicon IOReport: ~1 mJ, ~10 ms. **Model-based**, not measured.
- Apple Silicon IOReport: ~1 mJ, ~10 ms. `HwShunt` while its CPU
energy rail is live. On macOS 27.0.1 an unprivileged reader's CPU
rails are frozen (observed on an M5 Max and an M3 Ultra), so the
counter is declined there and the host is estimated (`Estimator`).
Marketing claims of microjoule precision on Apple Silicon are
sales talk; the spec is honest about this.
- Consumer AMD GPU, ARM PMU: no usable counter. Use a calibrated
@ -1031,6 +1062,14 @@ counter (`Provenance::HwShunt`), a documented vendor model
(`Provenance::ModelBased`), or a calibrated static estimator
(`Provenance::Estimator`) with a wider tolerance band.
A hardware counter here means one that was live for the domain the
figure claims (§1.3, Liveness). A counter that is present but not
live — frozen, or republished in lumps — is not a hardware reading
however real the silicon behind it: its span is the estimator's, or
the work is reported unmetered, and the receipt says so. Grading a
frozen counter's figure `HwShunt` is the receipts-as-marketing
failure mode in its most literal form.
The breaker enforces at the granularity of the worst counter in
the span. This is normative.

View file

@ -341,11 +341,13 @@ async fn run(cli: Cli) -> Result<(), CliError> {
// EnergyCounter impl, this command reads the live one.
println!("Energy counter (not bound in v0.1)");
println!(" resolution varies by platform: 1 μJ on Intel/AMD RAPL,");
println!(" ~1 mJ on Apple IOReport (model-based),");
println!(" ~1 mJ on Apple IOReport,");
println!(" ~1 mJ on NVIDIA NVML,");
println!(" ~10 mW on Jetson INA3221 (shunt; integrate).");
println!(" honesty `Provenance::HwShunt` on RAPL/NVML/INA3221,");
println!(" `Provenance::ModelBased` on Apple IOReport / ROCm SMI,");
println!(" honesty `Provenance::HwShunt` on RAPL/NVML/INA3221, and on");
println!(" Apple IOReport while its CPU energy rail is live");
println!(" (a counter that is not live gives no HwShunt figure),");
println!(" `Provenance::ModelBased` on ROCm SMI,");
println!(" `Provenance::Estimator` on consumer AMD GPU / ARM PMU.");
println!();
println!("Bind a counter at startup with `jouleclaw_energy::EnergyCounter`.");

View file

@ -17,9 +17,16 @@
//! Every passport links a [`passport::MeasurementMethod`] to a
//! [`jouleclaw_energy::Provenance`] tag — measurement honesty travels
//! all the way from the platform counter into the compliance report.
//! Apple Silicon (until the IOReport FFI ships) flows through as
//! `Provenance::Estimator`; RAPL and NVML flow through as
//! `Provenance::HwShunt`.
//! RAPL and NVML flow through as `Provenance::HwShunt`. So does Apple
//! Silicon IOReport observed live (`MeasurementMethod::IoReportLive`):
//! a vendor energy counter's reading is a real reading, and what
//! decides its grade is provenance — was the counter live for the
//! domain the figure claims — not accuracy. A bare
//! `MeasurementMethod::IoReport` records no such observation; where
//! the CPU energy rail is frozen (macOS 27.0.1 for an unprivileged
//! reader) the figure came from the estimator, so it flows through as
//! `Provenance::Estimator`. A passport may say its inference energy
//! was measured only when its method is graded `HwShunt`.
#![forbid(unsafe_code)]
#![warn(missing_docs)]

View file

@ -114,8 +114,19 @@ impl Ord for EnergyLabel {
pub enum MeasurementMethod {
/// Intel / AMD RAPL counters.
Rapl,
/// Apple Silicon IOReport (or estimator until FFI ships).
/// Apple Silicon IOReport, with no record that its CPU energy rail was
/// observed live while the figure was taken. Graded `Estimator`: the
/// counter yields a reading only where that rail advances under load
/// (`jouleclaw_energy::ioreport_status() == Live`), and where it does not
/// — every CPU rail frozen for an unprivileged reader on macOS 27.0.1 —
/// the host's figure is the estimator's. Use [`Self::IoReportLive`] for a
/// figure read while the rail was live.
IoReport,
/// Apple Silicon IOReport, read while its CPU energy rail was observed
/// live (`jouleclaw_energy::IoReportStatus::Live`). A live vendor energy
/// counter is a hardware reading, graded `HwShunt` like RAPL and NVML:
/// its trust gap is provenance, which liveness answers, not accuracy.
IoReportLive,
/// NVIDIA Management Library.
Nvml,
/// Combined GPU (NVML) + CPU (RAPL) measurement.
@ -129,11 +140,18 @@ impl MeasurementMethod {
/// Pure-Rust translation from the JouleClaw energy contract.
pub fn provenance(&self) -> Provenance {
match self {
Self::Rapl | Self::Nvml | Self::NvmlRapl => Provenance::HwShunt,
Self::Rapl | Self::Nvml | Self::NvmlRapl | Self::IoReportLive => Provenance::HwShunt,
Self::IoReport => Provenance::Estimator,
Self::Estimated => Provenance::Estimator,
}
}
/// Whether a figure obtained this way is a hardware reading from a live
/// counter — the only grade under which inference energy may be called
/// measured.
pub fn is_live_hardware_reading(&self) -> bool {
self.provenance() == Provenance::HwShunt
}
}
impl fmt::Display for MeasurementMethod {
@ -141,6 +159,7 @@ impl fmt::Display for MeasurementMethod {
match self {
Self::Rapl => write!(f, "RAPL"),
Self::IoReport => write!(f, "IOReport"),
Self::IoReportLive => write!(f, "IOReport (live)"),
Self::Nvml => write!(f, "NVML"),
Self::NvmlRapl => write!(f, "NVML+RAPL"),
Self::Estimated => write!(f, "Estimated"),
@ -335,6 +354,18 @@ pub enum PassportError {
/// A required field was missing.
#[error("required field missing: {0}")]
MissingField(&'static str),
/// The compliance record says inference energy was measured, but the
/// energy profile's method is not graded as a live hardware reading.
#[error(
"inference energy is claimed measured, but the energy profile's method {method} is graded \
{provenance}, not a live hardware reading"
)]
MeasuredWithoutHardwareReading {
/// The energy profile's measurement method.
method: MeasurementMethod,
/// That method's grade.
provenance: Provenance,
},
/// JSON encoding failed.
#[error("json encoding failed: {0}")]
JsonEncode(#[from] serde_json::Error),
@ -485,9 +516,13 @@ impl PassportBuilder {
}
/// Consume the builder and return a passport. Errors on missing
/// required fields.
/// required fields, and on a compliance record that calls inference
/// energy measured (`inference_energy_measured`) when the energy
/// profile's method is not graded as a live hardware reading
/// ([`MeasurementMethod::is_live_hardware_reading`]): a bare
/// `IoReport`, or `Estimated`.
pub fn build(self) -> Result<AiServicePassport, PassportError> {
Ok(AiServicePassport {
let passport = AiServicePassport {
passport_version: self.passport_version,
service_id: self.service_id,
provider: self.provider,
@ -512,7 +547,15 @@ impl PassportBuilder {
.compliance
.ok_or(PassportError::MissingField("compliance"))?,
pricing: self.pricing.unwrap_or_default(),
})
};
let method = passport.energy_profile.measurement_method;
if passport.compliance.eu_ai_act.inference_energy_measured && !method.is_live_hardware_reading() {
return Err(PassportError::MeasuredWithoutHardwareReading {
method,
provenance: method.provenance(),
});
}
Ok(passport)
}
}
@ -698,7 +741,13 @@ mod tests {
MeasurementMethod::NvmlRapl.provenance(),
Provenance::HwShunt
);
// IOReport is Estimator until the FFI is wired — see apple.rs.
// IOReport read while its CPU rail was live is a hardware reading, the
// grade of RAPL and NVML. A bare IoReport records no liveness: where the
// rail is frozen (macOS 27.0.1) the figure is the estimator's.
assert_eq!(
MeasurementMethod::IoReportLive.provenance(),
Provenance::HwShunt
);
assert_eq!(
MeasurementMethod::IoReport.provenance(),
Provenance::Estimator
@ -717,6 +766,51 @@ mod tests {
assert_eq!(p.model.parameters, 70_000_000_000);
}
fn build_with(method: MeasurementMethod, measured: bool) -> Result<AiServicePassport, PassportError> {
let now = Utc::now();
let mut compliance = sample_compliance();
compliance.eu_ai_act.inference_energy_measured = measured;
AiServicePassport::builder("svc-method")
.provider("jouleclaw.dev")
.issued_at(now)
.valid_until(now + Duration::days(90))
.model(sample_model())
.hardware(sample_hardware())
.energy_profile(EnergyProfile { measurement_method: method, ..sample_energy_profile() })
.carbon_profile(sample_carbon_profile())
.energy_label(sample_label(EnergyLabel::A, 0.35))
.compliance(compliance)
.build()
}
/// A passport may call its inference energy measured only when its method is
/// graded as a live hardware reading. A bare IOReport records no liveness, so
/// paired with "measured" it is refused, as an estimate is; IOReport observed
/// live, RAPL and NVML are accepted. Unclaimed, every method builds.
#[test]
fn a_measured_claim_needs_a_live_hardware_reading() {
for method in [MeasurementMethod::IoReport, MeasurementMethod::Estimated] {
match build_with(method, true) {
Err(PassportError::MeasuredWithoutHardwareReading { method: m, provenance }) => {
assert_eq!((m, provenance), (method, Provenance::Estimator));
}
other => panic!("{method} paired with a measured claim was not refused: {other:?}"),
}
assert!(build_with(method, false).is_ok(), "{method} without a measured claim was refused");
}
for method in [
MeasurementMethod::IoReportLive,
MeasurementMethod::Rapl,
MeasurementMethod::Nvml,
MeasurementMethod::NvmlRapl,
] {
let p = build_with(method, true).unwrap_or_else(|e| panic!("{method} refused: {e}"));
assert!(p.compliance.eu_ai_act.inference_energy_measured);
}
let why = build_with(MeasurementMethod::IoReport, true).unwrap_err().to_string();
assert!(why.contains("IOReport") && why.contains("estimator") && why.contains("live hardware reading"), "{why}");
}
#[test]
fn build_passport_errors_without_required_field() {
let now = Utc::now();
@ -754,6 +848,7 @@ mod tests {
fn measurement_method_display() {
assert_eq!(format!("{}", MeasurementMethod::Rapl), "RAPL");
assert_eq!(format!("{}", MeasurementMethod::IoReport), "IOReport");
assert_eq!(format!("{}", MeasurementMethod::IoReportLive), "IOReport (live)");
assert_eq!(format!("{}", MeasurementMethod::Nvml), "NVML");
assert_eq!(format!("{}", MeasurementMethod::NvmlRapl), "NVML+RAPL");
assert_eq!(format!("{}", MeasurementMethod::Estimated), "Estimated");

View file

@ -16,7 +16,9 @@ serde_json = { workspace = true, optional = true }
tracing = { workspace = true, optional = true }
nvml-wrapper = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
# `libc`: the IOReport backend's CoreFoundation types and thread clock on macOS,
# and the liveness probe's thread CPU clock on every Unix (RAPL on Linux).
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[features]
@ -25,8 +27,8 @@ serde = ["dep:serde", "dep:serde_json"]
# Per-platform energy backends. Off by default so the foundation is OS-neutral;
# downstream crates / binaries enable what their target supports.
rapl = [] # Intel/AMD x86 — Linux sysfs powercap
nvml = ["dep:nvml-wrapper", "dep:tracing"] # NVIDIA discrete — NVML cumulative-energy counter
ioreport = [] # Apple Silicon M-series — real IOReport energy counter (ModelBased), load-avg estimator fallback
rapl = [] # Intel/AMD x86 — Linux sysfs powercap (HwShunt while its counter is live)
nvml = ["dep:nvml-wrapper", "dep:tracing"] # NVIDIA discrete — NVML cumulative-energy counter (HwShunt while live), power-draw integral (ModelBased) otherwise
ioreport = [] # Apple Silicon M-series — real IOReport energy counter (HwShunt while every CPU rail is live), load-avg estimator fallback
ina3221 = [] # NVIDIA Jetson (Orin / Thor) — sysfs i2c shunt reader
hwmon = [] # Linux generic — for AMD Strix Halo / Ryzen AI Max iGPU + others

View file

@ -1,11 +1,18 @@
# Measured energy — cross-platform verification
JouleClaw energy readings carry an honest [`Provenance`](src/lib.rs): `HwShunt`
(a real shunt / MSR / coulomb counter), `ModelBased` (a vendor freq/voltage/
utilisation model — a real on-device reading, not a shunt), or `Estimator` (a
static cost model, used only where no counter exists).
(a hardware reading: a shunt, or a vendor energy counter — RAPL, NVML, Apple
IOReport — while it is live for the domain it claims), `ModelBased` (a vendor
freq/voltage/utilisation model), or `Estimator` (a static cost model, used where
no live counter exists).
The goal is that on real hardware a reading is **never** an `Estimator`. The
A vendor energy counter's reading is a real reading; what has to be established
is its provenance — which counter, which domain, and whether it was live — not
its accuracy. A counter that is present but not live (frozen, or republished in
lumps) gives no `HwShunt` figure: the meter falls back to its estimator.
The goal is that on real hardware a reading is **never** an `Estimator`. It is
not met on Apple Silicon under macOS 27.0.1 (below). The
[`energy_probe`](examples/energy_probe.rs) example reads the best available
counter for its build features, integrates over a ~2 s CPU-churn window, and
prints the measured microjoules with provenance:
@ -20,18 +27,32 @@ cargo run -p jouleclaw-energy --release --example energy_probe --features iorepo
| Platform | Counter | Domain | Provenance | Reading (2 s churn) |
|---|---|---|---|---|
| Apple Silicon (this dev host) | IOReport "Energy Model" | SoC total | **ModelBased** | 74.1 J · 25.5 W |
| Apple Silicon (this dev host; chip and macOS version not recorded) | IOReport "Energy Model" | SoC total | **ModelBased** (the tag then) | 74.1 J · 25.5 W |
| Windows 11 + GeForce RTX 2060 | NVML (`nvml.dll`) | GPU | **HwShunt** | 18.8 J · 9.4 W |
| Linux x86 + Tesla T4 (AWS g4dn.metal) | NVML (`libnvidia-ml`) | GPU | **HwShunt** | 148.4 J · 71.8 W |
| Linux x86, Xeon Platinum 8259CL (AWS g4dn.metal) | RAPL powercap | CPU package | **HwShunt** | 154.2 J · 77.1 W (1 µJ res) |
Notes:
- **Apple** replaced a load-avg `Estimator` (which guessed ~10.7 W for the same
churn — under-reporting the real ~25.5 W by ~2.4×). The IOReport counter
([`apple_ioreport`](src/apple_ioreport.rs)) reads the SoC's CPU/GPU/ANE/DRAM
Energy Model channels with no sudo. End-to-end, a Joule Code L3 inference on
Apple now emits a receipt tagged `ModelBased`, not `Estimator`.
- **Apple — dated note (2026-09-30).** The Apple row was committed on
2026-06-12 and predates macOS 27. Which chip and which macOS version it was
read on were not recorded, so it cannot be reproduced or re-checked; it was
tagged `ModelBased`, the grade IOReport carried then, and no liveness check
existed. It stands as a historical figure only. A live IOReport reading is now
graded `HwShunt`, like RAPL and NVML.
- **Apple on macOS 27.0.1.** The IOReport counter
([`apple_ioreport`](src/apple_ioreport.rs)) opens with no sudo, but an
unprivileged reader does **not** get live CPU energy: every CPU rail in the
Energy Model group holds its value under minutes of full CPU load, moving only
in batch refreshes tens of minutes apart. Observed on an M5 Max (`CPU Energy`
frozen while `GPU Energy` advances) and on an M3 Ultra (all 645 Energy Model
rails frozen, CPU, DRAM and display included). So the counter checks that its
CPU rail is live before it reads (`ioreport_status()`), declines when it is
not, and the load-avg estimator meters the host instead: on those hosts a
Joule Code L3 inference emits a receipt tagged `Estimator`. Where the
rail is live, the receipt is tagged `HwShunt`. The load-avg estimator is
coarse: on the dev host it once guessed ~10.7 W for the churn above, against
25.5 W read from IOReport.
- **NVML** is one cross-platform counter ([`nvml`](src/nvml.rs)) — the same code
reports `HwShunt` on Windows and Linux; it loads the driver's NVML library by
name on each OS.
@ -42,6 +63,8 @@ Notes:
## Not yet a hardware reading
- **Apple Silicon CPU energy on macOS 27.0.1**, for an unprivileged process —
the IOReport CPU rails are frozen (above), so the load-avg estimator meters it.
- **x86 CPU energy on Windows** — RAPL MSRs need a kernel driver (e.g. WinRing0)
on Windows; the NVML GPU path is the Windows `HwShunt` route. (CPU-side is an
`Estimator` there for now.)

View file

@ -3,11 +3,15 @@
Honest energy accounting requires honest energy readings. The
[`EnergyCounter`] trait in this crate names three provenance tiers:
- **HwShunt** — a hardware shunt reading (Intel/AMD RAPL MSR,
NVML pstate sampling, board-level current sense, IPMI sensor).
Authoritative.
- **HwShunt** — a hardware reading: a shunt (board-level current
sense, IPMI sensor), or a vendor energy counter (Intel/AMD RAPL
MSR, NVML cumulative energy, Apple IOReport) while it is
demonstrably live for the domain the figure claims — the
measurer observed the counter covering that domain advance under
load. A counter that is present but not live (frozen, or
republished in lumps) gives no HwShunt figure. Authoritative.
- **ModelBased** — a vendor-published energy model (DCGM-X,
Apple `powermetrics`, vendor whitepapers) consulted at run time.
NVML `power.draw`, vendor whitepapers) consulted at run time.
Trustworthy if the model itself was validated by HwShunt.
- **Estimator** — anything derived from wall-clock × static
power ceiling. Useful as a floor; never as a ceiling.
@ -37,8 +41,12 @@ Every workload measurement MUST capture:
1. **Hardware identification** —
`vendor`, `model`, `tdp_w` (datasheet), `host_os`.
2. **Counter** — which trait impl was used (`RaplCounter`,
`NvmlCounter`, `AppleEstimator`, or a vendor-specific impl).
3. **Provenance** — `HwShunt` | `ModelBased` | `Estimator`.
`NvmlCounter`, `AppleIoReportCounter`, `AppleSiliconMeter`, or a
vendor-specific impl).
3. **Provenance** — `HwShunt` | `ModelBased` | `Estimator`. `HwShunt`
only for a counter observed live over the run (for Apple IOReport,
`ioreport_status()` returned `Live`); record in `method` how
liveness was established.
4. **Resolution** — `resolution_uj` (smallest delta the counter
meaningfully reports) and `min_window_ns` (smallest sampling
window below which the reading is noise-dominated). Honest

View file

@ -24,7 +24,7 @@ under named workloads. This directory carries the second half.
| `vendors/amd-cpu-rapl.json` | `RaplCounter` | HwShunt |
| `vendors/nvidia-gpu-nvml.json` | `NvmlCounter` | HwShunt |
| `vendors/amd-gpu-rocm.json` | `RocmSmiCounter` | HwShunt |
| `vendors/apple-silicon-ioreport.json` | `AppleIoReport` | ModelBased |
| `vendors/apple-silicon-ioreport.json` | `AppleIoReportCounter` | HwShunt, while live (below) |
| `vendors/huawei-ascend-npu.json` | `AscendSmiCounter` | HwShunt |
The Cloud TPU, Intel Gaudi, Cambricon, and Qualcomm Cloud AI
@ -63,6 +63,18 @@ past peer review.
| Tag | What you're trusting | Use case |
|---|---|---|
| `HwShunt` | A hardware shunt or vendor-grade MSR | Authoritative; the circuit breaker may use these directly. |
| `ModelBased` | A validated vendor energy model (Apple `powermetrics`, DCGM-X) | Trustworthy iff the model was validated against HwShunt elsewhere. |
| `HwShunt` | A hardware shunt, or a vendor energy counter (RAPL, NVML, Apple IOReport) while it is live for the domain it claims | Authoritative; the circuit breaker may use these directly. |
| `ModelBased` | A validated vendor energy model (DCGM-X, NVML `power.draw`) | Trustworthy iff the model was validated against HwShunt elsewhere. |
| `Estimator` | Wall-clock × static TDP, or a hand-tuned heuristic | A floor, never a ceiling. The breaker treats Estimator as worst-case. |
A vendor energy counter's reading is a real reading; what a fixture must
pin down is its provenance, not its accuracy. A `HwShunt` figure in a
fixture is a claim that the counter was demonstrably live for the domain
the figure claims while the workload ran: the measurer observed the counter
covering that domain advance under load. For Apple IOReport that means
`jouleclaw_energy::ioreport_status()` was `Live` (the CPU energy rail
advanced in at least 5 sample intervals, in at least 4 of every 5, over at
least 300 ms while the probing thread burned CPU). A counter that is present
but not live — frozen, or republished in lumps, as the CPU rails are for an
unprivileged reader on macOS 27.0.1 — gives no `HwShunt` figure; that run is
an `Estimator` run, or no run at all.

View file

@ -8,8 +8,8 @@
"host_os": null
},
"counter": {
"impl": "AppleIoReport",
"provenance": "ModelBased",
"impl": "AppleIoReportCounter",
"provenance": "HwShunt",
"resolution_uj": 1000,
"min_window_ns": 10000000
},

View file

@ -1,9 +1,9 @@
//! Cross-platform energy probe — read the best available hardware counter on
//! this host and print a **measured** energy delta with its honest provenance.
//! this host and print its energy delta with its honest provenance.
//!
//! The point of the probe: prove that on real hardware JouleClaw reports a
//! `HwShunt` / `ModelBased` reading, not an `Estimator` fallback. Build with the
//! feature that matches the host's counter:
//! The point of the probe: show whether on real hardware JouleClaw reports a
//! `HwShunt` / `ModelBased` reading or an `Estimator` fallback, and label the
//! figure accordingly. Build with the feature that matches the host's counter:
//!
//! # NVIDIA discrete GPU (Linux or Windows — loads libnvidia-ml / nvml.dll):
//! cargo run -p jouleclaw-energy --release --example energy_probe --features nvml
@ -11,7 +11,8 @@
//! # Intel/AMD x86 on Linux (powercap sysfs RAPL):
//! cargo run -p jouleclaw-energy --release --example energy_probe --features rapl
//!
//! # Apple Silicon (load-avg estimator until IOReport is wired):
//! # Apple Silicon (IOReport Energy Model when its CPU rail is live, else the
//! # load-avg estimator, with the reason printed):
//! cargo run -p jouleclaw-energy --release --example energy_probe --features ioreport
//!
//! It samples the counter across a ~2 s window (driving a small CPU workload so
@ -29,16 +30,17 @@ fn pick_counter() -> Option<(&'static str, Box<dyn EnergyCounter>)> {
{
let c = jouleclaw_energy::nvml::NvmlCounter::new();
if c.is_available() {
// Report which NVML path is live — the true cumulative-energy
// counter (accurate even when `power_usage()`/`nvidia-smi power.draw`
// report garbage) or the power-integration fallback.
// Report which NVML path reads — the cumulative-energy counter,
// only where it was observed live (HwShunt), or the power-draw
// integral (ModelBased) — and the counter's liveness.
println!(
"nvml path : {}",
"nvml path : {} (counter liveness: {:?})",
if c.uses_true_counter() {
"nvmlDeviceGetTotalEnergyConsumption (true cumulative counter)"
"nvmlDeviceGetTotalEnergyConsumption (live cumulative counter, HwShunt)"
} else {
"power_usage() integration (fallback)"
}
"power_usage() integration (ModelBased)"
},
c.liveness()
);
return Some(("nvml (NVIDIA GPU shunt)", Box::new(c)));
}
@ -46,18 +48,23 @@ fn pick_counter() -> Option<(&'static str, Box<dyn EnergyCounter>)> {
#[cfg(feature = "rapl")]
{
let c = jouleclaw_energy::rapl::RaplCounter::new();
println!("rapl : liveness {:?}", c.liveness());
if c.is_available() {
return Some(("rapl (x86 package MSR)", Box::new(c)));
}
}
#[cfg(all(feature = "ioreport", target_os = "macos"))]
{
// Prefer the real IOReport energy-model counter (ModelBased); fall back
// to the load-avg estimator only if IOReport will not open.
// Prefer the real IOReport energy counter (HwShunt, like RAPL and NVML).
// It is unavailable unless IOReport opens AND its CPU energy rail
// advances under load — a counter that is present but not live gives no
// hardware-graded figure; say which before falling back to the load-avg
// estimator.
let c = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
if c.is_available() {
return Some(("apple-ioreport (Energy Model)", Box::new(c)));
}
println!("ioreport : declined ({:?})", c.status());
let c = jouleclaw_energy::apple::AppleSiliconMeter::new();
if c.is_available() {
return Some(("apple (load-avg estimator)", Box::new(c)));
@ -82,6 +89,7 @@ fn churn(deadline: Instant) -> u64 {
}
fn main() {
println!("== JouleClaw energy probe ==");
let Some((label, counter)) = pick_counter() else {
eprintln!(
"no hardware energy counter available on this host/build.\n\
@ -90,7 +98,6 @@ fn main() {
std::process::exit(1);
};
println!("== JouleClaw energy probe ==");
println!("counter : {label}");
println!("provenance : {}", counter.provenance());
println!("domain : {:?}", counter.domain());
@ -128,7 +135,12 @@ fn main() {
let elapsed = start.elapsed().as_secs_f64();
let delta_uj = after.uj.saturating_sub(before.uj);
let joules = delta_uj as f64 / 1_000_000.0;
println!("--- measured ---");
// An estimator's figure is not a measurement; say which one this is.
if after.provenance == jouleclaw_energy::Provenance::Estimator {
println!("--- estimated ---");
} else {
println!("--- measured ---");
}
println!("window : {:.3} s", elapsed);
println!(
"energy : {} uj ({:.3} J) avg {:.2} W [{}]",

View file

@ -1,10 +1,12 @@
//! Apple Silicon energy estimator — `Provenance::Estimator`.
//!
//! **HONEST DISCLOSURE.** The production-quality path is IOReport's
//! `"Energy Model"` channel group (`PMP`/`Energy Model` keys), which
//! returns vendor-modelled package energy at ~1 mJ / ~10 ms. JouleClaw
//! has not yet wired the IOReport FFI; until then this backend uses
//! the system 1-minute load average, normalises it against
//! **HONEST DISCLOSURE.** The measured Apple path is IOReport's
//! `"Energy Model"` channel group, wired as
//! [`crate::apple_ioreport::AppleIoReportCounter`] (`HwShunt`, while live). This
//! backend is the fallback wherever that counter is unavailable: IOReport
//! will not open, or its CPU energy rail does not keep advancing under load
//! (macOS 27.0.1 on an M5 Max and an M3 Ultra; [`crate::ioreport_status`]
//! says which). It uses the system 1-minute load average, normalises it against
//! `available_parallelism`, and interpolates between a fixed
//! `idle_watts = 8 W` and `peak_watts = 40 W` band.
//!
@ -14,9 +16,9 @@
//! ~100 ms (load-average is updated by the kernel at ~5 s but quoted
//! at much higher frequency; the practical noise floor is ~100 ms).
//!
//! When the IOReport bridge ships, this module should be replaced or
//! re-tagged as `Provenance::ModelBased`. **DO NOT change the tag in
//! place** — the breaker depends on it.
//! It is selected precisely when no live reading exists, so it stays
//! `Provenance::Estimator` on every host. **DO NOT change the tag** —
//! the breaker depends on it.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@ -87,7 +89,7 @@ impl EnergyCounter for AppleSiliconMeter {
}
fn provenance(&self) -> Provenance {
// Until IOReport is wired this is unambiguously an Estimator.
// A load-average model: unambiguously an Estimator, on every host.
Provenance::Estimator
}
@ -151,7 +153,7 @@ mod tests {
fn apple_meter_declares_estimator_provenance() {
let m = AppleSiliconMeter::new();
// The honesty tag is what the breaker reads. It MUST be
// Estimator until IOReport ships.
// Estimator: this is what runs when IOReport has no live CPU rail.
assert_eq!(m.provenance(), Provenance::Estimator);
assert_eq!(m.domain(), EnergyDomain::SocTotal);
assert_eq!(m.name(), "apple-silicon");

View file

@ -5,16 +5,30 @@
//! Every energy reading in JouleClaw carries a [`Provenance`] tag declaring
//! how the value was obtained:
//!
//! - [`Provenance::HwShunt`] — a real hardware shunt measurement (Intel/AMD
//! x86 RAPL MSRs, NVIDIA discrete GPU cumulative-energy counter,
//! Jetson INA3221 i2c shunts). Honest to the counter's resolution.
//! - [`Provenance::HwShunt`] — a hardware reading: a shunt (Jetson INA3221
//! i2c), or a vendor energy counter (Intel/AMD x86 RAPL MSRs, the NVIDIA
//! discrete GPU cumulative-energy counter, Apple Silicon IOReport) while it
//! is demonstrably live for the domain the figure claims. Honest to the
//! counter's resolution.
//! - [`Provenance::ModelBased`] — a vendor-provided model estimate derived
//! from frequency, voltage, and utilization (Apple Silicon IOReport,
//! NVML `power.draw` on older GPUs). Millijoule-quantized at best.
//! from frequency, voltage, and utilization (NVML `power.draw` on older
//! GPUs, ROCm-SMI `power1_average`). Millijoule-quantized at best.
//! - [`Provenance::Estimator`] — a JouleClaw static cost model derived from
//! architecture × precision × batch tables. Used only on platforms
//! with no usable hardware counter (consumer AMD GPUs, ARM PMU).
//!
//! A vendor energy counter's reading is a real reading; the trust gap is
//! provenance, not accuracy. It may be graded `HwShunt` 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. A counter that is present but not live — frozen, or republished
//! in lumps — MUST NOT produce a `HwShunt` figure; the meter falls back to its
//! estimator. On Apple Silicon that is [`ioreport_status`]: the IOReport counter
//! reads only when it is [`IoReportStatus::Live`]. RAPL and NVML are gated on the
//! same rule ([`CounterLiveness`]): a RAPL counter reads only when it is live, and
//! an NVML cumulative-energy counter that is not live gives way to the
//! power-draw integral, which is graded [`Provenance::ModelBased`].
//!
//! The thermodynamic circuit breaker is only as honest as the **worst**
//! counter in a request's span. The breaker MUST surface
//! [`EnergyCounter::resolution_uj`] and [`EnergyCounter::min_window_ns`] so
@ -52,19 +66,25 @@ pub mod apple_ioreport;
pub mod meter;
pub mod ledger;
mod liveness;
pub use liveness::CounterLiveness;
/// Provenance of an energy reading. Load-bearing — every consumer of a
/// reading MUST inspect this before claiming accuracy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Provenance {
/// Real hardware shunt or coulomb counter. Honest to the counter's
/// resolution. Examples: Intel/AMD x86 RAPL MSRs, NVIDIA discrete
/// `nvmlDeviceGetTotalEnergyConsumption`, Jetson INA3221 i2c.
/// A hardware reading: a shunt or coulomb counter, or a vendor energy
/// counter while it is demonstrably live for the domain the reading
/// claims (see the crate docs). Honest to the counter's resolution.
/// Examples: Intel/AMD x86 RAPL MSRs, NVIDIA discrete
/// `nvmlDeviceGetTotalEnergyConsumption`, Apple IOReport "Energy Model"
/// channels whose CPU rails are all live, Jetson INA3221 i2c.
HwShunt,
/// Vendor-provided model estimate derived from frequency, voltage,
/// and utilization. Millijoule-quantized at best; not a true
/// measurement. Examples: Apple IOReport "Energy Model" channels,
/// NVML `power.draw` interpolation, ROCm-SMI `power1_average`.
/// measurement. Examples: NVML `power.draw` interpolation, ROCm-SMI
/// `power1_average`.
ModelBased,
/// JouleClaw static cost model from architecture × precision × batch
/// tables. Used only when no hardware counter exists. Tolerance
@ -82,6 +102,102 @@ impl fmt::Display for Provenance {
}
}
/// Why the Apple Silicon IOReport counter (`apple_ioreport::AppleIoReportCounter`,
/// built with the `ioreport` feature on macOS) is, or is not, usable on this host.
///
/// The counter is available — and [`meter::detect_meter`] selects it — only when
/// this is [`IoReportStatus::Live`]. Under any other status the host is metered
/// by the next rung instead (on Apple Silicon, the load-average estimator, whose
/// readings say [`Provenance::Estimator`]); this says why.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IoReportStatus {
/// IOReport is not usable here: this build has no IOReport backend (not
/// macOS, or built without the `ioreport` feature), or the probe could not
/// subscribe to the `Energy Model` group or never got a sample of it (not
/// Apple Silicon, Rosetta, a future macOS that changes the private
/// surface). The probe saw nothing, so it claims neither a live rail nor a
/// missing one.
Unavailable,
/// The `Energy Model` group was subscribed and sampled, but no `CPU Energy`
/// rail (nor `DIE_<n>_CPU Energy`) exists in it — an unfamiliar channel
/// naming. The counter is never used.
CpuRailAbsent,
/// A CPU energy rail exists, but not every one keeps advancing like a live
/// reading while this process burns CPU: one held its value, or moved only
/// in the few steps of a batch refresh (one step, or two 64 ms apart, as
/// observed). The platform is not exposing live energy for the whole CPU to
/// this process, so the summed rails would report GPU-only energy, zero, or
/// (one die live beside a frozen one, on a multi-die part) one die's energy
/// as the energy of CPU work. Observed on macOS 27.0.1 on an M5 Max and an
/// M3 Ultra, every CPU rail frozen.
CpuRailFrozen,
/// Every CPU energy rail — `CPU Energy`, or each `DIE_<n>_CPU Energy` on a
/// multi-die part — keeps advancing under load, sample after sample: each
/// in at least five sample intervals, four in every five of those observed,
/// over at least 300 ms. The counter is usable.
Live,
}
/// This host's [`IoReportStatus`], probed once per process and cached.
///
/// On macOS with the `ioreport` feature, the first call opens an IOReport
/// subscription to the `Energy Model` group and burns CPU on the calling thread
/// while it watches the CPU energy rails, re-sampling after every 25 ms of the
/// thread's own CPU time (a tick gives up after 500 ms of wall time on a host
/// too loaded to grant it that): about
/// `apple_ioreport::CPU_RAIL_LIVENESS_BUDGET` where the rail is frozen, less
/// where it is live (it stops as soon as every CPU rail has advanced in five
/// intervals, four in every five of those observed, over at least 300 ms), and
/// up to five budgets where samples are slow or fail. Every later call — and
/// every `AppleIoReportCounter::new()` and [`meter::detect_meter`] — reuses
/// that answer: this process-wide cache is the probe's one production caller,
/// whichever thread asks first. Anywhere else it returns
/// [`IoReportStatus::Unavailable`] without probing.
pub fn ioreport_status() -> IoReportStatus {
#[cfg(all(feature = "ioreport", target_os = "macos"))]
{
static STATUS: std::sync::OnceLock<IoReportStatus> = std::sync::OnceLock::new();
*STATUS.get_or_init(apple_ioreport::probe_host_status)
}
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
{
IoReportStatus::Unavailable
}
}
/// How many times this process has run the real IOReport liveness probe, on
/// any thread. For the test that pins [`ioreport_status`] to one probe per
/// process, from a process of its own; not part of the metering API. Always 0
/// without the IOReport backend, which never probes.
#[doc(hidden)]
pub fn ioreport_probe_runs() -> usize {
#[cfg(all(feature = "ioreport", target_os = "macos"))]
{
apple_ioreport::REAL_RUNS.load(std::sync::atomic::Ordering::SeqCst)
}
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
{
0
}
}
/// How many times this process has asked IOReport for the `Energy Model` group
/// subscription the liveness probe samples, on any thread. Only the probe asks
/// for it, once a run, so a path that probes around the cache shows here even
/// if it bypasses the run counter ([`ioreport_probe_runs`]). For the same test;
/// always 0 without the IOReport backend.
#[doc(hidden)]
pub fn ioreport_group_opens() -> usize {
#[cfg(all(feature = "ioreport", target_os = "macos"))]
{
apple_ioreport::GROUP_OPENS.load(std::sync::atomic::Ordering::SeqCst)
}
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
{
0
}
}
/// Hardware energy domain a counter reports on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
@ -238,6 +354,16 @@ mod tests {
assert_eq!(counter.resolution_uj(), 1);
}
/// A build without the IOReport backend (not macOS, or no `ioreport`
/// feature) never claims it: the status is `Unavailable`, with no probe.
/// The macOS + `ioreport` side is asserted in `apple_ioreport`'s tests.
#[test]
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
fn without_the_ioreport_backend_the_status_is_unavailable() {
assert_eq!(ioreport_status(), IoReportStatus::Unavailable);
assert_eq!((ioreport_probe_runs(), ioreport_group_opens()), (0, 0), "a build with no IOReport backend probed");
}
#[test]
fn ranking_provenance_by_honesty() {
// The breaker MUST treat HwShunt as the most honest source.

View file

@ -0,0 +1,445 @@
//! The sustained-advance liveness rule, shared by the vendor energy counters that
//! read one cumulative value per channel (RAPL's `energy_uj`, NVML's
//! cumulative-energy counter).
//!
//! A vendor energy counter's reading may be graded a hardware reading
//! ([`crate::Provenance::HwShunt`]) only while the counter is demonstrably live
//! for the domain the figure claims (JouleClaw SPEC §1.3, JCR-1 §2.3): the
//! implementation must have observed the counter covering that domain advance
//! under load. Present is not live. A counter can be readable and still hold its
//! value, or be republished in lumps — Apple's IOReport CPU rail does exactly
//! that for an unprivileged reader on macOS 27.0.1 (see
//! `apple_ioreport`) — and then a span across it reads zero, or reads one
//! refresh's lump as the energy of whatever ran.
//!
//! The rule is the one the IOReport backend is gated on, with the same numbers:
//! a counter is live only if, while the probing thread burns its own CPU time
//! between samples, every channel the figure sums advanced in at least
//! [`MIN_LIVE_ADVANCES`] sample intervals, in at least [`LIVE_SHARE`] (four in
//! every five) of the intervals observed, over at least [`MIN_LIVE_WINDOW`] of
//! wall-clock time. A batch refresh of one or a few steps cannot meet that; a
//! live counter does within a fraction of a second. Each counter probes once per
//! process and caches the answer. The rule asks **every** channel to be live,
//! here as in IOReport, which asks it of every CPU rail it sums (`CPU Energy`,
//! or each die's `DIE_<n>_CPU Energy`): a RAPL figure is the sum over every
//! package zone, an NVML figure the sum over every GPU, and an IOReport figure
//! the sum over every die, so one frozen package, device or die would leave part
//! of the claimed domain unread.
//!
//! The decision ([`AdvanceWatch`]) is kept apart from the sampling, the burn and
//! the clock, so the tests drive it with scripts.
// The probe machinery runs for the RAPL backend on Linux and the NVML backend;
// the rule's numbers are also read by the IOReport backend. A build with neither
// probing backend keeps the public `CounterLiveness` type, and nothing calls the
// rest outside the tests.
#![cfg_attr(not(any(all(feature = "rapl", target_os = "linux"), feature = "nvml")), allow(dead_code))]
use std::time::{Duration, Instant};
/// Whether a vendor energy counter (RAPL, NVML) is live on this host, as its
/// liveness probe found it. Only [`CounterLiveness::Live`] lets the counter
/// produce a hardware-graded ([`crate::Provenance::HwShunt`]) reading.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CounterLiveness {
/// The counter could not be watched: it is absent, this process cannot read
/// it (RAPL's `energy_uj` is root-only on kernels hardened after
/// CVE-2020-8694), it is not supported by the device, or the probe never got
/// two samples of it. The probe saw nothing, so it claims neither a live
/// counter nor a frozen one.
Unavailable,
/// The counter was read, but did not keep advancing while the probe burned
/// CPU: it held its value, or moved in too few of the probe's intervals (a
/// batch refresh). Its figure would not be a reading of the work.
Frozen,
/// Every channel the counter's figure sums advanced in at least five sample
/// intervals, four in every five of those observed, over at least 300 ms,
/// while the probing thread burned CPU.
Live,
}
/// In at least how many separate sample intervals a channel must advance to
/// count as live. One is not enough, nor two: a batch refresh can be published
/// in two steps (observed on IOReport, macOS 27.0.1).
pub(crate) const MIN_LIVE_ADVANCES: u32 = 5;
/// The least share of all the intervals observed so far in which a channel must
/// have advanced, as (numerator, denominator): four in every five.
pub(crate) const LIVE_SHARE: (u64, u64) = (4, 5);
/// The least wall-clock span, from the probe's first sample to its latest, over
/// which those advances must have been observed.
pub(crate) const MIN_LIVE_WINDOW: Duration = Duration::from_millis(300);
/// The probe never runs longer than this many budgets, however slow or failing
/// its samples are.
pub(crate) const PROBE_CAP_BUDGETS: u32 = 5;
/// How long (wall clock) a probe watches a counter before concluding it is
/// frozen; it runs past this only until it has [`MIN_LIVE_ADVANCES`] intervals,
/// up to [`PROBE_CAP_BUDGETS`] budgets.
pub(crate) const LIVENESS_BUDGET: Duration = Duration::from_millis(1000);
/// The most wall-clock time one tick of the probe's burn may take, on a host too
/// loaded to grant the probing thread its CPU.
pub(crate) const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500);
/// The rule, fed whole samples — one cumulative value per channel, in a fixed
/// channel order — each with the wall-clock time it was taken. Each interval is
/// one pair of consecutive samples, and each channel advances at most once in
/// it (a value strictly above the previous sample's). Comparing every sample
/// with the first instead would count one refresh again at every later sample.
#[derive(Debug)]
pub(crate) struct AdvanceWatch {
/// How many channels a sample carries; a sample of another length is not
/// a sample of this counter, and is ignored.
channels: usize,
/// Sample intervals observed so far.
intervals: u32,
/// The wall-clock time of the first sample; `None` before it.
first_at: Option<Duration>,
/// How much wall-clock time the samples so far span.
window: Duration,
/// The previous sample; `None` before the first.
prev: Option<Vec<u64>>,
/// For each channel, the number of intervals in which it advanced.
advances: Vec<u32>,
}
impl AdvanceWatch {
/// A rule for a counter whose samples carry `channels` values.
pub(crate) fn new(channels: usize) -> Self {
Self {
channels,
intervals: 0,
first_at: None,
window: Duration::ZERO,
prev: None,
advances: vec![0; channels],
}
}
/// Fold in the next sample, taken at `at` on any clock whose origin is
/// fixed for the watch. The first sample is only the baseline for the next,
/// and the start of the window.
pub(crate) fn observe(&mut self, sample: &[u64], at: Duration) {
if sample.len() != self.channels {
return;
}
let first_at = *self.first_at.get_or_insert(at);
if let Some(prev) = self.prev.take() {
self.intervals += 1;
self.window = at.saturating_sub(first_at);
for ((count, now), before) in self.advances.iter_mut().zip(sample).zip(&prev) {
*count += u32::from(now > before);
}
}
self.prev = Some(sample.to_vec());
}
/// Has every channel kept advancing — in enough separate intervals, in
/// enough of all the intervals so far, over a long enough window — to be
/// live? A counter with no channel is never live.
pub(crate) fn is_live(&self) -> bool {
self.window >= MIN_LIVE_WINDOW
&& !self.advances.is_empty()
&& self.advances.iter().all(|a| self.sustained(*a))
}
/// Whether a channel that advanced in `advances` intervals did so in at
/// least [`MIN_LIVE_ADVANCES`] of them and in at least [`LIVE_SHARE`] of all
/// the intervals observed so far.
fn sustained(&self, advances: u32) -> bool {
let (num, den) = LIVE_SHARE;
advances >= MIN_LIVE_ADVANCES && u64::from(advances) * den >= u64::from(self.intervals) * num
}
/// Keep sampling? Until the counter has proved live or `budget` is spent —
/// but never stop before a live counter could have shown
/// [`MIN_LIVE_ADVANCES`] advances, up to [`PROBE_CAP_BUDGETS`] budgets.
pub(crate) fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool {
!self.is_live()
&& (elapsed < budget || (self.intervals < MIN_LIVE_ADVANCES && elapsed < budget * PROBE_CAP_BUDGETS))
}
/// The verdict: `Unavailable` if not one interval was observed, else `Live`
/// or `Frozen`.
pub(crate) fn verdict(&self) -> CounterLiveness {
if self.intervals == 0 {
CounterLiveness::Unavailable
} else if self.is_live() {
CounterLiveness::Live
} else {
CounterLiveness::Frozen
}
}
}
/// The probe's loop, over any sampler, tick and clock: take a baseline sample,
/// then `tick` and sample again for as long as [`AdvanceWatch::wants_more`]
/// says, feeding every sample that came back to the rule with the clock's
/// reading as it came back. A failed sample (`None`) is skipped; the clock still
/// runs, so a sampler that keeps failing ends at the cap with no verdict.
pub(crate) fn watch(
channels: usize,
mut sample: impl FnMut() -> Option<Vec<u64>>,
mut tick: impl FnMut(),
now: impl Fn() -> Duration,
budget: Duration,
) -> AdvanceWatch {
let started = now();
let mut w = AdvanceWatch::new(channels);
if let Some(s) = sample() {
w.observe(&s, now());
}
while w.wants_more(now().saturating_sub(started), budget) {
tick();
if let Some(s) = sample() {
w.observe(&s, now());
}
}
w
}
/// The probe over any opener, tick and clock: `open` says how many channels
/// the counter has and hands a sampler over them, or `None` if the counter
/// cannot be opened (then nothing is sampled or ticked, and the verdict is
/// `Unavailable`).
pub(crate) fn probe_with<F>(
open: impl FnOnce() -> Option<(usize, F)>,
tick: impl FnMut(),
now: impl Fn() -> Duration,
budget: Duration,
) -> CounterLiveness
where
F: FnMut() -> Option<Vec<u64>>,
{
match open() {
Some((channels, sample)) => watch(channels, sample, tick, now, budget).verdict(),
None => CounterLiveness::Unavailable,
}
}
/// The real probe: [`probe_with`] over `open`, a tick that burns `tick_cpu` of
/// this thread's own CPU time ([`burn_thread_cpu`]), the monotonic clock and
/// [`LIVENESS_BUDGET`]. Runs on the calling thread.
pub(crate) fn probe<F>(open: impl FnOnce() -> Option<(usize, F)>, tick_cpu: Duration) -> CounterLiveness
where
F: FnMut() -> Option<Vec<u64>>,
{
let epoch = Instant::now();
probe_with(open, || burn_thread_cpu(tick_cpu, PROBE_TICK_WALL_CAP), move || epoch.elapsed(), LIVENESS_BUDGET)
}
/// This thread's CPU time so far (`CLOCK_THREAD_CPUTIME_ID`), or `None` where
/// it cannot be read.
#[cfg(unix)]
fn thread_cpu_time() -> Option<Duration> {
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
// SAFETY: `ts` is a valid, writable timespec for the duration of the call.
let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) };
(rc == 0 && ts.tv_sec >= 0 && ts.tv_nsec >= 0).then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32))
}
/// No thread CPU clock off Unix: the burn falls back to wall-clock time.
#[cfg(not(unix))]
fn thread_cpu_time() -> Option<Duration> {
None
}
/// Burn `cpu` of this thread's own CPU time, or stop after `wall_cap` of
/// wall-clock time, whichever comes first. Where the thread CPU clock cannot be
/// read, spin for `cpu` of wall-clock time instead.
pub(crate) fn burn_thread_cpu(cpu: Duration, wall_cap: Duration) {
let wall = Instant::now();
let start = thread_cpu_time();
let mut acc: u64 = 0;
loop {
for _ in 0..1024 {
acc = std::hint::black_box(acc.wrapping_add(1));
}
let burned = match (start, thread_cpu_time()) {
(Some(start), Some(now)) => now.saturating_sub(start),
_ => wall.elapsed(),
};
if burned >= cpu || wall.elapsed() >= wall_cap {
break;
}
}
std::hint::black_box(acc);
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::cell::Cell;
/// The probe interval of an unloaded RAPL probe, in the tests' own literal.
pub(crate) const EVERY: Duration = Duration::from_millis(31);
/// The tests' own statement of the bar: at least five advancing intervals,
/// four in every five, over at least 300 ms.
const TEST_MIN_ADVANCES: u32 = 5;
const TEST_MIN_WINDOW: Duration = Duration::from_millis(300);
/// Feed a fresh rule `samples`, the i-th at `i * every`; return it and
/// whether it was live after each interval.
fn feed(channels: usize, samples: &[Vec<u64>], every: Duration) -> (AdvanceWatch, Vec<bool>) {
let mut w = AdvanceWatch::new(channels);
let mut live = Vec::new();
for (i, s) in samples.iter().enumerate() {
w.observe(s, every * i as u32);
if i > 0 {
live.push(w.is_live());
}
}
(w, live)
}
/// `n` one-channel samples of a counter that advances in interval k
/// (counted from 1) exactly when `advances(k)`.
pub(crate) fn one_channel(n: usize, advances: impl Fn(usize) -> bool) -> Vec<Vec<u64>> {
let mut v = 8_000_000_000u64;
(0..n)
.map(|i| {
if i > 0 && advances(i) {
v += 1_234;
}
vec![v]
})
.collect()
}
/// A counter advancing in every interval is live once it has five advances
/// AND 300 ms of window — not before.
#[test]
fn a_counter_advancing_every_interval_is_live_at_five_advances_and_300_ms() {
let (w, live) = feed(1, &one_channel(40, |_| true), EVERY);
let first = live.iter().position(|&l| l).map(|k| k + 1).expect("never live");
// 300 ms / 31 ms → the 10th interval is the first at or past 300 ms.
assert_eq!(first, 10, "{live:?}");
assert!(EVERY * first as u32 >= TEST_MIN_WINDOW && first as u32 >= TEST_MIN_ADVANCES);
assert_eq!(w.verdict(), CounterLiveness::Live);
}
/// On a loaded host the probe's intervals are long (100 ms here): 300 ms of
/// window comes after three intervals, but a counter is live only at its
/// fifth advance. Three steps a refresh happened to publish 100 ms apart,
/// filling those three intervals, are not a live counter.
#[test]
fn on_long_intervals_five_advances_are_still_required() {
const LOADED: Duration = Duration::from_millis(100);
let (_, live) = feed(1, &one_channel(12, |_| true), LOADED);
let first = live.iter().position(|&l| l).map(|k| k + 1);
assert_eq!(first, Some(TEST_MIN_ADVANCES as usize), "{live:?}");
let (w, live) = feed(1, &one_channel(12, |k| k <= 3), LOADED);
assert!(live.iter().all(|l| !l), "three 100 ms steps counted as live: {live:?}");
assert_eq!(w.verdict(), CounterLiveness::Frozen);
}
/// One jump, or a refresh in two to five quick steps, is not a live counter,
/// however many intervals follow.
#[test]
fn a_batch_refresh_is_frozen() {
for steps in 1..=5usize {
let (w, live) = feed(1, &one_channel(40, |k| k <= steps), EVERY);
assert!(live.iter().all(|l| !l), "{steps} quick steps counted as live: {live:?}");
assert_eq!(w.verdict(), CounterLiveness::Frozen, "{steps} steps");
}
}
/// A counter that holds its value is frozen; a counter going down is not
/// advancing.
#[test]
fn a_held_or_falling_counter_is_frozen() {
let held: Vec<Vec<u64>> = (0..40).map(|_| vec![42]).collect();
assert_eq!(feed(1, &held, EVERY).0.verdict(), CounterLiveness::Frozen);
let falling: Vec<Vec<u64>> = (0..40).map(|i| vec![1_000_000 - i as u64]).collect();
assert_eq!(feed(1, &falling, EVERY).0.verdict(), CounterLiveness::Frozen);
}
/// Four in every five: missing one interval in five stays live; missing two
/// in five does not.
#[test]
fn the_share_is_four_in_every_five() {
let (w, _) = feed(1, &one_channel(41, |k| k % 5 != 0), EVERY);
assert_eq!(w.verdict(), CounterLiveness::Live);
let (w, _) = feed(1, &one_channel(41, |k| k % 5 != 0 && k % 5 != 1), EVERY);
assert_eq!(w.verdict(), CounterLiveness::Frozen);
}
/// Every channel must be live: one frozen GPU beside a live one leaves part of
/// the summed figure unread.
#[test]
fn every_channel_must_be_live() {
let both: Vec<Vec<u64>> = (0..40).map(|i| vec![100 + i as u64, 900 + i as u64]).collect();
assert_eq!(feed(2, &both, EVERY).0.verdict(), CounterLiveness::Live);
let one_frozen: Vec<Vec<u64>> = (0..40).map(|i| vec![100 + i as u64, 900]).collect();
assert_eq!(feed(2, &one_frozen, EVERY).0.verdict(), CounterLiveness::Frozen);
}
/// A sample of the wrong length is not a sample of this counter; no
/// interval at all is no verdict.
#[test]
fn no_interval_is_unavailable() {
assert_eq!(AdvanceWatch::new(1).verdict(), CounterLiveness::Unavailable);
let mut w = AdvanceWatch::new(1);
w.observe(&[1], Duration::ZERO);
w.observe(&[2, 3], EVERY);
assert_eq!(w.verdict(), CounterLiveness::Unavailable);
assert!(!AdvanceWatch::new(0).is_live());
}
/// The probe over a scripted opener, sampler and clock: the clock advances
/// `EVERY` per tick; returns the verdict and how many samples were taken.
pub(crate) fn scripted(opens: bool, value: impl Fn(usize) -> Option<u64>) -> (CounterLiveness, usize) {
let t = Cell::new(Duration::ZERO);
let n = Cell::new(0usize);
let verdict = probe_with(
|| {
opens.then_some((1, || {
let i = n.get();
n.set(i + 1);
value(i).map(|v| vec![v])
}))
},
|| t.set(t.get() + EVERY),
|| t.get(),
LIVENESS_BUDGET,
);
(verdict, n.get())
}
#[test]
fn the_probe_reports_each_verdict() {
assert_eq!(scripted(false, |_| Some(1)), (CounterLiveness::Unavailable, 0), "nothing opened: nothing sampled");
assert_eq!(scripted(true, |i| Some(1_000 + i as u64)).0, CounterLiveness::Live);
assert_eq!(scripted(true, |_| Some(7)).0, CounterLiveness::Frozen);
assert_eq!(scripted(true, |_| None).0, CounterLiveness::Unavailable, "no sample ever came back");
}
/// A live counter stops the probe early; a frozen one takes the budget; a
/// failing sampler is capped.
#[test]
fn the_probe_stops_when_live_and_is_capped_when_failing() {
let (_, live) = scripted(true, |i| Some(i as u64));
assert_eq!(live, 11, "baseline + 10 intervals reach 300 ms at 31 ms");
let (_, frozen) = scripted(true, |_| Some(7));
assert_eq!(frozen, 1 + LIVENESS_BUDGET.as_millis() as usize / 31 + 1);
let (_, failing) = scripted(true, |_| None);
assert_eq!(failing, 1 + (LIVENESS_BUDGET * PROBE_CAP_BUDGETS).as_millis() as usize / 31 + 1);
}
/// The real burn takes at least the CPU time it was asked for on this
/// thread's own clock (where there is one).
#[test]
fn the_burn_burns_this_threads_cpu() {
let before = thread_cpu_time();
burn_thread_cpu(Duration::from_millis(20), Duration::from_millis(500));
match (before, thread_cpu_time()) {
(Some(b), Some(a)) => assert!(a - b >= Duration::from_millis(20), "{:?}", a - b),
_ if cfg!(unix) => panic!("a Unix host has a thread CPU clock"),
_ => {}
}
}
}

View file

@ -241,28 +241,47 @@ impl EnergyMeter for EstimationMeter {
/// Detect the best meter for this platform. Always returns *something*
/// — at minimum the [`EstimationMeter`] fallback.
///
/// With the `ioreport` feature on macOS, the IOReport counter is selected only
/// when [`crate::ioreport_status`] is `Live`; the first call in a process runs
/// that liveness probe on the calling thread. RAPL (`rapl`, Linux) is selected
/// only when every package counter is live ([`crate::rapl::rapl_liveness`]); NVML (`nvml`)
/// reads its cumulative-energy counter as a hardware reading only when that is
/// live, and otherwise its power-draw integral, graded `ModelBased`. Each of
/// those probes also runs once per process, on the first calling thread.
pub fn detect_meter() -> CompositeMeter {
let mut meters: Vec<Box<dyn EnergyMeter>> = Vec::new();
#[cfg(all(feature = "ioreport", target_os = "macos"))]
{
// Prefer the real IOReport energy counter (ModelBased — the same on-die
// counters powermetrics/Activity Monitor read). It reports
// `is_available() == false` if IOReport can't open (non-Apple-Silicon,
// Rosetta, a future macOS), in which case `CompositeMeter` falls through
// to the load-average estimator below. Without this the crate shipped a
// real meter that `detect_meter` never selected.
// Prefer the real IOReport energy counter (HwShunt, like RAPL and NVML —
// the same on-die counters powermetrics/Activity Monitor read), but only
// where it can meter CPU work: a vendor counter is a hardware reading
// only while it is live for the domain it claims. It reports `is_available() == false` unless IOReport
// opens AND every CPU energy rail it sums keeps advancing under load (not
// just a batch refresh's few steps, nor one die of two): a subscription
// can open while every CPU rail is frozen (macOS 27.0.1 on an M5 Max and
// an M3 Ultra), and the
// summed rails then move only with the GPU, or not at all.
// `crate::ioreport_status()` says which case this host is in. When it
// is unavailable, `CompositeMeter` falls through to the load-average
// estimator below, whose readings say `Provenance::Estimator`.
meters.push(Box::new(crate::apple_ioreport::AppleIoReportCounter::new()));
meters.push(Box::new(crate::apple::AppleSiliconMeter::new()));
}
#[cfg(all(feature = "rapl", target_os = "linux"))]
{
// Available only when every package zone's `energy_uj` is readable and
// keeps advancing under load (the sustained-advance rule); otherwise it
// falls through. It sums every package, never `psys` or a sub-zone.
meters.push(Box::new(crate::rapl::RaplCounter::new()));
}
#[cfg(feature = "nvml")]
{
// HwShunt from the cumulative-energy counter only while it is live; the
// power-draw integral (ModelBased) otherwise. Its domain is the GPUs.
meters.push(Box::new(crate::nvml::NvmlCounter::new()));
}

View file

@ -1,40 +1,87 @@
//! NVIDIA discrete GPU energy counter via NVML — `Provenance::HwShunt`.
//! NVIDIA discrete GPU energy counter via NVML — `Provenance::HwShunt` while its
//! cumulative-energy counter is live, `Provenance::ModelBased` otherwise.
//!
//! Sums `power_usage()` across all detected GPUs and integrates over
//! time to maintain a cumulative microjoule counter. On Hopper+ /
//! Blackwell, `nvmlDeviceGetTotalEnergyConsumption()` returns the
//! cumulative-energy counter directly; older silicon falls back to
//! `power_usage()` integration (still hardware-shunt-based — NVIDIA's
//! power-monitoring IC samples the rail at ~50 ms).
//! On Volta+ silicon, `nvmlDeviceGetTotalEnergyConsumption()` returns a
//! cumulative-energy counter (mJ since driver reload), summed here across every
//! GPU. That is a vendor energy counter, and its reading is graded a hardware
//! reading — but only while it is demonstrably live for the domain the figure
//! claims (JouleClaw SPEC §1.3): every GPU's counter must keep advancing under
//! the sustained-advance rule ([`crate::CounterLiveness`]) — in at least five
//! sample intervals, four in every five, over at least 300 ms, while the
//! probing thread burns 100 ms of its own CPU time between samples (twice the
//! counter's ~50 ms sample period, so a live counter has moved in every
//! interval; a powered GPU draws tens of watts at idle, ~1 J per interval,
//! against a 1 mJ quantum). Probed once per process and cached.
//!
//! Resolution: ~1 mJ (vendor-reported; rounded up from the underlying
//! INA3221 / on-die sense quantization).
//! Where that counter is unsupported (pre-Volta, some consumer parts) or not
//! live, the counter integrates `power_usage()` over wall time instead. That
//! integral is a Riemann sum of the driver's sampled power, not a counter: it
//! advances whenever time passes, so no liveness probe can vouch for it, and
//! its readings are graded [`Provenance::ModelBased`] — the grade JouleClaw
//! SPEC §1.3 gives NVML `power.draw`. Never `HwShunt`.
//!
//! Minimum useful sampling window: ~50 ms. Faster polls return the
//! same sampled value with re-quantization noise.
//! The domain is [`EnergyDomain::Gpu`]: every GPU NVML enumerates, nothing
//! else. The CPU work that drives them is not in it.
//!
//! Resolution: ~1 mJ (the counter's quantum). Minimum useful sampling window:
//! ~50 ms. Faster polls return the same sampled value.
//!
//! Without the `nvml` cargo feature this module is not compiled.
#![forbid(unsafe_code)]
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::meter::{EnergyMeter, EnergySource};
use crate::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance};
use crate::{CounterLiveness, EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance};
/// Thread CPU time the liveness probe burns between samples of the
/// cumulative-energy counter: two of its ~50 ms sample periods.
const PROBE_TICK: Duration = Duration::from_millis(100);
/// How `read()` obtains a figure, decided once at construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
/// NVML did not initialise, or found no GPU: no reading.
Unavailable,
/// The cumulative-energy counter, live: a hardware reading (`HwShunt`).
Counter,
/// The integral of `power_usage()`: a vendor energy model (`ModelBased`).
PowerIntegral,
}
/// The reading mode for a host whose cumulative-energy counter is `supported`
/// (or not) and whose liveness probe said `liveness`: the counter only when it
/// is supported AND live, the power integral otherwise.
fn mode_for(supported: bool, liveness: CounterLiveness) -> Mode {
match (supported, liveness) {
(true, CounterLiveness::Live) => Mode::Counter,
(_, CounterLiveness::Live | CounterLiveness::Frozen | CounterLiveness::Unavailable) => Mode::PowerIntegral,
}
}
/// The grade of every reading a counter in `mode` produces.
fn grade(mode: Mode) -> Provenance {
match mode {
Mode::Counter => Provenance::HwShunt,
Mode::PowerIntegral => Provenance::ModelBased,
// No reading is produced; this is the grade a reading would have had
// from the counter, as the IOReport backend reports for a declined one.
Mode::Unavailable => Provenance::HwShunt,
}
}
/// NVIDIA discrete GPU energy counter.
///
/// Initialises NVML at construction time. If NVML init fails or no
/// GPUs are present the counter is permanently unavailable.
pub struct NvmlCounter {
available: bool,
nvml: Option<nvml_wrapper::Nvml>,
device_count: u32,
/// `true` when the silicon exposes `nvmlDeviceGetTotalEnergyConsumption`
/// (Volta+): a real cumulative coulomb count, read directly rather than
/// integrated from `power_usage()`. Probed once at construction.
use_total_energy: bool,
mode: Mode,
liveness: CounterLiveness,
cumulative_uj: AtomicU64,
last_timestamp_ms: AtomicU64,
}
@ -46,7 +93,10 @@ impl Default for NvmlCounter {
}
impl NvmlCounter {
/// Initialise NVML and enumerate GPUs.
/// Initialise NVML and enumerate GPUs. Where every GPU supports the
/// cumulative-energy counter, the first call in a process runs its liveness
/// probe on the calling thread (about 0.5 s where it is live, about 1 s where
/// it is not) and caches the answer ([`Self::liveness`]).
pub fn new() -> Self {
let nvml = match nvml_wrapper::Nvml::init() {
Ok(nvml) => nvml,
@ -60,20 +110,31 @@ impl NvmlCounter {
tracing::debug!("NVML initialized but no GPUs found");
return Self::unavailable(Some(nvml));
}
// Prefer the true cumulative-energy counter when the silicon supports
// it — a real coulomb count, free of the timing error that integrating
// `power_usage()` accrues. `NotSupported` on pre-Volta / some consumer
// parts falls back to integration. Probe device 0 once here.
let use_total_energy = nvml
.device_by_index(0)
.and_then(|device| device.total_energy_consumption())
.is_ok();
tracing::info!(gpu_count = device_count, use_total_energy, "NVML initialized");
// The cumulative-energy counter must be readable on every GPU the figure
// sums; `NotSupported` on any of them leaves the power integral.
let supported = total_energy_uj(&nvml, device_count).is_some();
let liveness = if supported {
static LIVENESS: OnceLock<CounterLiveness> = OnceLock::new();
*LIVENESS.get_or_init(|| {
crate::liveness::probe(
|| total_energy_uj(&nvml, device_count).map(|first| (first.len(), || total_energy_uj(&nvml, device_count))),
PROBE_TICK,
)
})
} else {
CounterLiveness::Unavailable
};
let mode = mode_for(supported, liveness);
tracing::info!(gpu_count = device_count, supported, ?liveness, ?mode, "NVML initialized");
Self::with(Some(nvml), device_count, mode, liveness)
}
fn with(nvml: Option<nvml_wrapper::Nvml>, device_count: u32, mode: Mode, liveness: CounterLiveness) -> Self {
Self {
available: true,
nvml: Some(nvml),
nvml,
device_count,
use_total_energy,
mode,
liveness,
cumulative_uj: AtomicU64::new(0),
last_timestamp_ms: AtomicU64::new(0),
}
@ -82,27 +143,39 @@ impl NvmlCounter {
/// An unavailable counter (NVML init failed or no GPUs), keeping any NVML
/// handle so diagnostics can still query the library.
fn unavailable(nvml: Option<nvml_wrapper::Nvml>) -> Self {
Self {
available: false,
nvml,
device_count: 0,
use_total_energy: false,
cumulative_uj: AtomicU64::new(0),
last_timestamp_ms: AtomicU64::new(0),
}
Self::with(nvml, 0, Mode::Unavailable, CounterLiveness::Unavailable)
}
/// Whether NVML initialised and at least one GPU was found.
pub fn is_available(&self) -> bool {
self.available
self.mode != Mode::Unavailable
}
/// `true` when `read()` uses the hardware's cumulative-energy counter
/// (`nvmlDeviceGetTotalEnergyConsumption`), `false` when it falls back to
/// integrating `power_usage()`. Lets callers report which path is live.
/// (`nvmlDeviceGetTotalEnergyConsumption`) — supported on every GPU and
/// live — and grades its readings `HwShunt`; `false` when it integrates
/// `power_usage()` and grades them `ModelBased`, or reads nothing.
pub fn uses_true_counter(&self) -> bool {
self.use_total_energy
self.mode == Mode::Counter
}
/// The cumulative-energy counter's liveness on this host:
/// [`CounterLiveness::Live`] exactly when [`Self::uses_true_counter`];
/// `Unavailable` where it is unsupported or NVML has no GPU.
pub fn liveness(&self) -> CounterLiveness {
self.liveness
}
}
/// Every GPU's cumulative energy, in µJ, in device order; `None` if any of them
/// cannot be read.
fn total_energy_uj(nvml: &nvml_wrapper::Nvml, device_count: u32) -> Option<Vec<u64>> {
(0..device_count)
.map(|i| {
let mj = nvml.device_by_index(i).ok()?.total_energy_consumption().ok()?;
Some(mj.saturating_mul(1_000))
})
.collect()
}
impl EnergyCounter for NvmlCounter {
@ -111,11 +184,7 @@ impl EnergyCounter for NvmlCounter {
}
fn provenance(&self) -> Provenance {
// NVIDIA's on-card power-monitor IC is a real shunt. The
// `nvmlDeviceGetTotalEnergyConsumption` path is unambiguously
// HwShunt; the `power_usage()` integration path is also shunt-
// sourced, just with ~50 ms quantization.
Provenance::HwShunt
grade(self.mode)
}
fn resolution_uj(&self) -> u64 {
@ -134,7 +203,7 @@ impl EnergyCounter for NvmlCounter {
.as_ref()
.ok_or(EnergyError::NoCounter(EnergyDomain::Gpu))?;
if !self.available {
if self.mode == Mode::Unavailable {
return Err(EnergyError::NoCounter(EnergyDomain::Gpu));
}
@ -143,11 +212,9 @@ impl EnergyCounter for NvmlCounter {
.map_err(|e| EnergyError::Platform(e.to_string()))?
.as_millis() as u64;
// Preferred path: the hardware's own cumulative-energy counter
// (mJ since driver reload), summed across GPUs. No integration, so no
// timing error — deltas across two reads are exact to the counter's
// resolution.
if self.use_total_energy {
// The hardware's own cumulative-energy counter, summed across GPUs, read
// only where it was observed live. No integration, so no timing error.
if self.mode == Mode::Counter {
let mut total_uj: u64 = 0;
for i in 0..self.device_count {
let device = nvml
@ -162,12 +229,12 @@ impl EnergyCounter for NvmlCounter {
uj: total_uj,
timestamp_ns: now_ms.saturating_mul(1_000_000),
domain: EnergyDomain::Gpu,
provenance: Provenance::HwShunt,
provenance: grade(self.mode),
});
}
// Fallback: integrate the shunt-sampled instantaneous power over wall
// time. Still shunt-sourced, but a Riemann sum — hence ~50 ms windows.
// The power integral: the shunt-sampled instantaneous power, integrated
// over wall time — a Riemann sum, hence ~50 ms windows, graded ModelBased.
let mut total_milliwatts: u64 = 0;
for i in 0..self.device_count {
let device = nvml
@ -192,7 +259,7 @@ impl EnergyCounter for NvmlCounter {
uj: total_uj,
timestamp_ns: now_ms.saturating_mul(1_000_000),
domain: EnergyDomain::Gpu,
provenance: Provenance::HwShunt,
provenance: grade(self.mode),
})
}
}
@ -203,7 +270,7 @@ impl EnergyMeter for NvmlCounter {
}
fn is_available(&self) -> bool {
self.available
self.mode != Mode::Unavailable
}
fn energy_source(&self) -> EnergySource {
@ -214,22 +281,23 @@ impl EnergyMeter for NvmlCounter {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nvml_counter_declares_hw_shunt() {
let c = NvmlCounter::new();
assert_eq!(c.provenance(), Provenance::HwShunt);
assert_eq!(c.domain(), EnergyDomain::Gpu);
assert_eq!(c.name(), "nvml");
}
use crate::liveness::tests::EVERY;
use crate::liveness::{probe_with, LIVENESS_BUDGET};
use std::cell::Cell;
#[test]
fn nvml_counter_unavailable_without_gpu() {
// CI / macOS / non-NVIDIA hosts will all hit this branch.
let c = NvmlCounter::new();
assert_eq!(c.name(), "nvml");
assert_eq!(c.domain(), EnergyDomain::Gpu);
if !c.is_available() {
assert!(c.read().is_err());
assert!(!c.uses_true_counter());
assert_eq!(c.liveness(), CounterLiveness::Unavailable);
}
eprintln!("nvml on this host: available={} true_counter={} liveness={:?} grade={:?}",
c.is_available(), c.uses_true_counter(), c.liveness(), c.provenance());
}
#[test]
@ -240,4 +308,59 @@ mod tests {
// 50 ms minimum window.
assert!(c.min_window_ns() >= 50_000_000);
}
/// The cumulative-energy counter is read, as a hardware reading, only where
/// it is supported AND live. A frozen or unprobed counter leaves the power
/// integral, which is never graded `HwShunt`.
#[test]
fn only_a_live_supported_counter_is_a_hardware_reading() {
use CounterLiveness::*;
for (supported, liveness, mode) in [
(true, Live, Mode::Counter),
(true, Frozen, Mode::PowerIntegral),
(true, Unavailable, Mode::PowerIntegral),
(false, Unavailable, Mode::PowerIntegral),
(false, Live, Mode::PowerIntegral),
] {
assert_eq!(mode_for(supported, liveness), mode, "supported={supported} {liveness:?}");
let c = NvmlCounter::with(None, 1, mode, liveness);
assert_eq!(c.uses_true_counter(), mode == Mode::Counter);
let want = if mode == Mode::Counter { Provenance::HwShunt } else { Provenance::ModelBased };
assert_eq!(c.provenance(), want, "supported={supported} {liveness:?}");
assert!(c.is_available());
}
assert_eq!(grade(Mode::PowerIntegral), Provenance::ModelBased, "a power.draw integral is a vendor model");
}
/// The probe over scripted per-GPU counters (mJ → µJ, as `total_energy_uj`
/// returns them) and a clock advancing `EVERY` per tick.
fn nvml_probe(gpus: usize, mj: impl Fn(usize, usize) -> Option<u64>) -> CounterLiveness {
let t = Cell::new(Duration::ZERO);
let n = Cell::new(0usize);
let sample = || {
let i = n.get();
n.set(i + 1);
(0..gpus).map(|g| mj(i, g).map(|v| v * 1_000)).collect::<Option<Vec<u64>>>()
};
probe_with(
|| sample().map(|first| (first.len(), sample)),
|| t.set(t.get() + EVERY),
|| t.get(),
LIVENESS_BUDGET,
)
}
#[test]
fn every_gpu_must_keep_advancing() {
// ~60 W idle per GPU: ~2 J per interval.
assert_eq!(nvml_probe(2, |i, g| Some(5_000_000 * (g as u64 + 1) + 2_000 * i as u64)), CounterLiveness::Live);
assert_eq!(
nvml_probe(2, |i, g| Some(if g == 0 { 5_000_000 + 2_000 * i as u64 } else { 9_000_000 })),
CounterLiveness::Frozen,
"one GPU's counter held: the sum would leave its share of the domain unread"
);
assert_eq!(nvml_probe(1, |_, _| Some(5_000_000)), CounterLiveness::Frozen);
assert_eq!(nvml_probe(1, |_, _| None), CounterLiveness::Unavailable);
assert_eq!(nvml_probe(0, |_, _| Some(1)), CounterLiveness::Frozen, "no GPU is never live");
}
}

View file

@ -1,33 +1,216 @@
//! Intel / AMD x86 RAPL energy counter — `Provenance::HwShunt`.
//! Intel / AMD x86 RAPL energy counter — `Provenance::HwShunt` while it is live.
//!
//! Reads from the Linux `powercap` sysfs interface
//! (`/sys/class/powercap/intel-rapl:0/energy_uj`). The kernel exposes
//! the package-domain energy counter directly in microjoules with the
//! MSR's native resolution (1 μJ on Intel, ~15 μJ on AMD Zen).
//! Reads the Linux `powercap` sysfs interface (`/sys/class/powercap`): the
//! energy of every package zone, summed. A package zone is a top-level
//! `intel-rapl:<n>` zone whose `name` file reads `package-<n>`, so the figure
//! covers every socket of a multi-socket server, not package 0 alone. Nothing
//! else is summed, wherever it sits, zone 0 included: not `psys` (the platform
//! zone, whose energy includes the packages', so summing it beside them counts
//! them twice), not a `dram` zone, not a package's `core` / `uncore` / `dram`
//! sub-zones (`intel-rapl:0:0`), and not the MMIO interface
//! (`intel-rapl-mmio:0`, which republishes a package). The kernel exposes each
//! package-domain counter directly in microjoules with the MSR's native
//! resolution (1 μJ on Intel, ~15 μJ on AMD Zen), and each wraps at its own
//! `max_energy_range_uj`, so the counter keeps a running total: each package's
//! delta between reads, taken modulo that package's own range.
//!
//! Resolution: 1 μJ. Minimum useful sampling window: ~10 ms (the MSR
//! itself updates at ~1 kHz; below this readings are quantization
//! noise).
//!
//! **Liveness.** A vendor energy counter is a hardware reading only while it is
//! demonstrably live for the domain the figure claims (JouleClaw SPEC §1.3).
//! A readable `energy_uj` is not yet a live one: a hypervisor can pass the file
//! through without updating it, and a counter that holds its value would bill
//! every span zero. So the counter is available only when every package's file
//! is readable **and** every package's value keeps advancing under the
//! sustained-advance rule ([`crate::CounterLiveness`]): while the probing thread
//! burns 25 ms of its own CPU time between samples, each advanced in at least
//! five intervals, four in every five, over at least 300 ms. Every package,
//! because the figure sums them all: one held beside one live would leave a
//! socket's energy out while claiming the CPU. A host with no package zone (one
//! that lists only `psys`) has no counter. Probed once per process for each
//! package set and cached ([`rapl_liveness`]); about 0.3 s where the counters
//! are live, about 1 s where one is frozen. Otherwise the counter is unavailable
//! and [`crate::meter::detect_meter`] falls through to the estimator.
//!
//! On non-Linux targets the counter constructs successfully but is
//! permanently unavailable — `read()` returns
//! [`EnergyError::NoCounter`].
//! [`EnergyError::NoCounter`] — and nothing is probed.
#![forbid(unsafe_code)]
#[cfg(target_os = "linux")]
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::meter::{EnergyMeter, EnergySource};
use crate::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance};
use crate::{CounterLiveness, EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance};
/// Default RAPL package-0 energy file on Linux.
pub const RAPL_ENERGY_PATH: &str = "/sys/class/powercap/intel-rapl:0/energy_uj";
/// Where the Linux kernel lists its powercap zones, RAPL's among them.
pub const POWERCAP_ROOT: &str = "/sys/class/powercap";
/// Intel / AMD x86 RAPL package-domain energy counter.
/// Thread CPU time the liveness probe burns between samples of `energy_uj`. The
/// MSR updates at ~1 kHz, so a live counter advances in every interval.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const PROBE_TICK: Duration = Duration::from_millis(25);
/// One RAPL package zone: a top-level `intel-rapl:<index>` zone whose `name`
/// reads `package-<n>`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Package {
/// The zone's directory, `<powercap>/intel-rapl:<index>`.
dir: PathBuf,
/// `<index>` in `intel-rapl:<index>`.
index: u32,
/// The zone's `max_energy_range_uj`, the value its `energy_uj` wraps at,
/// when that reads as a number above zero.
max_energy_range_uj: Option<u64>,
}
/// Whether a top-level zone's `name` file names a package: `package-<n>`, `<n>`
/// one or more digits (the file ends in a newline). Not `psys`, `dram`, `core`
/// or `uncore`.
pub(crate) 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()))
}
/// The index of a top-level RAPL zone directory, `intel-rapl:<index>`. `None`
/// for anything else: a sub-zone (`intel-rapl:0:0`), the control type itself
/// (`intel-rapl`), and the MMIO interface (`intel-rapl-mmio:0`, also named
/// `package-0`, which would count a package twice).
pub(crate) fn top_level_zone(dir: &str) -> Option<u32> {
let n = dir.strip_prefix("intel-rapl:")?;
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
n.parse().ok()
}
/// The file at `path`, read and parsed as an unsigned integer, or `None`.
fn read_u64(path: &Path) -> Option<u64> {
std::fs::read_to_string(path).ok()?.trim().parse::<u64>().ok()
}
/// The package zones under the powercap directory `root`, in index order: every
/// top-level zone whose `name` reads `package-<n>`, with the range its counter
/// wraps at. Everything else is left out, zone 0 included if it is not a
/// package. Empty when no zone is a package, or `root` cannot be listed.
pub(crate) fn packages_under(root: &Path) -> Vec<Package> {
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut packages: Vec<Package> = entries
.flatten()
.filter_map(|entry| {
let index = top_level_zone(entry.file_name().to_str()?)?;
let dir = entry.path();
let name = std::fs::read_to_string(dir.join("name")).ok()?;
is_package_name(&name).then(|| Package {
max_energy_range_uj: read_u64(&dir.join("max_energy_range_uj")).filter(|&m| m > 0),
dir,
index,
})
})
.collect();
packages.sort_by_key(|p| p.index);
packages
}
/// Every package's `energy_uj`, in order. `None` when there is no package, or
/// when any one cannot be read: a figure that left a package out would miss its
/// energy while claiming the whole CPU.
pub(crate) fn read_packages(packages: &[Package]) -> Option<Vec<u64>> {
if packages.is_empty() {
return None;
}
packages.iter().map(|p| read_u64(&p.dir.join("energy_uj"))).collect()
}
/// One zone's energy between two reads, modulo its own wrap. A counter that went
/// down wrapped at `max_range`; without a known (non-zero) range, or with an
/// earlier read above it, the energy is unknown and `None`. Undercounts a wrap
/// by at most one counter unit, and cannot see more than one wrap between two
/// reads: about a quarter of an hour at 300 W for a package's usual
/// 262,143,328,850 μJ range.
pub(crate) fn zone_delta_uj(e0: u64, e1: u64, max_range: Option<u64>) -> Option<u64> {
if e1 >= e0 {
return Some(e1 - e0);
}
max_range.filter(|&m| m > 0)?.checked_sub(e0)?.checked_add(e1)
}
/// A RAPL counter's running total across its reads.
#[derive(Debug)]
enum Running {
/// Not read yet.
Unread,
/// Each package's raw `energy_uj` at the last read, and the total so far:
/// the first read's counters summed, plus every package's energy since,
/// each taken modulo its own range.
At { last: Vec<u64>, total: u64 },
/// A package's energy between two reads could not be known (its counter
/// went down, and its range is unknown). The total no longer holds that
/// energy, so every read from then on is [`EnergyError::CounterWrap`]
/// rather than a figure short by it.
Lost,
}
impl Running {
/// Fold in `now`, every package's raw counter in `packages` order, and
/// return the running total. Each package's wrap is taken at ITS OWN range;
/// summing the raw counters instead would turn one package's wrap into a
/// fall of the sum.
fn advance(&mut self, packages: &[Package], now: &[u64]) -> Result<u64, EnergyError> {
match self {
Running::Lost => Err(EnergyError::CounterWrap),
Running::Unread => {
let total = now
.iter()
.try_fold(0u64, |sum, &uj| sum.checked_add(uj))
.ok_or_else(|| EnergyError::Platform("the RAPL package counters' sum overflows u64".into()))?;
*self = Running::At { last: now.to_vec(), total };
Ok(total)
}
Running::At { last, total } => {
if last.len() != now.len() || packages.len() != now.len() {
return Err(EnergyError::Platform("a RAPL read did not cover every package".into()));
}
match energy_between(packages, last, now).and_then(|energy| total.checked_add(energy)) {
Some(sum) => {
*total = sum;
last.copy_from_slice(now);
Ok(sum)
}
None => {
*self = Running::Lost;
Err(EnergyError::CounterWrap)
}
}
}
}
}
}
/// Every package's energy between the raw counters `last` and `now`, summed,
/// each taken modulo its own range ([`zone_delta_uj`]); `None` when one
/// package's energy is unknown.
fn energy_between(packages: &[Package], last: &[u64], now: &[u64]) -> Option<u64> {
let mut energy = 0u64;
for ((p, &e0), &e1) in packages.iter().zip(last).zip(now) {
energy = energy.checked_add(zone_delta_uj(e0, e1, p.max_energy_range_uj)?)?;
}
Some(energy)
}
/// Intel / AMD x86 RAPL package-domain energy counter: the sum over every
/// package zone of the host.
pub struct RaplCounter {
available: bool,
path: &'static str,
packages: Vec<Package>,
liveness: CounterLiveness,
running: Mutex<Running>,
}
impl Default for RaplCounter {
@ -37,38 +220,123 @@ impl Default for RaplCounter {
}
impl RaplCounter {
/// Build a counter against the default `intel-rapl:0` path.
/// Build a counter over this host's package zones (under [`POWERCAP_ROOT`]).
///
/// Availability requires the counter to be *readable*, not merely present.
/// Since CVE-2020-8694 (PLATYPUS) the kernel restricts `energy_uj` to root
/// (mode 0400), so on a hardened box the file exists but every read fails
/// with `EPERM`. A mere `Path::exists()` check would then mark RAPL
/// available, the composite meter would select it, and *every* `read()`
/// would error instead of falling through to another counter — the opposite
/// of the honesty contract. So probe with a real read+parse here.
/// Availability requires every package counter to be *readable* and *live*,
/// not merely present. Since CVE-2020-8694 (PLATYPUS) the kernel restricts
/// `energy_uj` to root (mode 0400), so on a hardened box the files exist but
/// every read fails with `EPERM`; the liveness probe then never gets a sample
/// and the counter is [`CounterLiveness::Unavailable`], as it is on a host
/// with no package zone. A readable package counter that does not keep
/// advancing is [`CounterLiveness::Frozen`]. Either way it is unavailable,
/// and the composite meter falls through instead of selecting a counter
/// whose every read errors, or reads zero, or leaves a socket out.
///
/// The first call for a package set in a process runs the liveness probe on
/// the calling thread; later calls reuse the answer ([`rapl_liveness`]).
pub fn new() -> Self {
Self::with_path(RAPL_ENERGY_PATH)
Self::with_root(POWERCAP_ROOT)
}
/// Build against an explicit `energy_uj` path (used by tests and hosts whose
/// package-0 domain lives at a non-default index).
pub fn with_path(path: &'static str) -> Self {
let available = cfg!(target_os = "linux") && Self::probe_readable(path);
Self { available, path }
/// Build over the package zones under an explicit powercap directory (a
/// container or chroot that mounts sysfs elsewhere). The same rules apply:
/// package zones only, every one live.
pub fn with_root(root: impl AsRef<Path>) -> Self {
let packages = packages_under(root.as_ref());
let liveness = package_liveness(&packages);
Self::given(packages, liveness)
}
/// Whether `path` can be opened, read, and parsed as a `u64` right now.
fn probe_readable(path: &str) -> bool {
std::fs::read_to_string(path)
.ok()
.and_then(|raw| raw.trim().parse::<u64>().ok())
.is_some()
/// The counter over `packages`, whose liveness is `liveness`: available
/// exactly when that is [`CounterLiveness::Live`].
fn given(packages: Vec<Package>, liveness: CounterLiveness) -> Self {
Self { available: admits(liveness), packages, liveness, running: Mutex::new(Running::Unread) }
}
/// Whether this counter found a readable powercap entry.
/// Whether this counter meters: every package's `energy_uj` is readable and
/// live.
pub fn is_available(&self) -> bool {
self.available
}
/// Why this counter is, or is not, available. It is
/// [`CounterLiveness::Live`] exactly when [`Self::is_available`] is `true`.
pub fn liveness(&self) -> CounterLiveness {
self.liveness
}
}
/// Whether a liveness verdict lets the counter read: `Live`, and nothing else.
fn admits(liveness: CounterLiveness) -> bool {
match liveness {
CounterLiveness::Live => true,
CounterLiveness::Unavailable | CounterLiveness::Frozen => false,
}
}
/// The liveness of this host's RAPL package counters, probed once per process
/// for each package set and cached: the first call for a set runs the probe on
/// the calling thread (see the module docs for its cost), and every later call —
/// and every [`RaplCounter::new`] over the same set — reuses it. A host with no
/// package zone is [`CounterLiveness::Unavailable`] with no probe. Off Linux it
/// is [`CounterLiveness::Unavailable`], with no probe.
pub fn rapl_liveness() -> CounterLiveness {
package_liveness(&packages_under(Path::new(POWERCAP_ROOT)))
}
/// The liveness of `packages`, every one of them, cached per package set.
fn package_liveness(packages: &[Package]) -> CounterLiveness {
#[cfg(target_os = "linux")]
{
if packages.is_empty() {
return CounterLiveness::Unavailable;
}
cached(&package_set(packages), || probe_packages(packages))
}
#[cfg(not(target_os = "linux"))]
{
let _ = packages;
CounterLiveness::Unavailable
}
}
/// A package set's cache key: its zone directories, in order.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn package_set(packages: &[Package]) -> Vec<PathBuf> {
packages.iter().map(|p| p.dir.clone()).collect()
}
/// The per-process, per-package-set cache in front of `probe`. The lock is held
/// while the probe runs, so concurrent first callers wait for one probe rather
/// than running several.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn cached(set: &[PathBuf], probe: impl FnOnce() -> CounterLiveness) -> CounterLiveness {
static CACHE: Mutex<Vec<(Vec<PathBuf>, CounterLiveness)>> = Mutex::new(Vec::new());
let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some((_, liveness)) = cache.iter().find(|(s, _)| s.as_slice() == set) {
return *liveness;
}
let liveness = probe();
cache.push((set.to_vec(), liveness));
liveness
}
/// One real, uncached run of the liveness probe over `packages`: the shared
/// probe ([`crate::liveness::probe`]) over [`open_sampler`], burning
/// [`PROBE_TICK`] of this thread's CPU time between samples. Every package is a
/// channel, and the rule asks every channel to be live. Its one production
/// caller is the cache in [`package_liveness`].
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn probe_packages(packages: &[Package]) -> CounterLiveness {
crate::liveness::probe(|| open_sampler(packages), PROBE_TICK)
}
/// The probe's opener for `packages`: one channel per package, sampled by
/// reading every package's file. `None` — the counter cannot be opened — when
/// there is no package, or a package's file cannot be read and parsed now.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn open_sampler(packages: &[Package]) -> Option<(usize, impl FnMut() -> Option<Vec<u64>> + '_)> {
read_packages(packages).map(|_| (packages.len(), move || read_packages(packages)))
}
impl EnergyCounter for RaplCounter {
@ -77,7 +345,8 @@ impl EnergyCounter for RaplCounter {
}
fn provenance(&self) -> Provenance {
// MSR-backed hardware shunt. Honest to 1 μJ.
// MSR-backed vendor energy counters, read only while every one is live.
// Honest to 1 μJ.
Provenance::HwShunt
}
@ -91,41 +360,32 @@ impl EnergyCounter for RaplCounter {
10_000_000
}
/// The running total of every package's energy. Fails — no figure, never a
/// partial one — when the counter is not available, when any package's
/// counter cannot be read, and ([`EnergyError::CounterWrap`]) once a
/// package's energy between two reads is unknown. The files are read under
/// the lock, so two threads' reads are folded in the order they were taken,
/// and an older read is never taken for a wrap.
fn read(&self) -> Result<EnergyReading, EnergyError> {
if !self.available {
return Err(EnergyError::NoCounter(EnergyDomain::CpuPkg));
}
#[cfg(target_os = "linux")]
{
let raw = std::fs::read_to_string(self.path)
.map_err(|e| EnergyError::Platform(format!("reading {}: {e}", self.path)))?;
let uj: u64 = raw
.trim()
.parse()
.map_err(|e| EnergyError::Platform(format!("parsing {}: {e}", self.path)))?;
let timestamp_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| EnergyError::Platform(e.to_string()))?
.as_nanos() as u64;
Ok(EnergyReading {
uj,
timestamp_ns,
domain: EnergyDomain::CpuPkg,
provenance: Provenance::HwShunt,
})
}
#[cfg(not(target_os = "linux"))]
{
// Branch is dead on non-Linux but keeps the closure happy.
let _ = self.path;
Err(EnergyError::Platform(
"RAPL only available on Linux".to_string(),
))
}
let uj = {
let mut running = self.running.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let now = read_packages(&self.packages)
.ok_or_else(|| EnergyError::Platform("reading a RAPL package zone's energy_uj failed".to_string()))?;
running.advance(&self.packages, &now)?
};
let timestamp_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| EnergyError::Platform(e.to_string()))?
.as_nanos() as u64;
Ok(EnergyReading {
uj,
timestamp_ns,
domain: EnergyDomain::CpuPkg,
provenance: Provenance::HwShunt,
})
}
}
@ -146,6 +406,16 @@ impl EnergyMeter for RaplCounter {
#[cfg(test)]
mod tests {
use super::*;
use crate::liveness::{probe_with, LIVENESS_BUDGET};
use crate::liveness::tests::EVERY;
use std::cell::Cell;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A package zone's range as the kernel commonly reports it (2^32 units of
/// 61.035 μJ), and a smaller one, so that a wrap taken at the wrong zone's
/// range shows.
const RANGE: u64 = 262_143_328_850;
const RANGE_B: u64 = 65_532_610_987;
#[test]
fn rapl_counter_declares_hw_shunt() {
@ -161,8 +431,366 @@ mod tests {
let c = RaplCounter::new();
if cfg!(not(target_os = "linux")) {
assert!(!c.is_available());
assert_eq!(c.liveness(), CounterLiveness::Unavailable);
assert!(matches!(c.read(), Err(EnergyError::NoCounter(_))));
}
assert_eq!(c.name(), "rapl");
}
/// Only a live counter is available; a frozen or unreadable one never reads,
/// so it never hands the composite a zero or an error to bill.
#[test]
fn only_a_live_counter_is_available() {
for (liveness, available) in [
(CounterLiveness::Live, true),
(CounterLiveness::Frozen, false),
(CounterLiveness::Unavailable, false),
] {
let c = RaplCounter::given(Vec::new(), liveness);
assert_eq!((c.is_available(), c.liveness()), (available, liveness));
assert_eq!(EnergyMeter::is_available(&c), available);
if !available {
assert!(matches!(c.read(), Err(EnergyError::NoCounter(EnergyDomain::CpuPkg))), "{liveness:?}");
}
}
}
/// A fake powercap directory for one test, removed when dropped.
struct Powercap(PathBuf);
impl Powercap {
fn new(tag: &str) -> Self {
let root = std::env::temp_dir().join(format!("jouleclaw-powercap-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create a fake powercap directory");
Self(root)
}
/// Add the zone directory `dir` (`intel-rapl:0`, `intel-rapl:0:1`,
/// `intel-rapl-mmio:0`, …) with its `name`, its `energy_uj` and, when
/// given, its `max_energy_range_uj`, newline-terminated as the kernel
/// writes them.
fn zone(&self, dir: &str, name: &str, energy_uj: u64, max: Option<u64>) -> &Self {
let d = self.0.join(dir);
std::fs::create_dir_all(&d).expect("create a fake zone");
std::fs::write(d.join("name"), format!("{name}\n")).expect("write name");
if let Some(m) = max {
std::fs::write(d.join("max_energy_range_uj"), format!("{m}\n")).expect("write max_energy_range_uj");
}
self.set(dir, energy_uj);
self
}
/// Set zone `dir`'s `energy_uj` atomically (write a sibling, rename
/// over), so a concurrent read never sees a half-written file.
fn set(&self, dir: &str, energy_uj: u64) {
let file = self.0.join(dir).join("energy_uj");
let tmp = self.0.join(dir).join("energy_uj.tmp");
std::fs::write(&tmp, format!("{energy_uj}\n")).expect("write energy_uj");
std::fs::rename(&tmp, &file).expect("rename energy_uj");
}
fn root(&self) -> &Path {
&self.0
}
}
impl Drop for Powercap {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A two-socket server's powercap directory as the kernel lists it: the
/// control type, two packages each with core or dram sub-zones, a platform
/// (`psys`) zone, a top-level `dram` zone, and the MMIO interface
/// republishing package 0.
fn two_socket_server(tag: &str) -> Powercap {
let pc = Powercap::new(tag);
std::fs::create_dir_all(pc.root().join("intel-rapl")).expect("the control type");
pc.zone("intel-rapl:0", "package-0", 1_000_000, Some(RANGE))
.zone("intel-rapl:0:0", "core", 400_000, Some(RANGE))
.zone("intel-rapl:0:1", "dram", 90_000, Some(RANGE_B))
.zone("intel-rapl:1", "package-1", 5_000_000, Some(RANGE_B))
.zone("intel-rapl:1:0", "dram", 70_000, Some(RANGE_B))
.zone("intel-rapl:2", "psys", 80_000_000, Some(RANGE))
.zone("intel-rapl:3", "dram", 3_000_000, Some(RANGE_B))
.zone("intel-rapl-mmio:0", "package-0", 1_000_000, Some(RANGE));
pc
}
/// The zones `packages` lists, as (index, range).
fn listed(packages: &[Package]) -> Vec<(u32, Option<u64>)> {
packages.iter().map(|p| (p.index, p.max_energy_range_uj)).collect()
}
/// The live counter over the package zones under `pc`.
fn live_counter(pc: &Powercap) -> RaplCounter {
RaplCounter::given(packages_under(pc.root()), CounterLiveness::Live)
}
fn uj(c: &RaplCounter) -> u64 {
c.read().expect("every package reads").uj
}
/// A package zone is named `package-<n>`; `psys`, `dram`, `core` and
/// malformed names are not. A top-level zone is `intel-rapl:<n>`; a
/// sub-zone, the control type and the MMIO interface are not.
#[test]
fn only_a_top_level_zone_named_package_n_is_a_package() {
for name in ["package-0", "package-1\n", "package-12"] {
assert!(is_package_name(name), "{name:?}");
}
for name in ["psys", "psys\n", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", ""] {
assert!(!is_package_name(name), "{name:?}");
}
assert_eq!(top_level_zone("intel-rapl:0"), Some(0));
assert_eq!(top_level_zone("intel-rapl:12"), Some(12));
for dir in ["intel-rapl", "intel-rapl:", "intel-rapl:0:0", "intel-rapl:1:2", "intel-rapl-mmio:0", "intel-rapl:x", "dtpm"] {
assert_eq!(top_level_zone(dir), None, "{dir:?}");
}
}
/// A two-socket server: the figure is both packages' energy and nothing else
/// — not `psys` (which includes the packages, so would count them twice),
/// not a `dram` zone at the top level or under a package, not a core
/// sub-zone, not the MMIO copy of package 0.
#[test]
fn a_two_socket_server_sums_its_two_packages_and_nothing_else() {
let pc = two_socket_server("two-socket");
let packages = packages_under(pc.root());
assert_eq!(listed(&packages), vec![(0, Some(RANGE)), (1, Some(RANGE_B))]);
assert_eq!(read_packages(&packages), Some(vec![1_000_000, 5_000_000]));
let c = live_counter(&pc);
let before = uj(&c);
assert_eq!(before, 1_000_000 + 5_000_000);
for (dir, v) in [
("intel-rapl:0", 1_250_000),
("intel-rapl:1", 5_400_000),
("intel-rapl:0:0", 600_000),
("intel-rapl:0:1", 190_000),
("intel-rapl:1:0", 170_000),
("intel-rapl:2", 99_000_000),
("intel-rapl:3", 3_900_000),
("intel-rapl-mmio:0", 1_250_000),
] {
pc.set(dir, v);
}
assert_eq!(uj(&c) - before, 250_000 + 400_000, "package 0's energy and package 1's, nothing else");
}
/// Zone 0 is the platform zone and the package is zone 1: zone 0 is neither
/// summed nor sampled, and the figure is zone 1's.
#[test]
fn a_zone_0_named_psys_is_left_out() {
let pc = Powercap::new("psys-at-0");
pc.zone("intel-rapl:0", "psys", 70_000_000, Some(RANGE))
.zone("intel-rapl:1", "package-0", 2_000_000, Some(RANGE))
.zone("intel-rapl:1:0", "core", 1_000, Some(RANGE));
let packages = packages_under(pc.root());
assert_eq!(listed(&packages), vec![(1, Some(RANGE))]);
let (channels, mut sample) = open_sampler(&packages).expect("the package reads");
assert_eq!((channels, sample()), (1, Some(vec![2_000_000])), "the liveness sample holds the package alone");
let c = live_counter(&pc);
let before = uj(&c);
pc.set("intel-rapl:0", 90_000_000);
pc.set("intel-rapl:1", 2_300_000);
assert_eq!(uj(&c) - before, 300_000);
}
/// Each package wraps at its own range: package 0 rolls over at RANGE while
/// package 1 advances, then package 1 rolls over at RANGE_B. The energy is
/// each zone's delta modulo its own range, and the total keeps rising across
/// both wraps.
#[test]
fn each_package_wraps_at_its_own_range() {
let pc = Powercap::new("wrap");
pc.zone("intel-rapl:0", "package-0", RANGE - 1_000, Some(RANGE))
.zone("intel-rapl:1", "package-1", RANGE_B - 300, Some(RANGE_B));
let c = live_counter(&pc);
let r0 = uj(&c);
pc.set("intel-rapl:0", 500);
pc.set("intel-rapl:1", RANGE_B - 100);
let r1 = uj(&c);
assert_eq!(r1 - r0, (1_000 + 500) + 200);
pc.set("intel-rapl:0", 2_500);
pc.set("intel-rapl:1", 400);
let r2 = uj(&c);
assert_eq!(r2 - r1, 2_000 + (100 + 400));
// At the other zone's range, package 1's wrap would be off by the
// difference of the two ranges.
assert_eq!(zone_delta_uj(RANGE_B - 100, 400, Some(RANGE_B)), Some(500));
assert_eq!(zone_delta_uj(RANGE_B - 100, 400, Some(RANGE)), Some(RANGE - RANGE_B + 500));
}
/// A package whose range is unknown (absent, or zero): summed while it rises;
/// once it goes down its energy since the last read is unknown, so that read
/// is a counter wrap, and so is every later one, since the total would be
/// short by that energy.
#[test]
fn a_fall_with_no_known_range_ends_the_figure() {
let pc = Powercap::new("no-range");
pc.zone("intel-rapl:0", "package-0", 10_000, None).zone("intel-rapl:1", "package-1", 20_000, Some(0));
let c = live_counter(&pc);
assert_eq!(listed(&c.packages), vec![(0, None), (1, None)]);
let r0 = uj(&c);
pc.set("intel-rapl:0", 15_000);
pc.set("intel-rapl:1", 21_000);
assert_eq!(uj(&c) - r0, 5_000 + 1_000);
pc.set("intel-rapl:0", 3_000);
assert!(matches!(c.read(), Err(EnergyError::CounterWrap)));
pc.set("intel-rapl:0", 4_000);
assert!(matches!(c.read(), Err(EnergyError::CounterWrap)), "a total short by the lost energy");
assert_eq!(zone_delta_uj(1_000, 10, Some(0)), None, "a zero range is no range");
}
/// A package that cannot be read fails the whole read: no figure that leaves
/// a package out.
#[test]
fn an_unreadable_package_fails_the_read() {
let pc = Powercap::new("unreadable");
pc.zone("intel-rapl:0", "package-0", 10_000, Some(RANGE)).zone("intel-rapl:1", "package-1", 20_000, Some(RANGE));
let c = live_counter(&pc);
std::fs::remove_file(pc.root().join("intel-rapl:1/energy_uj")).expect("remove");
assert_eq!(read_packages(&c.packages), None);
assert!(open_sampler(&c.packages).is_none());
assert!(matches!(c.read(), Err(EnergyError::Platform(_))));
}
/// A host whose only zone is the platform (`psys`) has no package: the probe
/// opens nothing and burns nothing, the liveness is unavailable, and the
/// counter is unavailable and never reads. So too an empty powercap
/// directory, and a missing one.
#[test]
fn a_host_with_only_psys_has_no_rapl_counter() {
let pc = Powercap::new("only-psys");
pc.zone("intel-rapl:0", "psys", 70_000_000, Some(RANGE));
let empty = Powercap::new("empty");
for root in [pc.root(), empty.root(), Path::new("/no/such/powercap")] {
let packages = packages_under(root);
assert!(packages.is_empty(), "{root:?}");
let t = std::time::Instant::now();
assert_eq!(probe_packages(&packages), CounterLiveness::Unavailable);
assert!(t.elapsed() < Duration::from_millis(100), "burned {:?} probing no package", t.elapsed());
assert_eq!(package_liveness(&packages), CounterLiveness::Unavailable);
let c = RaplCounter::with_root(root);
assert!(!c.is_available() && c.liveness() == CounterLiveness::Unavailable, "{root:?}");
assert!(matches!(c.read(), Err(EnergyError::NoCounter(EnergyDomain::CpuPkg))));
}
let forced = RaplCounter::given(Vec::new(), CounterLiveness::Live);
assert!(forced.read().is_err(), "a counter over no package read");
}
/// The RAPL probe over scripted package counters (one value per package per
/// sample, `None` for an unreadable file) and a clock that advances `EVERY`
/// per tick.
fn rapl_probe(packages: usize, file: impl Fn(usize) -> Option<Vec<u64>>) -> CounterLiveness {
let t = Cell::new(Duration::ZERO);
let n = Cell::new(0usize);
let read = || {
let i = n.get();
n.set(i + 1);
file(i)
};
probe_with(
|| read().map(|_| (packages, || read())),
|| t.set(t.get() + EVERY),
|| t.get(),
LIVENESS_BUDGET,
)
}
/// The cases a host can present: a live MSR, a file that holds its value, a
/// counter republished in one lump, a root-only file; and with two packages,
/// both live, or one held.
#[test]
fn the_rapl_probe_admits_only_counters_that_keep_advancing() {
// ~45 W package: ~1.4 J per 31 ms interval.
assert_eq!(rapl_probe(1, |i| Some(vec![81_000_000 + 1_400_000 * i as u64])), CounterLiveness::Live);
assert_eq!(rapl_probe(1, |_| Some(vec![81_000_000])), CounterLiveness::Frozen, "a held value");
assert_eq!(
rapl_probe(1, |i| Some(vec![if i < 4 { 81_000_000 } else { 95_000_000 }])),
CounterLiveness::Frozen,
"one lump"
);
assert_eq!(rapl_probe(1, |_| None), CounterLiveness::Unavailable, "EPERM on every read");
let both = |i: usize| Some(vec![81_000_000 + 1_400_000 * i as u64, 9_000_000 + 900_000 * i as u64]);
assert_eq!(rapl_probe(2, both), CounterLiveness::Live);
let one = |i: usize| Some(vec![81_000_000 + 1_400_000 * i as u64, 9_000_000]);
assert_eq!(rapl_probe(2, one), CounterLiveness::Frozen, "package 1 held");
}
/// The cache probes once per package set, whatever the verdict, and keeps
/// sets apart: a set with one more package is another set.
#[test]
fn the_liveness_is_probed_once_per_package_set() {
static RUNS: AtomicUsize = AtomicUsize::new(0);
let probe = |verdict| {
move || {
RUNS.fetch_add(1, Ordering::SeqCst);
verdict
}
};
let a = vec![PathBuf::from("/test/only/rapl-a/intel-rapl:0")];
let b = vec![PathBuf::from("/test/only/rapl-a/intel-rapl:0"), PathBuf::from("/test/only/rapl-a/intel-rapl:1")];
assert_eq!(cached(&a, probe(CounterLiveness::Frozen)), CounterLiveness::Frozen);
assert_eq!(cached(&a, probe(CounterLiveness::Live)), CounterLiveness::Frozen, "a cached verdict is reused");
assert_eq!(cached(&b, probe(CounterLiveness::Live)), CounterLiveness::Live);
assert_eq!(cached(&b, probe(CounterLiveness::Frozen)), CounterLiveness::Live);
assert_eq!(RUNS.load(Ordering::SeqCst), 2);
let pc = two_socket_server("cache-key");
let key = package_set(&packages_under(pc.root()));
assert_eq!(key, vec![pc.root().join("intel-rapl:0"), pc.root().join("intel-rapl:1")], "the key is the package set");
}
/// The production probe — real file reads, the real CPU burn, the real clock
/// and budget — over two packages that hold their values: frozen, after the
/// whole budget.
#[test]
fn the_production_probe_finds_held_packages_frozen() {
let pc = two_socket_server("held");
let packages = packages_under(pc.root());
let t = std::time::Instant::now();
assert_eq!(probe_packages(&packages), CounterLiveness::Frozen);
assert!(t.elapsed() >= LIVENESS_BUDGET, "a frozen verdict took only {:?}", t.elapsed());
}
/// The production probe over a two-socket tree another thread keeps
/// advancing, as the kernel advances live MSRs: live, well inside the budget,
/// when both packages advance; frozen when package 1 holds while package 0
/// and `psys` advance.
#[test]
fn the_production_probe_needs_every_package_to_keep_advancing() {
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
let run = |tag: &str, moving: &'static [&'static str]| {
let pc = Arc::new(two_socket_server(tag));
let stop = Arc::new(AtomicBool::new(false));
let writer = {
let (pc, stop) = (Arc::clone(&pc), Arc::clone(&stop));
std::thread::spawn(move || {
let mut v = 100_000_000u64;
while !stop.load(Ordering::SeqCst) {
v += 90_000; // ~45 W at a 2 ms cadence
for dir in moving {
pc.set(dir, v);
}
std::thread::sleep(Duration::from_millis(2));
}
})
};
let packages = packages_under(pc.root());
let t = std::time::Instant::now();
let liveness = probe_packages(&packages);
let took = t.elapsed();
stop.store(true, Ordering::SeqCst);
writer.join().expect("writer thread");
(liveness, took)
};
let (liveness, took) = run("advancing-both", &["intel-rapl:0", "intel-rapl:1"]);
assert_eq!(liveness, CounterLiveness::Live);
assert!(took >= Duration::from_millis(300) && took < LIVENESS_BUDGET, "{took:?}");
let (liveness, _) = run("advancing-one", &["intel-rapl:0", "intel-rapl:2"]);
assert_eq!(liveness, CounterLiveness::Frozen, "package 1 held its value");
}
}

View file

@ -0,0 +1,105 @@
//! The IOReport liveness probe runs once per PROCESS.
//!
//! This file holds this one test, so cargo runs it as a process of its own.
//! Nothing else in the process probes, and its threads' first calls are the
//! process's first use of the cache. That is the case a per-thread cache, a
//! cache that checks and then sets, or a counter constructor or meter
//! selection that probes around the cache gets wrong. Run it with
//! `--features ioreport` on macOS for the probe to exist; without that backend
//! it asserts that nothing probes at all.
use std::sync::{Arc, Barrier};
use jouleclaw_energy::meter::detect_meter;
use jouleclaw_energy::{ioreport_group_opens, ioreport_probe_runs, ioreport_status, IoReportStatus};
/// What one IOReport counter built on this thread says: its status, and
/// whether it is available. `None` without the IOReport backend.
fn counter() -> Option<(IoReportStatus, bool)> {
#[cfg(all(feature = "ioreport", target_os = "macos"))]
{
let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
Some((counter.status(), counter.is_available()))
}
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
{
None
}
}
/// Whether `detect_meter` selects the IOReport counter.
fn selected() -> bool {
detect_meter().active_name() == "apple-ioreport"
}
/// However many threads ask at once, and whatever they ask first — the
/// status, a counter, or a meter selection, each gated on it — the real probe
/// runs once. It opens the `Energy Model` group once, and every caller gets
/// its one status.
#[test]
fn the_liveness_probe_runs_once_per_process() {
assert_eq!(
(ioreport_probe_runs(), ioreport_group_opens()),
(0, 0),
"the probe ran before this test's first use of the cache"
);
const THREADS: usize = 9;
let start = Arc::new(Barrier::new(THREADS));
let askers: Vec<_> = (0..THREADS)
.map(|i| {
let start = Arc::clone(&start);
std::thread::spawn(move || {
start.wait();
// A third of the threads ask for the status first, a third
// build a counter first, a third select a meter first.
let (status, built, chosen) = match i % 3 {
0 => {
let status = ioreport_status();
(status, counter(), selected())
}
1 => {
let built = counter();
let chosen = selected();
(ioreport_status(), built, chosen)
}
_ => {
let chosen = selected();
let built = counter();
(ioreport_status(), built, chosen)
}
};
(status, built, chosen, ioreport_status())
})
})
.collect();
let seen: Vec<_> = askers.into_iter().map(|t| t.join().expect("a thread that asked")).collect();
let status = ioreport_status();
let (runs, opens) = (ioreport_probe_runs(), ioreport_group_opens());
eprintln!(
"ioreport once per process: {THREADS} threads, status={status:?}, real probe runs={runs}, Energy Model group \
opens={opens}"
);
for (first, built, chosen, again) in &seen {
assert_eq!((*first, *again), (status, status), "threads saw different statuses: {seen:?}");
if let Some((counter_status, available)) = built {
assert_eq!(*available, status == IoReportStatus::Live, "a counter's availability disagrees: {seen:?}");
if status != IoReportStatus::Live {
assert_eq!(*counter_status, status, "a declined counter names another status: {seen:?}");
}
}
assert_eq!(*chosen, status == IoReportStatus::Live, "meter selection disagrees with the status: {seen:?}");
}
#[cfg(all(feature = "ioreport", target_os = "macos"))]
assert_eq!(
(runs, opens),
(1, 1),
"the real probe ran {runs} time(s) and asked for the Energy Model group {opens} time(s) in this process \
({THREADS} threads asked at once): {seen:?}"
);
#[cfg(not(all(feature = "ioreport", target_os = "macos")))]
{
assert_eq!((runs, opens), (0, 0), "no IOReport probe runs without the IOReport backend");
assert_eq!(status, IoReportStatus::Unavailable);
assert!(seen.iter().all(|(_, built, _, _)| built.is_none()));
}
}

View file

@ -18,7 +18,7 @@ pub fn detect_local() -> Vec<FabricMember> {
// ~130 mJ per GFLOP order of magnitude.
cpu_energy.insert(
"matmul".to_string(),
EnergyCost { uj_per_gflop: 130_000.0, provenance: EnergyProvenance::Benchmarked },
EnergyCost { uj_per_gflop: 130_000.0, provenance: EnergyProvenance::Benchmarked, calibration: None },
);
members.push(FabricMember {
id: "local/cpu-neon".into(),
@ -77,7 +77,7 @@ fn probe_amx() -> Option<FabricMember> {
// per GFLOP order of magnitude in its favoured regime.
energy.insert(
"matmul".to_string(),
EnergyCost { uj_per_gflop: 7_000.0, provenance: EnergyProvenance::Benchmarked },
EnergyCost { uj_per_gflop: 7_000.0, provenance: EnergyProvenance::Benchmarked, calibration: None },
);
Some(FabricMember {
id: "local/amx".into(),
@ -105,7 +105,7 @@ fn probe_metal_gpu() -> Option<FabricMember> {
// 2 mJ per GFLOP.
energy.insert(
"matmul".to_string(),
EnergyCost { uj_per_gflop: 2_000.0, provenance: EnergyProvenance::Estimated },
EnergyCost { uj_per_gflop: 2_000.0, provenance: EnergyProvenance::Estimated, calibration: None },
);
Some(FabricMember {
id: "local/metal-gpu".into(),
@ -137,7 +137,7 @@ fn probe_metal_mpp() -> Option<FabricMember> {
let mut energy = BTreeMap::new();
energy.insert(
"matmul".to_string(),
EnergyCost { uj_per_gflop: 6_000.0, provenance: EnergyProvenance::Estimated },
EnergyCost { uj_per_gflop: 6_000.0, provenance: EnergyProvenance::Estimated, calibration: None },
);
Some(FabricMember {
id: "local/metal-mpp".into(),

View file

@ -30,10 +30,13 @@
//! but nothing was contacted; `Declared` is configuration
//! the runtime was told about but cannot verify.
//! - Energy figures carry provenance: `Measured` (a meter
//! read it), `Benchmarked` (a recorded run on this member
//! class), `Estimated` (a coarse model). Placement may use
//! any of them; receipts must not upgrade one into
//! another.
//! read it, live for the domain the figure claims),
//! `Benchmarked` (a recorded run on this member class),
//! `Estimated` (a coarse model). Placement may use any of
//! them; receipts must not upgrade one into another, and
//! neither does loading one: a calibration row is loaded at
//! the grade its writer declared, with its method, and a row
//! that declares none is never `Measured`.
//! - Numerics carry a parity tier against the CPU reference
//! oracle: `BitExact`, `ToleranceCertified { max_ulp }`,
//! or `Uncertified`. A member that has not been certified
@ -120,7 +123,9 @@ pub enum ParityTier {
/// reading never upgrades its own provenance).
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum EnergyProvenance {
/// A hardware meter read it (IOReport / RAPL / NVML).
/// A hardware meter read it (IOReport / RAPL / NVML) while
/// the meter was live for the domain the figure claims
/// (JouleClaw SPEC §1.3): a hardware-graded reading.
Measured,
/// Recorded benchmark on this member class.
Benchmarked,
@ -130,12 +135,17 @@ pub enum EnergyProvenance {
/// Energy cost of one unit of work on a member, with
/// provenance.
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EnergyCost {
/// Microjoules per gigaflop of the op class (order of
/// magnitude is what placement needs).
pub uj_per_gflop: f64,
pub provenance: EnergyProvenance,
/// The calibration row this figure was loaded from — its
/// method, its writer's declared grade, and when and on
/// what it was taken — or `None` for a seeded figure.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub calibration: Option<CalibrationEntry>,
}
/// One member of the compute fabric.
@ -156,16 +166,64 @@ pub struct FabricMember {
pub detail: String,
}
/// One measured calibration row (see
/// `Fabric::apply_calibration_file`).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
/// One calibration row (see `Fabric::apply_calibration_file`).
///
/// The optional fields default when absent, so a file written
/// before they existed still loads.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CalibrationEntry {
pub member_id: String,
/// Op class key, e.g. `"matmul"`.
pub op: String,
/// Must be positive and finite, or the row is refused:
/// nothing runs on zero or negative energy.
pub uj_per_gflop: f64,
/// Free-form method note (idle-subtraction caveat etc.).
pub method: String,
/// The grade of the figure, as its writer declared it from
/// the meter that read it: `Measured` only for a hardware
/// reading taken while that meter was live for the domain
/// it claims. A row without it is loaded as
/// [`UNDECLARED_ROW_PROVENANCE`], never as `Measured`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<EnergyProvenance>,
/// When the row was taken (RFC 3339, UTC).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
/// The OS and version it was taken on, e.g. `"macOS 27.0.1 (26A123)"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
/// The chip it was taken on, e.g. `"Apple M5 Max"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chip: Option<String>,
}
/// The grade a calibration row loads at when it declares none.
///
/// Such a row was written before rows carried a grade: a
/// recorded run on this member class, by a meter whose grade
/// nobody wrote down, and before the IOReport counter was
/// required to show its CPU rail live (on a frozen rail its
/// figure was GPU-only energy, or zero). It is this machine's
/// recorded run, so it still replaces a seeded figure, but at
/// `Benchmarked`: loading it as `Measured` would upgrade a
/// figure whose reading nobody can vouch for.
pub const UNDECLARED_ROW_PROVENANCE: EnergyProvenance = EnergyProvenance::Benchmarked;
impl CalibrationEntry {
/// The grade this row loads at: the one its writer
/// declared, else [`UNDECLARED_ROW_PROVENANCE`].
pub fn loaded_provenance(&self) -> EnergyProvenance {
self.provenance.unwrap_or(UNDECLARED_ROW_PROVENANCE)
}
}
/// A calibration row the fabric refused to load, and why.
#[derive(Debug, Clone, PartialEq)]
pub struct RejectedCalibration {
pub member_id: String,
pub op: String,
pub why: String,
}
/// The fabric as seen from this process: detected local
@ -177,8 +235,8 @@ pub struct Fabric {
impl Fabric {
/// Full discovery: local probes + ambient-credential
/// reachability + this machine's measured calibration
/// (when present).
/// reachability + this machine's calibration file (when
/// present), each row at the grade it declares.
pub fn discover() -> Self {
let mut members = detect_local();
members.extend(reach_remote());
@ -187,30 +245,74 @@ impl Fabric {
fabric
}
/// Overlay measured µJ figures from the calibration file
/// written by `fabric_calibrate` (loader-gguf). Path from
/// `JOULECLAW_FABRIC_CALIBRATION`, defaulting to
/// `~/.jouleclaw/fabric-calibration.json`. Measured
/// figures replace seeded Benchmarked/Estimated entries —
/// placement should run on this machine's numbers, not a
/// table written for another one.
pub fn apply_calibration_file(&mut self) {
/// Overlay this machine's µJ figures from the calibration
/// file written by `fabric_calibrate` (loader-gguf). Path
/// from `JOULECLAW_FABRIC_CALIBRATION`, defaulting to
/// `~/.jouleclaw/fabric-calibration.json`. Calibrated
/// figures replace seeded entries — placement should run on
/// this machine's numbers, not a table written for another
/// one — at the grade each row declares (see
/// [`Self::apply_calibration`]). Returns the rows refused.
pub fn apply_calibration_file(&mut self) -> Vec<RejectedCalibration> {
let path = std::env::var("JOULECLAW_FABRIC_CALIBRATION").unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_default();
format!("{home}/.jouleclaw/fabric-calibration.json")
});
let Ok(bytes) = std::fs::read(&path) else { return };
self.apply_calibration_path(std::path::Path::new(&path))
}
/// [`Self::apply_calibration`] over the rows in the JSON
/// file at `path`. A missing or unparseable file overlays
/// nothing.
pub fn apply_calibration_path(&mut self, path: &std::path::Path) -> Vec<RejectedCalibration> {
let Ok(bytes) = std::fs::read(path) else { return Vec::new() };
let Ok(entries) = serde_json::from_slice::<Vec<CalibrationEntry>>(&bytes) else {
return;
return Vec::new();
};
self.apply_calibration(entries)
}
/// Overlay calibration rows onto the members they name.
///
/// Each row loads at [`CalibrationEntry::loaded_provenance`]
/// — the grade its writer declared, never upgraded; a row
/// that declares none is not `Measured` — and the row itself
/// (its method, date, OS, chip) rides along on the figure as
/// [`EnergyCost::calibration`]. A row whose `uj_per_gflop`
/// is not positive and finite is refused and returned with
/// why, and the member keeps the figure it had: a zero would
/// make the member free to place on, and no work is. Rows
/// for members this host does not have are skipped.
pub fn apply_calibration(
&mut self,
entries: impl IntoIterator<Item = CalibrationEntry>,
) -> Vec<RejectedCalibration> {
let mut rejected = Vec::new();
for e in entries {
if !(e.uj_per_gflop.is_finite() && e.uj_per_gflop > 0.0) {
rejected.push(RejectedCalibration {
why: format!(
"uj_per_gflop {} is not a positive, finite figure: no work runs on zero or negative energy",
e.uj_per_gflop
),
member_id: e.member_id,
op: e.op,
});
continue;
}
if let Some(m) = self.members.iter_mut().find(|m| m.id == e.member_id) {
let op = e.op.clone();
m.energy.insert(
e.op,
EnergyCost { uj_per_gflop: e.uj_per_gflop, provenance: EnergyProvenance::Measured },
op,
EnergyCost {
uj_per_gflop: e.uj_per_gflop,
provenance: e.loaded_provenance(),
calibration: Some(e),
},
);
}
}
rejected
}
/// Members that may actually execute work right now:
@ -249,27 +351,114 @@ mod tests {
}
}
/// A fabric with the local members and no calibration file
/// read: these tests never touch the real
/// `~/.jouleclaw/fabric-calibration.json`, nor the env var
/// other tests' `discover()` reads.
fn local() -> Fabric {
Fabric { members: detect_local() }
}
fn row(op: &str, uj_per_gflop: f64, provenance: Option<EnergyProvenance>) -> CalibrationEntry {
CalibrationEntry {
member_id: "local/cpu-neon".into(),
op: op.into(),
uj_per_gflop,
method: format!("{op}: test method"),
provenance,
date: Some("2026-09-30T12:00:00Z".into()),
os: Some("macOS 27.0.1".into()),
chip: Some("Apple M5 Max".into()),
}
}
/// A row loads at the grade its writer declared — never
/// upgraded, never downgraded — and the row itself, method
/// and all, rides along on the figure.
#[test]
fn calibration_overlays_measured_figures() {
let dir = std::env::temp_dir().join(format!("fab-cal-{}", std::process::id()));
fn a_calibration_row_loads_at_its_declared_grade_with_its_method() {
let mut fabric = local();
let rows = [
row("matmul", 123_456.0, Some(EnergyProvenance::Measured)),
row("bench-op", 2_000.0, Some(EnergyProvenance::Benchmarked)),
row("est-op", 3_000.0, Some(EnergyProvenance::Estimated)),
];
assert_eq!(fabric.apply_calibration(rows.clone()), vec![]);
let cpu = fabric.get("local/cpu-neon").unwrap();
for r in &rows {
let cost = &cpu.energy[&r.op];
assert_eq!(cost.uj_per_gflop, r.uj_per_gflop);
assert_eq!(Some(cost.provenance), r.provenance, "{} loaded at another grade", r.op);
assert_eq!(cost.calibration.as_ref(), Some(r), "{}: the row did not ride along", r.op);
assert_eq!(cost.calibration.as_ref().unwrap().method, format!("{}: test method", r.op));
}
}
/// A row that declares no grade — every row written before
/// rows carried one, in exactly that file format — still
/// loads, and replaces the seeded figure, but is never
/// `Measured`: nothing in it says a live meter read it.
#[test]
fn a_row_that_declares_no_grade_loads_but_never_as_measured() {
let legacy = br#"[{"member_id":"local/cpu-neon","op":"matmul","uj_per_gflop":98765.0,
"method":"IOReport SoC dE - idle(4.10 W)*dt over 12 ops of prefill-batch (m=16)"}]"#;
let dir = std::env::temp_dir().join(format!("fab-cal-legacy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cal.json");
let entries = vec![CalibrationEntry {
member_id: "local/cpu-neon".into(),
op: "matmul".into(),
uj_per_gflop: 123_456.0,
method: "test".into(),
}];
std::fs::write(&path, serde_json::to_vec(&entries).unwrap()).unwrap();
// SAFETY: single-threaded test scope.
unsafe { std::env::set_var("JOULECLAW_FABRIC_CALIBRATION", &path) };
let fabric = Fabric::discover();
unsafe { std::env::remove_var("JOULECLAW_FABRIC_CALIBRATION") };
let cpu = fabric.get("local/cpu-neon").unwrap();
let cost = cpu.energy.get("matmul").unwrap();
assert_eq!(cost.uj_per_gflop, 123_456.0);
assert_eq!(cost.provenance, EnergyProvenance::Measured);
std::fs::write(&path, legacy).unwrap();
let mut fabric = local();
let rejected = fabric.apply_calibration_path(&path);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(rejected, vec![]);
let cost = &fabric.get("local/cpu-neon").unwrap().energy["matmul"];
assert_eq!(cost.uj_per_gflop, 98_765.0, "the legacy row did not load");
assert_ne!(cost.provenance, EnergyProvenance::Measured, "a row with no declared grade was loaded as Measured");
assert_eq!(cost.provenance, UNDECLARED_ROW_PROVENANCE);
let carried = cost.calibration.as_ref().expect("the row rides along");
assert!(carried.method.starts_with("IOReport SoC"), "{carried:?}");
assert_eq!((&carried.provenance, &carried.date, &carried.os, &carried.chip), (&None, &None, &None, &None));
}
/// A zero, negative, or non-finite figure is refused, says
/// why, and leaves the member's figure as it was — a zero
/// would make the member free to place on.
#[test]
fn a_non_positive_or_non_finite_figure_is_refused() {
for bad in [0.0, -0.0, -5.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut fabric = local();
let seeded = fabric.get("local/cpu-neon").unwrap().energy["matmul"].clone();
let rejected = fabric.apply_calibration([row("matmul", bad, Some(EnergyProvenance::Measured))]);
assert_eq!(rejected.len(), 1, "uj_per_gflop {bad} was accepted");
assert_eq!((rejected[0].member_id.as_str(), rejected[0].op.as_str()), ("local/cpu-neon", "matmul"));
assert!(rejected[0].why.contains("positive, finite"), "{}", rejected[0].why);
let kept = &fabric.get("local/cpu-neon").unwrap().energy["matmul"];
assert_eq!(
(kept.uj_per_gflop, kept.provenance, kept.calibration.is_none()),
(seeded.uj_per_gflop, seeded.provenance, true),
"a refused row of {bad} changed the member's figure"
);
}
// Zero from a file is refused the same way.
let mut fabric = local();
let dir = std::env::temp_dir().join(format!("fab-cal-zero-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cal.json");
std::fs::write(&path, serde_json::to_vec(&[row("matmul", 0.0, None)]).unwrap()).unwrap();
let rejected = fabric.apply_calibration_path(&path);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(rejected.len(), 1);
}
/// The new fields round-trip, and a row written with them
/// omits none of them.
#[test]
fn a_calibration_row_round_trips() {
let r = row("matmul", 1.5, Some(EnergyProvenance::Measured));
let json = serde_json::to_string(&r).unwrap();
for key in ["\"provenance\":\"Measured\"", "\"date\"", "\"os\"", "\"chip\"", "\"method\""] {
assert!(json.contains(key), "{key} missing from {json}");
}
assert_eq!(serde_json::from_str::<CalibrationEntry>(&json).unwrap(), r);
}
#[test]

View file

@ -21,7 +21,7 @@ thiserror = { workspace = true }
# only want the economics types don't pull in the inference substrate.
[dev-dependencies]
jouleclaw-loader-gguf = { path = "../jouleclaw-loader-gguf", features = ["rayon"] }
# `ioreport` selects the Apple-Silicon load-derived energy estimator on macOS
# (honest Provenance::Estimator — the hardware-shunt IOReport meter lives in a
# separate repo's sandbox-meter and is intentionally NOT cross-dep'd here).
# `ioreport` gives `detect_meter` the Apple-Silicon IOReport energy counter
# (Provenance::HwShunt), used only where its CPU energy rail advances under
# load, else the load-derived estimator (honest Provenance::Estimator).
jouleclaw-energy = { path = "../jouleclaw-energy", features = ["ioreport"] }

View file

@ -1,21 +1,22 @@
//! Real local-model dispatch, real (Estimator-grade) measured joules, in the
//! econ report — and the cheapest-sufficient energy collapse, measured.
//! Real local-model dispatch, metered joules at the meter's honest provenance, in
//! the econ report — and the cheapest-sufficient energy collapse.
//!
//! Wires the pure-Rust `jouleclaw-loader-gguf` inference substrate as a cascade
//! [`Tier`]: it loads a real GGUF/safetensors model, greedily decodes an answer,
//! and meters the energy of that decode window via `jouleclaw-energy`'s
//! auto-detected meter. A recurring task is run several rounds with
//! skill-compilation on: the first few rounds pay the **real model** (real
//! decode time, real load-derived joules), then the class compiles into a
//! decode time, real metered joules), then the class compiles into a
//! deterministic skill and the rest resolve at ~0 — the cheapest-sufficient
//! thesis in measured energy.
//! thesis in metered energy.
//!
//! HONEST ENERGY: on Apple Silicon the auto-detected meter is a load-average ×
//! power-band ESTIMATOR (`Provenance::Estimator`), not a hardware shunt (the
//! shunt-grade IOReport meter lives in a separate repo and is deliberately not
//! cross-dep'd into this public crate). Decode wall-time and token counts are
//! exact measurements; the joules are an honest load-derived estimate over that
//! window. A LOCAL model is priced energy-only (no per-call API dollars).
//! HONEST ENERGY: the auto-detected meter is `jouleclaw-energy`'s. On Apple Silicon
//! that is the IOReport energy counter (`Provenance::HwShunt`, a live vendor counter)
//! only where its CPU energy rail advances under load; elsewhere — macOS 27.0.1 on an M5 Max
//! and an M3 Ultra — it is a load-average × power-band ESTIMATOR
//! (`Provenance::Estimator`). Each dispatch prints which meter ran and whether its
//! joules were measured or estimated. Decode wall-time and token counts are exact
//! measurements. A LOCAL model is priced energy-only (no per-call API dollars).
//!
//! Run (defaults to a Nemotron-3-Nano-4B snapshot if present):
//! ```text
@ -34,7 +35,8 @@ use jouleclaw_cascade::{
VerificationStatus,
};
use jouleclaw_energy::meter::detect_meter;
use jouleclaw_harness_econ::{EconHarness, PriceModel, Task};
use jouleclaw_energy::Provenance;
use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task};
use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer;
use jouleclaw_loader_gguf::nemotron_h::{NemotronHConfig, NemotronHModel};
use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file;
@ -42,12 +44,15 @@ use jouleclaw_skill::{ConstantSkillInducer, SkillTier};
const DEFAULT_SNAPSHOT: &str = "/Volumes/macos_4TB_external_1/hf_hub/models--nvidia--NVIDIA-Nemotron-3-Nano-4B-BF16/snapshots/dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f";
/// One real model dispatch: wall-time, tokens, and metered joules.
/// One real model dispatch: wall-time, tokens, and metered joules — with the meter
/// that metered them and the provenance of its readings.
#[derive(Clone, Copy)]
struct DispatchStat {
secs: f64,
tokens: usize,
joules: f64,
meter: &'static str,
provenance: Provenance,
}
/// A cascade tier backed by a real local model. Greedy-decodes an answer and
@ -89,9 +94,12 @@ impl Tier for LocalModelTier {
std::iter::once(self.bos).chain(self.tok.encode(&prompt)).collect();
// Fresh meter per dispatch: its first read sets the baseline, the second
// yields the energy integrated over the decode window.
// yields the energy integrated over the decode window. A window the meter
// put no figure on stops the run (`window_joules`): a failed read is not a
// zero-energy reading, and a figure the meter did not produce is never
// reported.
let meter = detect_meter();
let e0 = meter.read().map(|r| r.uj).unwrap_or(0);
let e0 = meter.read();
let t = Instant::now();
let mut state = self.model.new_state();
@ -114,15 +122,18 @@ impl Tier for LocalModelTier {
}
let secs = t.elapsed().as_secs_f64();
let e1 = meter.read().map(|r| r.uj).unwrap_or(e0);
let joules = e1.saturating_sub(e0) as f64 / 1e6;
let (joules, provenance) = window_joules(e0, meter.read())
.unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name()));
let text = self.tok.decode(&generated);
self.calls.fetch_add(1, Ordering::SeqCst);
self.log
.lock()
.unwrap()
.push(DispatchStat { secs, tokens: generated.len(), joules });
self.log.lock().unwrap().push(DispatchStat {
secs,
tokens: generated.len(),
joules,
meter: meter.active_name(),
provenance,
});
Ok(Answer {
output: AnswerOutput::Text(text),
@ -208,18 +219,27 @@ fn main() {
println!("\nreal model dispatches: {} (rest served by the compiled skill)", stats.len());
for (i, s) in stats.iter().enumerate() {
println!(
" dispatch {}: {} tok in {:.2}s ({:.1} tok/s), {:.3} J measured",
" dispatch {}: {} tok in {:.2}s ({:.1} tok/s), {:.3} J {} by {} ({})",
i + 1,
s.tokens,
s.secs,
if s.secs > 0.0 { s.tokens as f64 / s.secs } else { 0.0 },
s.joules
s.joules,
joules_obtained(s.provenance),
s.meter,
s.provenance
);
}
let model_j: f64 = stats.iter().map(|s| s.joules).sum();
let model_n = stats.len().max(1);
// The dispatches' own provenance, not an assumed one.
let provenance = match stats.first().map(|s| s.provenance) {
Some(p) if stats.iter().all(|s| s.provenance == p) => format!("{p:?}"),
Some(_) => "mixed provenance".to_string(),
None => "no dispatch".to_string(),
};
println!(
"\navg real-model dispatch : {:.3} J (Estimator, energy-only $)",
"\navg real-model dispatch : {:.3} J ({provenance}, energy-only $)",
model_j / model_n as f64
);
println!(
@ -235,7 +255,9 @@ fn main() {
);
println!("dollars per verified fix : ${:.8}", report.usd_per_verified_fix);
println!(
"\nThe energy axis is real inference measured on this machine; the curve bends\n\
because the recurring class compiled into a skill and stopped paying the model."
"\nThe energy axis is real inference on this machine, at each dispatch's meter and\n\
provenance above; the curve bends because the recurring class compiled into a\n\
skill and stopped paying the model."
);
}

View file

@ -1,15 +1,18 @@
//! The genuine number, end to end: a real instruction-tuned **code model**
//! (Granite-4.0-h, pure-Rust loader-gguf) generates a fix, which is applied as
//! the whole corrected file and judged by a **real `python3` test run**
//! ([`WholeFileValidator`]), with the decode energy measured. Output is the econ
//! ([`WholeFileValidator`]), with the decode energy metered. Output is the econ
//! report's joules-and-$ per VERIFIED fix on an operationally-verified fix.
//!
//! The local model emits the corrected file (the protocol that works for local
//! models — they fumble unified-diff syntax), wrapped in Granite's chat
//! template. Verification is operational: the patch is written and `python3`
//! actually imports + asserts. Energy is the honest `Provenance::Estimator`
//! load-derived figure (decode time + tokens are exact); a local model is
//! priced energy-only.
//! actually imports + asserts. Energy is `jouleclaw-energy`'s auto-detected
//! meter at its own provenance: the IOReport energy counter (`HwShunt`) only
//! where its CPU energy rail advances under load, else the load-derived
//! estimator (`Estimator`), as on macOS 27.0.1 on an M5 Max and an M3 Ultra; the
//! report says which (decode time + tokens are exact). A local model is priced
//! energy-only.
//!
//! Run (defaults to the granite-4.0-h-micro snapshot if present):
//! ```text
@ -29,9 +32,10 @@ use jouleclaw_cascade::{
VerificationStatus,
};
use jouleclaw_energy::meter::detect_meter;
use jouleclaw_energy::Provenance;
use jouleclaw_harness_econ::swebench::exec::{ShellTestRunner, WholeFileValidator};
use jouleclaw_harness_econ::swebench::{PatchValidator, SweInstance};
use jouleclaw_harness_econ::{EconHarness, PriceModel, Task};
use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task};
use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer;
use jouleclaw_loader_gguf::granite_h::{GraniteHConfig, GraniteHModel};
use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file;
@ -43,6 +47,9 @@ struct DispatchStat {
secs: f64,
tokens: usize,
joules: f64,
/// The meter that metered the decode, and the provenance of its readings.
meter: &'static str,
provenance: Provenance,
text: String,
}
@ -89,8 +96,10 @@ impl Tier for LocalCoderTier {
};
let ids = self.chat_prompt(&user);
// A window the meter put no figure on stops the run (`window_joules`): a
// failed read is not a zero-energy reading.
let meter = detect_meter();
let e0 = meter.read().map(|r| r.uj).unwrap_or(0);
let e0 = meter.read();
let t = Instant::now();
let mut state = self.model.new_state();
@ -117,8 +126,8 @@ impl Tier for LocalCoderTier {
}
let secs = t.elapsed().as_secs_f64();
let e1 = meter.read().map(|r| r.uj).unwrap_or(e0);
let joules = e1.saturating_sub(e0) as f64 / 1e6;
let (joules, provenance) = window_joules(e0, meter.read())
.unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name()));
let text = self.tok.decode(&generated);
self.calls.fetch_add(1, Ordering::SeqCst);
@ -126,6 +135,8 @@ impl Tier for LocalCoderTier {
secs,
tokens: generated.len(),
joules,
meter: meter.active_name(),
provenance,
text: text.clone(),
});
@ -255,11 +266,14 @@ fn main() {
let stats = log.lock().unwrap();
for s in stats.iter() {
println!(
"model: {} tok in {:.1}s ({:.1} tok/s), {:.3} J measured",
"model: {} tok in {:.1}s ({:.1} tok/s), {:.3} J {} by {} ({})",
s.tokens,
s.secs,
if s.secs > 0.0 { s.tokens as f64 / s.secs } else { 0.0 },
s.joules
s.joules,
joules_obtained(s.provenance),
s.meter,
s.provenance
);
println!("--- model output ---\n{}\n--------------------", s.text.trim());
}

View file

@ -2,14 +2,17 @@
//! code model. Five distinct bug classes (wrong-operator, off-by-one,
//! empty-guard, boundary-comparison, identity-vs-abs) — each a real Python file
//! the Granite coder must fix as a whole-file replacement, judged by a real
//! `python3` test ([`WholeFileValidator`]), with the decode energy measured.
//! `python3` test ([`WholeFileValidator`]), with the decode energy metered.
//!
//! This is the multi-instance generalisation of `swebench_local_coder`: instead
//! of one fix it reports a measured curve across bug types — which classes a 4B
//! of one fix it reports a metered curve across bug types — which classes a 4B
//! local model resolves, and the joules/$ each verified fix actually cost. The
//! denominator is fixes that PASS their test, never attempts. Energy is the
//! honest `Provenance::Estimator` figure (decode time + tokens exact); a local
//! model is priced energy-only.
//! denominator is fixes that PASS their test, never attempts. Energy is
//! `jouleclaw-energy`'s auto-detected meter at its own provenance: the IOReport
//! energy counter (`HwShunt`) only where its CPU energy rail advances under
//! load, else the load-derived estimator (`Estimator`), as on macOS 27.0.1 on an
//! M5 Max and an M3 Ultra; the report says which (decode time + tokens exact). A
//! local model is priced energy-only.
//!
//! Run (defaults to the granite-4.0-h-micro snapshot if present):
//! ```text
@ -30,9 +33,10 @@ use jouleclaw_cascade::{
VerificationStatus,
};
use jouleclaw_energy::meter::detect_meter;
use jouleclaw_energy::Provenance;
use jouleclaw_harness_econ::swebench::exec::{ShellTestRunner, WholeFileValidator};
use jouleclaw_harness_econ::swebench::{PatchValidator, SweInstance};
use jouleclaw_harness_econ::{EconHarness, PriceModel, Task};
use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task};
use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer;
use jouleclaw_loader_gguf::granite_h::{GraniteHConfig, GraniteHModel};
use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file;
@ -86,6 +90,9 @@ struct DispatchStat {
secs: f64,
tokens: usize,
joules: f64,
/// The meter that metered the decode, and the provenance of its readings.
meter: &'static str,
provenance: Provenance,
text: String,
}
@ -121,8 +128,10 @@ impl Tier for LocalCoderTier {
);
let ids = self.tok.encode(&prompt);
// A window the meter put no figure on stops the run (`window_joules`): a
// failed read is not a zero-energy reading.
let meter = detect_meter();
let e0 = meter.read().map(|r| r.uj).unwrap_or(0);
let e0 = meter.read();
let t = Instant::now();
let mut state = self.model.new_state();
@ -145,10 +154,17 @@ impl Tier for LocalCoderTier {
}
let secs = t.elapsed().as_secs_f64();
let e1 = meter.read().map(|r| r.uj).unwrap_or(e0);
let joules = e1.saturating_sub(e0) as f64 / 1e6;
let (joules, provenance) = window_joules(e0, meter.read())
.unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name()));
let text = self.tok.decode(&generated);
self.log.lock().unwrap().push(DispatchStat { secs, tokens: generated.len(), joules, text: text.clone() });
self.log.lock().unwrap().push(DispatchStat {
secs,
tokens: generated.len(),
joules,
meter: meter.active_name(),
provenance,
text: text.clone(),
});
Ok(Answer {
output: AnswerOutput::Text(text),
@ -310,6 +326,9 @@ fn main() {
"totals: {toks} tok in {secs:.1}s ({:.1} tok/s), {model_j:.3} J spent",
if secs > 0.0 { toks as f64 / secs } else { 0.0 }
);
for s in stats.iter() {
println!(" {:.3} J {} by {} ({})", s.joules, joules_obtained(s.provenance), s.meter, s.provenance);
}
if report.verified > 0 {
println!(
"joules per VERIFIED fix : {:.3} J ({:?})",
@ -321,8 +340,8 @@ fn main() {
}
println!(
"\nThe denominator is fixes that PASS their test, not attempts. Energy is real\n\
inference measured on this machine; swap a stronger model tier to raise the\n\
resolve rate and lower the joules-per-verified-fix."
inference on this machine, at each dispatch's meter and provenance above; swap a\n\
stronger model tier to raise the resolve rate and lower the joules-per-verified-fix."
);
drop(stats);

View file

@ -55,7 +55,7 @@ use jouleclaw_cascade::{
Answer, AnswerError, ExecutionTrace, L4ModelId, Query, QueryInput, Runtime, Tier,
TierEstimate, TierId, VerificationStatus,
};
use jouleclaw_energy::Provenance;
use jouleclaw_energy::{EnergyReading, Provenance};
use jouleclaw_skill::{InMemorySkillStore, SharedSkills, SkillInducer, SkillStore};
use serde::{Deserialize, Serialize};
@ -515,12 +515,87 @@ fn worst_provenance(a: Provenance, b: Provenance) -> Provenance {
if rank(a) <= rank(b) { a } else { b }
}
/// The joules a meter put on one window of work — a model dispatch, say — from its
/// cumulative readings before and after it, with the provenance those readings carry.
/// Or why there is no figure: a read failed, the readings disagree about provenance,
/// the counter went backwards, or it registered nothing at all (no work runs on zero
/// energy, so a zero means the meter did not see the work). A failed read is never
/// taken as zero, which would bill the window nothing, or bill it the counter's whole
/// history.
pub fn window_joules<E: std::fmt::Display>(
before: Result<EnergyReading, E>,
after: Result<EnergyReading, E>,
) -> Result<(f64, Provenance), String> {
let before = before.map_err(|e| format!("the meter read failed before the window: {e}"))?;
let after = after.map_err(|e| format!("the meter read failed after the window: {e}"))?;
if before.provenance != after.provenance {
return Err(format!(
"the meter's readings disagree about provenance ({} then {})",
before.provenance, after.provenance
));
}
match after.uj.checked_sub(before.uj) {
None => Err(format!("the counter went backwards ({} µJ, then {} µJ)", before.uj, after.uj)),
Some(0) => Err("the meter registered no energy across the window".to_string()),
Some(uj) => Ok((uj as f64 / 1e6, after.provenance)),
}
}
/// How a report says a metered figure at `provenance` was obtained: `"measured"` for a
/// reading (a hardware shunt, a live vendor energy counter, or a vendor energy model),
/// `"estimated"` for an estimate. The local-model examples print each dispatch's joules
/// through it, beside the meter that ran, so an estimate — what
/// `jouleclaw_energy::meter::detect_meter` yields on Apple Silicon whose IOReport CPU
/// rail does not advance — is never called a measurement.
pub fn joules_obtained(provenance: Provenance) -> &'static str {
match provenance {
Provenance::HwShunt | Provenance::ModelBased => "measured",
Provenance::Estimator => "estimated",
}
}
#[cfg(test)]
mod tests {
use super::*;
use jouleclaw_cascade::{Cascade, ContextRef, JouleBudget, QualityFloor};
use jouleclaw_skill::{ConstantSkillInducer, SkillTier, SKILL_JOULES};
fn reading(uj: u64, provenance: Provenance) -> Result<EnergyReading, String> {
Ok(EnergyReading { uj, timestamp_ns: 0, domain: jouleclaw_energy::EnergyDomain::SocTotal, provenance })
}
#[test]
fn a_window_is_the_delta_at_its_readings_provenance() {
let est = Provenance::Estimator;
assert_eq!(window_joules(reading(1_000_000, est), reading(3_500_000, est)), Ok((2.5, est)));
let mb = Provenance::ModelBased;
assert_eq!(window_joules(reading(7, mb), reading(250_007, mb)), Ok((0.25, mb)));
}
#[test]
fn a_window_without_two_good_readings_is_never_zero() {
let est = Provenance::Estimator;
let failed: Result<EnergyReading, String> = Err("no joule counter available".into());
let why = window_joules(failed.clone(), reading(9_000_000, est)).unwrap_err();
assert!(why.contains("read failed before the window"), "{why}");
let why = window_joules(reading(9_000_000, est), failed).unwrap_err();
assert!(why.contains("read failed after the window"), "{why}");
let why = window_joules(reading(5, est), reading(4, est)).unwrap_err();
assert!(why.contains("went backwards"), "{why}");
let why = window_joules(reading(5, Provenance::ModelBased), reading(9, est)).unwrap_err();
assert!(why.contains("disagree about provenance"), "{why}");
let why = window_joules(reading(541_893, Provenance::ModelBased), reading(541_893, Provenance::ModelBased))
.unwrap_err();
assert!(why.contains("registered no energy"), "{why}");
}
#[test]
fn an_estimate_is_never_reported_as_measured() {
assert_eq!(joules_obtained(Provenance::Estimator), "estimated");
assert_eq!(joules_obtained(Provenance::ModelBased), "measured");
assert_eq!(joules_obtained(Provenance::HwShunt), "measured");
}
fn q(text: &str) -> Query {
Query {
input: QueryInput::Text(text.to_string()),

View file

@ -91,7 +91,9 @@ use jouleclaw_jcr1::{
consumer's job.
- The 10 vectors are TOY joule values exercising each spec path. They
become measurements only when minted off real RAPL / NVML / IOReport
counters and cross-validated against Zeus.
counters that were live for the domain they claim (JCR-1 §2.3,
Liveness) and cross-validated against Zeus. Vector 6's `ModelBased`
span is a toy grade on a toy figure, not an IOReport reading.
## License

View file

@ -28,7 +28,8 @@ Expected: `10/10 vectors verified`.
tokens**, with a gateway HMAC. (The spec's worked example.)
4. **L2** retrieval-grounded — claim provenance + a span citation.
5. **L3** local model — joule class, HwShunt GPU.
6. **L3** retrieval + model on Apple IOReport — **ModelBased**, mJ-floored.
6. **L3** retrieval + model on a **ModelBased** span, mJ-floored (toy figures,
like every vector here: not an IOReport or any other reading).
7. **L4** remote frontier RPC — **Estimator** (remote energy unmeasurable).
8. **Mixed span** HwShunt + Estimator → worst-counter rule forces **Estimator**.
9. **Gateway HMAC** — two metered tool calls, both signed by `gw-edge-9`.

View file

@ -145,7 +145,7 @@
},
{
"n": 6,
"description": "L3 model grounded on retrieval, measured on Apple IOReport (ModelBased, mJ floor).",
"description": "L3 model grounded on retrieval; toy joule values on a ModelBased span (vendor-model grade, mJ floor), not a measurement.",
"receipt": {
"jc_receipt": "1",
"id": "rcpt-0006",

View file

@ -66,12 +66,36 @@ outer Smart Byte envelope. Carrying it inline makes the receipt self-describing.
### 2.3 Provenance (the honesty tag) — normative
Every energy reading carries one of:
- `HwShunt` — real hardware counter (x86 RAPL MSR, NVIDIA cumulative-energy
counter, Jetson INA3221). Honest to the counter's resolution.
- `ModelBased` — vendor model estimate from freq/voltage/utilization (Apple
IOReport, NVML `power.draw`). Millijoule-quantized at best.
- `HwShunt` — hardware reading: a shunt (Jetson INA3221), or a vendor energy
counter (x86 RAPL MSR, NVIDIA cumulative-energy counter, Apple IOReport) while
it is live for the domain it claims (below). Honest to the counter's
resolution.
- `ModelBased` — vendor model estimate from freq/voltage/utilization (NVML
`power.draw`, ROCm SMI). Millijoule-quantized at best.
- `Estimator` — JouleClaw static cost model. Wide tolerance by construction.
A vendor energy counter's reading is a real reading; the trust gap is
provenance (which counter, which domain, whether it was live), not accuracy.
Hence a live vendor counter is `HwShunt`, under this condition:
**Liveness (MUST):** 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. (JCR-1 v1 has no coverage field: a figure for a
domain its counter does not read is not a `HwShunt` figure for that work at all.)
Apple IOReport's GPU rail is machine-wide (display compositing moves it with
nothing of the metered work running), so it is not a partial reading of CPU work:
where only the GPU rail is live, the reference implementation declines the
counter and the CPU work is graded `Estimator`.
**Worst-counter rule (MUST):** `Receipt.energy_provenance` = the least-honest
provenance among all readings contributing to `joules_uj`, ordered
`HwShunt > ModelBased > Estimator`. A receipt whose span mixed a HwShunt GPU read

View file

@ -1,7 +1,8 @@
//! `jcr1_mint` — mint a fresh JCR-1 receipt from an energy reading.
//!
//! Reads CPU energy the way `jouleclaw-energy::rapl` does — straight from
//! `/sys/class/powercap/intel-rapl:0/energy_uj`, no FFI. On a system
//! Reads CPU energy through `jouleclaw-energy::rapl`: the sum of every
//! powercap package zone (`/sys/class/powercap/intel-rapl:<n>` named
//! `package-<n>`), no FFI. On a system
//! without RAPL (Apple, BSD, CI sandboxes, this Mac the example is
//! built on) the producer does NOT fake a `HwShunt` reading; it falls
//! back to a time-based `Estimator` value rounded to the 1 mJ

View file

@ -9,7 +9,9 @@
//! global profile — executes regardless of box load; only the *accuracy* of the
//! µJ figures needs a quiet machine (the marginal-energy subtraction assumes a
//! stable idle draw). A backend the platform can't run for a shape reports
//! `u64::MAX` and simply never wins.
//! `u64::MAX` and simply never wins. A shape the meter cannot measure — the IOReport
//! counter unavailable (its CPU energy rail not advancing, among other reasons), or a
//! window that draws nothing above idle — is left untuned: nothing is recorded.
use jouleclaw_loader_gguf::energy_autotune;
use jouleclaw_loader_gguf::fabric_dispatch;
@ -39,14 +41,20 @@ fn main() {
cost.uj,
cost.ns
),
None => println!(" m={m:<4} n={n} k={k} → meter unavailable (need IOReport)"),
None => println!(
" m={m:<4} n={n} k={k} → not tuned: the meter produced no figure (IOReport {:?})",
jouleclaw_energy::ioreport_status()
),
}
}
println!("\nglobal tuning profile (the dispatcher's lookup):");
let snapshot = energy_autotune::global_snapshot();
if snapshot.is_empty() {
println!(" (empty — no shape was tuned; is the IOReport meter present?)");
println!(
" (empty — no shape was measured; IOReport {:?})",
jouleclaw_energy::ioreport_status()
);
}
for (key, cost) in snapshot {
println!(

View file

@ -15,14 +15,44 @@
//! dataset records. Run this on a quiet machine or the
//! numbers are someone else's workload.
//!
//! It writes nothing, and exits non-zero saying why, unless
//! every figure is a measurement: the IOReport counter must
//! be available (on Apple Silicon, its CPU energy rail must
//! advance — `jouleclaw_energy::ioreport_status`), the idle
//! baseline must advance, and every member's marginal
//! energy must be positive (`marginal_energy`). A zero,
//! clamped or not, would be loaded as a measured placement
//! cost.
//!
//! Each row it writes declares its grade — the meter's, so a
//! live IOReport reading (`HwShunt`) is `Measured` — and
//! records its method, its date, and the OS and chip it was
//! taken on (`marginal_energy::calibration_row`); the fabric
//! loads it at that grade and carries the rest along.
//!
//! ```bash
//! cargo run --release --features "rayon fabric-meter" \
//! --example fabric_calibrate
//! ```
use jouleclaw_energy::apple_ioreport::AppleIoReportCounter;
use jouleclaw_energy::EnergyCounter;
use jouleclaw_loader_gguf::marginal_energy::{
calibration_row, idle_draw_uw, marginal_uj, refusal, NotMeasured,
};
use std::time::Instant;
/// Write nothing: say why, with the IOReport status, on stderr and exit non-zero.
fn refuse(path: &str, why: impl std::fmt::Display) -> ! {
eprintln!("{}", refusal(path, why, jouleclaw_energy::ioreport_status()));
std::process::exit(1);
}
/// One cumulative reading, µJ.
fn read_uj(counter: &AppleIoReportCounter) -> Result<u64, NotMeasured> {
counter.read().map(|r| r.uj).map_err(|e| NotMeasured::ReadFailed(e.to_string()))
}
struct Shape {
label: &'static str,
m: usize,
@ -38,19 +68,22 @@ fn main() {
Shape { label: "prefill-batch (m=16)", m: 16, n: 4096, k: 2304 },
];
let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
let home = std::env::var("HOME").unwrap_or_default();
let dir = format!("{home}/.jouleclaw");
let path = format!("{dir}/fabric-calibration.json");
let counter = AppleIoReportCounter::new();
if !counter.is_available() {
eprintln!("IOReport unavailable on this host — nothing honest to write.");
std::process::exit(1);
refuse(&path, NotMeasured::CounterUnavailable("apple-ioreport declined".into()));
}
// Idle baseline: 3 s of nothing.
let idle_uw = {
let e0 = counter.read().expect("read").uj;
let e0 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e));
let t0 = Instant::now();
std::thread::sleep(std::time::Duration::from_secs(3));
let e1 = counter.read().expect("read").uj;
(e1 - e0) as f64 / t0.elapsed().as_secs_f64()
let e1 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e));
idle_draw_uw(e0, e1, t0.elapsed().as_secs_f64()).unwrap_or_else(|e| refuse(&path, e))
};
println!("idle draw: {:.2} W (machine-wide SoC)", idle_uw / 1e6);
@ -108,31 +141,33 @@ fn main() {
run(&mut out);
// Measure ≥ 2 s of work.
let mut iters = 0u64;
let e0 = counter.read().expect("read").uj;
let e0 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e));
let t0 = Instant::now();
while t0.elapsed().as_secs_f64() < 2.0 {
run(&mut out);
iters += 1;
}
let dt = t0.elapsed().as_secs_f64();
let e1 = counter.read().expect("read").uj;
let marginal_uj = ((e1 - e0) as f64 - idle_uw * dt).max(0.0);
let uj_per_op = marginal_uj / iters as f64;
let e1 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e));
let marginal = marginal_uj(e0, e1, dt, idle_uw)
.unwrap_or_else(|e| refuse(&path, format!("{member} on {}: {e}", shape.label)));
let uj_per_op = marginal / iters as f64;
let uj_per_gflop = uj_per_op / gflop;
println!(
"{member:>18}: {iters:>5} ops in {dt:.2}s → {:.0} µJ/op, {:.0} µJ/GFLOP ({:.1} ms/op)",
uj_per_op, uj_per_gflop, dt * 1e3 / iters as f64,
);
entries.push(jouleclaw_fabric::CalibrationEntry {
member_id: member.to_string(),
op: "matmul".into(),
entries.push(calibration_row(
member,
"matmul",
uj_per_gflop,
method: format!(
counter.provenance(),
format!(
"IOReport SoC ΔE − idle({:.2} W)·Δt over {} ops of {} — \
idle-subtraction model on a hardware reading",
idle_uw / 1e6, iters, shape.label,
idle-subtraction model on a hardware reading; IOReport {:?}",
idle_uw / 1e6, iters, shape.label, jouleclaw_energy::ioreport_status(),
),
});
));
}
}
@ -145,10 +180,7 @@ fn main() {
latest.insert(e.member_id.clone(), e);
}
let out_entries: Vec<_> = latest.into_values().collect();
let home = std::env::var("HOME").unwrap_or_default();
let dir = format!("{home}/.jouleclaw");
std::fs::create_dir_all(&dir).expect("mkdir");
let path = format!("{dir}/fabric-calibration.json");
std::fs::write(&path, serde_json::to_vec_pretty(&out_entries).unwrap()).expect("write");
println!("\nwrote {path} — Fabric::discover() now places on measured figures.");
}

View file

@ -2,22 +2,41 @@
//! threaded-NEON CPU path, via the IOReport SoC energy counter.
//! Run on a QUIET box: marginal energy = ΔE_busy − P_idle·Δt.
//! `cargo run --release --features "rayon fabric fabric-meter" --example mpp_energy`
//!
//! It writes nothing, and exits non-zero saying why, unless both figures are
//! measurements: the IOReport counter available (its CPU energy rail live —
//! `jouleclaw_energy::ioreport_status`), an idle baseline that advances, and a
//! positive marginal for the GPU **and** the CPU run. A CPU run that draws nothing
//! above idle means the meter cannot see CPU work, so its SoC figure for the GPU run
//! is not a whole-SoC figure either.
#[cfg(all(feature = "fabric", feature = "fabric-meter"))]
fn main() {
use jouleclaw_energy::EnergyCounter;
use jouleclaw_loader_gguf::marginal_energy::{
calibration_row, energy_ratio, idle_draw_uw, marginal_uj, refusal, NotMeasured,
};
use std::time::{Duration, Instant};
fn f32_to_bf16(v: f32) -> u16 { let b = v.to_bits(); (((b + (((b >> 16) & 1) + 0x7fff)) >> 16)) as u16 }
/// Write nothing: say why, with the IOReport status, on stderr and exit non-zero.
fn refuse(path: &str, why: impl std::fmt::Display) -> ! {
eprintln!("{}", refusal(path, why, jouleclaw_energy::ioreport_status()));
std::process::exit(1);
}
let path = std::env::var("JOULECLAW_FABRIC_CALIBRATION").unwrap_or_else(|_| {
format!("{}/.jouleclaw/fabric-calibration.json", std::env::var("HOME").unwrap_or_default())
});
let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
if !counter.is_available() {
eprintln!("IOReport unavailable"); std::process::exit(1);
refuse(&path, NotMeasured::CounterUnavailable("apple-ioreport declined".into()));
}
let read_uj = || counter.read().map(|r| r.uj).map_err(|e| NotMeasured::ReadFailed(e.to_string()));
let idle_uw = {
let e0 = counter.read().unwrap().uj;
let e0 = read_uj().unwrap_or_else(|e| refuse(&path, e));
let t0 = Instant::now();
std::thread::sleep(Duration::from_secs(3));
let e1 = counter.read().unwrap().uj;
(e1 - e0) as f64 / t0.elapsed().as_secs_f64()
let e1 = read_uj().unwrap_or_else(|e| refuse(&path, e));
idle_draw_uw(e0, e1, t0.elapsed().as_secs_f64()).unwrap_or_else(|e| refuse(&path, e))
};
println!("idle draw: {:.2} W (machine-wide SoC)", idle_uw / 1e6);
@ -29,10 +48,11 @@ fn main() {
.collect();
let gflop = 2.0 * m as f64 * n as f64 * k as f64 / 1e9;
// run a workload for ~`secs` and return (µJ/GFLOP, GFLOP/s).
let measure = |secs: f64, mut run: Box<dyn FnMut()>| -> (f64, f64) {
// run a workload for ~`secs` and return (µJ/GFLOP, GFLOP/s), or why the meter
// produced no figure for it.
let measure = |secs: f64, mut run: Box<dyn FnMut()>| -> Result<(f64, f64), NotMeasured> {
run(); // warm
let e0 = counter.read().unwrap().uj;
let e0 = read_uj()?;
let t0 = Instant::now();
let mut ops = 0u64;
while t0.elapsed().as_secs_f64() < secs {
@ -40,16 +60,16 @@ fn main() {
ops += 1;
}
let dt = t0.elapsed().as_secs_f64();
let e1 = counter.read().unwrap().uj;
let e1 = read_uj()?;
let total_gf = gflop * ops as f64;
let marginal_uj = (e1 - e0) as f64 - idle_uw * dt;
(marginal_uj / total_gf, total_gf / dt)
let marginal = marginal_uj(e0, e1, dt, idle_uw)?;
Ok((marginal / total_gf, total_gf / dt))
};
let mut out = vec![0f32; m * n];
let optr = out.as_mut_ptr();
let (a_p, w_p) = (&a, &w);
let (gpu_ujpgf, gpu_gfps) = measure(3.0, Box::new(move || {
let gpu = measure(3.0, Box::new(move || {
let o = unsafe { std::slice::from_raw_parts_mut(optr, m * n) };
let ok = jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_mpp_bt(a_p, w_p, m, n, k, o);
assert!(ok, "mpp_bt fell back — set JOULECLAW_FABRIC=1 on a Metal-4 box");
@ -57,42 +77,61 @@ fn main() {
let mut out2 = vec![0f32; m * n];
let o2ptr = out2.as_mut_ptr();
let (cpu_ujpgf, cpu_gfps) = measure(3.0, Box::new(move || {
let cpu = measure(3.0, Box::new(move || {
let o = unsafe { std::slice::from_raw_parts_mut(o2ptr, m * n) };
jouleclaw_loader_gguf::matmul_bf16::matmul_bf16_threaded(a_p, w_p, &[], m, n, k, o);
// Assert it ran, as the GPU run does: a refused shape would meter an empty loop.
jouleclaw_loader_gguf::matmul_bf16::matmul_bf16_threaded(a_p, w_p, &[], m, n, k, o)
.expect("threaded-NEON matmul refused this shape");
}));
println!("shape {m}x{n}x{k}");
println!(" matmul2d (metal-gpu): {:>9.0} µJ/GFLOP {:>7.1} GFLOP/s", gpu_ujpgf, gpu_gfps);
println!(" threaded-NEON (cpu) : {:>9.0} µJ/GFLOP {:>7.1} GFLOP/s", cpu_ujpgf, cpu_gfps);
println!(" energy ratio (CPU/GPU per GFLOP): {:.2}×", cpu_ujpgf / gpu_ujpgf);
for (label, r) in [("matmul2d (metal-gpu)", &gpu), ("threaded-NEON (cpu) ", &cpu)] {
match r {
Ok((ujpgf, gfps)) => println!(" {label}: {ujpgf:>9.0} µJ/GFLOP {gfps:>7.1} GFLOP/s"),
Err(e) => println!(" {label}: not measured — {e}"),
}
}
let ratio = match (&gpu, &cpu) {
(Ok((gpu_ujpgf, _)), Ok((cpu_ujpgf, _))) => energy_ratio(*cpu_ujpgf, *gpu_ujpgf),
_ => None,
};
match ratio {
Some(ratio) => println!(" energy ratio (CPU/GPU per GFLOP): {ratio:.2}×"),
None => println!(" energy ratio (CPU/GPU per GFLOP): n/a — both runs must be measured"),
}
let gpu_ujpgf = match (gpu, cpu) {
(Ok((gpu_ujpgf, _)), Ok(_)) => gpu_ujpgf,
(Err(e), _) => refuse(&path, format!("the matmul2d (metal-gpu) run: {e}")),
(_, Err(e)) => refuse(&path, format!("the threaded-NEON (cpu) run: {e}")),
};
// Overlay the measured matmul2d µJ/GFLOP into this box's
// calibration file under local/metal-mpp, so the placer +
// ledger price the prefill path at its TRUE cost (not the
// borrowed MPSMatrix metal-gpu figure). Merge, don't clobber.
let path = std::env::var("JOULECLAW_FABRIC_CALIBRATION").unwrap_or_else(|_| {
format!("{}/.jouleclaw/fabric-calibration.json", std::env::var("HOME").unwrap_or_default())
});
let mut entries: Vec<jouleclaw_fabric::CalibrationEntry> = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
entries.retain(|e| e.member_id != "local/metal-mpp");
entries.push(jouleclaw_fabric::CalibrationEntry {
member_id: "local/metal-mpp".into(),
op: "matmul".into(),
uj_per_gflop: gpu_ujpgf,
method: format!(
"IOReport SoC ΔE − idle({:.2} W)·Δt over matmul2d {m}×{n}×{k} on a quiet box",
idle_uw / 1e6
let row = calibration_row(
"local/metal-mpp",
"matmul",
gpu_ujpgf,
counter.provenance(),
format!(
"IOReport SoC ΔE − idle({:.2} W)·Δt over matmul2d {m}×{n}×{k} on a quiet box; IOReport {:?}",
idle_uw / 1e6,
jouleclaw_energy::ioreport_status()
),
});
);
let grade = row.loaded_provenance();
entries.push(row);
if let Some(dir) = std::path::Path::new(&path).parent() {
let _ = std::fs::create_dir_all(dir);
}
if std::fs::write(&path, serde_json::to_vec_pretty(&entries).unwrap()).is_ok() {
println!("wrote local/metal-mpp = {:.0} µJ/GFLOP (Measured) → {path}", gpu_ujpgf);
println!("wrote local/metal-mpp = {:.0} µJ/GFLOP ({grade:?}) → {path}", gpu_ujpgf);
}
}
#[cfg(not(all(feature = "fabric", feature = "fabric-meter")))]

View file

@ -119,6 +119,82 @@ pub fn tune_shape(
joules_minimal(&costs)
}
/// [`tune_shape`] over a meter that can fail to measure a window: `metered(backend)`
/// returns `None` when the meter did not register that backend's work (a read failed,
/// the idle baseline did not advance, no energy above idle). One unmeasured window voids
/// the whole tune — `None`, nothing to record — because ranking a measured backend
/// against an unmeasured one decides by a missing figure. A backend that cannot run
/// the shape reports `Some((u64::MAX, u64::MAX))` and, as in [`tune_shape`], never wins.
#[cfg(any(test, feature = "fabric-meter"))]
pub(crate) fn tune_measured(
candidates: &[Backend],
reps: u32,
mut metered: impl FnMut(Backend) -> Option<(u64, u64)>,
) -> Option<Cost> {
let reps = reps.max(1);
let mut costs = Vec::with_capacity(candidates.len());
for &backend in candidates {
let (mut uj_sum, mut ns_sum) = (0u64, 0u64);
for _ in 0..reps {
let (uj, ns) = metered(backend)?;
uj_sum = uj_sum.saturating_add(uj);
ns_sum = ns_sum.saturating_add(ns);
}
costs.push(Cost { backend, uj: uj_sum / reps as u64, ns: ns_sum / reps as u64 });
}
joules_minimal(&costs)
}
/// The meter a recorded cost was measured on: which meter read it, the grade of its
/// readings, and the energy domain they cover. A sealed tuning receipt carries it per
/// shape, and [`TuningProfile`] ranks records by it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Metered {
/// The meter's stable name, e.g. `"apple-ioreport"`.
pub meter: String,
/// The grade of its readings, as JCR-1 spells it: `"HwShunt"`, `"ModelBased"` or
/// `"Estimator"`. A vendor energy counter reads `HwShunt` only while it is live for
/// its domain (JouleClaw SPEC §1.3); one that is not live produces no reading.
pub energy_provenance: String,
/// The energy domain its readings cover, e.g. `"SocTotal"`.
pub domain: String,
}
/// What a sealed receipt says for a cost recorded through [`TuningProfile::record`],
/// with no [`Metered`]: nothing recorded what meter measured it, or how.
pub const UNRECORDED: &str = "unrecorded";
/// How far a record can be trusted against another for the same shape: a hardware
/// reading over a vendor model over an estimate, and any of them over a cost recorded
/// with no meter at all, which ranks last, as the worst-counter rule ranks an unknown.
fn rank(metered: Option<&Metered>) -> u8 {
match metered.map(|m| m.energy_provenance.as_str()) {
Some("HwShunt") => 3,
Some("ModelBased") => 2,
Some("Estimator") => 1,
_ => 0,
}
}
#[cfg(feature = "fabric-meter")]
impl Metered {
/// The record for a cost measured on `meter`: its name, its readings' grade, and
/// their domain.
pub fn of(meter: &dyn jouleclaw_energy::meter::EnergyMeter) -> Self {
use jouleclaw_energy::Provenance as P;
let grade = match meter.provenance() {
P::HwShunt => "HwShunt",
P::ModelBased => "ModelBased",
P::Estimator => "Estimator",
};
Metered {
meter: meter.name().to_string(),
energy_provenance: grade.to_string(),
domain: format!("{:?}", meter.domain()),
}
}
}
/// A learned, deterministic map from op-shape to the backend that runs it in the
/// fewest joules. Deterministic (a `BTreeMap`) so a persisted or sealed profile is
/// byte-stable. The dispatcher consults it; a shape with no entry falls back to
@ -126,7 +202,7 @@ pub fn tune_shape(
/// measured-better one).
#[derive(Clone, Debug, Default)]
pub struct TuningProfile {
chosen: BTreeMap<ShapeKey, Cost>,
chosen: BTreeMap<ShapeKey, (Cost, Option<Metered>)>,
}
impl TuningProfile {
@ -135,18 +211,53 @@ impl TuningProfile {
TuningProfile { chosen: BTreeMap::new() }
}
/// Record a measurement for `key`, keeping whichever is joules-minimal — so a
/// later, noisier measurement can never regress a shape's decision.
/// Record a measurement for `key` with no record of the meter that took it, keeping
/// whichever is joules-minimal among such records — so a later, noisier
/// measurement can never regress a shape's decision.
///
/// Such a cost ranks below any cost recorded with its meter
/// ([`Self::record_metered`]): nothing says the meter behind it was live, and a
/// cost taken on a counter that was not — before the IOReport counter had to show
/// its CPU rail live, a frozen rail metered CPU work as GPU-only energy, or zero —
/// is a low figure that would otherwise beat every honest measurement after it.
///
/// A zero-joule cost is never recorded. Nothing runs on zero energy, so it means
/// the meter did not see the work, not that the work was free; recorded, it would
/// beat every later measurement and pin the shape to that backend for good.
pub fn record(&mut self, key: ShapeKey, cost: Cost) {
match self.chosen.get(&key) {
Some(current) if current.uj <= cost.uj => {}
_ => {
self.chosen.insert(key, cost);
}
}
self.insert(key, cost, None);
}
/// Tune `key` across `candidates` and record the winner; returns it.
/// Record a measurement for `key` taken on `metered`'s meter.
///
/// It replaces whatever the shape holds from a less trusted record (see
/// [`Self::record`]: a hardware reading over a vendor model over an estimate, over
/// a cost with no meter recorded), however many joules either drew: the decision
/// is re-made on the better evidence. Against a record of the same standing the
/// joules-minimal one is kept, and a record of better standing is never displaced
/// by a worse one. A zero-joule cost is never recorded, and a zero the shape holds
/// (an older build recorded them) is replaced by any figure.
pub fn record_metered(&mut self, key: ShapeKey, cost: Cost, metered: Metered) {
self.insert(key, cost, Some(metered));
}
fn insert(&mut self, key: ShapeKey, cost: Cost, metered: Option<Metered>) {
if cost.uj == 0 {
return;
}
if let Some((current, current_meter)) = self.chosen.get(&key) {
let (held, offered) = (rank(current_meter.as_ref()), rank(metered.as_ref()));
let keep = current.uj != 0 && (held > offered || (held == offered && current.uj <= cost.uj));
if keep {
return;
}
}
self.chosen.insert(key, (cost, metered));
}
/// Tune `key` across `candidates` and record the winner; returns it. `None`, with
/// nothing recorded, when the joules-minimal candidate drew zero joules (see
/// [`Self::record`]).
pub fn tune_and_record(
&mut self,
key: ShapeKey,
@ -155,24 +266,37 @@ impl TuningProfile {
metered: impl FnMut(Backend) -> (u64, u64),
) -> Option<Cost> {
let cost = tune_shape(candidates, reps, metered)?;
if cost.uj == 0 {
return None;
}
self.record(key, cost);
Some(cost)
}
/// The backend chosen for `key`, if this shape has been tuned.
pub fn choose(&self, key: ShapeKey) -> Option<Backend> {
self.chosen.get(&key).map(|c| c.backend)
self.chosen.get(&key).map(|(c, _)| c.backend)
}
/// The full measured cost recorded for `key`, if any.
pub fn cost(&self, key: ShapeKey) -> Option<Cost> {
self.chosen.get(&key).copied()
self.chosen.get(&key).map(|(c, _)| *c)
}
/// The tuned shapes and their winning costs, in deterministic key order — the
/// preimage a sealed tuning receipt commits to.
/// The meter `key`'s cost was recorded with, if it was recorded with one.
pub fn metered(&self, key: ShapeKey) -> Option<&Metered> {
self.chosen.get(&key).and_then(|(_, m)| m.as_ref())
}
/// The tuned shapes and their winning costs, in deterministic key order.
pub fn entries(&self) -> impl Iterator<Item = (&ShapeKey, &Cost)> {
self.chosen.iter()
self.chosen.iter().map(|(k, (c, _))| (k, c))
}
/// The tuned shapes, their winning costs and the meter each was recorded with, in
/// deterministic key order — the preimage a sealed tuning receipt commits to.
pub fn entries_metered(&self) -> impl Iterator<Item = (&ShapeKey, &Cost, Option<&Metered>)> {
self.chosen.iter().map(|(k, (c, m))| (k, c, m.as_ref()))
}
/// Number of tuned shapes.
@ -207,13 +331,22 @@ pub fn autotune_enabled() -> bool {
})
}
/// Record a measured cost into the process-global profile (the live tuner calls this).
/// Record a measured cost into the process-global profile with no record of its meter
/// ([`TuningProfile::record`]).
pub fn record_global(key: ShapeKey, cost: Cost) {
if let Ok(mut profile) = global_profile().lock() {
profile.record(key, cost);
}
}
/// Record a cost measured on `metered`'s meter into the process-global profile
/// ([`TuningProfile::record_metered`]); the live tuner calls this.
pub fn record_global_metered(key: ShapeKey, cost: Cost, metered: Metered) {
if let Ok(mut profile) = global_profile().lock() {
profile.record_metered(key, cost, metered);
}
}
/// The globally-tuned backend for `key`, if this shape has been tuned — the
/// dispatcher's hot-path lookup. `None` (untuned) ⇒ the caller keeps its heuristic.
pub fn decide_global(key: ShapeKey) -> Option<Backend> {
@ -221,7 +354,7 @@ pub fn decide_global(key: ShapeKey) -> Option<Backend> {
}
/// A deterministic snapshot of every globally-tuned shape and its winning cost —
/// for display or a sealed tuning receipt.
/// for display.
pub fn global_snapshot() -> Vec<(ShapeKey, Cost)> {
global_profile()
.lock()
@ -229,14 +362,26 @@ pub fn global_snapshot() -> Vec<(ShapeKey, Cost)> {
.unwrap_or_default()
}
/// [`global_snapshot`] with the meter each cost was recorded with — the preimage of a
/// sealed tuning receipt.
pub fn global_snapshot_metered() -> Vec<(ShapeKey, Cost, Option<Metered>)> {
global_profile()
.lock()
.map(|profile| profile.entries_metered().map(|(k, c, m)| (*k, *c, m.cloned())).collect())
.unwrap_or_default()
}
// ───────────────────────── sealing (the `attest` feature) ─────────────────────────
/// Seal the current global tuning profile as an **`autotune/1`** JCR-1 receipt
/// (COSE_Sign1 / Ed25519) — [`global_snapshot`] is the preimage.
/// (COSE_Sign1 / Ed25519) — [`global_snapshot_metered`] is the preimage.
///
/// The receipt commits to every tuned shape and its winning backend + measured
/// joules, so a dispatch decision ("this shape ran on the CPU because it measured
/// fewer joules there") becomes a verifiable artifact rather than a claim. Reuses
/// fewer joules there") becomes a verifiable artifact rather than a claim. Each shape
/// also says what its joules are: the `meter` that read them, its readings'
/// `energy_provenance` and their `domain` ([`Metered`]), or [`UNRECORDED`] in all
/// three (`"Unrecorded"` as the provenance) for a cost recorded with no meter. Reuses
/// jouleclaw's own `jouleclaw_jcr1` envelope — no new crypto, and it verifies with
/// every jouleclaw JCR-1 verifier. Energy is sealed as integer microjoules (JCR-1
/// forbids floats); the backend is a stable label.
@ -246,9 +391,13 @@ pub fn seal_profile(
steward: &str,
) -> Result<Vec<u8>, jouleclaw_jcr1::JcrError> {
use serde_json::json;
let shapes: Vec<serde_json::Value> = global_snapshot()
let shapes: Vec<serde_json::Value> = global_snapshot_metered()
.iter()
.map(|(key, cost)| {
.map(|(key, cost, metered)| {
let (meter, provenance, domain) = match metered {
Some(m) => (m.meter.as_str(), m.energy_provenance.as_str(), m.domain.as_str()),
None => (UNRECORDED, "Unrecorded", UNRECORDED),
};
json!({
"m_bucket": key.m_bucket,
"n": key.n,
@ -256,6 +405,9 @@ pub fn seal_profile(
"backend": cost.backend.label(),
"uj": cost.uj,
"ns": cost.ns,
"meter": meter,
"energy_provenance": provenance,
"domain": domain,
})
})
.collect();
@ -343,6 +495,128 @@ mod tests {
assert_eq!(p.cost(key).unwrap().uj, 600);
}
#[test]
fn a_zero_joule_cost_is_never_recorded() {
let key = ShapeKey::new(1, 32, 32);
let mut p = TuningProfile::new();
p.record(key, Cost { backend: Backend::Cpu, uj: 0, ns: 100 });
assert!(p.is_empty(), "the meter did not see the work: nothing to record");
// Nor does a zero displace a measured decision…
p.record(key, Cost { backend: Backend::Gpu, uj: 500, ns: 100 });
p.record(key, Cost { backend: Backend::Cpu, uj: 0, ns: 50 });
assert_eq!(p.cost(key), Some(Cost { backend: Backend::Gpu, uj: 500, ns: 100 }));
// …while a real, lower measurement still wins the shape.
p.record(key, Cost { backend: Backend::Cpu, uj: 300, ns: 900 });
assert_eq!(p.choose(key), Some(Backend::Cpu));
// The process-global profile goes through the same gate. Unique dims.
let key = ShapeKey::new(1, 999, 333);
record_global(key, Cost { backend: Backend::Cpu, uj: 0, ns: 5 });
assert_eq!(decide_global(key), None);
}
#[test]
fn tune_and_record_records_nothing_when_the_winner_drew_zero_joules() {
let key = ShapeKey::new(1, 48, 48);
let mut p = TuningProfile::new();
let chosen = p.tune_and_record(key, &[Backend::Gpu, Backend::Cpu], 1, |b| match b {
Backend::Gpu => (500, 1_000),
Backend::Cpu => (0, 1_500), // the CPU's window never registered on the meter
});
assert_eq!(chosen, None);
assert!(p.is_empty());
}
#[test]
fn an_unmeasured_candidate_voids_the_tune() {
// The meter saw the GPU's window but not the CPU's: comparing them would decide
// by a missing figure, so there is no decision at all.
let tuned = tune_measured(&[Backend::Gpu, Backend::Cpu], 1, |b| match b {
Backend::Gpu => Some((500, 1_000)),
Backend::Cpu => None,
});
assert_eq!(tuned, None);
// Every window measured: the ordinary joules-minimal tune, averaged over reps.
let tuned = tune_measured(&[Backend::Gpu, Backend::Cpu], 2, |b| match b {
Backend::Gpu => Some((500, 1_000)),
Backend::Cpu => Some((300, 1_500)),
});
assert_eq!(tuned, Some(Cost { backend: Backend::Cpu, uj: 300, ns: 1_500 }));
// A backend that cannot run the shape still never wins, even over reps.
let tuned = tune_measured(&[Backend::Gpu, Backend::Cpu], 2, |b| match b {
Backend::Gpu => Some((u64::MAX, u64::MAX)),
Backend::Cpu => Some((300, 1_500)),
});
assert_eq!(tuned.map(|c| c.backend), Some(Backend::Cpu));
}
fn hw(meter: &str) -> Metered {
Metered { meter: meter.into(), energy_provenance: "HwShunt".into(), domain: "SocTotal".into() }
}
fn graded(grade: &str) -> Metered {
Metered { meter: "m".into(), energy_provenance: grade.into(), domain: "SocTotal".into() }
}
/// A cost recorded with no meter — as every cost was before the IOReport counter
/// had to show its CPU rail live, when a frozen rail could meter CPU work as a
/// sliver of GPU energy — is replaced by a measurement on a recorded meter, even
/// one that drew more joules: the decision is re-made on the better evidence. It
/// never comes back: an unrecorded cost cannot displace a metered one.
#[test]
fn a_cost_recorded_without_its_meter_is_replaced_by_a_metered_one() {
let key = ShapeKey::new(1, 64, 96);
let mut p = TuningProfile::new();
p.record(key, Cost { backend: Backend::Cpu, uj: 5, ns: 100 });
assert_eq!((p.choose(key), p.metered(key)), (Some(Backend::Cpu), None));
p.record_metered(key, Cost { backend: Backend::Gpu, uj: 500, ns: 100 }, hw("apple-ioreport"));
assert_eq!(p.choose(key), Some(Backend::Gpu), "a metered measurement did not replace an unrecorded one");
assert_eq!(p.metered(key), Some(&hw("apple-ioreport")));
p.record(key, Cost { backend: Backend::Cpu, uj: 1, ns: 100 });
assert_eq!(p.choose(key), Some(Backend::Gpu), "an unrecorded cost displaced a metered one");
// Within a standing, joules-minimal as before: a lower hardware reading wins…
p.record_metered(key, Cost { backend: Backend::Cpu, uj: 300, ns: 900 }, hw("apple-ioreport"));
assert_eq!(p.cost(key), Some(Cost { backend: Backend::Cpu, uj: 300, ns: 900 }));
// …and a cheaper figure of lower standing does not.
p.record_metered(key, Cost { backend: Backend::Gpu, uj: 2, ns: 1 }, graded("Estimator"));
p.record_metered(key, Cost { backend: Backend::Gpu, uj: 2, ns: 1 }, graded("ModelBased"));
assert_eq!(p.cost(key), Some(Cost { backend: Backend::Cpu, uj: 300, ns: 900 }));
// Standings rank hardware > vendor model > estimate > no meter.
let key = ShapeKey::new(1, 64, 98);
let mut p = TuningProfile::new();
for (grade, uj) in [("Estimator", 900), ("ModelBased", 950), ("HwShunt", 990)] {
p.record_metered(key, Cost { backend: Backend::Cpu, uj, ns: 1 }, graded(grade));
assert_eq!(p.metered(key).map(|m| m.energy_provenance.as_str()), Some(grade), "{grade} did not take the shape");
}
}
/// A zero the shape holds — an older build recorded them, and nothing reads energy
/// as zero — is replaced by the next figure of any standing.
#[test]
fn a_zero_the_shape_holds_is_replaced() {
let key = ShapeKey::new(1, 64, 100);
for metered in [None, Some(hw("apple-ioreport"))] {
let mut p = TuningProfile::new();
p.chosen.insert(key, (Cost { backend: Backend::Cpu, uj: 0, ns: 100 }, metered.clone()));
p.record(key, Cost { backend: Backend::Gpu, uj: 700, ns: 100 });
assert_eq!(p.cost(key).map(|c| (c.backend, c.uj)), Some((Backend::Gpu, 700)), "held zero, meter {metered:?}");
}
}
/// A live IOReport counter's costs are recorded, and sealed, as hardware readings
/// over the SoC — the counter's own name, grade and domain; an estimator's as
/// estimates.
#[cfg(all(feature = "fabric-meter", target_os = "macos"))]
#[test]
fn a_meter_is_recorded_as_its_name_grade_and_domain() {
let ioreport = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
assert_eq!(Metered::of(&ioreport), hw("apple-ioreport"));
let estimator = jouleclaw_energy::meter::EstimationMeter::new(65.0);
assert_eq!(
Metered::of(&estimator),
Metered { meter: "estimation".into(), energy_provenance: "Estimator".into(), domain: "SocTotal".into() }
);
}
#[test]
fn global_profile_records_and_is_consulted() {
// Odd, unique dims so parallel tests never collide on the shared global.
@ -357,9 +631,12 @@ mod tests {
#[test]
fn seal_profile_produces_a_verifiable_autotune_receipt() {
use ed25519_dalek::SigningKey;
use serde_json::json;
// Unique dims so parallel tests don't collide on the shared global.
let key = ShapeKey::new(1, 4242, 2424);
record_global(key, Cost { backend: Backend::Gpu, uj: 6000, ns: 200_000 });
record_global_metered(key, Cost { backend: Backend::Gpu, uj: 6000, ns: 200_000 }, hw("apple-ioreport"));
let unrecorded = ShapeKey::new(1, 4243, 2425);
record_global(unrecorded, Cost { backend: Backend::Cpu, uj: 7000, ns: 200_000 });
let sk = SigningKey::from_bytes(&[9u8; 32]);
let steward = "did:jouleclaw:autotune-test";
let env = seal_profile(&sk, steward).unwrap();
@ -369,9 +646,20 @@ mod tests {
let payload = jouleclaw_jcr1::verify_cose_sign1(&env, steward, &pk).unwrap();
let v = jouleclaw_jcr1::cbor_to_json(&payload).unwrap();
assert_eq!(v["autotune_receipt"], "autotune/1");
assert!(v["shapes"].as_array().unwrap().iter().any(|s| {
s["n"] == 4242 && s["k"] == 2424 && s["backend"] == "gpu" && s["uj"] == 6000
}));
let shapes = v["shapes"].as_array().unwrap();
let ours = shapes.iter().find(|s| s["n"] == 4242 && s["k"] == 2424).expect("our shape is sealed");
assert_eq!((&ours["backend"], &ours["uj"]), (&json!("gpu"), &json!(6000)));
// What the joules are: the meter, its grade and its domain, in the signed core.
assert_eq!(
(&ours["meter"], &ours["energy_provenance"], &ours["domain"]),
(&json!("apple-ioreport"), &json!("HwShunt"), &json!("SocTotal"))
);
let theirs = shapes.iter().find(|s| s["n"] == 4243 && s["k"] == 2425).expect("the unrecorded shape is sealed");
assert_eq!(
(&theirs["meter"], &theirs["energy_provenance"], &theirs["domain"]),
(&json!(UNRECORDED), &json!("Unrecorded"), &json!(UNRECORDED)),
"a cost with no meter recorded is sealed as one"
);
// A flipped byte fails verification.
let mut tampered = env.clone();

View file

@ -336,6 +336,13 @@ mod enabled {
/// no matmul figure) — carries the calibration's
/// provenance, it is NOT a per-op measurement.
pub projected_uj: Option<f64>,
/// The grade of the figure that priced `projected_uj`
/// — `Measured`, `Benchmarked` or `Estimated`, as the
/// member's matmul cost was loaded — or `None` exactly
/// when `projected_uj` is. A projection is only as good
/// as its weakest priced input, so a consumer grading
/// one needs each input's grade, not just the sum.
pub projected_grade: Option<jouleclaw_fabric::EnergyProvenance>,
}
/// Snapshot the attribution ledger (per-member totals
@ -344,16 +351,13 @@ mod enabled {
let l = ledger().lock().unwrap();
l.iter()
.map(|(member, cell)| {
let projected_uj = fabric().and_then(|f| {
f.get(member)
.and_then(|m| m.energy.get("matmul"))
.map(|c| c.uj_per_gflop * cell.gflops)
});
let cost = fabric().and_then(|f| f.get(member).and_then(|m| m.energy.get("matmul")));
LedgerEntry {
member_id: member.to_string(),
ops: cell.ops,
gflops: cell.gflops,
projected_uj,
projected_uj: cost.map(|c| c.uj_per_gflop * cell.gflops),
projected_grade: cost.map(|c| c.provenance),
}
})
.collect()
@ -426,11 +430,19 @@ mod enabled {
/// Tune one shape **live** against the real backends: run `matmul_bf16_amx` and
/// `matmul_bf16_gpu` under the IOReport meter and record the **joules-minimal**
/// choice into the global profile. `a`/`w` are representative inputs of the
/// choice into the global profile, with the meter that measured it (its name, its
/// readings' grade — `HwShunt`, the counter being live — and its domain), so the
/// record outranks any cost the shape holds with no meter recorded. `a`/`w` are representative inputs of the
/// shape; a warmup pass tunes each shape a model uses, then the run is launched
/// with `JOULECLAW_ENERGY_AUTOTUNE=1`. Energy is honest marginal draw
/// (`ΔE − P_idle·Δt`), averaged over a `window_secs` loop past the counter's
/// update window. Returns the winning cost, or `None` if the meter is absent.
/// update window. Returns the winning cost, or `None` — recording nothing — when
/// the meter cannot measure: the IOReport counter is unavailable (including an
/// Apple Silicon host whose CPU energy rail does not advance, see
/// `jouleclaw_energy::ioreport_status`), a read fails, the idle baseline does not
/// advance, or either backend's window draws no energy above idle
/// ([`crate::marginal_energy`]). An unmeasured backend never wins, or loses, a
/// tune-off against a measured one.
#[cfg(feature = "fabric-meter")]
pub fn tune_shape_live(
a: &[f32],
@ -440,7 +452,8 @@ mod enabled {
k: usize,
window_secs: f64,
) -> Option<crate::energy_autotune::Cost> {
use crate::energy_autotune::{record_global, tune_shape, Backend, Cost, ShapeKey};
use crate::energy_autotune::{record_global_metered, tune_measured, Backend, Cost, Metered, ShapeKey};
use crate::marginal_energy::{idle_draw_uw, marginal_uj, per_op_uj};
use jouleclaw_energy::EnergyCounter;
use std::time::{Duration, Instant};
@ -448,27 +461,28 @@ mod enabled {
if !counter.is_available() {
return None;
}
let read = || counter.read().ok().map(|r| r.uj);
// Idle baseline, so we charge each kernel its *marginal* energy, not the
// whole SoC's — the same honest figure the mpp_energy example reports.
let idle_uw = {
let e0 = counter.read().ok()?.uj;
let e0 = read()?;
let t0 = Instant::now();
std::thread::sleep(Duration::from_millis(300));
let e1 = counter.read().ok()?.uj;
e1.saturating_sub(e0) as f64 / t0.elapsed().as_secs_f64().max(1e-9)
let e1 = read()?;
idle_draw_uw(e0, e1, t0.elapsed().as_secs_f64()).ok()?
};
let mut out = vec![0f32; m * n];
let mut measure = |backend: Backend| -> (u64, u64) {
let measure = |backend: Backend| -> Option<(u64, u64)> {
let run = |out: &mut [f32]| match backend {
Backend::Cpu => matmul_bf16_amx(a, w, &[], m, n, k, out),
Backend::Gpu => matmul_bf16_gpu(a, w, &[], m, n, k, out),
};
// A backend that refuses this shape can never win the tune-off.
if !run(&mut out) {
return (u64::MAX, u64::MAX);
return Some((u64::MAX, u64::MAX));
}
let e0 = counter.read().map(|r| r.uj).unwrap_or(0);
let e0 = read()?;
let t0 = Instant::now();
let mut ops = 0u64;
while t0.elapsed().as_secs_f64() < window_secs {
@ -476,14 +490,14 @@ mod enabled {
ops += 1;
}
let dt = t0.elapsed().as_secs_f64();
let e1 = counter.read().map(|r| r.uj).unwrap_or(e0);
let e1 = read()?;
let ops = ops.max(1);
let marginal = (e1.saturating_sub(e0) as f64 - idle_uw * dt).max(0.0);
((marginal / ops as f64) as u64, (dt / ops as f64 * 1e9) as u64)
let uj = marginal_uj(e0, e1, dt, idle_uw).and_then(|mj| per_op_uj(mj, ops)).ok()?;
Some((uj, (dt / ops as f64 * 1e9) as u64))
};
let cost: Cost = tune_shape(&[Backend::Cpu, Backend::Gpu], 1, &mut measure)?;
record_global(ShapeKey::new(m, n, k), cost);
let cost: Cost = tune_measured(&[Backend::Cpu, Backend::Gpu], 1, measure)?;
record_global_metered(ShapeKey::new(m, n, k), cost, Metered::of(&counter));
Some(cost)
}
@ -1379,6 +1393,37 @@ pub fn matmul_bf16_auto(
#[cfg(all(test, feature = "fabric", target_os = "macos", target_arch = "aarch64"))]
mod tests {
/// On this host, with the IOReport meter (`fabric-meter`): a live tune records a
/// shape only when the meter measured every backend's window, never a zero, and
/// what it returns is exactly what it recorded. Where the counter is declined —
/// macOS 27.0.1 on an M5 Max and an M3 Ultra, whose CPU energy rail does not
/// advance — it returns `None` and the global profile gains nothing for the shape.
/// It used to fall to `(ΔE − P_idle·Δt).max(0) = 0` µJ per op on a frozen rail and
/// record that zero, which then beat every later measurement.
#[cfg(feature = "fabric-meter")]
#[test]
fn a_live_tune_records_only_what_the_meter_measured() {
use crate::energy_autotune::{global_snapshot, ShapeKey};
let status = jouleclaw_energy::ioreport_status();
// Odd, unique dims so parallel tests never collide on the shared global profile.
let (m, n, k) = (1usize, 48usize, 80usize);
let a = vec![0.25f32; m * k];
let w: Vec<u8> = (0..n * k)
.flat_map(|i| crate::matmul_bf16::f32_to_bf16_rne(((i % 7) as f32 - 3.0) * 0.01).to_le_bytes())
.collect();
let tuned = super::tune_shape_live(&a, &w, m, n, k, 0.05);
let key = ShapeKey::new(m, n, k);
let recorded = global_snapshot().into_iter().find(|(s, _)| *s == key).map(|(_, c)| c);
eprintln!("tune_shape_live on this host: ioreport={status:?} tuned={tuned:?} recorded={recorded:?}");
assert_eq!(tuned, recorded, "a live tune records exactly what it returns");
if let Some(cost) = recorded {
assert!(cost.uj > 0 && cost.uj != u64::MAX, "recorded {cost:?}");
}
if status != jouleclaw_energy::IoReportStatus::Live {
assert_eq!(tuned, None, "the IOReport counter is declined ({status:?}), so nothing is measured");
}
}
/// The attribution ledger records every dispatch with
/// the member that actually ran it and the op's GFLOPs.
#[test]
@ -1400,6 +1445,11 @@ mod tests {
.expect("the m=1 dispatch is attributed to cpu-neon");
let expect_gf = 2.0 * (m * n * k) as f64 / 1e9;
assert!(neon.ops >= 1 && neon.gflops >= expect_gf - 1e-9);
// A projection and its grade come from the same cost row:
// both present, or both absent.
for e in &snap {
assert_eq!(e.projected_uj.is_some(), e.projected_grade.is_some(), "{e:?}");
}
}
/// Certification: the Metal GPU member (resident-weight

View file

@ -181,16 +181,14 @@ mod tests {
use std::io::Write;
fn aligned_temp_mmap(len: usize) -> memmap2::Mmap {
// A name unique within this process. A timestamp is not: it ticks in
// microseconds on macOS, and parallel tests get here at once, so two
// tests could get one file and truncate or delete it under each other.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let dir = std::env::temp_dir();
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = dir.join(format!(
"jouleclaw-gpu-overlay-{}-{}.bin",
pid, nanos
));
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!("jouleclaw-gpu-overlay-{pid}-{seq}.bin"));
let mut f = std::fs::OpenOptions::new()
.read(true)
.write(true)

View file

@ -38,6 +38,7 @@
pub mod mmap_advise;
pub mod cpu_prefetch;
pub mod energy_autotune;
pub mod marginal_energy;
pub mod huge_page;
pub mod io_uring_prefetch;
pub mod tier_store;
@ -1369,17 +1370,15 @@ mod prefetch_policy_tests {
use std::io::Write;
fn write_synth_gguf() -> std::path::PathBuf {
// A name unique within this process. A timestamp is not: it ticks in
// microseconds on macOS, and parallel tests get here at once, so two
// tests could get one file and truncate or delete it under each other.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let bytes = synthesize_llama_gguf(&SyntheticConfig::default());
let dir = std::env::temp_dir();
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = dir.join(format!(
"jouleclaw-prefetch-test-{}-{}.gguf",
pid, nanos
));
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!("jouleclaw-prefetch-test-{pid}-{seq}.gguf"));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(&bytes).unwrap();
f.sync_all().unwrap();

View file

@ -0,0 +1,412 @@
//! Marginal energy on a machine-wide cumulative energy counter, and when there is none.
//!
//! The live autotuner ([`crate::fabric_dispatch`]'s `tune_shape_live`) and the
//! calibration examples (`fabric_calibrate`, `mpp_energy`) price work the same way:
//! sample the counter across an idle window for the idle draw, then across a busy
//! window, and charge the work `ΔE_busy − P_idle·Δt`. That is a figure only if the
//! counter metered both windows. A counter that does not advance at idle is not
//! metering this machine (an SoC draws power doing nothing), and a busy window that
//! draws nothing above idle means the meter did not see the work. Either way there is
//! no figure, and nothing may be recorded from it: a zero, or a negative clamped to
//! zero, would be written down as a measurement the meter never made.
//!
//! Pure arithmetic over raw readings, so the refusals are unit-tested without hardware.
use std::fmt;
/// Why a marginal-energy measurement has no figure.
#[derive(Debug, Clone, PartialEq)]
pub enum NotMeasured {
/// The energy counter is not available on this host; the payload says why.
CounterUnavailable(String),
/// A read of the counter failed.
ReadFailed(String),
/// The cumulative counter went backwards across a window.
CounterWentBackwards {
/// Reading at the start of the window, µJ.
before_uj: u64,
/// Reading at the end of the window, µJ.
after_uj: u64,
},
/// A window with no duration, or no operations in it.
EmptyWindow,
/// The counter did not advance across the idle window.
IdleDidNotAdvance {
/// Length of the idle window, seconds.
secs: f64,
},
/// The busy window drew no energy above the idle draw.
NoMarginal {
/// `ΔE_busy − P_idle·Δt`, µJ — zero or negative.
marginal_uj: f64,
},
/// The marginal energy per operation is below one microjoule, so it rounds to 0.
BelowResolution {
/// Marginal energy of the whole window, µJ.
marginal_uj: f64,
/// Operations in the window.
ops: u64,
},
}
impl fmt::Display for NotMeasured {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CounterUnavailable(why) => write!(
f,
"the energy counter is not available on this host ({why})"
),
Self::ReadFailed(e) => write!(f, "a read of the energy counter failed: {e}"),
Self::CounterWentBackwards {
before_uj,
after_uj,
} => {
write!(
f,
"the cumulative counter went backwards ({before_uj} µJ, then {after_uj} µJ)"
)
}
Self::EmptyWindow => {
f.write_str("the measurement window has no duration or no operations")
}
Self::IdleDidNotAdvance { secs } => write!(
f,
"the counter did not advance across {secs:.2} s of idle, so it is not metering this machine"
),
Self::NoMarginal { marginal_uj } => write!(
f,
"the busy window drew {marginal_uj:.0} µJ above the idle draw: the meter did not see the work"
),
Self::BelowResolution { marginal_uj, ops } => {
write!(f, "{marginal_uj:.0} µJ over {ops} ops is below 1 µJ per op")
}
}
}
}
impl std::error::Error for NotMeasured {}
/// The energy a window drew, from the counter's reading at its start and end.
fn window_uj(before_uj: u64, after_uj: u64, secs: f64) -> Result<u64, NotMeasured> {
if !(secs.is_finite() && secs > 0.0) {
return Err(NotMeasured::EmptyWindow);
}
after_uj
.checked_sub(before_uj)
.ok_or(NotMeasured::CounterWentBackwards {
before_uj,
after_uj,
})
}
/// The idle draw in µW, from the counter's readings before and after `secs` of idle.
/// Refused when the counter did not advance.
pub fn idle_draw_uw(before_uj: u64, after_uj: u64, secs: f64) -> Result<f64, NotMeasured> {
match window_uj(before_uj, after_uj, secs)? {
0 => Err(NotMeasured::IdleDidNotAdvance { secs }),
delta => Ok(delta as f64 / secs),
}
}
/// The marginal energy of a busy window, `ΔE − idle_uw·secs`, in µJ. Refused unless it
/// is positive: never clamped to zero.
pub fn marginal_uj(
before_uj: u64,
after_uj: u64,
secs: f64,
idle_uw: f64,
) -> Result<f64, NotMeasured> {
let delta = window_uj(before_uj, after_uj, secs)?;
let marginal = delta as f64 - idle_uw * secs;
if marginal.is_finite() && marginal > 0.0 {
Ok(marginal)
} else {
Err(NotMeasured::NoMarginal {
marginal_uj: marginal,
})
}
}
/// Whole microjoules per operation of a window's marginal energy. Refused when it would
/// round to zero, so a per-op cost is never recorded as 0.
pub fn per_op_uj(marginal_uj: f64, ops: u64) -> Result<u64, NotMeasured> {
if ops == 0 {
return Err(NotMeasured::EmptyWindow);
}
let per_op = marginal_uj / ops as f64;
if !(per_op.is_finite() && per_op > 0.0) {
return Err(NotMeasured::NoMarginal { marginal_uj });
}
match per_op as u64 {
0 => Err(NotMeasured::BelowResolution { marginal_uj, ops }),
uj => Ok(uj),
}
}
/// The ratio of two marginal-energy figures, or `None` unless both are positive and
/// finite: a ratio against an unmeasured figure is undefined, not `NaN` or `inf`.
pub fn energy_ratio(numerator: f64, denominator: f64) -> Option<f64> {
let measured = |x: f64| x.is_finite() && x > 0.0;
(measured(numerator) && measured(denominator)).then(|| numerator / denominator)
}
/// What a calibration example prints before it exits non-zero having written nothing:
/// the file it refused to write, why the figure it needed is not a measurement, and
/// the IOReport status (`jouleclaw_energy::ioreport_status`), which says whether the
/// counter is metering this host at all and, if not, why. Every figure in that file is
/// loaded as a measurement, so a refusal must leave it untouched.
pub fn refusal(path: &str, why: impl fmt::Display, ioreport_status: impl fmt::Debug) -> String {
format!(
"refusing to write {path}: {why}\nIOReport status: {ioreport_status:?}; nothing was written \
(its figures are loaded as measurements)"
)
}
// ── Calibration rows (the `fabric-meter` feature) ──────────────────────────────
/// The fabric grade of a calibration figure read on a meter whose readings carry
/// `meter`. A hardware reading is `Measured` — and a counter that is not live produces
/// no reading at all, so `HwShunt` here means the meter was live for its domain. A
/// vendor-model reading is a recorded run on the member, not a measurement
/// (`Benchmarked`); an estimator's is `Estimated`. Never upgraded.
#[cfg(feature = "fabric-meter")]
pub fn calibration_grade(meter: jouleclaw_energy::Provenance) -> jouleclaw_fabric::EnergyProvenance {
use jouleclaw_energy::Provenance as Meter;
use jouleclaw_fabric::EnergyProvenance as Grade;
match meter {
Meter::HwShunt => Grade::Measured,
Meter::ModelBased => Grade::Benchmarked,
Meter::Estimator => Grade::Estimated,
}
}
/// A calibration row: `op` on `member` at `uj_per_gflop`, graded from the meter that
/// read it ([`calibration_grade`]), with `method`, and stamped with when it was taken
/// and on what OS and chip — so a row can be told apart from one taken on another
/// machine or OS, where the same counter may not be live.
#[cfg(feature = "fabric-meter")]
pub fn calibration_row(
member: &str,
op: &str,
uj_per_gflop: f64,
meter: jouleclaw_energy::Provenance,
method: String,
) -> jouleclaw_fabric::CalibrationEntry {
let date = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| rfc3339_utc(d.as_secs()));
jouleclaw_fabric::CalibrationEntry {
member_id: member.to_string(),
op: op.to_string(),
uj_per_gflop,
method,
provenance: Some(calibration_grade(meter)),
date,
os: host_os(),
chip: host_chip(),
}
}
/// The first line a command prints, trimmed, if it ran and printed one.
#[cfg(feature = "fabric-meter")]
fn command_line(program: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(program).args(args).output().ok()?;
if !out.status.success() {
return None;
}
let line = String::from_utf8(out.stdout).ok()?.lines().next()?.trim().to_string();
(!line.is_empty()).then_some(line)
}
/// This host's OS and version, e.g. `"macOS 27.0.1 (26A434)"`; elsewhere the OS name.
#[cfg(feature = "fabric-meter")]
fn host_os() -> Option<String> {
if cfg!(target_os = "macos") {
let name = command_line("sw_vers", &["-productName"])?;
let version = command_line("sw_vers", &["-productVersion"])?;
Some(match command_line("sw_vers", &["-buildVersion"]) {
Some(build) => format!("{name} {version} ({build})"),
None => format!("{name} {version}"),
})
} else {
Some(std::env::consts::OS.to_string())
}
}
/// This host's chip, e.g. `"Apple M5 Max"`, where the OS names it.
#[cfg(feature = "fabric-meter")]
fn host_chip() -> Option<String> {
if cfg!(target_os = "macos") {
command_line("sysctl", &["-n", "machdep.cpu.brand_string"])
} else {
None
}
}
/// `secs` since the Unix epoch as an RFC 3339 UTC timestamp (proleptic Gregorian).
#[cfg(any(test, feature = "fabric-meter"))]
fn rfc3339_utc(secs: u64) -> String {
let (days, rem) = (secs / 86_400, secs % 86_400);
// Civil date from days since 1970-01-01 (Howard Hinnant's `civil_from_days`).
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
rem / 3_600,
rem % 3_600 / 60,
rem % 60
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_counter_that_holds_at_idle_has_no_idle_baseline() {
// A frozen rail reads the same before and after: not metering, so no baseline.
assert_eq!(
idle_draw_uw(480_083_160, 480_083_160, 3.0),
Err(NotMeasured::IdleDidNotAdvance { secs: 3.0 })
);
assert_eq!(
idle_draw_uw(2_000, 1_000, 3.0),
Err(NotMeasured::CounterWentBackwards {
before_uj: 2_000,
after_uj: 1_000
})
);
assert_eq!(idle_draw_uw(0, 5, 0.0), Err(NotMeasured::EmptyWindow));
// A live counter: 6 J over 3 s is a 2 W idle draw.
assert_eq!(idle_draw_uw(1_000, 6_001_000, 3.0), Ok(2_000_000.0));
}
#[test]
fn a_busy_window_at_or_below_idle_is_refused_never_clamped_to_zero() {
let idle_uw = 2_000_000.0; // 2 W
// 10 J over 2 s at a 2 W idle: 6 J of marginal energy.
assert_eq!(marginal_uj(0, 10_000_000, 2.0, idle_uw), Ok(6_000_000.0));
// Exactly idle, below idle, and a counter that did not move at all: no figure.
assert_eq!(
marginal_uj(0, 4_000_000, 2.0, idle_uw),
Err(NotMeasured::NoMarginal { marginal_uj: 0.0 })
);
assert_eq!(
marginal_uj(0, 3_000_000, 2.0, idle_uw),
Err(NotMeasured::NoMarginal {
marginal_uj: -1_000_000.0
})
);
assert_eq!(
marginal_uj(7, 7, 2.0, idle_uw),
Err(NotMeasured::NoMarginal {
marginal_uj: -4_000_000.0
})
);
assert!(matches!(
marginal_uj(0, 10_000_000, 2.0, f64::NAN),
Err(NotMeasured::NoMarginal { .. })
));
assert_eq!(
marginal_uj(9, 8, 2.0, idle_uw),
Err(NotMeasured::CounterWentBackwards {
before_uj: 9,
after_uj: 8
})
);
}
#[test]
fn a_per_op_cost_never_rounds_to_zero() {
assert_eq!(per_op_uj(6_000_000.0, 1_000), Ok(6_000));
assert_eq!(
per_op_uj(500.0, 1_000),
Err(NotMeasured::BelowResolution {
marginal_uj: 500.0,
ops: 1_000
})
);
assert_eq!(per_op_uj(6_000_000.0, 0), Err(NotMeasured::EmptyWindow));
assert_eq!(
per_op_uj(0.0, 10),
Err(NotMeasured::NoMarginal { marginal_uj: 0.0 })
);
}
#[test]
fn the_cpu_gpu_ratio_is_undefined_unless_both_are_measured() {
assert_eq!(energy_ratio(6.0, 3.0), Some(2.0));
// Frozen rails left both marginals at zero: the old ratio was 0/0 = NaN.
assert_eq!(energy_ratio(0.0, 0.0), None);
assert_eq!(energy_ratio(5.0, 0.0), None, "was +inf");
assert_eq!(energy_ratio(-1.0, 3.0), None);
assert_eq!(energy_ratio(f64::NAN, 3.0), None);
}
#[test]
fn a_refusal_names_the_file_the_reason_and_the_ioreport_status() {
#[derive(Debug)]
#[allow(dead_code)]
enum Status {
CpuRailFrozen,
}
let msg = refusal(
"/tmp/x/fabric-calibration.json",
NotMeasured::IdleDidNotAdvance { secs: 3.0 },
Status::CpuRailFrozen,
);
assert!(msg.starts_with("refusing to write /tmp/x/fabric-calibration.json: "), "{msg}");
assert!(msg.contains("did not advance across 3.00 s of idle"), "{msg}");
assert!(msg.contains("IOReport status: CpuRailFrozen"), "{msg}");
assert!(msg.contains("nothing was written"), "{msg}");
}
#[test]
fn a_refusal_says_why() {
let why = NotMeasured::CounterUnavailable("IOReport: CpuRailFrozen".into()).to_string();
assert!(
why.contains("not available") && why.contains("CpuRailFrozen"),
"{why}"
);
let why = NotMeasured::NoMarginal { marginal_uj: -12.0 }.to_string();
assert!(why.contains("did not see the work"), "{why}");
}
#[test]
fn a_calibration_row_is_dated_in_rfc3339_utc() {
assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339_utc(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(rfc3339_utc(1_790_000_000), "2026-09-21T14:13:20Z");
assert_eq!(rfc3339_utc(4_102_444_799), "2099-12-31T23:59:59Z");
}
/// Only a hardware reading becomes a `Measured` row; nothing is upgraded.
#[cfg(feature = "fabric-meter")]
#[test]
fn only_a_hardware_reading_is_a_measured_calibration_row() {
use jouleclaw_energy::Provenance as Meter;
use jouleclaw_fabric::EnergyProvenance as Grade;
assert_eq!(calibration_grade(Meter::HwShunt), Grade::Measured);
assert_eq!(calibration_grade(Meter::ModelBased), Grade::Benchmarked);
assert_eq!(calibration_grade(Meter::Estimator), Grade::Estimated);
let row = calibration_row("local/amx", "matmul", 7.5, Meter::Estimator, "m".into());
assert_eq!(
(row.member_id.as_str(), row.op.as_str(), row.uj_per_gflop, row.provenance, row.method.as_str()),
("local/amx", "matmul", 7.5, Some(Grade::Estimated), "m")
);
assert!(row.date.as_deref().is_some_and(|d| d.len() == 20 && d.ends_with('Z')), "{:?}", row.date);
if cfg!(target_os = "macos") {
assert!(row.os.as_deref().is_some_and(|os| os.starts_with("macOS ")), "{:?}", row.os);
assert!(row.chip.is_some(), "the chip was not recorded");
}
eprintln!("a calibration row on this host: date={:?} os={:?} chip={:?}", row.date, row.os, row.chip);
}
}

View file

@ -253,16 +253,14 @@ mod tests {
fn tempfile_compat() -> std::fs::File {
// Minimal tempfile shim — keep dep surface tiny.
// A name unique within this process. A timestamp is not: it ticks in
// microseconds on macOS, and parallel tests get here at once, so two
// tests could get one file and truncate or delete it under each other.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let dir = std::env::temp_dir();
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = dir.join(format!(
"jouleclaw-mmap-advise-{}-{}.bin",
pid, nanos
));
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!("jouleclaw-mmap-advise-{pid}-{seq}.bin"));
std::fs::OpenOptions::new()
.read(true)
.write(true)

View file

@ -467,12 +467,13 @@ mod tests {
// promote/demote helpers exercise the lookup path.
let bytes = synthesize_llama_gguf(&SyntheticConfig::default());
let dir = std::env::temp_dir();
// A name unique within this process. A timestamp is not: it ticks in
// microseconds on macOS, and parallel tests get here at once, so two
// tests could get one file and truncate or delete it under each other.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = dir.join(format!("jouleclaw-moe-test-{pid}-{nanos}.gguf"));
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!("jouleclaw-moe-test-{pid}-{seq}.gguf"));
std::fs::write(&path, &bytes).unwrap();
let model = crate::read_gguf_file(&path).unwrap();
let data_offset = match &model.buf {

View file

@ -716,17 +716,15 @@ mod tests {
use std::io::Write;
fn write_synth_gguf() -> PathBuf {
// A name unique within this process. A timestamp is not: it ticks in
// microseconds on macOS, and parallel tests get here at once, so two
// tests could get one file and truncate or delete it under each other.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let bytes = synthesize_llama_gguf(&SyntheticConfig::default());
let dir = std::env::temp_dir();
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = dir.join(format!(
"jouleclaw-tier-store-{}-{}.gguf",
pid, nanos
));
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!("jouleclaw-tier-store-{pid}-{seq}.gguf"));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(&bytes).unwrap();
f.sync_all().unwrap();

View file

@ -114,9 +114,12 @@ impl<I: LocalIndex> LocalIndexTier<I> {
/// The L1 envelope is a JouleClaw static cost model — the donor
/// measured ~890 µJ on its tantivy reference path but no hardware
/// shunt is sampled here, so [`Provenance::Estimator`] is the
/// honest label. Consumers whose backend exposes a real shunt
/// (NVML, IOReport) SHOULD surface that separately through the
/// `jouleclaw-prov` receipt path.
/// honest label. Consumers whose backend reads a vendor energy
/// counter (RAPL, NVML, Apple IOReport) SHOULD surface that
/// reading separately through the `jouleclaw-prov` receipt path,
/// graded as a hardware reading only while the counter is live for
/// the domain the figure claims (JouleClaw SPEC §1.3); a counter
/// that is present but not live gives no hardware-graded figure.
pub const fn provenance() -> Provenance {
Provenance::Estimator
}

View file

@ -112,9 +112,12 @@ impl<R: Reranker> RerankTier<R> {
/// The L2.5 envelope is derived from the reranker's self-report
/// (`typical_joules_per_doc * doc_count`), not from a hardware
/// shunt sampled by JouleClaw, so [`Provenance::Estimator`] is the
/// honest label. Consumers whose backend exposes a real shunt
/// (NVML, IOReport) SHOULD surface that separately through the
/// `jouleclaw-prov` receipt path.
/// honest label. Consumers whose backend reads a vendor energy
/// counter (RAPL, NVML, Apple IOReport) SHOULD surface that
/// reading separately through the `jouleclaw-prov` receipt path,
/// graded as a hardware reading only while the counter is live for
/// the domain the figure claims (JouleClaw SPEC §1.3); a counter
/// that is present but not live gives no hardware-graded figure.
pub const fn provenance() -> Provenance {
Provenance::Estimator
}

View file

@ -1262,6 +1262,7 @@ dependencies = [
name = "jouledesk-github"
version = "0.1.0"
dependencies = [
"jcp-efficiency",
"jouleclaw-energy",
"jouledesk",
"serde_json",

View file

@ -17,8 +17,13 @@ path = "src/main.rs"
[dependencies]
jouledesk = { path = "../jouledesk" }
# Real, measured energy for the work — Apple-Silicon IOReport on this box.
# The host's best energy meter: Apple-Silicon IOReport where its CPU rail is live,
# else the load-average estimator (sealed and posted as an estimate).
jouleclaw-energy = { path = "../../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy", default-features = false, features = ["ioreport"] }
serde_json = "1"
ureq = { version = "2", features = ["json"] }
tiny_http = "0.12"
[dev-dependencies]
# Tests read the sealed efficiency/1 receipt back to check its provenance.
jcp-efficiency = { path = "../../../../jcp/jcp-rs/crates/jcp-efficiency" }

View file

@ -2,13 +2,20 @@
//!
//! The chat/forge transforms in [`jouledesk::surface`] turn an event into a reply body
//! offline. This crate closes the loop: it takes a real `@joule` mention, **does the
//! work with a real model under a real energy meter**, **seals a verifiable
//! `efficiency/1` at the measured cost**, and **posts the reply back through the GitHub
//! API**. So the joules in the receipt are measured on the machine that did the work,
//! not asserted — and a human can actually tag `@joule` and get an accountable answer.
//! work with a real model under the best energy meter this host exposes**, **seals a
//! verifiable `efficiency/1` at that meter's figure and provenance**, and **posts the
//! reply back through the GitHub API**. The joules in the receipt come from the machine
//! that did the work, not a constant: measured where the host exposes a live counter,
//! estimated — and sealed and posted as an estimate — where it does not (Apple Silicon
//! whose IOReport CPU rail does not advance, for one). A human can actually tag `@joule`
//! and get an accountable answer.
use std::time::{Duration, Instant};
use jouleclaw_energy::meter::CompositeMeter;
use jouleclaw_energy::{EnergyReading, Provenance};
use jouledesk::surface::{parse_event, Surface};
use jouledesk::{Skill, SkillOutcome, TagOutcome, Teammate};
use jouledesk::{EnergyProvenance, Skill, SkillOutcome, TagOutcome, Teammate};
use serde_json::{json, Value};
/// The Slack binding — the same core ([`do_work`] + [`seal`]) on a second surface.
@ -18,61 +25,176 @@ pub mod slack;
const DEFAULT_MODEL: &str =
"/Volumes/macos_4TB_external_1/neuronexus_ai/model_library/gemma-4-E2B-q4_k_m.gguf";
/// The work product: a real answer and the **real energy it cost**, measured on this
/// machine (not a constant).
/// The model the public entry points ([`do_work`], [`handle`], [`slack::handle`]) run:
/// `JOULEDESK_MODEL`, else [`DEFAULT_MODEL`]. Read once, at the entry point, and passed
/// down, so the model that runs is the model the reply names.
#[cfg(not(test))]
fn configured_model() -> String {
std::env::var("JOULEDESK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into())
}
/// Unit tests pass the model path themselves. None may read `JOULEDESK_MODEL` — a
/// process-global that tests running in parallel would race on — or reach the default
/// model, which runs a real model through `llama-completion` on a host that has it.
#[cfg(test)]
fn configured_model() -> String {
panic!("a unit test reached the configured model ({DEFAULT_MODEL} unless JOULEDESK_MODEL); pass a model path")
}
/// The work product: a real answer and the energy this machine's meter puts on it (not
/// a constant) — a reading or an estimate, as `provenance` says.
pub struct Work {
pub answer: String,
/// The meter's figure for the work, in µJ: the delta between its readings before
/// and after, as read, with no floor. A measurement only when `provenance` says so.
/// `0` and meaningless when `provenance` is `unmetered`; such work is never sealed.
pub measured_uj: u64,
/// The provenance of the meter's readings (`hw_shunt`, `model_based`, `estimator`),
/// or `unmetered` when the meter produced no figure for the work.
pub provenance: String,
/// The meter that actually ran (`apple-ioreport`, `apple-silicon`, `estimation`…).
pub meter: String,
pub model: String,
/// Why the meter produced no figure for the work, when `provenance` is `unmetered`.
pub unmetered: Option<String>,
}
/// Do the actual work for a mention — generate an answer with a real model, with a real
/// hardware energy counter read around it so the cost is measured.
/// `Work::provenance` for work whose meter produced no figure for it.
const UNMETERED: &str = "unmetered";
/// Do the actual work for a mention — generate an answer with a real model, with the
/// host's best energy meter read around it: measured where the host exposes a live
/// counter, estimated where it does not, and the provenance says which.
pub fn do_work(question: &str) -> Work {
do_work_with(question, &configured_model())
}
/// [`do_work`] with the model given as the path to a local GGUF; a path that does not
/// exist answers with the deterministic fallback, and runs no subprocess.
pub(crate) fn do_work_with(question: &str, model: &str) -> Work {
let meter = jouleclaw_energy::meter::detect_meter();
let before = meter.read().ok().map(|r| r.uj);
let (answer, model) = match run_model(question) {
Some(a) => (a, model_label()),
let ((answer, model), figure) = metered(&meter, || match run_model(question, model) {
Some(a) => (a, model_label(model)),
None => (fallback_answer(question), "deterministic-extract".to_string()),
});
let (measured_uj, provenance, unmetered) = match figure {
Ok((uj, provenance)) => (uj, provenance.to_string(), None),
Err(why) => (0, UNMETERED.to_string(), Some(why)),
};
let after = meter.read().ok().map(|r| r.uj);
let res = meter.active().map(|m| m.resolution_uj()).unwrap_or(1000);
let provenance = meter
.active()
.map(|m| m.provenance().to_string())
.unwrap_or_else(|| "estimator".into());
// A cumulative counter: the work's cost is the delta, floored to what the meter can
// resolve so we never seal below the counter's honest resolution.
let measured_uj = match (before, after) {
(Some(a), Some(b)) if b >= a => (b - a).max(res),
_ => res,
};
Work { answer, measured_uj, provenance, meter: meter.active_name().to_string(), model }
Work { answer, measured_uj, provenance, meter: meter.active_name().to_string(), model, unmetered }
}
fn model_label() -> String {
std::env::var("JOULEDESK_MODEL")
.unwrap_or_else(|_| DEFAULT_MODEL.into())
.rsplit('/')
.next()
.unwrap_or("model")
.to_string()
/// Run `work` under `meter`: its result, and the meter's figure for it (see
/// [`window_energy`]) or why there is none.
fn metered<T>(meter: &CompositeMeter, work: impl FnOnce() -> T) -> (T, Result<(u64, Provenance), String>) {
let min_window = Duration::from_nanos(meter.active().map_or(u64::MAX, |m| m.min_window_ns()));
let started = Instant::now();
let before = meter.read().ok();
let out = work();
let after = meter.read().ok();
let figure = window_energy(before.as_ref(), after.as_ref(), started.elapsed(), min_window);
(out, figure)
}
/// Generate an answer with a local model via `llama-completion` (the reference model
/// hook — swap for Mesh / JouleClaw in production). `None` if no model is configured.
fn run_model(question: &str) -> Option<String> {
let model = std::env::var("JOULEDESK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
if !std::path::Path::new(&model).exists() {
/// What a window of work cost, from a cumulative meter's readings before and after it:
/// the delta, with the provenance those readings carry — or why there is no honest
/// figure: a read failed, the two readings disagree about provenance, the counter went
/// backwards, the window was shorter than the meter says it can resolve
/// (`min_window_ns`), or the meter registered no energy at all across the work. The
/// delta is never floored to the meter's resolution (a floor fabricates energy the meter
/// did not report), and nothing becomes a zero: no work runs on zero energy, so a zero
/// means the meter did not see the work.
fn window_energy(
before: Option<&EnergyReading>,
after: Option<&EnergyReading>,
window: Duration,
min_window: Duration,
) -> Result<(u64, Provenance), String> {
let (Some(before), Some(after)) = (before, after) else {
return Err("a read of it failed".to_string());
};
if before.provenance != after.provenance {
return Err(format!(
"its readings disagree about provenance ({} then {})",
before.provenance, after.provenance
));
}
let Some(uj) = after.uj.checked_sub(before.uj) else {
return Err(format!("its cumulative reading went backwards ({} µJ, then {} µJ)", before.uj, after.uj));
};
if window < min_window {
return Err(format!(
"the work took {}, shorter than the {} window it can resolve",
span(window),
span(min_window)
));
}
if uj == 0 {
return Err(format!("it registered no energy across {} of work", span(window)));
}
Ok((uj, after.provenance))
}
/// A duration as a reply states it: µs below a millisecond, else ms.
fn span(d: Duration) -> String {
if d < Duration::from_millis(1) {
format!("{} µs", d.as_micros())
} else if d.subsec_nanos().is_multiple_of(1_000_000) {
format!("{} ms", d.as_millis())
} else {
format!("{:.1} ms", d.as_secs_f64() * 1e3)
}
}
/// The provenance `work`'s energy is sealed at: its meter's, as the ledger schema names
/// it. `None` for work with no figure, which has nothing to seal.
fn ledger_provenance(work: &Work) -> Option<EnergyProvenance> {
[Provenance::HwShunt, Provenance::ModelBased, Provenance::Estimator]
.into_iter()
.find(|p| p.to_string() == work.provenance)
.map(|p| match p {
Provenance::HwShunt => EnergyProvenance::HwShunt,
Provenance::ModelBased => EnergyProvenance::ModelBased,
Provenance::Estimator => EnergyProvenance::Estimator,
})
}
/// How `work`'s energy figure was obtained, as a reply states it: a reading was
/// "measured via" its meter, an estimate "estimated by" it. `None` for work with no
/// figure, which a reply never shows.
fn obtained(work: &Work) -> Option<&'static str> {
match ledger_provenance(work)? {
EnergyProvenance::Estimator => Some("estimated by"),
EnergyProvenance::HwShunt | EnergyProvenance::ModelBased => Some("measured via"),
}
}
/// A reply's energy line for work the meter put no figure on, saying why.
fn unmetered_line(work: &Work) -> String {
let why = work.unmetered.as_deref().unwrap_or("it produced no figure");
format!("not metered — `{}`: {why}; no energy figure was sealed", work.meter)
}
/// The outcome of work that was not sealed because its meter put no figure on it.
const NOT_SEALED: &str = "not sealed (no energy figure)";
/// The model a reply names: the file name of its path.
fn model_label(model: &str) -> String {
model.rsplit('/').next().unwrap_or("model").to_string()
}
/// Generate an answer with the local model at `model` via `llama-completion` (the
/// reference model hook — swap for Mesh / JouleClaw in production). `None` if there is
/// no model file there.
fn run_model(question: &str, model: &str) -> Option<String> {
if !std::path::Path::new(model).exists() {
return None;
}
let bin = std::env::var("JOULEDESK_LLAMA").unwrap_or_else(|_| "llama-completion".into());
let prompt = format!("<start_of_turn>user\n{question}<end_of_turn>\n<start_of_turn>model\n");
let out = std::process::Command::new(&bin)
.args([
"-m", &model, "-p", &prompt, "-n", "160", "--temp", "0.2", "-r", "<end_of_turn>",
"-m", model, "-p", &prompt, "-n", "160", "--temp", "0.2", "-r", "<end_of_turn>",
"--no-display-prompt",
])
.output()
@ -93,19 +215,25 @@ fn fallback_answer(question: &str) -> String {
}
/// The result of handling one mention: who to address, the reply body to post, and the
/// accountable facts (sealed decision + measured energy + verification).
/// accountable facts (sealed decision + metered energy + verification).
pub struct Handled {
pub who: String,
pub body: String,
pub outcome: String,
/// [`Work::measured_uj`]: `0` and meaningless when the work was not sealed.
pub measured_uj: u64,
pub verified: bool,
}
/// Handle one GitHub issue-comment event (`{ "comment": {…} }`): parse the mention, do
/// the work under the meter, seal it through the teammate loop at the **measured** cost,
/// and compose the reply body. `None` if the comment does not address `handle`.
/// the work under the meter, seal it through the teammate loop at the meter's figure and
/// provenance, and compose the reply body. `None` if the comment does not address `handle`.
pub fn handle(event: &Value, handle: &str) -> Option<Handled> {
handle_with(event, handle, &configured_model())
}
/// [`handle`] with the model given as the path to a local GGUF (see [`do_work_with`]).
pub(crate) fn handle_with(event: &Value, handle: &str, model: &str) -> Option<Handled> {
let mention = parse_event(Surface::GitHub, event)?;
if !mention.text.contains(handle) {
return None;
@ -114,24 +242,21 @@ pub fn handle(event: &Value, handle: &str) -> Option<Handled> {
let question =
if question.is_empty() { "Please help with this thread.".into() } else { question };
// real work + real measured energy → seal at the measured cost → compose.
let work = do_work(&question);
// real work + metered energy → seal at the meter's figure and provenance → compose.
let work = do_work_with(&question, model);
let (outcome_str, verified) = seal(&work, &question);
let body = compose_github(&mention.who, &work, &outcome_str, verified);
Some(Handled { who: mention.who, body, outcome: outcome_str, measured_uj: work.measured_uj, verified })
}
/// Seal the work through the teammate loop at the **measured** cost — shared by every
/// surface binding. Returns (outcome, ledger-verified).
/// Seal the work through the teammate loop at the meter's figure, labelled with the
/// meter's provenance — shared by every surface binding. Returns (outcome,
/// ledger-verified). Work its meter put no figure on is not sealed: there is no figure to
/// commit, and none is invented.
pub fn seal(work: &Work, intent: &str) -> (String, bool) {
let mut t = Teammate::new();
let cost = work.measured_uj;
let kind = "answer";
let _ = t.grant_skill(
Skill::new("responder", &[kind], cost, move |_| SkillOutcome::done(cost)),
u64::MAX,
);
let outcome = t.tag(kind, intent);
let Some((t, outcome)) = seal_work(work, intent) else {
return (NOT_SEALED.to_string(), false);
};
let verified = t.verify_ledger().is_ok();
let outcome_str = match &outcome {
TagOutcome::Done => "completed".to_string(),
@ -141,6 +266,22 @@ pub fn seal(work: &Work, intent: &str) -> (String, bool) {
(outcome_str, verified)
}
/// The teammate that sealed `work`, and the tag's outcome: the work's figure committed as
/// the skill's cost, its `efficiency/1` sealed at the meter's provenance. `None` for work
/// its meter put no figure on.
fn seal_work(work: &Work, intent: &str) -> Option<(Teammate, TagOutcome)> {
let provenance = ledger_provenance(work)?;
let mut t = Teammate::new();
let cost = work.measured_uj;
let kind = "answer";
let _ = t.grant_skill(
Skill::new("responder", &[kind], cost, move |_| SkillOutcome::done(cost)),
u64::MAX,
);
let outcome = t.tag_with_provenance(kind, intent, provenance);
Some((t, outcome))
}
fn compose_github(who: &str, work: &Work, outcome: &str, verified: bool) -> String {
let mj = work.measured_uj as f64 / 1000.0;
let answer = &work.answer;
@ -149,12 +290,26 @@ fn compose_github(who: &str, work: &Work, outcome: &str, verified: bool) -> Stri
let meter = &work.meter;
let prov = &work.provenance;
let verify = if verified { "verified ✓" } else { "UNVERIFIED ✗" };
// Work with no figure was never put to a grant and never reached a ledger: say so,
// rather than name a grant decision or a ledger that does not exist.
let (how_done, energy, ledger) = match obtained(work) {
Some(how) => (
"Decided under a signed grant, worked on real hardware, sealed to a ledger anyone can verify",
format!("{mj:.1} mJ ({uj} µJ) — {how} `{meter}`, provenance `{prov}`"),
format!("ledger {verify}"),
),
None => (
"Worked on real hardware; not metered, so no grant decided it and nothing was sealed",
unmetered_line(work),
"no ledger entry".to_string(),
),
};
format!(
"🔷 **@joule** — {outcome}, @{who}.\n\n{answer}\n\n---\n\
_Decided under a signed grant, worked on real hardware, sealed to a ledger anyone can verify._\n\
_{how_done}._\n\
- **model:** `{model}`\n\
- **energy:** {mj:.1} mJ ({uj} µJ) — measured via `{meter}`, provenance `{prov}`\n\
- **decision:** {outcome} · ledger {verify}"
- **energy:** {energy}\n\
- **decision:** {outcome} · {ledger}"
)
}
@ -235,25 +390,225 @@ fn describe(e: ureq::Error) -> String {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use jouleclaw_energy::{EnergyDomain, IoReportStatus};
fn reading(uj: u64, provenance: Provenance) -> EnergyReading {
EnergyReading { uj, timestamp_ns: 0, domain: EnergyDomain::SocTotal, provenance }
}
const SECOND: Duration = Duration::from_secs(1);
/// A model path with no file behind it: the work is the deterministic fallback and
/// no subprocess runs. Tests pass it explicitly; none reads `JOULEDESK_MODEL`.
pub(crate) const NO_MODEL: &str = "/nonexistent.gguf";
/// The Apple estimator's declared minimum window.
const ESTIMATOR_WINDOW: Duration = Duration::from_millis(100);
/// Work as `do_work` would produce it under a meter named `meter`.
pub(crate) fn work(uj: u64, provenance: &str, meter: &str) -> Work {
Work {
answer: "an answer".into(),
measured_uj: uj,
provenance: provenance.into(),
meter: meter.into(),
model: "a-model".into(),
unmetered: (provenance == UNMETERED)
.then(|| "the work took 200 µs, shorter than the 100 ms window it can resolve".into()),
}
}
/// The provenance label and µJ sealed into `work`'s one `efficiency/1` receipt.
fn sealed(work: &Work) -> (String, u64) {
let (t, outcome) = seal_work(work, "a question").expect("work with a figure is sealed");
assert_eq!(outcome, TagOutcome::Done);
let recs = jcp_efficiency::verify_chain(t.efficiency_ledger(), t.steward(), &t.steward_pubkey())
.expect("the energy ledger verifies");
assert_eq!(recs.len(), 1);
(recs[0].provenance.clone(), recs[0].energy_uj)
}
#[test]
fn handles_a_mention_and_seals_measured_energy() {
std::env::set_var("JOULEDESK_MODEL", "/nonexistent.gguf"); // force the fallback (no subprocess)
fn a_figure_is_the_delta_as_read_never_floored() {
// Both Apple meters resolve 1000 µJ; a 250 µJ delta over a window the meter can
// resolve stays 250 — it is not raised to the resolution.
let est = Provenance::Estimator;
let fig = |b, a, p| window_energy(Some(&reading(b, p)), Some(&reading(a, p)), SECOND, ESTIMATOR_WINDOW);
assert_eq!(fig(5_000, 5_250, est), Ok((250, est)));
let mb = Provenance::ModelBased;
assert_eq!(fig(7, 80_007, mb), Ok((80_000, mb)));
}
#[test]
fn a_window_without_two_good_readings_has_no_figure() {
let est = Provenance::Estimator;
let fig = |b: Option<EnergyReading>, a: Option<EnergyReading>| {
window_energy(b.as_ref(), a.as_ref(), SECOND, ESTIMATOR_WINDOW)
};
let failed = Err("a read of it failed".to_string());
assert_eq!(fig(None, Some(reading(5_000, est))), failed, "the first read failed");
assert_eq!(fig(Some(reading(5_000, est)), None), failed, "the second read failed");
assert_eq!(fig(None, None), failed);
let backwards = fig(Some(reading(5_000, est)), Some(reading(4_000, est))).unwrap_err();
assert!(backwards.contains("went backwards"), "{backwards}");
let disagree = fig(Some(reading(5_000, Provenance::ModelBased)), Some(reading(9_000, est))).unwrap_err();
assert!(disagree.contains("disagree about provenance"), "{disagree}");
}
/// A window shorter than the meter can resolve has no figure, whatever the delta;
/// a meter that registered nothing has none either. Neither becomes a zero.
#[test]
fn a_window_the_meter_cannot_resolve_is_never_a_zero() {
let est = Provenance::Estimator;
let short = Duration::from_micros(200);
let why = window_energy(Some(&reading(5_000, est)), Some(&reading(5_000, est)), short, ESTIMATOR_WINDOW)
.unwrap_err();
assert_eq!(why, "the work took 200 µs, shorter than the 100 ms window it can resolve");
// A step landing in a window too short to resolve is not a figure for the work.
let why = window_energy(Some(&reading(5_000, est)), Some(&reading(20_000, est)), short, ESTIMATOR_WINDOW)
.unwrap_err();
assert!(why.contains("shorter than the 100 ms window"), "{why}");
let mb = Provenance::ModelBased;
let why = window_energy(Some(&reading(9, mb)), Some(&reading(9, mb)), SECOND, Duration::from_millis(10))
.unwrap_err();
assert_eq!(why, "it registered no energy across 1000 ms of work");
assert_eq!(span(Duration::from_micros(1_500)), "1.5 ms");
}
#[test]
fn an_estimate_is_sealed_and_posted_as_an_estimate() {
let w = work(12_345, &Provenance::Estimator.to_string(), "apple-silicon");
assert_eq!(sealed(&w), ("Estimator".to_string(), 12_345));
let body = compose_github("dana", &w, "completed", true);
assert!(
body.contains("12.3 mJ (12345 µJ) — estimated by `apple-silicon`, provenance `estimator`"),
"{body}"
);
assert!(!body.contains("measured"), "an estimate is never called a measurement: {body}");
assert!(body.contains("_Decided under a signed grant, worked on real hardware, sealed to a ledger"), "{body}");
assert!(body.contains("- **decision:** completed · ledger verified ✓"), "{body}");
}
#[test]
fn a_reading_is_sealed_at_its_own_provenance() {
// A live IOReport counter's reading is a hardware reading, as RAPL's is.
let w = work(80_000, &Provenance::HwShunt.to_string(), "apple-ioreport");
assert_eq!(sealed(&w), ("HwShunt".to_string(), 80_000));
let body = compose_github("dana", &w, "completed", true);
assert!(body.contains("measured via `apple-ioreport`, provenance `hw_shunt`"), "{body}");
let w = work(80_000, &Provenance::HwShunt.to_string(), "rapl");
assert_eq!(sealed(&w), ("HwShunt".to_string(), 80_000));
// A vendor energy model's reading is sealed as one.
let w = work(80_000, &Provenance::ModelBased.to_string(), "vendor-model");
assert_eq!(sealed(&w), ("ModelBased".to_string(), 80_000));
let body = compose_github("dana", &w, "completed", true);
assert!(body.contains("measured via `vendor-model`, provenance `model_based`"), "{body}");
}
#[test]
fn work_with_no_figure_is_neither_sealed_nor_given_one() {
let w = work(0, UNMETERED, "apple-silicon");
assert!(seal_work(&w, "a question").is_none(), "no figure, so nothing is committed");
assert_eq!(seal(&w, "a question"), (NOT_SEALED.to_string(), false));
let body = compose_github("dana", &w, NOT_SEALED, false);
assert!(
body.contains(
"not metered — `apple-silicon`: the work took 200 µs, shorter than the 100 ms window it \
can resolve; no energy figure was sealed"
),
"{body}"
);
assert!(body.contains("nothing was sealed") && !body.contains("mJ"), "{body}");
assert_unmetered_reply(&body);
}
/// A reply for work with no figure names no grant decision and no ledger: neither
/// exists, because such work is never put to a grant or sealed.
pub(crate) fn assert_unmetered_reply(body: &str) {
assert!(!body.to_lowercase().contains("signed grant"), "no grant decided this work: {body}");
assert!(!body.contains("UNVERIFIED") && !body.contains("ledger verified"), "there is no ledger: {body}");
assert!(body.contains("no ledger entry"), "{body}");
}
/// End to end on this host, through `handle`: the reply names the meter that
/// actually ran. With no model configured the work is a deterministic extract that
/// takes well under a millisecond, shorter than any meter here can resolve (the
/// estimator's 100 ms, IOReport's 10 ms), so the reply says it was not metered and
/// why, and nothing is sealed — where it used to seal the meter's resolution,
/// 1000 µJ, as a measurement. The meter is IOReport only where its CPU rail is live.
#[test]
fn a_mention_answered_too_fast_to_meter_is_posted_as_unmetered() {
let event =
json!({ "comment": { "user": { "login": "dana" }, "body": "@joule summarize the thread" } });
let h = handle(&event, "@joule").expect("addresses @joule");
let h = handle_with(&event, "@joule", NO_MODEL).expect("addresses @joule");
let status = jouleclaw_energy::ioreport_status();
let energy = h.body.lines().find(|l| l.contains("**energy:**")).unwrap_or_default();
println!("jouledesk-github on this host: ioreport={status:?} {energy} · outcome={}", h.outcome);
assert_eq!(h.who, "dana");
assert!(h.verified, "the sealed ledger verifies");
assert!(h.measured_uj >= 1000, "measured energy floored to the meter resolution");
assert!(h.body.contains("@dana") && h.body.contains("measured via"));
std::env::remove_var("JOULEDESK_MODEL");
assert_eq!((h.outcome.as_str(), h.verified, h.measured_uj), (NOT_SEALED, false, 0));
assert!(energy.contains("not metered — `") && energy.contains("shorter than the"), "{energy}");
assert!(!h.body.contains("measured") && !h.body.contains(" mJ"), "{}", h.body);
assert!(h.body.contains("- **model:** `deterministic-extract`"), "{}", h.body);
assert_unmetered_reply(&h.body);
let ioreport = energy.contains("`apple-ioreport`");
assert_eq!(ioreport, status == IoReportStatus::Live, "IOReport runs exactly when its CPU rail is live");
}
/// End to end on this host with the real meter, for work long enough to meter: the
/// meter's own figure, sealed and posted at the meter's own provenance. IOReport
/// (`hw_shunt`) only where its CPU rail is live; elsewhere — macOS 27.0.1 on an
/// M5 Max and an M3 Ultra — an estimator, sealed `Estimator` and posted "estimated".
#[test]
fn on_this_host_work_the_meter_can_resolve_is_sealed_at_its_provenance() {
let status = jouleclaw_energy::ioreport_status();
let meter = jouleclaw_energy::meter::detect_meter();
let name = meter.active_name();
let min_window = Duration::from_nanos(meter.active().expect("detect_meter has a meter").min_window_ns());
let ((), figure) = metered(&meter, || {
let t = Instant::now();
let mut x = 1u64;
while t.elapsed() < min_window + Duration::from_millis(50) {
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
}
std::hint::black_box(x);
});
let (uj, provenance) = figure.unwrap_or_else(|why| panic!("`{name}` put no figure on resolvable work: {why}"));
let w = Work {
answer: "an answer".into(),
measured_uj: uj,
provenance: provenance.to_string(),
meter: name.to_string(),
model: "a-model".into(),
unmetered: None,
};
let (label, sealed_uj) = sealed(&w);
let body = compose_github("dana", &w, "completed", true);
let energy = body.lines().find(|l| l.contains("**energy:**")).unwrap_or_default();
println!("jouledesk-github on this host: ioreport={status:?} sealed {label} {sealed_uj} µJ; {energy}");
assert!(uj > 0 && sealed_uj == uj, "{uj} µJ read, {sealed_uj} µJ sealed");
assert_eq!(name == "apple-ioreport", status == IoReportStatus::Live, "IOReport runs exactly when live");
match provenance {
Provenance::Estimator => {
assert_eq!(label, "Estimator");
assert!(energy.contains(&format!("estimated by `{name}`, provenance `estimator`")), "{energy}");
assert!(!body.contains("measured"), "{body}");
}
Provenance::ModelBased | Provenance::HwShunt => {
assert_ne!(label, "Estimator");
assert!(energy.contains(&format!("measured via `{name}`")), "{energy}");
}
}
if matches!(name, "apple-silicon" | "estimation") {
assert_eq!(provenance, Provenance::Estimator, "an estimator's figure is an estimate");
}
if name == "apple-ioreport" {
assert_eq!(provenance, Provenance::HwShunt, "a live IOReport reading is a hardware reading");
}
}
#[test]
fn ignores_comments_that_dont_address_the_handle() {
let event = json!({ "comment": { "user": { "login": "x" }, "body": "just chatting" } });
assert!(handle(&event, "@joule").is_none());
assert!(handle_with(&event, "@joule", NO_MODEL).is_none());
}
}

View file

@ -7,7 +7,7 @@
//! Env: `GITHUB_TOKEN` (for --reply / --serve), `JOULEDESK_MODEL` (a local GGUF),
//! `JOULEDESK_HANDLE` (default `@joule`).
use jouledesk_github::{handle, GitHub};
use jouledesk_github::{handle, GitHub, Handled};
use serde_json::{json, Value};
fn arg(a: &[String], k: &str) -> Option<String> {
@ -17,6 +17,16 @@ fn has(a: &[String], k: &str) -> bool {
a.iter().any(|x| x == k)
}
/// A handled mention's energy for a log line: the sealed figure, or that there is none.
/// The reply body says whether the figure was measured or estimated, and by which meter.
fn energy(h: &Handled) -> String {
if h.verified {
format!("{} µJ", h.measured_uj)
} else {
format!("no sealed figure ({})", h.outcome)
}
}
fn main() {
let a: Vec<String> = std::env::args().skip(1).collect();
let handle_tok = arg(&a, "--handle")
@ -29,7 +39,7 @@ fn main() {
let event = json!({ "comment": { "user": { "login": "you" }, "body": format!("{handle_tok} {q}") } });
match handle(&event, &handle_tok) {
Some(h) => {
eprintln!("[measured {} µJ · verified={}]", h.measured_uj, h.verified);
eprintln!("[energy {} · verified={}]", energy(&h), h.verified);
println!("{}", h.body);
}
None => eprintln!("no mention"),
@ -44,7 +54,7 @@ fn main() {
"text": format!("<@UBOT> {q}") } });
match jouledesk_github::slack::handle(&event) {
Some(h) => {
eprintln!("[measured {} µJ · verified={}]", h.measured_uj, h.verified);
eprintln!("[energy {} · verified={}]", energy(&h), h.verified);
println!("{}", h.body);
}
None => eprintln!("no mention"),
@ -64,7 +74,7 @@ fn main() {
let event = json!({ "event": { "user": "someone", "channel": channel, "text": format!("<@UBOT> {q}") } });
let h = jouledesk_github::slack::handle(&event).expect("mention");
match jouledesk_github::slack::Slack::new(token).post_message(&channel, &h.body) {
Ok(ts) => println!("posted to {channel} ts={ts} (measured {} µJ)", h.measured_uj),
Ok(ts) => println!("posted to {channel} ts={ts} (energy {})", energy(&h)),
Err(e) => {
eprintln!("slack post failed: {e}");
std::process::exit(1);
@ -95,7 +105,7 @@ fn main() {
match gh.latest_mention(&repo, issue, &handle_tok, &bot) {
Ok(Some(event)) => {
let h = handle(&event, &handle_tok).expect("mention parses");
eprintln!("→ {} (measured {} µJ, verified={})", h.outcome, h.measured_uj, h.verified);
eprintln!("→ {} (energy {}, verified={})", h.outcome, energy(&h), h.verified);
match gh.post_comment(&repo, issue, &h.body) {
Ok(id) => println!("posted reply comment id={id} to {repo}#{issue}"),
Err(e) => {
@ -137,7 +147,7 @@ fn main() {
if let Some(h) = handle(&event, &handle_tok) {
if !repo.is_empty() && issue != 0 {
match gh.post_comment(&repo, issue, &h.body) {
Ok(id) => eprintln!("auto-replied to {repo}#{issue} (comment {id}, {} µJ)", h.measured_uj),
Ok(id) => eprintln!("auto-replied to {repo}#{issue} (comment {id}, energy {})", energy(&h)),
Err(e) => eprintln!("post failed: {e}"),
}
}

View file

@ -1,21 +1,27 @@
//! The Slack binding — the second surface, on the same core.
//!
//! An `app_mention` event comes in; `@joule` does the work with the same real model
//! under the same real energy meter, seals the same verifiable `efficiency/1` at the
//! measured cost, and posts the reply with `chat.postMessage`. Only the wire shape and
//! the delivery client differ from GitHub — the accountable core is identical.
//! under the same energy meter, seals the same verifiable `efficiency/1` at that meter's
//! figure and provenance, and posts the reply with `chat.postMessage`. Only the wire
//! shape and the delivery client differ from GitHub — the accountable core is identical.
//!
//! Live delivery needs a Slack **bot token** (`SLACK_BOT_TOKEN`) + a workspace app
//! subscribed to `app_mention`; `--slack-dry-run` proves the payload here without one.
use serde_json::{json, Value};
use super::{do_work, seal, Handled};
use super::{do_work_with, seal, Handled};
/// Handle one Slack `app_mention` event: `{ "event": { "user", "text", "channel" } }`.
/// Strips the bot mention, does the work under the meter, seals, and composes the reply
/// in Slack `mrkdwn`. `None` if there is no addressable text.
pub fn handle(event: &Value) -> Option<Handled> {
handle_with(event, &super::configured_model())
}
/// [`handle`] with the model given as the path to a local GGUF (see
/// [`super::do_work_with`]).
pub(crate) fn handle_with(event: &Value, model: &str) -> Option<Handled> {
let ev = &event["event"];
let who = ev["user"].as_str()?.to_string();
let question = strip_mentions(ev["text"].as_str()?);
@ -23,7 +29,7 @@ pub fn handle(event: &Value) -> Option<Handled> {
return None;
}
let work = do_work(&question);
let work = do_work_with(&question, model);
let (outcome, verified) = seal(&work, &question);
let body = compose(&who, &work, &outcome, verified);
Some(Handled { who, body, outcome, measured_uj: work.measured_uj, verified })
@ -48,7 +54,7 @@ fn strip_mentions(text: &str) -> String {
}
/// The reply in Slack `mrkdwn` (single `*` for bold, `<@id>` mentions) — same facts as
/// the GitHub reply: real answer + the sealed, measured attestation.
/// the GitHub reply: real answer + the sealed, metered attestation, at its provenance.
fn compose(who: &str, work: &super::Work, outcome: &str, verified: bool) -> String {
let mj = work.measured_uj as f64 / 1000.0;
let answer = &work.answer;
@ -56,12 +62,25 @@ fn compose(who: &str, work: &super::Work, outcome: &str, verified: bool) -> Stri
let meter = &work.meter;
let prov = &work.provenance;
let verify = if verified { "verified ✓" } else { "UNVERIFIED ✗" };
// As on GitHub: work with no figure names no grant decision and no ledger.
let (how_done, energy, ledger) = match super::obtained(work) {
Some(how) => (
"Signed grant · worked on real hardware · sealed to a verifiable ledger",
format!("{mj:.1} mJ {how} `{meter}` (provenance `{prov}`)"),
format!("ledger {verify}"),
),
None => (
"Worked on real hardware · not metered · no grant decision · nothing sealed",
super::unmetered_line(work),
"no ledger entry".to_string(),
),
};
format!(
"🔷 *@joule* — {outcome}, <@{who}>.\n\n{answer}\n\n\
_Signed grant · metered on real hardware · sealed to a verifiable ledger_\n\
_{how_done}_\n\
• *model:* `{model}`\n\
• *energy:* {mj:.1} mJ measured via `{meter}` (provenance `{prov}`)\n\
• *decision:* {outcome} · ledger {verify}"
• *energy:* {energy}\n\
• *decision:* {outcome} · {ledger}"
)
}
@ -95,19 +114,42 @@ impl Slack {
mod tests {
use super::*;
/// The same end-to-end path as the GitHub binding's: the deterministic fallback is
/// too fast for any meter here to resolve, so the Slack reply says it was not
/// metered, and why, and seals nothing — never a figure, never "measured".
#[test]
fn slack_app_mention_seals_and_composes_mrkdwn() {
std::env::set_var("JOULEDESK_MODEL", "/nonexistent.gguf"); // force fallback (no subprocess)
fn slack_app_mention_composes_mrkdwn_and_never_claims_a_figure_it_lacks() {
let event = json!({
"event": { "type": "app_mention", "user": "U123", "channel": "C1",
"text": "<@UBOT> summarize the thread" }
});
let h = handle(&event).expect("addresses the app");
let h = handle_with(&event, crate::tests::NO_MODEL).expect("addresses the app");
assert_eq!(h.who, "U123");
assert!(h.verified);
assert!(h.measured_uj >= 1000);
assert!(h.body.contains("<@U123>") && h.body.contains("measured via"));
std::env::remove_var("JOULEDESK_MODEL");
assert!(h.body.contains("<@U123>"), "{}", h.body);
let energy = h.body.lines().find(|l| l.contains("• *energy:* ")).expect("an energy line");
assert_eq!((h.outcome.as_str(), h.verified), (super::super::NOT_SEALED, false), "{energy}");
assert!(energy.contains("not metered — `") && energy.contains("shorter than the"), "{energy}");
assert!(!h.body.contains("measured") && !h.body.contains(" mJ"), "{}", h.body);
assert!(h.body.contains("• *model:* `deterministic-extract`"), "{}", h.body);
crate::tests::assert_unmetered_reply(&h.body);
}
#[test]
fn slack_posts_an_estimate_as_an_estimate() {
use jouleclaw_energy::Provenance;
let w = crate::tests::work(12_345, &Provenance::Estimator.to_string(), "apple-silicon");
let body = compose("U123", &w, "completed", true);
assert!(body.contains("12.3 mJ estimated by `apple-silicon` (provenance `estimator`)"), "{body}");
assert!(!body.contains("measured") && !body.contains("metered on"), "{body}");
assert!(body.contains("_Signed grant · worked on real hardware · sealed to a verifiable ledger_"), "{body}");
assert!(body.contains("• *decision:* completed · ledger verified ✓"), "{body}");
let w = crate::tests::work(80_000, &Provenance::HwShunt.to_string(), "apple-ioreport");
let body = compose("U123", &w, "completed", true);
assert!(body.contains("80.0 mJ measured via `apple-ioreport` (provenance `hw_shunt`)"), "{body}");
let w = crate::tests::work(0, super::super::UNMETERED, "apple-ioreport");
let body = compose("U123", &w, super::super::NOT_SEALED, false);
assert!(body.contains("not metered") && body.contains("nothing sealed") && !body.contains("mJ"), "{body}");
crate::tests::assert_unmetered_reply(&body);
}
#[test]

View file

@ -20,6 +20,10 @@ use jcp_schema::{Did, Energy};
// jcp-orchestrate directly.
pub use jcp_orchestrate::{HarnessOutcome as SkillOutcome, Task as SkillTask};
// Re-exported so a binding can say how a tag's energy was obtained
// ([`Teammate::tag_with_provenance`]) without depending on jcp-schema directly.
pub use jcp_schema::Provenance as EnergyProvenance;
/// Host-surface bindings (§8): Slack, GitHub, GitLab, Discord, Matrix, and a neutral
/// shape — parse an inbound event → tag → render the reply.
pub mod surface;
@ -151,7 +155,25 @@ impl Teammate {
/// A member tags `@joule` to delegate `intent` of a given `kind` (SPEC §4). The
/// teammate authorises it against its grants **outside the model**, runs it, and
/// seals the decision. Returns the outcome.
///
/// The task's `efficiency/1` receipt carries its energy as a hardware reading
/// ([`EnergyProvenance::HwShunt`]). A binding whose figure came from anything
/// else — an energy model, an estimator — uses [`Self::tag_with_provenance`].
pub fn tag(&mut self, kind: &str, intent: &str) -> TagOutcome {
self.tag_with_provenance(kind, intent, EnergyProvenance::HwShunt)
}
/// [`Self::tag`], sealing the task's `efficiency/1` energy at `provenance`: how
/// the skill's committed cost was actually obtained. A binding that meters the
/// work passes its meter's provenance, so a figure from an energy model is
/// sealed as modeled and an estimate as an estimate, never as a hardware
/// reading (SPEC §6: energy "at honest provenance").
pub fn tag_with_provenance(
&mut self,
kind: &str,
intent: &str,
provenance: EnergyProvenance,
) -> TagOutcome {
self.n += 1;
let id = format!("tag-{}", self.n);
self.orch.submit(Task::new(id.clone(), kind, intent.as_bytes().to_vec()));
@ -175,13 +197,18 @@ impl Teammate {
.map(|c| c.cost_uj)
.unwrap_or(0);
let completed = matches!(outcome, TagOutcome::Done);
let energy = match provenance {
EnergyProvenance::HwShunt => Energy::measured(cost),
EnergyProvenance::ModelBased => Energy::modeled(cost),
EnergyProvenance::Estimator => Energy::estimator(cost),
};
self.eff.append(&Efficiency {
task: id,
completed,
verdict: if completed { "done".into() } else { "failed".into() },
tokens_in: 0,
tokens_out: 0,
energy: Energy::measured(cost),
energy,
wall_ns: 0,
value_uusd: None,
energy_attestation: None,
@ -357,6 +384,35 @@ mod tests {
assert_eq!(t.verify_ledger(), Ok(1), "only the real mention dispatched");
}
/// The provenance label sealed into the one `efficiency/1` receipt of a
/// teammate that ran one tag, read back through the verified chain.
fn sealed_provenance(t: &Teammate) -> String {
let recs = jcp_efficiency::verify_chain(t.efficiency_ledger(), t.steward(), &t.steward_pubkey())
.expect("the energy ledger verifies under the steward key");
assert_eq!(recs.len(), 1, "one efficiency/1 receipt per task that ran");
assert_eq!(recs[0].energy_uj, 100_000, "the committed cost, as sealed");
recs[0].provenance.clone()
}
#[test]
fn a_tags_energy_is_sealed_at_the_provenance_it_was_obtained_at() {
for (provenance, label) in [
(EnergyProvenance::Estimator, "Estimator"),
(EnergyProvenance::ModelBased, "ModelBased"),
(EnergyProvenance::HwShunt, "HwShunt"),
] {
let mut t = Teammate::new();
t.grant_skill(summarizer(), 1_000_000).unwrap();
assert_eq!(t.tag_with_provenance("summarize", "the Q3 thread", provenance), TagOutcome::Done);
assert_eq!(sealed_provenance(&t), label, "an energy figure obtained as {provenance:?}");
}
// Plain `tag` is unchanged: it seals the committed cost as a hardware reading.
let mut t = Teammate::new();
t.grant_skill(summarizer(), 1_000_000).unwrap();
assert_eq!(t.tag("summarize", "the Q3 thread"), TagOutcome::Done);
assert_eq!(sealed_provenance(&t), "HwShunt");
}
#[test]
fn efficiency_accounting_rolls_up_completed_and_wasted() {
let mut t = Teammate::new();

View file

@ -65,7 +65,8 @@ cargo clippy --workspace --all-targets
# end-to-end: mint a JCP grant, serve, print the sealed receipt
cargo run -p mesh-core --example demo
# on Linux/RAPL or a CUDA/NVML box, meter real silicon (HwShunt provenance)
# on Linux/RAPL or a CUDA/NVML box, meter real silicon (HwShunt provenance, where
# the counter is live; a counter that is not live falls back to the estimator)
cargo run -p mesh-core --example demo --features hw-meter-rapl
# regenerate the published conformance vectors

View file

@ -24,11 +24,13 @@ serde_json = { workspace = true }
[features]
default = []
# Forwarded to mesh-meter: use the family's hardware-backed meter.
# Forwarded to mesh-meter: use the family's hardware-backed meter. Each platform
# feature implies `hw-meter`, which is what the demo checks: without it
# `--features hw-meter-rapl` built the backend and then used the estimator.
hw-meter = ["mesh-meter/hw-meter"]
hw-meter-rapl = ["mesh-meter/hw-meter-rapl"]
hw-meter-nvml = ["mesh-meter/hw-meter-nvml"]
hw-meter-apple = ["mesh-meter/hw-meter-apple"]
hw-meter-rapl = ["hw-meter", "mesh-meter/hw-meter-rapl"]
hw-meter-nvml = ["hw-meter", "mesh-meter/hw-meter-nvml"]
hw-meter-apple = ["hw-meter", "mesh-meter/hw-meter-apple"]
[lints]
workspace = true

View file

@ -15,6 +15,15 @@
#![forbid(unsafe_code)]
// A platform meter feature must select the family meter, not only build its
// backend: callers (the demo among them) gate on `hw-meter`, and without it the
// documented `--features hw-meter-rapl` built RAPL and then ran the estimator.
#[cfg(all(
any(feature = "hw-meter-rapl", feature = "hw-meter-nvml", feature = "hw-meter-apple"),
not(feature = "hw-meter")
))]
compile_error!("a hw-meter-* feature must enable mesh-core's `hw-meter` feature");
use std::collections::HashMap;
use ed25519_dalek::SigningKey;

View file

@ -4,7 +4,11 @@
//! This is the production drop-in the reference engine stands in for. With it,
//! the mesh routes and **meters real model tokens** — the energy span around
//! [`ServingEngine::serve`] is a genuine decode, so the receipt's
//! `uj_per_accepted_token` is a measured figure on real silicon.
//! `uj_per_accepted_token` is a measured figure on real silicon — where the
//! span's meter is a hardware counter that is live for the domain it reads.
//! Where it is not (Apple Silicon whose IOReport CPU rail is frozen, a build or
//! host without a hardware meter) the span says `Estimator`, and so does the
//! figure.
//!
//! It is gated behind the `gguf` feature so the default workspace build (and
//! `cargo test --workspace`) stays light and needs no model weights. Enable it,

View file

@ -11,8 +11,21 @@
//! `Estimator` provenance. The default so the foundation is OS-neutral.
//! * [`ManualMeter`] — caller-driven, for tests and replaying recorded spans.
//!
//! With the `hw-meter` feature, [`HwMeter`] bridges the family's hardware meter
//! (RAPL / NVML / Apple Silicon) so a span on real silicon yields `HwShunt`.
//! With the `hw-meter` feature, [`HwMeter`] bridges the family's meter (RAPL /
//! NVML / Apple Silicon IOReport, else the family's estimator), so a span carries
//! the provenance of whatever actually metered it: `HwShunt` from a hardware
//! counter that is live for the domain it reads (Apple IOReport included, where
//! its CPU energy rail advances under load), `ModelBased` from a vendor energy
//! model, `Estimator` where the host exposes no live counter — as on Apple Silicon
//! whose IOReport CPU rail is frozen (macOS 27.0.1).
//!
//! A `mesh/1` receipt has no coverage field (SPEC §3.3), so its figure is read
//! as the energy of the whole request. A counter whose domain does not cover the
//! whole request — an NVML GPU counter, with the CPU that drives the GPU
//! unmetered — therefore never yields a `HwShunt` span through [`HwMeter`]: the
//! span is graded as mesh grades an estimate of the whole request, `Estimator`
//! with no `measured_uj`, and its figure is the counter's reading plus the
//! bridge's own estimate of the rest of the host over the same window.
#![forbid(unsafe_code)]
@ -197,31 +210,84 @@ impl Meter for SteppingMeter {
#[cfg(feature = "hw-meter")]
mod hw {
use super::{Meter, Sample};
use std::sync::{Mutex, MutexGuard};
use super::{EstimatorMeter, Meter, Sample};
use jouleclaw_energy::meter::{detect_meter, CompositeMeter};
use jouleclaw_energy::EnergyDomain;
use jouleclaw_energy::Provenance as JpProvenance;
use mesh_schema::Provenance;
/// Bridges the family's hardware-backed [`CompositeMeter`] (RAPL / NVML /
/// Apple Silicon, with an estimator fallback) into the Mesh [`Meter`]
/// interface. The active backend's provenance is mapped through unchanged,
/// so honesty is preserved across the bridge.
/// [`EstimatorMeter`]'s name: what [`HwMeter`] reports once it has fallen back.
const FALLBACK_NAME: &str = "estimator";
/// Bridges the family's [`CompositeMeter`] (RAPL / NVML / Apple Silicon
/// IOReport where the host exposes a live counter, else the family's
/// estimator) into the Mesh [`Meter`] interface. The active backend's
/// provenance is mapped through unchanged, so honesty is preserved across the
/// bridge. On Apple Silicon whose IOReport CPU energy rail does not advance
/// (macOS 27.0.1 on an M5 Max and an M3 Ultra), the active backend is the load
/// estimator and spans say `Estimator`.
///
/// If the backend fails a read, the bridge takes this crate's no-hardware path
/// from then on: an [`EstimatorMeter`], continuing from the last good reading,
/// tagged `Estimator`. A failed read is never a zero reading, which would bill a
/// span nothing, or bill it the counter's whole history.
///
/// A backend whose readings do not cover the whole request
/// ([`covers_whole_request`]: an NVML counter reads the GPUs alone) never
/// yields a `HwShunt` span, live or not: `mesh/1` has no coverage field, so
/// its figure would claim the CPU it does not read (SPEC §3.3). Its readings
/// are graded as an estimate of the whole request, `Estimator`, and carry the
/// counter's figure plus this crate's [`EstimatorMeter`] for the rest of the
/// host, so a span is billed what the counter read and an estimate of what it
/// did not.
pub struct HwMeter {
inner: CompositeMeter,
fallback: EstimatorMeter,
state: Mutex<Fallback>,
}
/// Where the fallback estimate picks up.
#[derive(Default)]
struct Fallback {
/// A read of the backend has failed; every snapshot since is an estimate.
failed: bool,
/// The backend's last good reading, µJ.
last_uj: u64,
/// The fallback estimator's cumulative figure at that reading, µJ.
fallback_at_last_uj: u64,
}
impl HwMeter {
/// Detect and wrap the best meter for this platform.
pub fn detect() -> Self {
Self::bridging(detect_meter())
}
fn bridging(inner: CompositeMeter) -> Self {
Self {
inner: detect_meter(),
inner,
fallback: EstimatorMeter::default(),
state: Mutex::new(Fallback::default()),
}
}
/// The active backend's short name (`"rapl"`, `"nvml"`, `"estimation"`,
/// `"none"`).
/// The active backend's short name (`"rapl"`, `"nvml"`, `"apple-ioreport"`,
/// `"apple-silicon"`, `"estimation"`, `"none"`), or `"estimator"` once a
/// read has failed and the bridge estimates.
pub fn active_name(&self) -> &'static str {
self.inner.active_name()
if self.state().failed {
FALLBACK_NAME
} else {
self.inner.active_name()
}
}
fn state(&self) -> MutexGuard<'_, Fallback> {
self.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
@ -233,23 +299,399 @@ mod hw {
}
}
/// Whether a reading over `domain` covers the whole of a served request, so
/// that its grade can stand for the request on a `mesh/1` receipt, which has
/// no coverage field. The whole SoC does, and so does a CPU package (a RAPL
/// figure, on a host serving from its CPU). A GPU, NPU or ANE alone does not:
/// the CPU that drives the accelerator is unmetered. Nor does one core, DRAM,
/// or an external rail whose scope this crate cannot know. The match is
/// exhaustive, so a new domain has to be decided here.
pub fn covers_whole_request(domain: EnergyDomain) -> bool {
match domain {
EnergyDomain::SocTotal | EnergyDomain::CpuPkg => true,
EnergyDomain::CpuCore
| EnergyDomain::Dram
| EnergyDomain::Gpu
| EnergyDomain::Npu
| EnergyDomain::Ane
| EnergyDomain::ExternalRail(_) => false,
}
}
impl Meter for HwMeter {
fn name(&self) -> &str {
self.inner.active_name()
self.active_name()
}
fn snapshot(&self) -> Sample {
match self.inner.read() {
Ok(r) => Sample {
uj: r.uj,
provenance: map_prov(r.provenance),
},
// No meter available: report a zero estimator reading rather
// than panic; the resulting delta is honestly tagged Estimator.
Err(_) => Sample {
let mut state = self.state();
if !state.failed {
match self.inner.read() {
Ok(r) => {
let estimate_uj = self.fallback.snapshot().uj;
// A reading that does not cover the whole request is an
// estimate of it: the counter's figure plus the estimator's
// for the rest of the host, graded `Estimator`.
let (uj, provenance) = if covers_whole_request(r.domain) {
(r.uj, map_prov(r.provenance))
} else {
(r.uj.saturating_add(estimate_uj), Provenance::Estimator)
};
state.last_uj = uj;
state.fallback_at_last_uj = estimate_uj;
return Sample { uj, provenance };
}
Err(_) => state.failed = true,
}
}
// The backend could not be read: estimate from here on, continuing
// from its last good reading, so a span across the failure is billed an
// estimate of its own length — never zero, never the counter's history.
let since = self
.fallback
.snapshot()
.uj
.saturating_sub(state.fallback_at_last_uj);
Sample {
uj: state.last_uj.saturating_add(since),
provenance: Provenance::Estimator,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use jouleclaw_energy::meter::EnergyMeter;
use jouleclaw_energy::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
/// A cumulative counter with a long history: 1 MJ, in µJ.
const BASE: u64 = 1_000_000_000_000;
/// µJ per read.
const STEP: u64 = 1_000;
/// A backend whose read number `n` (from 0) fails when `fails(n)`; a good
/// read returns `BASE + n·STEP` µJ, `ModelBased`.
struct Flaky {
reads: AtomicU64,
fails: fn(u64) -> bool,
}
impl EnergyCounter for Flaky {
fn domain(&self) -> EnergyDomain {
EnergyDomain::SocTotal
}
fn provenance(&self) -> JpProvenance {
JpProvenance::ModelBased
}
fn resolution_uj(&self) -> u64 {
1
}
fn min_window_ns(&self) -> u64 {
1
}
fn read(&self) -> Result<EnergyReading, EnergyError> {
let n = self.reads.fetch_add(1, Ordering::SeqCst);
if (self.fails)(n) {
return Err(EnergyError::Platform("sample failed".into()));
}
Ok(EnergyReading {
uj: BASE + n * STEP,
timestamp_ns: 0,
domain: EnergyDomain::SocTotal,
provenance: JpProvenance::ModelBased,
})
}
}
impl EnergyMeter for Flaky {
fn name(&self) -> &'static str {
"flaky"
}
fn is_available(&self) -> bool {
true
}
}
/// A backend that is not available, as the IOReport counter is on a host whose
/// CPU energy rail does not advance. Its read would claim a `ModelBased` zero;
/// the bridge must never get there.
struct Declined;
impl EnergyCounter for Declined {
fn domain(&self) -> EnergyDomain {
EnergyDomain::SocTotal
}
fn provenance(&self) -> JpProvenance {
JpProvenance::ModelBased
}
fn resolution_uj(&self) -> u64 {
1
}
fn min_window_ns(&self) -> u64 {
1
}
fn read(&self) -> Result<EnergyReading, EnergyError> {
Ok(EnergyReading {
uj: 0,
provenance: Provenance::Estimator,
},
timestamp_ns: 0,
domain: EnergyDomain::SocTotal,
provenance: JpProvenance::ModelBased,
})
}
}
impl EnergyMeter for Declined {
fn name(&self) -> &'static str {
"declined"
}
fn is_available(&self) -> bool {
false
}
}
fn bridge(fails: fn(u64) -> bool) -> HwMeter {
let flaky = Flaky {
reads: AtomicU64::new(0),
fails,
};
HwMeter::bridging(CompositeMeter::new(vec![Box::new(flaky)]))
}
/// A 20 ms span on `m`.
fn span(m: &HwMeter) -> mesh_schema::Energy {
let s = Session::start(m);
std::thread::sleep(Duration::from_millis(20));
s.stop()
}
#[test]
fn a_read_that_fails_mid_span_is_estimated_never_zero() {
let m = bridge(|n| n >= 1); // the span's closing read fails
let e = span(&m);
assert_eq!(e.provenance, Provenance::Estimator, "{e:?}");
assert_eq!(e.measured_uj, None);
assert!(e.estimated_uj > 0, "a 20 ms span is not free: {e:?}");
assert!(e.check_provenance().is_ok());
assert_eq!(m.name(), EstimatorMeter::default().name());
}
#[test]
fn a_span_that_opens_on_a_failed_read_is_not_billed_the_counters_history() {
let m = bridge(|n| n == 0); // only the opening read fails; the backend recovers
let e = span(&m);
assert_eq!(e.provenance, Provenance::Estimator, "{e:?}");
assert!(e.estimated_uj > 0 && e.estimated_uj < BASE, "{e:?}");
}
#[test]
fn a_backend_that_never_reads_is_estimated() {
let e = span(&bridge(|_| true));
assert_eq!(e.provenance, Provenance::Estimator, "{e:?}");
assert!(e.estimated_uj > 0, "{e:?}");
}
#[test]
fn once_a_read_fails_the_bridge_estimates_and_never_runs_backwards() {
let m = bridge(|n| n == 1);
let good = m.snapshot();
assert_eq!((good.uj, good.provenance), (BASE, Provenance::ModelBased));
let mut prev = good.uj;
for _ in 0..3 {
std::thread::sleep(Duration::from_millis(2));
let s = m.snapshot();
assert_eq!(
s.provenance,
Provenance::Estimator,
"the backend is not trusted again"
);
assert!(s.uj >= prev, "{} then {}", prev, s.uj);
prev = s.uj;
}
}
/// An unavailable counter takes this crate's no-hardware path: spans say
/// `Estimator`, carry no measured figure and are not free — never the
/// `ModelBased` zero the declined counter would have read.
#[test]
fn an_unavailable_counter_takes_the_no_hardware_path_never_a_modelbased_zero() {
let m = HwMeter::bridging(CompositeMeter::new(vec![Box::new(Declined)]));
let e = span(&m);
assert_eq!(e.provenance, Provenance::Estimator, "{e:?}");
assert_eq!(e.measured_uj, None);
assert!(e.estimated_uj > 0, "a 20 ms span is not free: {e:?}");
assert_eq!(m.name(), EstimatorMeter::default().name());
}
/// The same with the real IOReport counter, bridged alone (no estimator behind
/// it in the composite): where its CPU rail is not live (macOS 27.0.1 on an
/// M5 Max and an M3 Ultra) it is unavailable, and the bridge estimates.
#[cfg(all(feature = "hw-meter-apple", target_os = "macos"))]
#[test]
fn the_real_ioreport_counter_when_declined_takes_the_no_hardware_path() {
let status = jouleclaw_energy::ioreport_status();
let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new();
let available = counter.is_available();
let m = HwMeter::bridging(CompositeMeter::new(vec![Box::new(counter)]));
let e = span(&m);
println!("mesh-meter, IOReport bridged alone: ioreport={status:?} available={available} meter={} energy={e:?}", m.name());
assert_eq!(available, status == jouleclaw_energy::IoReportStatus::Live);
if available {
assert_eq!((m.name(), e.provenance), ("apple-ioreport", Provenance::HwShunt));
} else {
assert_eq!(m.name(), EstimatorMeter::default().name());
assert_eq!((e.provenance, e.measured_uj), (Provenance::Estimator, None));
assert!(e.estimated_uj > 0, "{e:?}");
}
}
/// A backend that steps `STEP` µJ per read from `BASE`, over `domain`, at
/// `grade` — a live NVML counter is `(Gpu, HwShunt)`.
struct Stepping {
reads: AtomicU64,
domain: EnergyDomain,
grade: JpProvenance,
}
impl EnergyCounter for Stepping {
fn domain(&self) -> EnergyDomain {
self.domain
}
fn provenance(&self) -> JpProvenance {
self.grade
}
fn resolution_uj(&self) -> u64 {
1
}
fn min_window_ns(&self) -> u64 {
1
}
fn read(&self) -> Result<EnergyReading, EnergyError> {
let n = self.reads.fetch_add(1, Ordering::SeqCst);
Ok(EnergyReading {
uj: BASE + n * STEP,
timestamp_ns: 0,
domain: self.domain,
provenance: self.grade,
})
}
}
impl EnergyMeter for Stepping {
fn name(&self) -> &'static str {
"stepping"
}
fn is_available(&self) -> bool {
true
}
}
fn stepping(domain: EnergyDomain, grade: JpProvenance) -> HwMeter {
let backend = Stepping { reads: AtomicU64::new(0), domain, grade };
HwMeter::bridging(CompositeMeter::new(vec![Box::new(backend)]))
}
/// A live GPU counter's span is not a measurement of the request: mesh/1
/// has no coverage field, so a HwShunt GPU figure would claim the CPU it
/// did not read. It is graded as an estimate of the whole request —
/// `Estimator`, no `measured_uj` — and billed the counter's reading plus
/// the estimator's figure for the rest of the host, never less than the
/// counter read. The same counter over the whole SoC passes through as a
/// measurement: only the domain differs.
#[test]
fn a_gpu_only_reading_is_an_estimate_of_the_whole_request() {
for grade in [JpProvenance::HwShunt, JpProvenance::ModelBased, JpProvenance::Estimator] {
let m = stepping(EnergyDomain::Gpu, grade);
let e = span(&m);
assert_eq!((e.provenance, e.measured_uj), (Provenance::Estimator, None), "{grade:?}: {e:?}");
assert!(e.check_provenance().is_ok());
// 20 ms at the estimator's 15 W is ~300,000 µJ beside the GPU's STEP.
assert!(e.estimated_uj > STEP, "{grade:?}: the unmetered CPU was billed nothing: {e:?}");
assert!(e.estimated_uj < STEP + 2_000_000, "{grade:?}: {e:?}");
assert_eq!(m.name(), "stepping", "the counter still reads; it is graded, not dropped");
}
let whole = span(&stepping(EnergyDomain::SocTotal, JpProvenance::HwShunt));
assert_eq!((whole.provenance, whole.measured_uj, whole.estimated_uj), (Provenance::HwShunt, Some(STEP), STEP));
}
/// Which domains stand for the whole request. Every one is listed, so a
/// domain added to the family meter fails to compile in
/// `covers_whole_request` before it can be graded here by accident.
#[test]
fn only_the_soc_or_a_cpu_package_covers_the_whole_request() {
use EnergyDomain::*;
for (d, whole) in [
(SocTotal, true),
(CpuPkg, true),
(CpuCore, false),
(Dram, false),
(Gpu, false),
(Npu, false),
(Ane, false),
(ExternalRail(0), false),
] {
assert_eq!(covers_whole_request(d), whole, "{d:?}");
}
}
/// The family's NVML counter reads the GPUs alone: through this bridge it
/// never grades a span HwShunt, whatever its liveness.
#[cfg(feature = "hw-meter-nvml")]
#[test]
fn the_nvml_counter_does_not_cover_the_whole_request() {
let nvml = jouleclaw_energy::nvml::NvmlCounter::new();
assert_eq!(nvml.domain(), EnergyDomain::Gpu);
assert!(!covers_whole_request(nvml.domain()));
}
#[test]
fn a_backend_that_reads_passes_through_unchanged() {
let m = bridge(|_| false);
let e = span(&m);
assert_eq!(
(e.provenance, e.estimated_uj),
(Provenance::ModelBased, STEP)
);
assert_eq!(m.name(), "flaky");
}
/// This host, with `hw-meter-apple`: the bridge says what the family meter
/// says. IOReport only where its CPU rail is live; elsewhere (macOS 27.0.1 on
/// an M5 Max and an M3 Ultra) the estimator, whose spans say `Estimator`.
#[cfg(feature = "hw-meter-apple")]
#[test]
fn on_this_host_the_bridge_is_as_honest_as_the_family_meter() {
let status = jouleclaw_energy::ioreport_status();
let m = HwMeter::detect();
let s = Session::start(&m);
let t = std::time::Instant::now();
let mut x = 1u64;
while t.elapsed() < Duration::from_millis(50) {
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
}
std::hint::black_box(x);
let e = s.stop();
println!(
"mesh-meter on this host: ioreport={status:?} meter={} energy={e:?}",
m.name()
);
if status == jouleclaw_energy::IoReportStatus::Live {
assert_eq!(m.name(), "apple-ioreport");
assert_eq!(e.provenance, Provenance::HwShunt, "a live IOReport span is a hardware reading");
} else {
assert_ne!(
m.name(),
"apple-ioreport",
"IOReport is used only when its CPU rail is live"
);
}
if matches!(m.name(), "apple-silicon" | "estimation") {
assert_eq!(e.provenance, Provenance::Estimator);
assert_eq!(e.measured_uj, None);
assert!(e.estimated_uj > 0, "{e:?}");
}
}
}

View file

@ -39,8 +39,12 @@ pub enum SchemaError {
/// [`Energy::check_provenance`] and again at seal time.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Provenance {
/// Read from a hardware energy interface (RAPL / NVML / SMC / shunt).
/// Requires a present `measured_uj`.
/// Read from a hardware energy interface: a shunt, or a vendor energy
/// counter (RAPL / NVML / Apple IOReport / SMC) while it is demonstrably
/// live for the domain the figure claims — the implementation observed the
/// counter covering that domain advance under load. A counter that is
/// present but not live (frozen, or republished in lumps) yields no
/// `HwShunt` figure. Requires a present `measured_uj`.
HwShunt,
/// Computed from a calibrated per-operation model. `measured_uj` may be
/// absent.