jouleclaw-energy: a ledger total carries its weakest grade; macOS meter code is linted in CI #259

Merged
dcharlot merged 1 commit from ci/meter-clippy-macos into main 2026-10-14 19:54:10 -04:00
2 changed files with 148 additions and 39 deletions

View file

@ -545,3 +545,19 @@ jobs:
cargo test -p jouleclaw-loader-gguf --features "rayon fabric-meter" --test process_calibration -- --nocapture 2>&1 | tee -a "$log" cargo test -p jouleclaw-loader-gguf --features "rayon fabric-meter" --test process_calibration -- --nocapture 2>&1 | tee -a "$log"
grep -q 'calibration row stamped on macOS:' "$log" grep -q 'calibration row stamped on macOS:' "$log"
grep -q 'process calibration: CPU member' "$log" grep -q 'process calibration: CPU member' "$log"
# The meter code the steps above test compiles only on macOS (IOReport,
# proc_pid_rusage, the process-tree readers), so no Linux job lints it.
# Lints in it went unseen until a macOS sweep found them (#244). Each
# crate is linted here, on its own platform and with the features its
# tests use, and a warning fails the step.
- name: clippy on the macOS meter code
shell: bash
run: |
set -eu
lint() { (cd "$1" && shift && cargo clippy --all-targets --no-deps "$@" -- -D warnings); }
lint jcp/jcp-rs -p jcp-energy -p jcp-gateway
lint proof/proof-rs -p proof-cascade -p proof-solvers
lint eoc/eoc-rs -p eoc-meter
lint jouleclaw/jouleclaw-rs -p jouleclaw-energy --features ioreport
lint sandbox/sandbox-rs -p sandbox-meter -p sandbox-proof

View file

@ -9,10 +9,18 @@
//! The ledger holds *integer microjoules only* — floating-point is //! The ledger holds *integer microjoules only* — floating-point is
//! reserved for derived quantities (carbon, joules). This matches the //! reserved for derived quantities (carbon, joules). This matches the
//! JouleClaw protocol's determinism guarantee. //! JouleClaw protocol's determinism guarantee.
//!
//! Every deposit carries the grade of the figure it deposits
//! ([`Provenance`]), and the ledger keeps the weakest grade it has been
//! given ([`EnergyLedger::provenance`]). A total is no better than its
//! weakest part: one estimated deposit makes the whole total an estimate,
//! however many readings sit beside it.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use crate::Provenance;
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -175,6 +183,32 @@ pub struct EnergyLedger {
total_uj: AtomicU64, total_uj: AtomicU64,
total_ops: AtomicU64, total_ops: AtomicU64,
carbon_gco2_per_kwh: AtomicU64, // f64 bits carbon_gco2_per_kwh: AtomicU64, // f64 bits
/// The weakest grade deposited ([`grade_code`]), or [`NO_DEPOSIT`].
weakest: AtomicU8,
}
/// [`EnergyLedger::weakest`] before anything is deposited: above every
/// grade's code, so the first deposit's `fetch_min` replaces it.
const NO_DEPOSIT: u8 = u8::MAX;
/// A grade as a code ordered weakest first, so the weakest of two is the
/// smaller code: the order of [`crate::span::weaker`].
fn grade_code(p: Provenance) -> u8 {
match p {
Provenance::Estimator => 0,
Provenance::ModelBased => 1,
Provenance::HwShunt => 2,
}
}
/// The grade a [`grade_code`] stands for; `None` for [`NO_DEPOSIT`].
fn grade_of(code: u8) -> Option<Provenance> {
match code {
0 => Some(Provenance::Estimator),
1 => Some(Provenance::ModelBased),
2 => Some(Provenance::HwShunt),
_ => None,
}
} }
impl EnergyLedger { impl EnergyLedger {
@ -186,29 +220,42 @@ impl EnergyLedger {
total_uj: AtomicU64::new(0), total_uj: AtomicU64::new(0),
total_ops: AtomicU64::new(0), total_ops: AtomicU64::new(0),
carbon_gco2_per_kwh: AtomicU64::new(0), carbon_gco2_per_kwh: AtomicU64::new(0),
weakest: AtomicU8::new(NO_DEPOSIT),
} }
} }
/// Record energy (μJ) consumed by a layer. Hot path. /// Record energy (μJ) consumed by a layer, graded `provenance`: the
/// grade of the figure, which for one of the [`costs`] defaults is
/// [`Provenance::Estimator`]. Hot path.
#[inline] #[inline]
pub fn record(&self, layer: OperationalLayer, microjoules: u64) { pub fn record(&self, layer: OperationalLayer, microjoules: u64, provenance: Provenance) {
let idx = layer.index(); let idx = layer.index();
self.weakest.fetch_min(grade_code(provenance), Ordering::Relaxed);
self.layers[idx].fetch_add(microjoules, Ordering::Relaxed); self.layers[idx].fetch_add(microjoules, Ordering::Relaxed);
self.ops[idx].fetch_add(1, Ordering::Relaxed); self.ops[idx].fetch_add(1, Ordering::Relaxed);
self.total_uj.fetch_add(microjoules, Ordering::Relaxed); self.total_uj.fetch_add(microjoules, Ordering::Relaxed);
self.total_ops.fetch_add(1, Ordering::Relaxed); self.total_ops.fetch_add(1, Ordering::Relaxed);
} }
/// Record an explicit batch (energy + operation count). /// Record an explicit batch (energy + operation count), graded
/// `provenance` (see [`EnergyLedger::record`]).
#[inline] #[inline]
pub fn record_batch(&self, layer: OperationalLayer, microjoules: u64, op_count: u64) { pub fn record_batch(&self, layer: OperationalLayer, microjoules: u64, op_count: u64, provenance: Provenance) {
let idx = layer.index(); let idx = layer.index();
self.weakest.fetch_min(grade_code(provenance), Ordering::Relaxed);
self.layers[idx].fetch_add(microjoules, Ordering::Relaxed); self.layers[idx].fetch_add(microjoules, Ordering::Relaxed);
self.ops[idx].fetch_add(op_count, Ordering::Relaxed); self.ops[idx].fetch_add(op_count, Ordering::Relaxed);
self.total_uj.fetch_add(microjoules, Ordering::Relaxed); self.total_uj.fetch_add(microjoules, Ordering::Relaxed);
self.total_ops.fetch_add(op_count, Ordering::Relaxed); self.total_ops.fetch_add(op_count, Ordering::Relaxed);
} }
/// The grade of every total this ledger reports: the weakest grade
/// among its deposits, `None` while it holds none.
#[inline]
pub fn provenance(&self) -> Option<Provenance> {
grade_of(self.weakest.load(Ordering::Relaxed))
}
/// Total energy in microjoules. /// Total energy in microjoules.
#[inline] #[inline]
pub fn total_uj(&self) -> u64 { pub fn total_uj(&self) -> u64 {
@ -285,6 +332,7 @@ impl EnergyLedger {
total_uwh: total_uj / 3600, total_uwh: total_uj / 3600,
total_joules: total_uj as f64 / 1_000_000.0, total_joules: total_uj as f64 / 1_000_000.0,
total_ops: self.total_ops(), total_ops: self.total_ops(),
provenance: self.provenance(),
carbon_gco2_per_kwh: self.carbon_intensity_gco2_per_kwh(), carbon_gco2_per_kwh: self.carbon_intensity_gco2_per_kwh(),
total_carbon_gco2e: self.total_carbon_gco2e(), total_carbon_gco2e: self.total_carbon_gco2e(),
layers, layers,
@ -327,6 +375,7 @@ impl EnergyLedger {
} }
self.total_uj.store(0, Ordering::Relaxed); self.total_uj.store(0, Ordering::Relaxed);
self.total_ops.store(0, Ordering::Relaxed); self.total_ops.store(0, Ordering::Relaxed);
self.weakest.store(NO_DEPOSIT, Ordering::Relaxed);
} }
} }
@ -350,6 +399,9 @@ pub struct LedgerSnapshot {
pub total_joules: f64, pub total_joules: f64,
/// Total operation count. /// Total operation count.
pub total_ops: u64, pub total_ops: u64,
/// The grade of every total here: the weakest grade among the
/// deposits, `None` for an empty ledger.
pub provenance: Option<Provenance>,
/// Grid carbon intensity (gCO2 / kWh). /// Grid carbon intensity (gCO2 / kWh).
pub carbon_gco2_per_kwh: f64, pub carbon_gco2_per_kwh: f64,
/// Total carbon emissions (gCO2e). /// Total carbon emissions (gCO2e).
@ -368,8 +420,9 @@ impl LedgerSnapshot {
/// One-line human-readable summary. /// One-line human-readable summary.
pub fn summary(&self) -> String { pub fn summary(&self) -> String {
let grade = self.provenance.map_or_else(|| "no deposits".to_string(), |p| p.to_string());
format!( format!(
"{:.3} J | {} µWh | {:.6} gCO₂e | {} ops across {} layers", "{:.3} J [{grade}] | {} µWh | {:.6} gCO₂e | {} ops across {} layers",
self.total_joules, self.total_joules,
self.total_uwh, self.total_uwh,
self.total_carbon_gco2e, self.total_carbon_gco2e,
@ -419,13 +472,16 @@ pub struct CategoryBreakdown {
} }
// ── Default cost constants ──────────────────────────────────────── // ── Default cost constants ────────────────────────────────────────
// Conservative upper-bound estimates used when hardware metering is // Conservative upper-bound figures for when no meter is read. They came
// unavailable. Calibrated against RAPL on Xeon and Apple Silicon // over with measurements from an earlier codebase (RAPL on Xeon,
// `powermetrics` (inv-energy donor measurements). // `powermetrics` on Apple Silicon); none is calibrated on the host that
// uses it.
/// Default per-operation energy cost estimates (μJ). /// Default per-operation energy costs (μJ). Each is a fixed figure, not a
/// reading of this host, so a deposit computed from one is
/// [`Provenance::Estimator`](crate::Provenance::Estimator).
pub mod costs { pub mod costs {
/// TLS 1.3 ECDHE-P256 handshake (~300 μJ measured). /// TLS 1.3 ECDHE-P256 handshake (~300 μJ).
pub const TLS_HANDSHAKE_UJ: u64 = 300; pub const TLS_HANDSHAKE_UJ: u64 = 300;
/// JWP frame encode/decode (~5 μJ). /// JWP frame encode/decode (~5 μJ).
pub const JWP_FRAME_UJ: u64 = 5; pub const JWP_FRAME_UJ: u64 = 5;
@ -468,6 +524,14 @@ pub mod costs {
/// JWP command dispatch (~10 μJ). /// JWP command dispatch (~10 μJ).
pub const COMMAND_DISPATCH_UJ: u64 = 10; pub const COMMAND_DISPATCH_UJ: u64 = 10;
// The orderings these defaults must keep, checked when the crate builds.
const _: () = {
assert!(TLS_HANDSHAKE_UJ > HEARTBEAT_UJ);
assert!(AUTH_ECDSA_VERIFY_UJ > AUTH_HMAC_UJ);
assert!(BLOCK_WRITE_4K_UJ > BLOCK_READ_4K_UJ);
assert!(BLAKE3_1K_UJ < SHA256_1K_UJ);
};
/// Bulk transfer cost. /// Bulk transfer cost.
pub fn transfer_uj(bytes: u64) -> u64 { pub fn transfer_uj(bytes: u64) -> u64 {
((bytes as f64) * TRANSFER_NJ_PER_BYTE / 1000.0) as u64 ((bytes as f64) * TRANSFER_NJ_PER_BYTE / 1000.0) as u64
@ -481,9 +545,9 @@ mod tests {
#[test] #[test]
fn record_and_read() { fn record_and_read() {
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::TlsHandshake, 300); ledger.record(OperationalLayer::TlsHandshake, 300, Provenance::HwShunt);
ledger.record(OperationalLayer::Authentication, 15); ledger.record(OperationalLayer::Authentication, 15, Provenance::HwShunt);
ledger.record(OperationalLayer::JwpFraming, 5); ledger.record(OperationalLayer::JwpFraming, 5, Provenance::HwShunt);
assert_eq!(ledger.total_uj(), 320); assert_eq!(ledger.total_uj(), 320);
assert_eq!(ledger.total_ops(), 3); assert_eq!(ledger.total_ops(), 3);
assert_eq!(ledger.layer_uj(OperationalLayer::TlsHandshake), 300); assert_eq!(ledger.layer_uj(OperationalLayer::TlsHandshake), 300);
@ -492,14 +556,14 @@ mod tests {
#[test] #[test]
fn uwh_conversion() { fn uwh_conversion() {
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::WasmExecution, 7200); ledger.record(OperationalLayer::WasmExecution, 7200, Provenance::HwShunt);
assert_eq!(ledger.total_uwh(), 2); assert_eq!(ledger.total_uwh(), 2);
} }
#[test] #[test]
fn joules_conversion() { fn joules_conversion() {
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::Inference, 1_000_000); ledger.record(OperationalLayer::Inference, 1_000_000, Provenance::HwShunt);
assert!((ledger.total_joules() - 1.0).abs() < 1e-10); assert!((ledger.total_joules() - 1.0).abs() < 1e-10);
} }
@ -507,15 +571,15 @@ mod tests {
fn carbon_accounting_kwh_scale() { fn carbon_accounting_kwh_scale() {
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.set_carbon_intensity(400.0); ledger.set_carbon_intensity(400.0);
ledger.record(OperationalLayer::WasmExecution, 3_600_000_000_000); ledger.record(OperationalLayer::WasmExecution, 3_600_000_000_000, Provenance::HwShunt);
assert!((ledger.total_carbon_gco2e() - 400.0).abs() < 0.01); assert!((ledger.total_carbon_gco2e() - 400.0).abs() < 0.01);
} }
#[test] #[test]
fn snapshot_only_active_layers() { fn snapshot_only_active_layers() {
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::TlsHandshake, 100); ledger.record(OperationalLayer::TlsHandshake, 100, Provenance::HwShunt);
ledger.record(OperationalLayer::MeshHeartbeat, 3); ledger.record(OperationalLayer::MeshHeartbeat, 3, Provenance::HwShunt);
let snap = ledger.snapshot(); let snap = ledger.snapshot();
assert_eq!(snap.layers.len(), 2); assert_eq!(snap.layers.len(), 2);
assert_eq!(snap.total_uj, 103); assert_eq!(snap.total_uj, 103);
@ -524,10 +588,10 @@ mod tests {
#[test] #[test]
fn category_breakdown_sums_correctly() { fn category_breakdown_sums_correctly() {
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::TlsHandshake, 300); ledger.record(OperationalLayer::TlsHandshake, 300, Provenance::HwShunt);
ledger.record(OperationalLayer::JwpFraming, 5); ledger.record(OperationalLayer::JwpFraming, 5, Provenance::HwShunt);
ledger.record(OperationalLayer::Authentication, 15); ledger.record(OperationalLayer::Authentication, 15, Provenance::HwShunt);
ledger.record(OperationalLayer::WasmExecution, 1000); ledger.record(OperationalLayer::WasmExecution, 1000, Provenance::HwShunt);
let cats = ledger.category_breakdown(); let cats = ledger.category_breakdown();
let protocol = cats.iter().find(|c| c.category == "protocol").expect("protocol"); let protocol = cats.iter().find(|c| c.category == "protocol").expect("protocol");
assert_eq!(protocol.energy_uj, 320); assert_eq!(protocol.energy_uj, 320);
@ -536,10 +600,47 @@ mod tests {
assert_eq!(compute.energy_uj, 1000); assert_eq!(compute.energy_uj, 1000);
} }
/// A total is graded by its weakest deposit, whatever the order of the
/// deposits; a ledger with none has no grade, and a reset clears it.
#[test]
fn a_total_is_graded_by_its_weakest_deposit() {
let ledger = EnergyLedger::new();
assert_eq!(ledger.provenance(), None);
assert_eq!(ledger.snapshot().provenance, None);
ledger.record(OperationalLayer::Inference, 1_000, Provenance::HwShunt);
assert_eq!(ledger.provenance(), Some(Provenance::HwShunt));
ledger.record(OperationalLayer::TlsHandshake, costs::TLS_HANDSHAKE_UJ, Provenance::Estimator);
ledger.record_batch(OperationalLayer::DataTransfer, 500, 10, Provenance::ModelBased);
ledger.record(OperationalLayer::Inference, 1_000, Provenance::HwShunt);
assert_eq!(ledger.provenance(), Some(Provenance::Estimator));
let snap = ledger.snapshot();
assert_eq!(snap.provenance, Some(Provenance::Estimator));
assert!(snap.summary().contains("[estimator]"), "{}", snap.summary());
ledger.reset();
assert_eq!(ledger.provenance(), None);
ledger.record_batch(OperationalLayer::DataTransfer, 500, 10, Provenance::ModelBased);
assert_eq!(ledger.provenance(), Some(Provenance::ModelBased));
}
/// The ledger's grade codes order grades as [`crate::span::weaker`]
/// does, so the weakest code is the weakest grade.
#[test]
fn grade_codes_keep_the_crate_order() {
let all = [Provenance::Estimator, Provenance::ModelBased, Provenance::HwShunt];
for a in all {
assert_eq!(grade_of(grade_code(a)), Some(a));
for b in all {
let weakest = grade_of(grade_code(a).min(grade_code(b)));
assert_eq!(weakest, Some(crate::span::weaker(a, b)), "{a} vs {b}");
}
}
assert_eq!(grade_of(NO_DEPOSIT), None);
}
#[test] #[test]
fn batch_recording() { fn batch_recording() {
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.record_batch(OperationalLayer::DataTransfer, 5000, 100); ledger.record_batch(OperationalLayer::DataTransfer, 5000, 100, Provenance::HwShunt);
assert_eq!(ledger.layer_uj(OperationalLayer::DataTransfer), 5000); assert_eq!(ledger.layer_uj(OperationalLayer::DataTransfer), 5000);
assert_eq!(ledger.layer_ops(OperationalLayer::DataTransfer), 100); assert_eq!(ledger.layer_ops(OperationalLayer::DataTransfer), 100);
assert_eq!(ledger.total_ops(), 100); assert_eq!(ledger.total_ops(), 100);
@ -550,8 +651,8 @@ mod tests {
// the meters are workload-agnostic, so training energy is attributed and totalled // the meters are workload-agnostic, so training energy is attributed and totalled
// exactly like inference — closing the "jouleclaw is inference-only" gap. // exactly like inference — closing the "jouleclaw is inference-only" gap.
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::Training, 12_000); ledger.record(OperationalLayer::Training, 12_000, Provenance::HwShunt);
ledger.record(OperationalLayer::Inference, 3_000); ledger.record(OperationalLayer::Inference, 3_000, Provenance::HwShunt);
assert_eq!(ledger.layer_uj(OperationalLayer::Training), 12_000); assert_eq!(ledger.layer_uj(OperationalLayer::Training), 12_000);
// training shares the "ai" category with inference… // training shares the "ai" category with inference…
assert_eq!(OperationalLayer::Training.category(), "ai"); assert_eq!(OperationalLayer::Training.category(), "ai");
@ -563,7 +664,7 @@ mod tests {
#[test] #[test]
fn reset_clears_all() { fn reset_clears_all() {
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::Inference, 999); ledger.record(OperationalLayer::Inference, 999, Provenance::HwShunt);
ledger.reset(); ledger.reset();
assert_eq!(ledger.total_uj(), 0); assert_eq!(ledger.total_uj(), 0);
assert_eq!(ledger.layer_uj(OperationalLayer::Inference), 0); assert_eq!(ledger.layer_uj(OperationalLayer::Inference), 0);
@ -594,8 +695,8 @@ mod tests {
#[test] #[test]
fn avg_uj_per_op() { fn avg_uj_per_op() {
let ledger = EnergyLedger::new(); let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::CommandDispatch, 100); ledger.record(OperationalLayer::CommandDispatch, 100, Provenance::HwShunt);
ledger.record(OperationalLayer::CommandDispatch, 200); ledger.record(OperationalLayer::CommandDispatch, 200, Provenance::HwShunt);
let snap = ledger.snapshot(); let snap = ledger.snapshot();
let cmd = snap let cmd = snap
.layers .layers
@ -615,7 +716,7 @@ mod tests {
let l = ledger.clone(); let l = ledger.clone();
handles.push(std::thread::spawn(move || { handles.push(std::thread::spawn(move || {
for _ in 0..1000 { for _ in 0..1000 {
l.record(OperationalLayer::JwpFraming, 5); l.record(OperationalLayer::JwpFraming, 5, Provenance::HwShunt);
} }
})); }));
} }
@ -626,14 +727,6 @@ mod tests {
assert_eq!(ledger.total_ops(), 10_000); assert_eq!(ledger.total_ops(), 10_000);
} }
#[test]
fn cost_constants_internally_consistent() {
assert!(costs::TLS_HANDSHAKE_UJ > costs::HEARTBEAT_UJ);
assert!(costs::AUTH_ECDSA_VERIFY_UJ > costs::AUTH_HMAC_UJ);
assert!(costs::BLOCK_WRITE_4K_UJ > costs::BLOCK_READ_4K_UJ);
assert!(costs::BLAKE3_1K_UJ < costs::SHA256_1K_UJ);
}
#[test] #[test]
fn transfer_cost_calculation() { fn transfer_cost_calculation() {
assert_eq!(costs::transfer_uj(1_000_000), 500); assert_eq!(costs::transfer_uj(1_000_000), 500);