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"
grep -q 'calibration row stamped on macOS:' "$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
//! reserved for derived quantities (carbon, joules). This matches the
//! 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)]
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use crate::Provenance;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
@ -175,6 +183,32 @@ pub struct EnergyLedger {
total_uj: AtomicU64,
total_ops: AtomicU64,
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 {
@ -186,29 +220,42 @@ impl EnergyLedger {
total_uj: AtomicU64::new(0),
total_ops: 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]
pub fn record(&self, layer: OperationalLayer, microjoules: u64) {
pub fn record(&self, layer: OperationalLayer, microjoules: u64, provenance: Provenance) {
let idx = layer.index();
self.weakest.fetch_min(grade_code(provenance), Ordering::Relaxed);
self.layers[idx].fetch_add(microjoules, Ordering::Relaxed);
self.ops[idx].fetch_add(1, Ordering::Relaxed);
self.total_uj.fetch_add(microjoules, 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]
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();
self.weakest.fetch_min(grade_code(provenance), Ordering::Relaxed);
self.layers[idx].fetch_add(microjoules, Ordering::Relaxed);
self.ops[idx].fetch_add(op_count, Ordering::Relaxed);
self.total_uj.fetch_add(microjoules, 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.
#[inline]
pub fn total_uj(&self) -> u64 {
@ -285,6 +332,7 @@ impl EnergyLedger {
total_uwh: total_uj / 3600,
total_joules: total_uj as f64 / 1_000_000.0,
total_ops: self.total_ops(),
provenance: self.provenance(),
carbon_gco2_per_kwh: self.carbon_intensity_gco2_per_kwh(),
total_carbon_gco2e: self.total_carbon_gco2e(),
layers,
@ -327,6 +375,7 @@ impl EnergyLedger {
}
self.total_uj.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,
/// Total operation count.
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).
pub carbon_gco2_per_kwh: f64,
/// Total carbon emissions (gCO2e).
@ -368,8 +420,9 @@ impl LedgerSnapshot {
/// One-line human-readable summary.
pub fn summary(&self) -> String {
let grade = self.provenance.map_or_else(|| "no deposits".to_string(), |p| p.to_string());
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_uwh,
self.total_carbon_gco2e,
@ -419,13 +472,16 @@ pub struct CategoryBreakdown {
}
// ── Default cost constants ────────────────────────────────────────
// Conservative upper-bound estimates used when hardware metering is
// unavailable. Calibrated against RAPL on Xeon and Apple Silicon
// `powermetrics` (inv-energy donor measurements).
// Conservative upper-bound figures for when no meter is read. They came
// over with measurements from an earlier codebase (RAPL on Xeon,
// `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 {
/// TLS 1.3 ECDHE-P256 handshake (~300 μJ measured).
/// TLS 1.3 ECDHE-P256 handshake (~300 μJ).
pub const TLS_HANDSHAKE_UJ: u64 = 300;
/// JWP frame encode/decode (~5 μJ).
pub const JWP_FRAME_UJ: u64 = 5;
@ -468,6 +524,14 @@ pub mod costs {
/// JWP command dispatch (~10 μJ).
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.
pub fn transfer_uj(bytes: u64) -> u64 {
((bytes as f64) * TRANSFER_NJ_PER_BYTE / 1000.0) as u64
@ -481,9 +545,9 @@ mod tests {
#[test]
fn record_and_read() {
let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::TlsHandshake, 300);
ledger.record(OperationalLayer::Authentication, 15);
ledger.record(OperationalLayer::JwpFraming, 5);
ledger.record(OperationalLayer::TlsHandshake, 300, Provenance::HwShunt);
ledger.record(OperationalLayer::Authentication, 15, Provenance::HwShunt);
ledger.record(OperationalLayer::JwpFraming, 5, Provenance::HwShunt);
assert_eq!(ledger.total_uj(), 320);
assert_eq!(ledger.total_ops(), 3);
assert_eq!(ledger.layer_uj(OperationalLayer::TlsHandshake), 300);
@ -492,14 +556,14 @@ mod tests {
#[test]
fn uwh_conversion() {
let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::WasmExecution, 7200);
ledger.record(OperationalLayer::WasmExecution, 7200, Provenance::HwShunt);
assert_eq!(ledger.total_uwh(), 2);
}
#[test]
fn joules_conversion() {
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);
}
@ -507,15 +571,15 @@ mod tests {
fn carbon_accounting_kwh_scale() {
let ledger = EnergyLedger::new();
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);
}
#[test]
fn snapshot_only_active_layers() {
let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::TlsHandshake, 100);
ledger.record(OperationalLayer::MeshHeartbeat, 3);
ledger.record(OperationalLayer::TlsHandshake, 100, Provenance::HwShunt);
ledger.record(OperationalLayer::MeshHeartbeat, 3, Provenance::HwShunt);
let snap = ledger.snapshot();
assert_eq!(snap.layers.len(), 2);
assert_eq!(snap.total_uj, 103);
@ -524,10 +588,10 @@ mod tests {
#[test]
fn category_breakdown_sums_correctly() {
let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::TlsHandshake, 300);
ledger.record(OperationalLayer::JwpFraming, 5);
ledger.record(OperationalLayer::Authentication, 15);
ledger.record(OperationalLayer::WasmExecution, 1000);
ledger.record(OperationalLayer::TlsHandshake, 300, Provenance::HwShunt);
ledger.record(OperationalLayer::JwpFraming, 5, Provenance::HwShunt);
ledger.record(OperationalLayer::Authentication, 15, Provenance::HwShunt);
ledger.record(OperationalLayer::WasmExecution, 1000, Provenance::HwShunt);
let cats = ledger.category_breakdown();
let protocol = cats.iter().find(|c| c.category == "protocol").expect("protocol");
assert_eq!(protocol.energy_uj, 320);
@ -536,10 +600,47 @@ mod tests {
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]
fn batch_recording() {
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_ops(OperationalLayer::DataTransfer), 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
// exactly like inference — closing the "jouleclaw is inference-only" gap.
let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::Training, 12_000);
ledger.record(OperationalLayer::Inference, 3_000);
ledger.record(OperationalLayer::Training, 12_000, Provenance::HwShunt);
ledger.record(OperationalLayer::Inference, 3_000, Provenance::HwShunt);
assert_eq!(ledger.layer_uj(OperationalLayer::Training), 12_000);
// training shares the "ai" category with inference…
assert_eq!(OperationalLayer::Training.category(), "ai");
@ -563,7 +664,7 @@ mod tests {
#[test]
fn reset_clears_all() {
let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::Inference, 999);
ledger.record(OperationalLayer::Inference, 999, Provenance::HwShunt);
ledger.reset();
assert_eq!(ledger.total_uj(), 0);
assert_eq!(ledger.layer_uj(OperationalLayer::Inference), 0);
@ -594,8 +695,8 @@ mod tests {
#[test]
fn avg_uj_per_op() {
let ledger = EnergyLedger::new();
ledger.record(OperationalLayer::CommandDispatch, 100);
ledger.record(OperationalLayer::CommandDispatch, 200);
ledger.record(OperationalLayer::CommandDispatch, 100, Provenance::HwShunt);
ledger.record(OperationalLayer::CommandDispatch, 200, Provenance::HwShunt);
let snap = ledger.snapshot();
let cmd = snap
.layers
@ -615,7 +716,7 @@ mod tests {
let l = ledger.clone();
handles.push(std::thread::spawn(move || {
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);
}
#[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]
fn transfer_cost_calculation() {
assert_eq!(costs::transfer_uj(1_000_000), 500);