From e2e4394eae5276244a4a38aa123e4f83f8160e5e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 09:45:02 -0400 Subject: [PATCH 01/12] joule-code-remediate: meter the L3 model's window into the run's figure l3_pass ran the L3 command under no meter at all, so RunReport::total_uj, uj_per_verified_fix and both receipts omitted the model's energy: the most expensive tier of a run was the one the figure left out. The command now runs under sh, which waits for a gate line and then execs a plain argv, so the model is the very process a TreeMeter is rooted at and the shell's start-up stays outside the window. The tree meter reads it at rest, the machine-wide span opens, the gate and prompt are written, stdout is drained, the child is waited for with WNOWAIT and the tree span stopped on its exited record, then it is reaped. grade_l3 is the one place the window is graded: attributed only for a whole reading of a command the operator declared self-contained (JOULE_FIX_L3_SELF_CONTAINED, the gateway's rule); any partial reading - every model that exits in its window has the Exited gap - is an estimate, the reading plus the machine-wide figure (mesh's ProcessMeter rule), never a hardware figure of the whole; no reading (Linux, counter not live) is the machine-wide figure as a cargo window's. JCR-1 DeviceEnergy and JouleContract EnergyClass carry no coverage, so a partial window grades the run down, never up. RunReport::l3 says what was read and why it is partial. Tested against an oracle sharing no code: a stand-in model (this test binary) reports its own ri_energy_nj from inside; the window read 0.999 of it, twice. Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/Cargo.lock | 1 + .../crates/joule-code-remediate/Cargo.toml | 5 + .../joule-code-remediate/src/bin/joule_fix.rs | 20 + .../crates/joule-code-remediate/src/lib.rs | 629 ++++++++++++++++-- 4 files changed, 594 insertions(+), 61 deletions(-) diff --git a/joule-code/joule-code-rs/Cargo.lock b/joule-code/joule-code-rs/Cargo.lock index 9b6eea4e3..3b3c147cf 100644 --- a/joule-code/joule-code-rs/Cargo.lock +++ b/joule-code/joule-code-rs/Cargo.lock @@ -1307,6 +1307,7 @@ dependencies = [ "joulecontract-receipt", "joulecontract-schema", "joulecontract-wire", + "libc", "proof-cascade", "proof-schema", "proof-solvers", diff --git a/joule-code/joule-code-rs/crates/joule-code-remediate/Cargo.toml b/joule-code/joule-code-rs/crates/joule-code-remediate/Cargo.toml index c2e15276f..b83ecaca9 100644 --- a/joule-code/joule-code-rs/crates/joule-code-remediate/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-code-remediate/Cargo.toml @@ -35,6 +35,11 @@ proof-cascade = { path = "../../../../proof/proof-rs/crates/proof-cascade" } proof-solvers = { path = "../../../../proof/proof-rs/crates/proof-solvers" } proof-schema = { path = "../../../../proof/proof-rs/crates/proof-schema" } +# macOS: the L3 model's process is waited for without being reaped +# (`waitid` with `WNOWAIT`), so the tree meter reads its exited count. +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" + [[bin]] name = "joule-fix" path = "src/bin/joule_fix.rs" diff --git a/joule-code/joule-code-rs/crates/joule-code-remediate/src/bin/joule_fix.rs b/joule-code/joule-code-rs/crates/joule-code-remediate/src/bin/joule_fix.rs index 35285fac0..57d57d6d3 100644 --- a/joule-code/joule-code-rs/crates/joule-code-remediate/src/bin/joule_fix.rs +++ b/joule-code/joule-code-rs/crates/joule-code-remediate/src/bin/joule_fix.rs @@ -42,6 +42,26 @@ fn main() { report.meter_source, ); } + if let Some(l3) = &report.l3 { + match l3.process_cpu_uj { + Some(read) => println!( + " of which the L3 model's window: {:.3} J ({}; its own process tree read {:.3} J of CPU, {})", + l3.figure_uj as f64 / 1e6, + if l3.attributed { "read whole" } else { "an estimate: that reading plus the machine-wide figure" }, + read as f64 / 1e6, + if l3.partial_because.is_empty() { + "whole".to_string() + } else { + format!("partial: {}", l3.partial_because.join("; ")) + }, + ), + None => println!( + " of which the L3 model's window: {:.3} J (machine-wide only{})", + l3.figure_uj as f64 / 1e6, + l3.partial_because.first().map(|w| format!("; {w}")).unwrap_or_default(), + ), + } + } if let Some(uj) = report.uj_per_verified_fix() { println!( "energy per verified fix: {:.3} J ({})", diff --git a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs index 79ff38c0f..f1b362171 100644 --- a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs @@ -38,7 +38,7 @@ use joulecontract_schema::{ Ceiling, ContentAddress, Contract, EnergyClass, GovernanceClass, ObligationKind, Profile, Rigor, }; -use sandbox_meter::{run_attributed, Attribution, PowerMeter}; +use sandbox_meter::{run_attributed, Attribution, PowerMeter, TreeError, TreeMeter, TreeReading}; use serde::{Deserialize, Serialize}; // The formally-verified fix tier. `DischargeStatus`/`Formalization`/ // `ContractDischarge` come from the proof bridge; the bridge's own @@ -53,37 +53,44 @@ use std::path::PathBuf; use std::process::Command; use std::time::{Duration, Instant}; -/// Accumulates per-process energy attribution across a run's cargo -/// subprocesses (the real compute). The harness's `run()` helper feeds this -/// for every `cargo` invocation; git and rustfmt are negligible overhead and -/// run unmetered. +/// Accumulates per-process energy attribution across a run's subprocesses +/// (the real compute): every `cargo` invocation, which the harness's `run()` +/// helper feeds, and the L3 model's command, which [`Harness::run_l3`] feeds. +/// git and rustfmt are negligible overhead and run unmetered. struct MeterAcc { /// `PowerMeter::detect()`, machine-wide, and deliberately not /// `detect_in_process()`: the metered work is `cargo` and the `rustc` - /// processes it runs, and this process's own energy counter covers no - /// child process. `run_attributed`'s per-child reading does not reach - /// them either: it covers a single-process child, and `cargo` runs its - /// work in descendants. + /// processes it runs, and the L3 model's process, and this process's own + /// energy counter covers no child process. `run_attributed`'s per-child + /// reading does not reach cargo's work either: it covers a single-process + /// child, and `cargo` runs its work in descendants. meter: PowerMeter, /// This run's metered windows, reset at the start of each run. tally: EnergyTally, + /// This run's L3 window, when the model was consulted: how it was metered + /// and what it added to `tally`. Reset with it. + l3: Option, } -/// The energy of one run's metered cargo windows, and the figure and grades -/// the receipts carry for it. +/// The energy of one run's metered windows — its cargo windows and its L3 +/// window — and the figure and grades the receipts carry for it. /// -/// A window is attributed when a hardware CPU reading that advanced was -/// apportioned to the child's process tree (`ModelBased`). Otherwise its +/// A cargo window is attributed when a hardware CPU reading that advanced +/// was apportioned to the child's process tree (`ModelBased`). Otherwise its /// figure is not a reading of this workload. It is the meter's wall × TDP /// estimate where the host exposes no live CPU counter (every macOS 27.0.1 /// host checked), or where the counter held; or it is a machine-wide figure -/// the meter could not apportion. The run is `ModelBased` only when every -/// window was attributed, because a sum is only as good as its weakest part. +/// the meter could not apportion. The L3 window is attributed only when its +/// model's own process tree was read whole ([`grade_l3`]). The run is +/// `ModelBased` only when every window was attributed, because a sum is only +/// as good as its weakest part. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct EnergyTally { - /// Σ machine-wide SoC-CPU figure over the windows (the upper bound). + /// Σ each window's figure as an estimate of its work: over a cargo + /// window the machine-wide SoC-CPU figure (an upper bound: every process + /// on the host), over the L3 window [`L3Energy::figure_uj`]. soc_uj: u64, - /// Σ figure apportioned to this run's process trees. + /// Σ figure apportioned or read for this run's process trees. attr_uj: u64, windows: u64, attributed_windows: u64, @@ -91,10 +98,17 @@ struct EnergyTally { impl EnergyTally { fn add(&mut self, a: &Attribution) { - self.soc_uj = self.soc_uj.saturating_add(a.soc_cpu_uj); - self.attr_uj = self.attr_uj.saturating_add(a.attributed_uj); + self.add_window(a.soc_cpu_uj, a.attributed_uj, a.attributed); + } + + /// One window: its figure as an estimate (`estimate_uj`), its figure as + /// a reading or apportionment of its own process tree (`attributed_uj`), + /// and whether that one is the window's honest figure (`attributed`). + fn add_window(&mut self, estimate_uj: u64, attributed_uj: u64, attributed: bool) { + self.soc_uj = self.soc_uj.saturating_add(estimate_uj); + self.attr_uj = self.attr_uj.saturating_add(attributed_uj); self.windows += 1; - if a.attributed { + if attributed { self.attributed_windows += 1; } } @@ -148,6 +162,205 @@ impl EnergyTally { } } +/// The L3 model's window: how its energy was read, and what it adds to the +/// run's figure ([`RunReport::total_uj`], and so +/// [`RunReport::uj_per_verified_fix`] and both receipts). +/// +/// Two meters span the window, from the moment the command is let go (see +/// [`Harness::l3_cmd`]) to the moment it has exited and been reaped: the +/// machine-wide meter, as over a cargo window, and — where this host counts +/// each process's CPU energy (macOS, its per-process counter live) — a +/// [`TreeMeter`] rooted at the command's own process, which reads the CPU +/// energy of that process and its descendants and lists every way that +/// figure was seen to fall short of their whole energy. [`grade_l3`] turns +/// the two into the window's figure, in one place. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct L3Energy { + /// The machine-wide meter's figure over the window, every device class + /// it reads, µJ: a hardware reading where the host has a live + /// machine-wide counter (RAPL, NVML on Linux), its wall-clock × TDP + /// estimate where it has none (every macOS 27.0.1 host checked). Like a + /// cargo window's, it is the whole host's, other processes included. + pub machine_uj: u64, + /// The CPU energy of the model's own process tree over the window, µJ + /// (the [`TreeReading`]'s nanojoules rounded up), where the host counts + /// it. `None` off macOS, where the per-process counter is not live, and + /// when the span gave no reading (`partial_because` says why). It covers + /// the model's CPU only: not its GPU or Neural Engine work, not work it + /// hands to a process outside its tree, and not the last time on a core + /// of a thread that exits (the kernel never folds it), which a model that + /// exits in the window always has. + pub process_cpu_uj: Option, + /// Why `process_cpu_uj` is not the model's whole energy over the window: + /// each gap the tree meter saw, as it words it, then + /// [`NOT_DECLARED_SELF_CONTAINED`] where the operator has not declared + /// the command self-contained ([`Harness::l3_self_contained`]); or why + /// there is no reading at all. Empty only for a whole reading. + pub partial_because: Vec, + /// Whether the command ran as the shell's `exec` — a plain argv — so the + /// model's process IS the process the tree meter is rooted at, and its + /// own count is read. A command that is not (a pipeline, a list, quoting, + /// a redirect) runs as the shell's script: what the shell forks and + /// reaps is a gap, its energy in no counter. + pub direct_child: bool, + /// What the window adds to the run's figure when the run is not + /// attributed (see [`grade_l3`]), µJ. + pub figure_uj: u64, + /// Whether the window is attributed: a whole reading of the model's own + /// process tree. Only then does `process_cpu_uj` stand for the window. + pub attributed: bool, +} + +/// Why a reading of the L3 command's process tree is partial although +/// nothing was seen missing from it: the operator has not declared that the +/// command does all its work in its own tree (no server, no daemon, no +/// remote API), which no reader of the tree can see. The same rule, and the +/// same words, as the MCP gateway's upstream-tree reading. +pub const NOT_DECLARED_SELF_CONTAINED: &str = "not-declared-self-contained"; + +impl L3Energy { + /// What the window's process reading added to the run's tree figures: + /// the reading itself, a whole one or a floor, else nothing. + fn attributed_uj(&self) -> u64 { + self.process_cpu_uj.unwrap_or(0) + } + + /// The window in one line, for [`RunReport::meter_source`]. + fn describe(&self) -> String { + let j = |uj: u64| uj as f64 / 1e6; + match self.process_cpu_uj { + Some(read) if self.attributed => format!( + "L3 model read whole on its own process tree, {:.3} J of CPU (per-process HwShunt, sealed no higher than \ + the run's grade)", + j(read) + ), + Some(read) => format!( + "L3 model's own process tree read {:.3} J of CPU, partial ({}), so the window is an estimate: that \ + reading plus the machine-wide {:.3} J, {:.3} J", + j(read), + self.partial_because.join("; "), + j(self.machine_uj), + j(self.figure_uj) + ), + None => format!( + "L3 model metered machine-wide only, {:.3} J ({})", + j(self.machine_uj), + if self.partial_because.is_empty() { + "no per-process counter on this host".to_string() + } else { + self.partial_because.join("; ") + } + ), + } + } +} + +/// The L3 window's figure and grade: the one place they are decided. +/// +/// - **A whole reading** — the tree meter saw nothing missing AND the +/// operator declared the command self-contained: the reading, attributed. +/// It is a hardware reading of the model's own process (`HwShunt`, +/// per-process), which the run's receipts carry at their strongest grade, +/// `ModelBased`: graded down, never up. Its GPU part is an exact zero (a +/// whole reading held no GPU client). +/// - **A partial reading** — any gap (a model that exits in the window +/// always has one: its exiting thread's last time on a core is never +/// folded, and its GPU time is unknown), or no declaration: an estimate of +/// the window's whole energy, never a hardware figure of it. The figure is +/// the reading plus the machine-wide meter's figure over the same window — +/// never less than was read, and nothing the model drew outside its CPU +/// left out — the rule mesh's in-process meter bills a partial span by. +/// The machine-wide figure covers the model's CPU too, so this errs high, +/// by at most the reading. +/// - **No reading** — no per-process counter (Linux, a counter that is not +/// live) or the span's [`TreeError`]: the machine-wide figure, exactly as +/// a cargo window's, never attributed (it is the whole host's). +fn grade_l3( + reading: Option<&Result>, + self_contained: bool, + machine_uj: u64, + direct_child: bool, +) -> L3Energy { + match reading { + Some(Ok(r)) => { + let read = r.cpu_nj.div_ceil(1_000); + let mut because: Vec = r.gaps.iter().map(ToString::to_string).collect(); + if !self_contained { + because.push(NOT_DECLARED_SELF_CONTAINED.to_string()); + } + let attributed = because.is_empty(); + let figure_uj = if attributed { read } else { read.saturating_add(machine_uj) }; + L3Energy { + machine_uj, + process_cpu_uj: Some(read), + partial_because: because, + direct_child, + figure_uj, + attributed, + } + } + Some(Err(e)) => L3Energy { + machine_uj, + process_cpu_uj: None, + partial_because: vec![format!("no reading of the model's process tree: {e}")], + direct_child, + figure_uj: machine_uj, + attributed: false, + }, + None => L3Energy { + machine_uj, + process_cpu_uj: None, + partial_because: Vec::new(), + direct_child, + figure_uj: machine_uj, + attributed: false, + }, + } +} + +/// The shell script an L3 command runs as, and whether it makes the model +/// the direct child. +/// +/// The script first waits on a gate — one line on its stdin, which +/// [`Harness::run_l3`] writes once the tree meter has read the waiting +/// shell — so the window opens before the model starts and the shell's own +/// start-up stays outside it. Then, for a plain argv (letters, digits, +/// spaces and `/._-:,+@` only: no quoting, expansion, assignment, redirect +/// or list), the shell `exec`s it, so the model is the same process the +/// meter is rooted at. Anything else runs as the rest of the script, as +/// `sh -c` would have run it: what it forks and reaps is in no counter. +fn l3_script(cmd: &str) -> (String, bool) { + let cmd = cmd.trim(); + let plain = !cmd.is_empty() && cmd.chars().all(|c| c.is_ascii_alphanumeric() || " /._-:,+@".contains(c)); + let body = if plain { format!("exec {cmd}") } else { cmd.to_string() }; + (format!("IFS= read -r joule_fix_gate || exit 1\n{body}\n"), plain) +} + +/// Wait for child `pid` to exit WITHOUT reaping it (`waitid` with +/// `WNOWAIT`), so its count stays readable for the tree meter's stop walk. +/// The caller reaps it afterwards. `false` if the wait failed. +#[cfg(target_os = "macos")] +fn wait_exited_unreaped(pid: u32) -> bool { + loop { + // SAFETY: an all-zero `siginfo_t` is a valid out-parameter. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is writable for the call; `pid` is this process's child. + let rc = unsafe { libc::waitid(libc::P_PID, pid as libc::id_t, &mut info, libc::WEXITED | libc::WNOWAIT) }; + if rc == 0 { + return true; + } + if std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + return false; + } + } +} + +/// No tree meter exists off macOS, so there is nothing to read before the reap. +#[cfg(not(target_os = "macos"))] +fn wait_exited_unreaped(_pid: u32) -> bool { + false +} + #[derive(Debug, thiserror::Error)] pub enum HarnessError { #[error("not a git work tree: {0} (the harness needs revert-ability before it will touch anything)")] @@ -237,18 +450,27 @@ pub struct RunReport { pub shipped: bool, /// The branch carrying the fix, when shipped. pub branch: Option, - /// Energy apportioned to this run's process trees, microjoules. When - /// `attributed`, it is a `ModelBased` apportionment of a real SoC reading: + /// Energy apportioned to, or read for, this run's process trees, + /// microjoules: each cargo window's apportionment, and the L3 model's + /// own process-tree reading where there is one ([`L3Energy`]). When + /// `attributed`, it is a `ModelBased` figure resting on real readings: /// the receipts' figure, and the honest per-workload cost. Otherwise it is - /// at best an estimate, and is context only. + /// at best an estimate or a floor, and is context only. pub attributed_uj: u64, - /// Machine-wide SoC-CPU figure over the metered windows, microjoules — the - /// upper bound. When not `attributed`, this is the receipts' figure, as an - /// estimate. + /// Each metered window's figure as an estimate, summed, microjoules: + /// over a cargo window the machine-wide SoC-CPU figure (an upper bound, + /// every process on the host), over the L3 window + /// [`L3Energy::figure_uj`]. When not `attributed`, this is the receipts' + /// figure, as an estimate. pub soc_cpu_uj: u64, /// True when every metered window rested on real per-process accounting /// of a hardware CPU reading. False means the receipts carry an estimate. pub attributed: bool, + /// The L3 model's window, when the model was consulted: its machine-wide + /// figure, its own process tree's reading and why that is partial, and + /// what it added to this run's figure. Its energy is in `soc_cpu_uj`, + /// `attributed_uj` and the receipts. + pub l3: Option, pub wall: Duration, pub meter_source: String, pub contract: Contract, @@ -268,10 +490,11 @@ pub struct RunReport { impl RunReport { /// Microjoules for the whole verified run (measure → fix → gate), the - /// figure the receipts carry. When `attributed`, it is the energy - /// ATTRIBUTED to this run's process trees, not the machine-wide total. - /// Otherwise it is the machine-wide figure over the metered windows, which - /// is then an estimate and is sealed as one (`Estimator`, `Unaccounted`). + /// figure the receipts carry: every cargo window and the L3 model's + /// window. When `attributed`, it is the energy ATTRIBUTED to this run's + /// process trees, not the machine-wide total. Otherwise it is each + /// window's estimate summed ([`soc_cpu_uj`](Self::soc_cpu_uj)), which is + /// then an estimate and is sealed as one (`Estimator`, `Unaccounted`). /// It is never an apportioned zero for work that ran. pub fn total_uj(&self) -> u64 { if self.attributed { @@ -374,7 +597,23 @@ pub struct Harness { /// a federation leg) — and whatever comes back faces the same gates as /// the deterministic tier, which is what makes a weak model SAFE to run. /// Defaults from `JOULE_FIX_L3_CMD`; `None` disables the tier. + /// + /// The command is metered ([`L3Energy`]) and its energy is part of the + /// run's figure. It runs under `sh`, which waits for a gate line before + /// it starts the command (so the meter opens on a shell at rest) and then + /// `exec`s a plain argv — `granite-fix /path/to/snapshot` — so the model + /// is the shell's own process and its own energy count is readable. A + /// command that needs the shell (a pipeline, a list, quoting) runs as the + /// shell's script instead, and what the shell forks and reaps is energy + /// no counter holds: the reading says so. pub l3_cmd: Option, + /// The operator's declaration that the L3 command does all its work in + /// its own process tree: no model server it calls, no daemon, no remote + /// API. No reader of the tree can see work handed outside it, so without + /// this a reading of the model's process is never whole + /// ([`NOT_DECLARED_SELF_CONTAINED`]). Defaults from + /// `JOULE_FIX_L3_SELF_CONTAINED=1`. + pub l3_self_contained: bool, /// Where induced skills persist. Defaults from `JOULE_FIX_SKILLS`, else /// `~/.joule-fix/skills.json`; the store is created on first induction. pub skill_store: PathBuf, @@ -418,6 +657,7 @@ impl Harness { signing: ed25519_dalek::SigningKey::from_bytes(&[11u8; 32]), meter_signing: ed25519_dalek::SigningKey::from_bytes(&[12u8; 32]), l3_cmd: std::env::var("JOULE_FIX_L3_CMD").ok().filter(|s| !s.trim().is_empty()), + l3_self_contained: std::env::var("JOULE_FIX_L3_SELF_CONTAINED").as_deref() == Ok("1"), skill_store: std::env::var("JOULE_FIX_SKILLS") .map(PathBuf::from) .unwrap_or_else(|_| { @@ -427,6 +667,7 @@ impl Harness { meter: RefCell::new(MeterAcc { meter: PowerMeter::detect(), tally: EnergyTally::default(), + l3: None, }), #[cfg(test)] cargo_target_dir, @@ -445,7 +686,7 @@ impl Harness { if program == "cargo" { let args = self.cargo_args(args); let mut acc = self.meter.borrow_mut(); - let MeterAcc { meter, tally } = &mut *acc; + let MeterAcc { meter, tally, .. } = &mut *acc; let (ok, text, a) = run_attributed(meter, program, &args, &self.workspace) .map_err(|e| HarnessError::Spawn(format!("{program}: {e}")))?; tally.add(&a); @@ -546,12 +787,17 @@ impl Harness { let prior_rev = self.git_head()?; let prior = ContentId::of_bytes(prior_rev.as_bytes()); - // Reset the per-run tally, all of it; this run's cargo calls feed it. - // An earlier run's windows are not this run's energy: without the - // reset, a second run on this Harness would seal the first run's - // cargo figure into its own receipts (on every host, estimated or - // not), and an attributed earlier window could grade this one. - self.meter.borrow_mut().tally = EnergyTally::default(); + // Reset the per-run tally, all of it; this run's cargo calls and its + // L3 window feed it. An earlier run's windows are not this run's + // energy: without the reset, a second run on this Harness would seal + // the first run's figure into its own receipts (on every host, + // estimated or not), and an attributed earlier window could grade + // this one. + { + let mut acc = self.meter.borrow_mut(); + acc.tally = EnergyTally::default(); + acc.l3 = None; + } let wall_start = Instant::now(); // ---- Stage 0: baseline (the `pre` obligation's evidence). @@ -787,17 +1033,18 @@ impl Harness { }; // ---- Energy: the per-window accounting accumulated across the run's - // cargo subprocesses. When every window was attributed, the receipts - // carry the ATTRIBUTED figure (ModelBased — an apportionment of a real - // SoC reading), not the machine-wide total. Otherwise they carry an - // estimate and grade it as one — never a figure signed as a reading. - let (tally, meter_sources) = { + // cargo subprocesses and its L3 model. When every window was + // attributed, the receipts carry the ATTRIBUTED figure (ModelBased — + // resting on real readings), not the machine-wide total. Otherwise + // they carry an estimate and grade it as one — never a figure signed + // as a reading. + let (tally, meter_sources, l3) = { let acc = self.meter.borrow(); - (acc.tally, acc.meter.sources()) + (acc.tally, acc.meter.sources(), acc.l3.clone()) }; let (soc_cpu_uj, attributed_uj, attributed) = (tally.soc_uj, tally.attr_uj, tally.attributed()); let wall = wall_start.elapsed(); - let meter_source = if attributed { + let base_source = if attributed { "attributed(ModelBased over IOReport HwShunt)".to_string() } else if meter_sources.is_empty() { "estimated(Unaccounted): wall-clock × TDP, no live energy counter on this host".to_string() @@ -808,6 +1055,10 @@ impl Harness { meter_sources.join("+") ) }; + let meter_source = match &l3 { + Some(l3) => format!("{base_source}; {}", l3.describe()), + None => base_source, + }; // The sandbox meter receipt seals the receipts' figure with its grade: // ModelBased for an attribution, the Estimator grade for an estimate. @@ -848,6 +1099,7 @@ impl Harness { attributed_uj, soc_cpu_uj, attributed, + l3, wall, meter_source, contract, @@ -1080,9 +1332,10 @@ impl Harness { /// One L3 consultation: prompt = the remaining findings plus the touched /// files' contents; reply = SEARCH/REPLACE blocks, applied verbatim. /// Returns true when the model changed anything. The GATES decide whether - /// any of it survives — this function trusts nothing it applies. + /// any of it survives — this function trusts nothing it applies. The + /// model's command is metered and its energy enters the run's figure + /// ([`run_l3`](Self::run_l3)). fn l3_pass(&self) -> Result { - use std::io::Write; let cmd = self.l3_cmd.as_ref().expect("caller checked"); let all = self.current_findings()?; @@ -1117,21 +1370,7 @@ impl Harness { } } - let mut child = Command::new("sh") - .args(["-c", cmd]) - .current_dir(&self.workspace) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|e| HarnessError::Spawn(format!("L3 cmd: {e}")))?; - if let Some(stdin) = child.stdin.as_mut() { - let _ = stdin.write_all(prompt.as_bytes()); - } - let out = child - .wait_with_output() - .map_err(|e| HarnessError::Spawn(format!("L3 cmd: {e}")))?; - let reply = String::from_utf8_lossy(&out.stdout).into_owned(); + let reply = self.run_l3(cmd, prompt.as_bytes())?; if self.apply_search_replace(&reply) { return Ok(true); @@ -1142,6 +1381,67 @@ impl Harness { Ok(false) } + /// Run the L3 command on `prompt` and meter it: its stdout, with the + /// window's [`L3Energy`] added to this run's tally. + /// + /// The command is spawned as [`l3_script`] says, waiting on its gate. + /// The tree meter (where this host has one) is rooted at that process and + /// reads it at rest; the machine-wide span opens; the gate line and the + /// prompt go to its stdin, and it runs. Its stdout is read to the end, it + /// is waited for without being reaped, so its exited count is still + /// readable, and the tree meter's span is stopped on it; then it is + /// reaped and the machine-wide span closed. [`grade_l3`] grades the two. + fn run_l3(&self, cmd: &str, prompt: &[u8]) -> Result { + use std::io::{Read, Write}; + let (script, direct_child) = l3_script(cmd); + // The per-process counter's liveness probe burns about 300 ms of CPU + // on this thread the first time: here, before anything is spawned. + let _ = sandbox_meter::process_energy_status(); + let mut child = Command::new("sh") + .args(["-c", &script]) + .current_dir(&self.workspace) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| HarnessError::Spawn(format!("L3 cmd: {e}")))?; + let pid = child.id(); + // `None` off macOS, where the per-process counter is not live, or + // where the process cannot be read. + let tree = TreeMeter::for_process(pid); + let tree_start = tree.as_ref().map(TreeMeter::start); + + let mut acc = self.meter.borrow_mut(); + let machine = acc.meter.start(); + if let Some(mut stdin) = child.stdin.take() { + // The gate, then the prompt; dropping stdin closes it. A command + // that stops reading early closes the pipe, and the rest of the + // prompt is its own business, as it always was. + let _ = stdin.write_all(b"\n").and_then(|()| stdin.write_all(prompt)); + } + let mut reply = Vec::new(); + if let Some(mut out) = child.stdout.take() { + let _ = out.read_to_end(&mut reply); + } + let reading = match (&tree, tree_start) { + (Some(tree), Some(start)) => { + // Exited and not reaped: the stop walk reads its final record. + // Should the wait fail, the walk reads whatever the process is + // then, and its gaps say so. + let _ = wait_exited_unreaped(pid); + Some(tree.stop(start)) + } + _ => None, + }; + let status = child.wait(); + let m = machine.stop(&mut acc.meter); + let l3 = grade_l3(reading.as_ref(), self.l3_self_contained, m.total_uj(), direct_child); + acc.tally.add_window(l3.figure_uj, l3.attributed_uj(), l3.attributed); + acc.l3 = Some(l3); + status.map_err(|e| HarnessError::Spawn(format!("L3 cmd: {e}")))?; + Ok(String::from_utf8_lossy(&reply).into_owned()) + } + /// Apply a whole-file reply: the first fenced code block becomes the /// file's new contents. Trusts nothing — the gates judge the result. fn apply_fenced_file(&self, reply: &str, path: &str) -> bool { @@ -1635,6 +1935,213 @@ fn answers() { assert_eq!(answer(), 42); } let src = std::fs::read_to_string(dir.join("src/main.rs")).unwrap(); assert!(src.contains("std::f64::consts::PI")); + // The model's window was metered and is in the run's figure. The + // mock is a shell script that forks `cat`: on a host that reads the + // model's own process, that reaped `cat` is a gap, so the window is + // an estimate holding the reading and the machine-wide figure. + let l3 = report.l3.clone().expect("the L3 window was metered"); + eprintln!("l3 window: {l3:?}; run total {} µJ ({})", report.total_uj(), report.meter_source); + assert!(l3.machine_uj > 0 && l3.figure_uj >= l3.machine_uj, "{l3:?}"); + assert!(l3.direct_child, "`./mock-l3.sh` is a plain argv"); + assert!(!l3.attributed, "a shell script that forks is never read whole: {l3:?}"); + assert!(!report.attributed); + assert!(report.total_uj() >= l3.figure_uj, "{} µJ total < the L3 window's {l3:?}", report.total_uj()); + assert!(report.meter_source.contains("L3 model"), "{}", report.meter_source); + if let Some(read) = l3.process_cpu_uj { + assert_eq!(l3.figure_uj, read + l3.machine_uj, "{l3:?}"); + assert!(l3.partial_because.iter().any(|w| w.contains("reaped")), "the forked `cat`: {l3:?}"); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The L3 window's grading, pure: a reading of the model's process tree + /// is attributed only with no gap AND the operator's declaration; any + /// other reading is an estimate of the window, the reading plus the + /// machine-wide figure; no reading is the machine-wide figure alone. + #[test] + fn the_l3_window_is_attributed_only_when_read_whole_and_declared_self_contained() { + use sandbox_meter::TreeGap; + let reading = |cpu_nj: u64, gaps: Vec| TreeReading { + cpu_uj: cpu_nj.div_ceil(1_000), + cpu_nj, + processes: 1, + pids: vec![4242], + wall: Duration::from_secs(2), + gaps, + }; + let machine = 40_000_000; + + let whole = grade_l3(Some(&Ok(reading(3_000_000_001, vec![]))), true, machine, true); + assert!(whole.attributed && whole.partial_because.is_empty(), "{whole:?}"); + assert_eq!((whole.process_cpu_uj, whole.figure_uj), (Some(3_000_001), 3_000_001), "rounded up, never down"); + + let undeclared = grade_l3(Some(&Ok(reading(3_000_000_000, vec![]))), false, machine, true); + assert!(!undeclared.attributed); + assert_eq!(undeclared.partial_because, vec![NOT_DECLARED_SELF_CONTAINED.to_string()]); + assert_eq!(undeclared.figure_uj, 3_000_000 + machine); + + // The shape every model that exits in its window has, declared or not. + let exited = grade_l3(Some(&Ok(reading(3_000_000_000, vec![TreeGap::Exited { pid: 4242 }]))), true, machine, true); + assert!(!exited.attributed); + assert_eq!(exited.partial_because, vec![TreeGap::Exited { pid: 4242 }.to_string()]); + assert_eq!((exited.process_cpu_uj, exited.figure_uj), (Some(3_000_000), 3_000_000 + machine)); + + let gpu = grade_l3(Some(&Ok(reading(5_000, vec![TreeGap::Gpu { ns: 9 }]))), false, machine, false); + assert_eq!(gpu.partial_because.len(), 2, "{gpu:?}"); + assert_eq!(gpu.figure_uj, 5 + machine); + assert!(!gpu.direct_child); + + let no_reading = grade_l3(Some(&Err(TreeError::RootGone { pid: 4242 })), true, machine, true); + assert!(!no_reading.attributed); + assert_eq!((no_reading.process_cpu_uj, no_reading.figure_uj), (None, machine)); + assert!(no_reading.partial_because[0].contains("no reading"), "{no_reading:?}"); + + let no_meter = grade_l3(None, true, machine, true); + assert_eq!((no_meter.process_cpu_uj, no_meter.figure_uj, no_meter.attributed), (None, machine, false)); + assert!(no_meter.partial_because.is_empty()); + + // In the tally: an estimate, unless whole. + for (l3, attributed) in [(&whole, true), (&exited, false), (&no_meter, false)] { + let mut t = EnergyTally::default(); + t.add_window(l3.figure_uj, l3.attributed_uj(), l3.attributed); + assert_eq!(t.attributed(), attributed, "{l3:?}"); + assert_eq!(t.figure_uj(), l3.figure_uj, "{l3:?}"); + let class = if attributed { EnergyClass::ModelBased } else { EnergyClass::Unaccounted }; + assert_eq!(t.energy_class(), class, "never above ModelBased, never a reading of a partial window"); + } + } + + /// A plain argv is `exec`ed after the gate, so the model is the meter's + /// root process; anything that needs the shell runs as its script. + #[test] + fn a_plain_l3_command_is_execed_after_the_gate_and_a_shell_command_is_not() { + let (script, direct) = l3_script(" granite-fix /models/granite-4.0-h_snapshot "); + assert!(direct); + assert_eq!(script, "IFS= read -r joule_fix_gate || exit 1\nexec granite-fix /models/granite-4.0-h_snapshot\n"); + assert!(l3_script("./mock-l3.sh").1); + assert!(l3_script("/bin/model --threads 8 -m a.gguf").1); + for shell in [ + "model | tee log", + "model; true", + "MODEL_THREADS=8 model", + "model > out", + "model \"a b\"", + "model $HOME/x", + "~/bin/model", + "model &", + "", + ] { + let (script, direct) = l3_script(shell); + assert!(!direct, "{shell:?}"); + assert!(script.starts_with("IFS= read -r joule_fix_gate || exit 1\n"), "{script}"); + assert!(!script.contains("exec "), "{script}"); + } + } + + /// The stand-in model's marker and report, in its working directory (the + /// scratch workspace). + const STAND_IN_MARKER: &str = ".l3-stand-in"; + const STAND_IN_REPORT: &str = ".l3-stand-in-report"; + + /// This process's own CPU energy count, nJ: `proc_pid_rusage(getpid(), + /// RUSAGE_INFO_V6)`'s `ri_energy_nj`, read with a bare buffer of the + /// struct's 464 bytes at its SDK offset, 336. Written out here, sharing + /// no code with the meter it checks. + #[cfg(target_os = "macos")] + fn own_energy_nj() -> u64 { + extern "C" { + fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut std::ffi::c_void) -> i32; + } + let mut buf = [0u64; 58]; + // SAFETY: `buf` is 464 writable bytes, the size of `rusage_info_v6`. + let rc = unsafe { proc_pid_rusage(std::process::id() as i32, 6, buf.as_mut_ptr().cast()) }; + assert_eq!(rc, 0, "proc_pid_rusage on this process"); + buf[336 / 8] + } + + #[cfg(not(target_os = "macos"))] + fn own_energy_nj() -> u64 { + 0 + } + + /// Not a test of its own: the stand-in L3 model, run as a subprocess by + /// [`the_l3_models_energy_enters_the_run_total_as_its_own_count_says`] + /// (this test binary, filtered to this test). It reads its prompt to the + /// end, does a fixed amount of CPU work, leaves the core (which folds its + /// count), and writes its own count and the prompt's length to its + /// working directory. Anywhere without its marker it returns at once. + #[test] + #[ignore = "a subprocess stand-in, run by the L3 energy test"] + fn l3_stand_in_model() { + use std::io::Read; + let Ok(iterations) = std::fs::read_to_string(STAND_IN_MARKER) else { return }; + let iterations: u64 = iterations.trim().parse().expect("the marker holds an iteration count"); + let mut prompt = Vec::new(); + std::io::stdin().read_to_end(&mut prompt).expect("the prompt"); + let mut x = 1u64; + for i in 0..iterations { + x = std::hint::black_box(x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(i)); + } + std::thread::sleep(Duration::from_millis(20)); + std::fs::write(STAND_IN_REPORT, format!("{} {} {x}", own_energy_nj(), prompt.len())).expect("the report"); + } + + /// The L3 model's energy is now in the run's figure. The model here is a + /// stand-in that burns a fixed amount of CPU and then reports its own + /// energy count, read from inside it before it exits: the oracle, which + /// shares no code with the tree meter. Where this host reads the model's + /// process (macOS, the per-process counter live) the window's reading of + /// it agrees with that count to within the shell's start-up before the + /// gate (in the oracle, not the window) and the stand-in's tear-down + /// after its report (in the window, not the oracle); the window is an + /// estimate, partial because the model exited in it; and its figure, the + /// reading plus the machine-wide figure, is what the run's tally holds. + /// Elsewhere it is the machine-wide figure. Two windows, accumulated. + #[test] + fn the_l3_models_energy_enters_the_run_total_as_its_own_count_says() { + // About a second of one core, unoptimized; a fixed amount of work, + // however loaded the host. + const ITERATIONS: u64 = 150_000_000; + let dir = scratch_crate("l3-energy"); + std::fs::write(dir.join(STAND_IN_MARKER), ITERATIONS.to_string()).unwrap(); + let exe = std::env::current_exe().expect("this test binary"); + let cmd = format!("{} tests::l3_stand_in_model --exact --ignored", exe.display()); + let h = Harness::new(&dir).unwrap(); + let prompt = vec![b'p'; 100_000]; + let reads_processes = + cfg!(target_os = "macos") && sandbox_meter::process_energy_status() == sandbox_meter::CounterStatus::Live; + + let mut figures = 0u64; + for window in 1..=2u64 { + let reply = h.run_l3(&cmd, &prompt).expect("the stand-in ran"); + assert!(reply.contains("1 passed"), "the stand-in did not run: {reply}"); + let report = std::fs::read_to_string(dir.join(STAND_IN_REPORT)).expect("the stand-in's report"); + let mut fields = report.split_whitespace().map(|f| f.parse::().unwrap()); + let (oracle_nj, prompt_len) = (fields.next().unwrap(), fields.next().unwrap()); + assert_eq!(prompt_len, prompt.len() as u64, "the model read the whole prompt, and not the gate"); + let acc = h.meter.borrow(); + let l3 = acc.l3.clone().expect("the window was metered"); + eprintln!("l3 window {window}: oracle {oracle_nj} nJ (the model's own count); {l3:?}"); + figures += l3.figure_uj; + assert_eq!(acc.tally.windows, window); + assert_eq!(acc.tally.figure_uj(), figures, "the run's figure holds every L3 window"); + assert!(!acc.tally.attributed()); + assert!(l3.machine_uj > 0, "{l3:?}"); + if reads_processes { + assert!(l3.direct_child, "setup: {cmd} is a plain argv"); + let read = l3.process_cpu_uj.unwrap_or_else(|| panic!("no reading of the model: {l3:?}")); + let oracle_uj = oracle_nj / 1_000; + let ratio = read as f64 / oracle_uj as f64; + assert!(oracle_uj > 100_000, "setup: the stand-in's work read {oracle_uj} µJ"); + assert!((0.8..=1.25).contains(&ratio), "the window read {read} µJ of a model that counted {oracle_uj} µJ"); + assert!(!l3.attributed, "{l3:?}"); + assert!(l3.partial_because.iter().any(|w| w.contains("exited")), "{l3:?}"); + assert_eq!(l3.figure_uj, read + l3.machine_uj); + } else { + assert_eq!((l3.process_cpu_uj, l3.figure_uj), (None, l3.machine_uj), "{l3:?}"); + } + } let _ = std::fs::remove_dir_all(&dir); } -- 2.54.0 (Apple Git-157) From 9546e45599235641af23858086697080aac0f4ff Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 09:54:50 -0400 Subject: [PATCH 02/12] jouledesk-github: read the model subprocess on its own process tree Each mention runs one llama-completion subprocess, metered only by the host meter: machine-wide, and on macOS 27 the load-average estimator. The model's own process was never read, although this host counts each process's CPU energy. The model now runs behind a gate (sh waits for a line, then execs "$0" "$@", so arguments are argv, never re-parsed): a TreeMeter rooted at that process reads it at rest, the gate lets it go, its stdout is drained, it is waited for with WNOWAIT, the span is stopped on its exited record, then it is reaped. grade_work is the one place the figure is decided: HwShunt only for a whole reading of a model the operator declared self-contained (JOULEDESK_LLAMA_SELF_CONTAINED=1); a partial reading - every model that exits in its window, and llama.cpp offloading to Metal - is sealed as an Estimator, the reading plus the host meter's figure, because efficiency/1 has no coverage field and its figure stands for the whole; a partial reading with no host figure is no figure and is not sealed; no reading leaves the host meter's figure as it was. The reply carries the reading and why it is partial on its own line, on GitHub and Slack. Tested against an oracle sharing no code: a stand-in model (this test binary behind a script taking the model's arguments) reports its own ri_energy_nj from inside; the reading was 0.998-0.999 of it, declared and undeclared. Co-Authored-By: Claude Opus 5.5 --- jouledesk/jouledesk-rs/Cargo.lock | 69 +++ .../crates/jouledesk-github/Cargo.toml | 8 + .../crates/jouledesk-github/src/lib.rs | 499 +++++++++++++++++- .../crates/jouledesk-github/src/slack.rs | 9 +- 4 files changed, 559 insertions(+), 26 deletions(-) diff --git a/jouledesk/jouledesk-rs/Cargo.lock b/jouledesk/jouledesk-rs/Cargo.lock index b5eb9e6ff..ab19c9b07 100644 --- a/jouledesk/jouledesk-rs/Cargo.lock +++ b/jouledesk/jouledesk-rs/Cargo.lock @@ -226,6 +226,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.6.6" @@ -350,6 +377,12 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -784,6 +817,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1266,6 +1310,8 @@ dependencies = [ "jcp-efficiency", "jouleclaw-energy", "jouledesk", + "libc", + "sandbox-meter", "serde_json", "tiny_http", "ureq", @@ -2302,6 +2348,29 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandbox-meter" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "libc", + "libloading", + "sandbox-schema", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "sandbox-schema" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "blake3", + "ciborium", + "serde", + "thiserror 2.0.20", +] + [[package]] name = "schannel" version = "0.1.29" diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml b/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml index 80a623aa4..cc0925d3b 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml @@ -20,10 +20,18 @@ jouledesk = { path = "../jouledesk" } # 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"] } +# The model subprocess's own CPU energy, where the host counts each process's +# (macOS): `TreeMeter`, rooted at the model's process. +sandbox-meter = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-meter" } serde_json = "1" ureq = { version = "2", features = ["json"] } tiny_http = "0.12" +# macOS: the model's process is waited for without being reaped (`waitid` with +# `WNOWAIT`), so the tree meter reads its exited count. +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" + [dev-dependencies] # Tests read the sealed efficiency/1 receipt back to check its provenance. jcp-efficiency = { path = "../../../../jcp/jcp-rs/crates/jcp-efficiency" } diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs index 8bd589e48..b81096b05 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs @@ -9,6 +9,15 @@ //! 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. +//! +//! The model runs in a subprocess (`llama-completion`), and where this host counts each +//! process's CPU energy (macOS, its per-process counter live) that process is read on +//! its own ([`sandbox_meter::TreeMeter`]) beside the host meter. The reading covers the +//! model's CPU only: a model that exits in its window always lacks its last time on a +//! core, and llama.cpp built for Apple offloads to the GPU by default, which the reading +//! does not cover. An `efficiency/1` receipt has no coverage field, so such a reading is +//! never sealed as a hardware figure of the work: it is sealed as an estimate, the +//! reading plus the host meter's figure ([`grade_work`]). use std::time::{Duration, Instant}; @@ -16,6 +25,7 @@ use jouleclaw_energy::meter::CompositeMeter; use jouleclaw_energy::{EnergyReading, Provenance}; use jouledesk::surface::{parse_event, Surface}; use jouledesk::{EnergyProvenance, Skill, SkillOutcome, TagOutcome, Teammate}; +use sandbox_meter::{TreeError, TreeMeter, TreeReading}; use serde_json::{json, Value}; /// The Slack binding — the same core ([`do_work`] + [`seal`]) on a second surface. @@ -41,6 +51,32 @@ fn configured_model() -> String { panic!("a unit test reached the configured model ({DEFAULT_MODEL} unless JOULEDESK_MODEL); pass a model path") } +/// The binary that runs a model, `JOULEDESK_LLAMA`, else `llama-completion`; and the +/// operator's declaration, `JOULEDESK_LLAMA_SELF_CONTAINED=1`, that it does all its work +/// in its own process tree (no model server it calls, no daemon, no remote API), which no +/// reader of the tree can see. Read only when a model file is there to run. +#[cfg(not(test))] +fn configured_llama() -> Llama { + Llama { + bin: std::env::var("JOULEDESK_LLAMA").unwrap_or_else(|_| "llama-completion".into()), + self_contained: std::env::var("JOULEDESK_LLAMA_SELF_CONTAINED").as_deref() == Ok("1"), + } +} + +/// Unit tests give the binary themselves ([`do_work_via`]): none may read the +/// process-global environment, which tests running in parallel would race on. +#[cfg(test)] +fn configured_llama() -> Llama { + panic!("a unit test reached the configured llama binary; pass one to do_work_via") +} + +/// How a model is run: the binary, and whether the operator declared it self-contained. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Llama { + pub(crate) bin: String, + pub(crate) self_contained: bool, +} + /// 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 { @@ -57,8 +93,35 @@ pub struct Work { pub model: String, /// Why the meter produced no figure for the work, when `provenance` is `unmetered`. pub unmetered: Option, + /// The model's own process, read where this host counts it: its CPU energy over the + /// run and why that is not the work's whole energy. `None` for the fallback, off + /// macOS, where the per-process counter is not live, or when the span gave no reading. + pub model_process: Option, } +/// What the model's own process tree read over its run ([`sandbox_meter::TreeMeter`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelProcess { + /// The CPU energy of the model's process and its descendants over the run, µJ (the + /// reading's nanojoules rounded up). Its CPU only: not its GPU or Neural Engine + /// work, not work handed to a process outside its tree, and not the last time on a + /// core of a thread that exits, which a model that exits in its window always has. + pub cpu_uj: u64, + /// Why `cpu_uj` is not the model's whole energy: each gap the meter saw, as it words + /// it, then [`NOT_DECLARED_SELF_CONTAINED`] where the operator has not declared the + /// model self-contained. Empty only for a whole reading. + pub partial_because: Vec, +} + +/// Why a reading of the model's process tree is partial although nothing was seen +/// missing from it: the operator has not declared that the model does all its work in +/// its own tree, which no reader of the tree can see. +pub const NOT_DECLARED_SELF_CONTAINED: &str = "not-declared-self-contained"; + +/// The meter name a work's figure carries when it was read on the model's own process +/// tree; joined with the host meter's name when that figure is in it too. +const MODEL_PROCESS_TREE: &str = "model-process-tree"; + /// `Work::provenance` for work whose meter produced no figure for it. const UNMETERED: &str = "unmetered"; @@ -71,22 +134,134 @@ pub fn do_work(question: &str) -> Work { /// [`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. -/// -/// The meter is the host's, `jouleclaw_energy::meter::detect_meter`, and never the -/// in-process meter (`detect_in_process_meter`): the model runs in a subprocess -/// (`llama-completion`), and this process's own CPU-energy counter reads this -/// process's threads alone, so it would bill the inference nothing. pub(crate) fn do_work_with(question: &str, model: &str) -> Work { + let llama = std::path::Path::new(model).exists().then(configured_llama); + do_work_via(question, model, llama.as_ref()) +} + +/// [`do_work_with`] with the binary that runs the model given: `None` answers with the +/// deterministic fallback, and runs no subprocess. +/// +/// Two meters span the work. The host's, `jouleclaw_energy::meter::detect_meter`, and +/// never the in-process meter (`detect_in_process_meter`): the model runs in a +/// subprocess, and this process's own CPU-energy counter reads this process's threads +/// alone, so it would bill the inference nothing. And, where this host counts each +/// process's CPU energy, the model's own process tree ([`run_model`]). [`grade_work`] +/// grades the two. +pub(crate) fn do_work_via(question: &str, model: &str, llama: Option<&Llama>) -> Work { + // The per-process counter's liveness probe burns about 300 ms of CPU on this thread + // the first time: here, before the host meter's window opens. + if llama.is_some() { + let _ = sandbox_meter::process_energy_status(); + } let meter = jouleclaw_energy::meter::detect_meter(); - 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 ((answer, label, reading), figure) = metered(&meter, || match llama { + Some(llama) => { + let run = run_model(question, model, &llama.bin); + match run.answer { + Some(a) => (a, model_label(model), run.reading), + None => (fallback_answer(question), "deterministic-extract".to_string(), run.reading), + } + } + None => (fallback_answer(question), "deterministic-extract".to_string(), None), }); - let (measured_uj, provenance, unmetered) = match figure { - Ok((uj, provenance)) => (uj, provenance.to_string(), None), - Err(why) => (0, UNMETERED.to_string(), Some(why)), + let self_contained = llama.is_some_and(|l| l.self_contained); + let graded = grade_work(figure, reading.as_ref(), self_contained, meter.active_name()); + Work { + answer, + measured_uj: graded.measured_uj, + provenance: graded.provenance, + meter: graded.meter, + model: label, + unmetered: graded.unmetered, + model_process: graded.model_process, + } +} + +/// A work's figure, as [`grade_work`] decides it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Graded { + measured_uj: u64, + provenance: String, + meter: String, + unmetered: Option, + model_process: Option, +} + +/// The work's figure, provenance and meter from its two meters: the one place they are +/// decided. `figure` is the host meter's ([`window_energy`]), `reading` the model's own +/// process tree's where there is one. +/// +/// - **A whole reading** — the meter saw nothing missing AND the operator declared the +/// model self-contained: the reading, `HwShunt`, metered by `model-process-tree`. Its +/// GPU part is an exact zero (a whole reading held no GPU client). +/// - **A partial reading** — any gap (a model that exits in its window always has one, +/// and llama.cpp offloading to Metal another: GPU time, unmeasured), or no +/// declaration: an estimate of the work, never a hardware figure of it, because an +/// `efficiency/1` receipt has no coverage field and its figure stands for the whole. +/// The figure is the reading plus the host meter's figure over the same window (the +/// rule mesh's in-process meter bills a partial span by): never less than was read, +/// and nothing the model drew outside its CPU left out; it errs high, by at most the +/// reading, the host figure covering the model's CPU too. `Estimator`, metered by +/// `model-process-tree+`. Where the host meter has no figure, the reading +/// alone cannot stand for the work: no figure, and the work is not sealed. +/// - **No reading** — no per-process counter (off macOS, a counter that is not live), +/// the span's [`TreeError`], or the fallback: the host meter's figure as it is. +fn grade_work( + figure: Result<(u64, Provenance), String>, + reading: Option<&Result>, + self_contained: bool, + host_meter: &str, +) -> Graded { + let host = |figure: Result<(u64, Provenance), String>, model_process| match figure { + Ok((uj, provenance)) => Graded { + measured_uj: uj, + provenance: provenance.to_string(), + meter: host_meter.to_string(), + unmetered: None, + model_process, + }, + Err(why) => Graded { + measured_uj: 0, + provenance: UNMETERED.to_string(), + meter: host_meter.to_string(), + unmetered: Some(why), + model_process, + }, }; - Work { answer, measured_uj, provenance, meter: meter.active_name().to_string(), model, unmetered } + let Some(Ok(r)) = reading else { return host(figure, None) }; + let cpu_uj = r.cpu_nj.div_ceil(1_000); + let mut partial_because: Vec = r.gaps.iter().map(ToString::to_string).collect(); + if !self_contained { + partial_because.push(NOT_DECLARED_SELF_CONTAINED.to_string()); + } + let process = ModelProcess { cpu_uj, partial_because }; + if process.partial_because.is_empty() { + return Graded { + measured_uj: cpu_uj, + provenance: Provenance::HwShunt.to_string(), + meter: MODEL_PROCESS_TREE.to_string(), + unmetered: None, + model_process: Some(process), + }; + } + match figure { + Ok((uj, _)) => Graded { + measured_uj: cpu_uj.saturating_add(uj), + provenance: Provenance::Estimator.to_string(), + meter: format!("{MODEL_PROCESS_TREE}+{host_meter}"), + unmetered: None, + model_process: Some(process), + }, + Err(why) => { + let why = format!( + "the model's own process read {cpu_uj} µJ of CPU, which is not all of its work ({}), and the host \ + meter put no figure on the rest: {why}", + process.partial_because.join("; ") + ); + host(Err(why), Some(process)) + } + } } /// Run `work` under `meter`: its result, and the meter's figure for it (see @@ -183,29 +358,110 @@ fn unmetered_line(work: &Work) -> String { /// 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)"; +/// A reply's line for the model's own process reading, when there is one: what it read, +/// and that it is whole or why it is not. +fn model_process_line(work: &Work) -> Option { + let p = work.model_process.as_ref()?; + let mj = p.cpu_uj as f64 / 1000.0; + Some(if p.partial_because.is_empty() { + format!("{mj:.1} mJ ({} µJ) of CPU read on its own process tree, whole", p.cpu_uj) + } else { + format!( + "{mj:.1} mJ ({} µJ) of CPU read on its own process tree, not all of its work ({}), so the energy \ + above is an estimate", + p.cpu_uj, + p.partial_because.join("; ") + ) + }) +} + /// 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 { - if !std::path::Path::new(model).exists() { - return None; - } - let bin = std::env::var("JOULEDESK_LLAMA").unwrap_or_else(|_| "llama-completion".into()); +/// One run of the model: its answer, `None` if it printed none (or could not be run), +/// and its own process tree's reading where this host has one. +struct ModelRun { + answer: Option, + reading: Option>, +} + +/// What the model's process runs as: `sh` waits for a gate line on its stdin, then +/// `exec`s the binary (`$0`) with its arguments (`$@`, passed as argv, never re-parsed), +/// so the model is the same process the tree meter is rooted at and the shell's own +/// start-up is outside the meter's span. +const GATED_EXEC: &str = "IFS= read -r jouledesk_gate || exit 1; exec \"$0\" \"$@\""; + +/// Generate an answer with the local model at `model` via `bin` (`llama-completion`, the +/// reference model hook — swap for Mesh / JouleClaw in production), its own process +/// read where this host counts it. +/// +/// The process is spawned waiting on its gate ([`GATED_EXEC`]); the tree meter (macOS, +/// the per-process counter live) is rooted at it and reads it at rest; the gate line +/// lets it go, and its stdin closes. Its stdout is read to the end, it is waited for +/// without being reaped, so its exited count is still readable, the meter's span is +/// stopped on it, and then it is reaped. Its stderr (llama.cpp's log) is discarded. +fn run_model(question: &str, model: &str, bin: &str) -> ModelRun { + use std::io::{Read, Write}; + use std::process::{Command, Stdio}; let prompt = format!("user\n{question}\nmodel\n"); - let out = std::process::Command::new(&bin) + let spawned = Command::new("sh") + .args(["-c", GATED_EXEC, bin]) .args([ "-m", model, "-p", &prompt, "-n", "160", "--temp", "0.2", "-r", "", "--no-display-prompt", ]) - .output() - .ok()?; - let text = String::from_utf8_lossy(&out.stdout).replace("", "").trim().to_string(); - (!text.is_empty()).then_some(text) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn(); + let Ok(mut child) = spawned else { return ModelRun { answer: None, reading: None } }; + let pid = child.id(); + let tree = TreeMeter::for_process(pid); + let start = tree.as_ref().map(TreeMeter::start); + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(b"\n"); + } + let mut out = Vec::new(); + if let Some(mut stdout) = child.stdout.take() { + let _ = stdout.read_to_end(&mut out); + } + let reading = match (&tree, start) { + (Some(tree), Some(start)) => { + let _ = wait_exited_unreaped(pid); + Some(tree.stop(start)) + } + _ => None, + }; + let _ = child.wait(); + let text = String::from_utf8_lossy(&out).replace("", "").trim().to_string(); + ModelRun { answer: (!text.is_empty()).then_some(text), reading } +} + +/// Wait for child `pid` to exit WITHOUT reaping it (`waitid` with `WNOWAIT`), so its +/// count stays readable for the tree meter's stop walk. The caller reaps it afterwards. +/// `false` if the wait failed. +#[cfg(target_os = "macos")] +fn wait_exited_unreaped(pid: u32) -> bool { + loop { + // SAFETY: an all-zero `siginfo_t` is a valid out-parameter. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is writable for the call; `pid` is this process's child. + let rc = unsafe { libc::waitid(libc::P_PID, pid as libc::id_t, &mut info, libc::WEXITED | libc::WNOWAIT) }; + if rc == 0 { + return true; + } + if std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + return false; + } + } +} + +/// No tree meter exists off macOS, so there is nothing to read before the reap. +#[cfg(not(target_os = "macos"))] +fn wait_exited_unreaped(_pid: u32) -> bool { + false } /// Honest fallback when no model is configured — still real work, still metered. @@ -313,11 +569,13 @@ fn compose_github(who: &str, work: &Work, outcome: &str, verified: bool) -> Stri "no ledger entry".to_string(), ), }; + let process = model_process_line(work).map(|l| format!("- **model process:** {l}\n")).unwrap_or_default(); format!( "🔷 **@joule** — {outcome}, @{who}.\n\n{answer}\n\n---\n\ _{how_done}._\n\ - **model:** `{model}`\n\ - **energy:** {energy}\n\ + {process}\ - **decision:** {outcome} · {ledger}" ) } @@ -424,6 +682,7 @@ pub(crate) mod tests { model: "a-model".into(), unmetered: (provenance == UNMETERED) .then(|| "the work took 200 µs, shorter than the 100 ms window it can resolve".into()), + model_process: None, } } @@ -589,6 +848,7 @@ pub(crate) mod tests { meter: name.to_string(), model: "a-model".into(), unmetered: None, + model_process: None, }; let (label, sealed_uj) = sealed(&w); let body = compose_github("dana", &w, "completed", true); @@ -625,6 +885,195 @@ pub(crate) mod tests { assert_eq!(w.meter, jouleclaw_energy::meter::detect_meter().active_name()); } + fn tree_reading(cpu_nj: u64, gaps: Vec) -> TreeReading { + TreeReading { cpu_uj: cpu_nj.div_ceil(1_000), cpu_nj, processes: 1, pids: vec![4242], wall: SECOND, gaps } + } + + /// The grading, pure: a reading of the model's process is sealed as a hardware + /// reading only when whole and declared self-contained; a partial one is an estimate, + /// the reading plus the host meter's figure, or no figure where the host has none; no + /// reading leaves the host meter's figure as it is. + #[test] + fn a_model_process_reading_is_a_hardware_figure_only_when_whole_and_declared() { + use sandbox_meter::TreeGap; + let est = Ok((40_000, Provenance::Estimator)); + let whole = grade_work(est.clone(), Some(&Ok(tree_reading(9_000_001, vec![]))), true, "apple-silicon"); + assert_eq!((whole.measured_uj, whole.provenance.as_str(), whole.meter.as_str()), (9_001, "hw_shunt", "model-process-tree")); + assert_eq!(whole.model_process, Some(ModelProcess { cpu_uj: 9_001, partial_because: vec![] }), "rounded up"); + + let undeclared = grade_work(est.clone(), Some(&Ok(tree_reading(9_000_000, vec![]))), false, "apple-silicon"); + assert_eq!((undeclared.measured_uj, undeclared.provenance.as_str()), (49_000, "estimator")); + assert_eq!(undeclared.meter, "model-process-tree+apple-silicon"); + assert_eq!(undeclared.model_process.unwrap().partial_because, vec![NOT_DECLARED_SELF_CONTAINED.to_string()]); + + // llama.cpp on Metal, exited in its window: declared or not, an estimate. + let metal = vec![TreeGap::Exited { pid: 4242 }, TreeGap::Gpu { ns: 2_000_000 }]; + let g = grade_work(est.clone(), Some(&Ok(tree_reading(9_000_000, metal.clone()))), true, "apple-silicon"); + assert_eq!((g.measured_uj, g.provenance.as_str(), g.unmetered), (49_000, "estimator", None)); + let because: Vec = metal.iter().map(ToString::to_string).collect(); + assert_eq!(g.model_process.as_ref().unwrap().partial_because, because); + // A hardware machine-wide figure does not make the sum a hardware reading. + let hw = grade_work(Ok((40_000, Provenance::HwShunt)), Some(&Ok(tree_reading(9_000_000, metal.clone()))), true, "rapl"); + assert_eq!((hw.measured_uj, hw.provenance.as_str()), (49_000, "estimator")); + + // A partial reading with no host figure is no figure: nothing is sealed. + let short = grade_work(Err("too short".into()), Some(&Ok(tree_reading(9_000_000, metal))), true, "apple-silicon"); + assert_eq!((short.measured_uj, short.provenance.as_str()), (0, UNMETERED)); + let why = short.unmetered.unwrap(); + assert!(why.contains("9000 µJ of CPU") && why.contains("too short"), "{why}"); + assert!(short.model_process.is_some()); + + // No reading: the host meter's figure, as it is. + for reading in [None, Some(&Err(TreeError::RootGone { pid: 4242 }))] { + let g = grade_work(est.clone(), reading, true, "apple-silicon"); + assert_eq!((g.measured_uj, g.provenance.as_str(), g.meter.as_str()), (40_000, "estimator", "apple-silicon")); + assert_eq!(g.model_process, None); + } + let g = grade_work(Ok((40_000, Provenance::HwShunt)), None, false, "rapl"); + assert_eq!(g.provenance, "hw_shunt", "a live machine-wide counter with no process reading is unchanged"); + } + + /// A partial model-process figure is sealed and posted as an estimate, with the + /// reading and why it is partial on its own line. + #[test] + fn a_partial_model_process_figure_is_sealed_and_posted_as_an_estimate() { + let partial = Some(&Ok(tree_reading(9_000_000, vec![sandbox_meter::TreeGap::Exited { pid: 7 }]))); + let g = grade_work(Ok((40_000, Provenance::Estimator)), partial, false, "apple-silicon"); + let w = Work { + answer: "an answer".into(), + measured_uj: g.measured_uj, + provenance: g.provenance, + meter: g.meter, + model: "a-model".into(), + unmetered: g.unmetered, + model_process: g.model_process, + }; + assert_eq!(sealed(&w), ("Estimator".to_string(), 49_000)); + let body = compose_github("dana", &w, "completed", true); + assert!(body.contains("49.0 mJ (49000 µJ) — estimated by `model-process-tree+apple-silicon`"), "{body}"); + let line = body.lines().find(|l| l.starts_with("- **model process:** ")).expect("a model-process line"); + assert!(line.contains("9.0 mJ (9000 µJ) of CPU read on its own process tree, not all of its work"), "{line}"); + assert!(line.contains("pid 7 exited") && line.contains(NOT_DECLARED_SELF_CONTAINED) && line.contains("estimate"), "{line}"); + assert!(!body.contains("measured"), "{body}"); + let slack = slack_compose(&w); + assert!(slack.contains("• *model process:* 9.0 mJ (9000 µJ) of CPU"), "{slack}"); + } + + fn slack_compose(w: &Work) -> String { + crate::slack::tests::compose_for_test(w) + } + + /// The stand-in model's marker and report, in its working directory. + const STAND_IN_MARKER: &str = ".model-stand-in"; + const STAND_IN_REPORT: &str = ".model-stand-in-report"; + + /// This process's own CPU energy count, nJ: `proc_pid_rusage(getpid(), + /// RUSAGE_INFO_V6)`'s `ri_energy_nj`, read with a bare buffer of the struct's 464 + /// bytes at its SDK offset, 336. Written out here, sharing no code with the meter it + /// checks. + #[cfg(target_os = "macos")] + fn own_energy_nj() -> u64 { + extern "C" { + fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut std::ffi::c_void) -> i32; + } + let mut buf = [0u64; 58]; + // SAFETY: `buf` is 464 writable bytes, the size of `rusage_info_v6`. + let rc = unsafe { proc_pid_rusage(std::process::id() as i32, 6, buf.as_mut_ptr().cast()) }; + assert_eq!(rc, 0, "proc_pid_rusage on this process"); + buf[336 / 8] + } + + #[cfg(not(target_os = "macos"))] + fn own_energy_nj() -> u64 { + 0 + } + + /// Not a test of its own: the stand-in model, run as a subprocess by + /// [`a_model_subprocess_is_read_on_its_own_process_as_its_own_count_says`] (this + /// test binary, filtered to this test, behind a script that takes the model's + /// arguments). It does a fixed amount of CPU work, leaves the core (which folds its + /// count), and writes its own count to its working directory. Anywhere without its + /// marker it returns at once. + #[test] + #[ignore = "a subprocess stand-in, run by the model-process energy test"] + fn model_stand_in() { + let Ok(iterations) = std::fs::read_to_string(STAND_IN_MARKER) else { return }; + let iterations: u64 = iterations.trim().parse().expect("the marker holds an iteration count"); + let mut x = 1u64; + for i in 0..iterations { + x = std::hint::black_box(x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(i)); + } + std::thread::sleep(Duration::from_millis(20)); + std::fs::write(STAND_IN_REPORT, format!("{} {x}", own_energy_nj())).expect("the report"); + } + + /// End to end with a model subprocess: the stand-in burns a fixed amount of CPU and + /// reports its own energy count, read from inside it before it exits — the oracle, + /// sharing no code with the tree meter. Where this host reads the model's process + /// (macOS, the per-process counter live), the reading agrees with that count to + /// within the shells' start-up and the stand-in's tear-down; it is partial (the model + /// exited in its window), so the work is sealed and posted as an estimate, the + /// reading plus the host meter's figure. Elsewhere the host meter's figure stands. + /// Twice: undeclared, then declared self-contained, which an exited model's reading + /// is partial either way. + #[test] + fn a_model_subprocess_is_read_on_its_own_process_as_its_own_count_says() { + // About a second of one core, unoptimized; a fixed amount of work. + const ITERATIONS: u64 = 150_000_000; + let dir = std::env::temp_dir().join(format!("jouledesk-model-stand-in-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(STAND_IN_MARKER), ITERATIONS.to_string()).unwrap(); + let exe = std::env::current_exe().expect("this test binary"); + let script = dir.join("stand-in-llama"); + std::fs::write( + &script, + format!("#!/bin/sh\ncd '{}' || exit 1\nexec '{}' tests::model_stand_in --exact --ignored\n", dir.display(), exe.display()), + ) + .unwrap(); + let chmod = std::process::Command::new("chmod").arg("+x").arg(&script).status().unwrap(); + assert!(chmod.success()); + let model = dir.join(STAND_IN_MARKER); // a file that exists: the "model" + let reads_processes = + cfg!(target_os = "macos") && sandbox_meter::process_energy_status() == sandbox_meter::CounterStatus::Live; + + for self_contained in [false, true] { + let llama = Llama { bin: script.display().to_string(), self_contained }; + let w = do_work_via("what changed?", model.to_str().unwrap(), Some(&llama)); + let report = std::fs::read_to_string(dir.join(STAND_IN_REPORT)).expect("the stand-in's report"); + let _ = std::fs::remove_file(dir.join(STAND_IN_REPORT)); + let oracle_nj: u64 = report.split_whitespace().next().unwrap().parse().unwrap(); + eprintln!( + "model subprocess (declared={self_contained}): oracle {oracle_nj} nJ; {} µJ {} by {} ({:?})", + w.measured_uj, w.provenance, w.meter, w.model_process + ); + assert!(w.answer.contains("1 passed"), "the stand-in did not run: {}", w.answer); + assert_eq!(w.model, STAND_IN_MARKER, "the model's answer, not the fallback"); + if reads_processes { + let p = w.model_process.clone().unwrap_or_else(|| panic!("no reading of the model's process: {:?}", w.unmetered)); + let oracle_uj = oracle_nj / 1_000; + assert!(oracle_uj > 100_000, "setup: the stand-in's work read {oracle_uj} µJ"); + let ratio = p.cpu_uj as f64 / oracle_uj as f64; + assert!((0.8..=1.25).contains(&ratio), "read {} µJ of a model that counted {oracle_uj} µJ", p.cpu_uj); + assert!(p.partial_because.iter().any(|g| g.contains("exited")), "{p:?}"); + assert_eq!(p.partial_because.iter().any(|g| g == NOT_DECLARED_SELF_CONTAINED), !self_contained); + assert_eq!(w.provenance, Provenance::Estimator.to_string(), "a partial reading sealed above an estimate"); + assert!(w.meter.starts_with("model-process-tree+"), "{}", w.meter); + assert!(w.measured_uj > p.cpu_uj, "the host meter's figure is in the sum: {} µJ", w.measured_uj); + assert_eq!(sealed(&w), ("Estimator".to_string(), w.measured_uj)); + // The stand-in's answer is test-runner output: check the reply's own lines. + let body = compose_github("dana", &w, "completed", true); + let energy = body.lines().find(|l| l.starts_with("- **energy:** ")).expect("an energy line"); + assert!(energy.contains("estimated by `model-process-tree+") && !energy.contains("measured"), "{energy}"); + assert!(body.lines().any(|l| l.starts_with("- **model process:** ")), "{body}"); + } else { + assert_eq!(w.model_process, None); + assert_eq!(w.meter, jouleclaw_energy::meter::detect_meter().active_name()); + } + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn ignores_comments_that_dont_address_the_handle() { let event = json!({ "comment": { "user": { "login": "x" }, "body": "just chatting" } }); diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs index 552fe6dd4..73ea51569 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs @@ -75,11 +75,13 @@ fn compose(who: &str, work: &super::Work, outcome: &str, verified: bool) -> Stri "no ledger entry".to_string(), ), }; + let process = super::model_process_line(work).map(|l| format!("• *model process:* {l}\n")).unwrap_or_default(); format!( "🔷 *@joule* — {outcome}, <@{who}>.\n\n{answer}\n\n\ _{how_done}_\n\ • *model:* `{model}`\n\ • *energy:* {energy}\n\ + {process}\ • *decision:* {outcome} · {ledger}" ) } @@ -111,9 +113,14 @@ impl Slack { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + /// The Slack reply for `work`, for the core's tests. + pub(crate) fn compose_for_test(work: &super::super::Work) -> String { + compose("U123", work, "completed", true) + } + /// 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". -- 2.54.0 (Apple Git-157) From 8ca94fd049fce7eb9428878ebca2504d77a4130a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 10:06:20 -0400 Subject: [PATCH 03/12] l3-jouleclaw: meter each engine on its own process, graded by coverage The run binary bracketed the inference with host_counter() reads: on macOS 27 the load-average estimator, so an in-process Metal decode and a llama.cpp subprocess were both estimated from host load, although this host reads both engines' own processes. The in-process engine (jouleclaw-runtime, Metal kernels) is now metered by ProcessSpanMeter(gpu_liveness_load) with SpanUses::CpuGpu: this process's CPU plus its GPU (an exact zero with no GPU time, the rail when it owned the GPU). The llama engine's subprocess runs behind a gate (sh waits, then execs "$0" "$@") and is read on its own process tree by TreeMeter, waited for with WNOWAIT before the reap (LlamaCompletionBackend::complete_metered). grade() is the one place the receipt's energy is decided, mesh ProcessMeter's rule: a reading that covers the whole inference keeps its grade; a partial one (GPU shared, or a child that exited in its window, or undeclared) is an Estimator, the reading plus the host counter's figure or the token estimate; no reading leaves that rest as it was. The receipt has no coverage field, so a partial reading is never sealed HwShunt. Tested against oracles sharing no code: this process's own ri_energy_nj read around a CPU burn (span 0.985 of it, GPU an exact zero, Whole, HwShunt), and a stand-in llama (this test binary) reporting its own count from inside (reading 0.998 of it, Estimator). The example workspace builds standalone. Co-Authored-By: Claude Opus 5.5 --- joule-code/examples/l3-jouleclaw/Cargo.lock | 43 ++ joule-code/examples/l3-jouleclaw/Cargo.toml | 12 + joule-code/examples/l3-jouleclaw/README.md | 39 +- .../examples/l3-jouleclaw/src/bin/run.rs | 423 ++++++++++++++++-- .../l3-jouleclaw/src/llama_backend.rs | 193 ++++++-- 5 files changed, 626 insertions(+), 84 deletions(-) diff --git a/joule-code/examples/l3-jouleclaw/Cargo.lock b/joule-code/examples/l3-jouleclaw/Cargo.lock index 95a850371..36d0ba076 100644 --- a/joule-code/examples/l3-jouleclaw/Cargo.lock +++ b/joule-code/examples/l3-jouleclaw/Cargo.lock @@ -279,6 +279,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core", "serde", "sha2", "subtle", @@ -500,11 +501,14 @@ dependencies = [ "joule-code-cascade", "joule-code-receipt", "joule-code-schema", + "jouleclaw-backend-apple", "jouleclaw-energy", "jouleclaw-fabric", "jouleclaw-llm", "jouleclaw-loader-gguf", "jouleclaw-runtime", + "libc", + "sandbox-meter", "serde_json", ] @@ -514,6 +518,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "memchr" version = "2.8.3" @@ -734,6 +748,29 @@ dependencies = [ "semver", ] +[[package]] +name = "sandbox-meter" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "libc", + "libloading", + "sandbox-schema", + "serde", + "thiserror", +] + +[[package]] +name = "sandbox-schema" +version = "0.1.0" +dependencies = [ + "base64", + "blake3", + "ciborium", + "serde", + "thiserror", +] + [[package]] name = "semver" version = "1.0.28" @@ -891,6 +928,12 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/joule-code/examples/l3-jouleclaw/Cargo.toml b/joule-code/examples/l3-jouleclaw/Cargo.toml index 4e0dddea4..bed3a13c5 100644 --- a/joule-code/examples/l3-jouleclaw/Cargo.toml +++ b/joule-code/examples/l3-jouleclaw/Cargo.toml @@ -26,4 +26,16 @@ jouleclaw-loader-gguf = { path = "../../../jouleclaw/jouleclaw-rs/crates/joulecl # 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" } +# The GPU load the in-process meter's GPU-counter liveness probe runs +# (`gpu_liveness_load`): the `run` binary meters the in-process engine on this +# process's own CPU and GPU (`jouleclaw_energy::span::ProcessSpanMeter`). +jouleclaw-backend-apple = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-backend-apple" } +# The llama engine's subprocess, read on its own process tree where the host +# counts each process's CPU energy (macOS): `TreeMeter`. +sandbox-meter = { path = "../../../sandbox/sandbox-rs/crates/sandbox-meter" } serde_json = "1" + +# macOS: the llama engine's process is waited for without being reaped +# (`waitid` with `WNOWAIT`), so the tree meter reads its exited count. +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" diff --git a/joule-code/examples/l3-jouleclaw/README.md b/joule-code/examples/l3-jouleclaw/README.md index 24ea69f5e..47453edd1 100644 --- a/joule-code/examples/l3-jouleclaw/README.md +++ b/joule-code/examples/l3-jouleclaw/README.md @@ -39,21 +39,34 @@ 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 puts the **host** energy spent during the inference on -the receipt, tagged with the provenance of whatever metered it: +a `CodingOp` (tier L3), and puts the energy of the inference on the receipt, read +as close to the work as the host allows and graded by how much of the inference +the reading covers: -- **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. +- **In-process engine (default)** — metered on this process's own CPU and GPU + (`jouleclaw_energy::span::ProcessSpanMeter`): the CPU energy the kernel counts + for this process's threads, plus its GPU energy — an exact zero where it spent + no GPU time, the GPU rail where it had the GPU to itself. A span that covers the + whole inference is a hardware reading, `HwShunt` with `measured_uj` set. One + that covers part of it (the GPU shared with another process, so the CPU alone) + is an estimate of the whole, `Estimator`: the reading plus the host meter's + figure over the same window. +- **llama engine** — the subprocess is read on its own process tree + (`sandbox_meter::TreeMeter`) where the host counts each process's CPU energy + (macOS). That is its CPU only: llama.cpp offloads to the GPU, and a process that + exits in its window lacks its last time on a core. So it is an estimate of the + whole, the reading plus the host meter's figure — `HwShunt` only for a whole + reading of an engine declared self-contained (`JOULE_CODE_L3_SELF_CONTAINED=1`). +- **No reading of the engine's own process** — the host meter's figure as it is: + JouleClaw's IOReport counter where its CPU energy rail is live (`HwShunt`), its + load-average estimator where it is not (macOS 27.0.1 for an unprivileged + process, `Estimator`), and the token-count estimate anywhere else (Linux and + Windows included; this binding builds `jouleclaw-energy` with the `ioreport` + feature only). -The printout names the meter that ran and the IOReport status, and says whether -the figure was measured or estimated. +The receipt has no coverage field, so a partial reading is never sealed as a +hardware reading of the whole. The printout names the engine, the host meter and +the IOReport status, and says how the figure was obtained. 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 diff --git a/joule-code/examples/l3-jouleclaw/src/bin/run.rs b/joule-code/examples/l3-jouleclaw/src/bin/run.rs index 52adaf3a2..8eb1a8ee1 100644 --- a/joule-code/examples/l3-jouleclaw/src/bin/run.rs +++ b/joule-code/examples/l3-jouleclaw/src/bin/run.rs @@ -7,17 +7,37 @@ //! 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 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. +//! result as a `CodingOp` and puts the energy of the inference on the receipt, +//! read as close to the work as this host allows and graded by how much of it the +//! reading covers ([`grade`]): +//! +//! - **The in-process engine** is metered on this process's own CPU and GPU +//! (`jouleclaw_energy::span::ProcessSpanMeter`): the CPU energy the kernel +//! counts for this process's threads, plus its GPU energy — an exact zero where +//! it spent no GPU time, the GPU rail where it had the GPU to itself. A span +//! that covers the whole inference keeps that grade (`HwShunt` for the two +//! counters). One that covers part of it — the GPU shared with another +//! process, so the CPU alone — is an estimate of the whole, never a reading. +//! - **The llama engine** runs in a subprocess, which this process's counters do +//! not see; its own process is read instead (`sandbox_meter::TreeMeter`), where +//! the host counts each process's CPU energy. That is its CPU only — llama.cpp +//! offloads to the GPU, and a process that exits in its window lacks its last +//! time on a core — so it is an estimate of the whole, unless whole and +//! declared self-contained (`JOULE_CODE_L3_SELF_CONTAINED=1`). +//! +//! An estimate of the whole is the reading plus the host meter's figure over the +//! same window (or, with no host counter, the token-count estimate): never less +//! than was read. The receipt has no coverage field, so a partial reading is +//! never sealed as a hardware reading. Where neither engine's own reading gives a +//! figure, the host meter's (a measurement where it is a live counter, an +//! estimate from host load where it is not) or the token-count estimate stands. +//! The printed receipt says which. use jouleclaw_energy::meter::EnergyMeter; -use jouleclaw_energy::Provenance as JcProvenance; -use jouleclaw_llm::{LlmBackend, LlmRequest}; +use jouleclaw_energy::span::{ProcessSpanMeter, SpanEnergy, SpanSource, SpanUses}; +use jouleclaw_energy::{Coverage, EnergyError, Provenance as JcProvenance}; +use jouleclaw_llm::{LlmBackend, LlmRequest, LlmResponse}; +use sandbox_meter::{TreeError, TreeReading}; use joule_code_cargo::{Anchor, CodingOp, Edit}; use joule_code_receipt::{Energy, Provenance, Receipt}; @@ -26,16 +46,16 @@ use joule_code_schema::{ContentId, Tier, ALG_ED25519}; 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). +/// The host's best energy counter, or None where unavailable (e.g. CI/Linux): +/// the meter of the rest of the host over the inference, which a partial reading +/// of the engine's own process is graded with, and the figure that stands where +/// the engine's own process gives none ([`grade`]). /// -/// Not this process's own CPU-energy counter (`jouleclaw_energy::process`, the -/// in-process meter), although that one is live on macOS 27.0.1 where IOReport's -/// CPU rail is not: neither engine's inference runs on this process's threads -/// alone. `llama` runs in a `llama-completion` subprocess, and the in-process -/// `jouleclaw-runtime` registers the Apple backend's GPU kernels (Metal and MPS -/// matmuls, the quantized GEMVs that serve decode), whose energy is the GPU's. -/// A per-process CPU figure would bill that work nothing, or claim a domain its -/// counter does not read. +/// Not, on its own, the inference's meter: this process's CPU counter alone +/// would bill the in-process engine's GPU kernels (Metal and MPS matmuls, the +/// quantized GEMVs that serve decode) nothing, and the llama engine's subprocess +/// all of nothing. Those are read by [`ProcessSpanMeter`] and the llama +/// backend's own tree meter. fn host_counter() -> Option> { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { @@ -96,6 +116,137 @@ fn map_provenance(p: JcProvenance) -> Provenance { } } +/// What the engine's own process read over the inference. +enum Own { + /// The in-process engine: this process's CPU and GPU over the call + /// ([`ProcessSpanMeter`], [`SpanUses::CpuGpu`]). + Process(Result), + /// The llama engine's subprocess: its own process tree, `None` where the + /// host has no per-process counter; and whether the operator declared it + /// self-contained (no server it calls, no remote API), which no reader of + /// the tree can see. + Child { reading: Option>, self_contained: bool }, +} + +/// The receipt's energy for the inference, and how it was obtained: the one +/// place it is graded. `rest` is the host meter's figure over the same window, +/// or the token-count estimate ([`receipt_energy`]), with its note. +/// +/// - **Whole** — the in-process span covers the whole inference (this process +/// the GPU's effective sole user, or no GPU time at all), or the child's tree +/// was read with no gap and declared self-contained: the reading at its own +/// grade, `HwShunt` with `measured_uj` for a hardware reading. A span that +/// fell back to the host meter keeps that meter's grade. +/// - **Partial** — the CPU alone (the GPU shared, a client vanished), or a child +/// with a gap (one that exits in its window always has one) or undeclared: an +/// estimate of the whole, `Estimator`, the reading plus `rest` — never less +/// than was read, nothing outside the reading left out; it errs high by at most +/// the reading, `rest` covering the host. The rule mesh's in-process meter +/// bills a partial span by. +/// - **None** — no figure from the engine's own process: `rest` as it is. +fn grade(own: &Own, rest: (Energy, String)) -> (Energy, String) { + let reading = |uj: u64, provenance: Provenance| Energy { + measured_uj: (provenance == Provenance::HwShunt).then_some(uj), + estimated_uj: uj, + provenance, + }; + let estimate = |uj: u64| Energy { measured_uj: None, estimated_uj: uj, provenance: Provenance::Estimator }; + let (rest, rest_note) = rest; + match own { + Own::Process(Ok(s)) if s.coverage == Coverage::Whole => { + let note = match &s.source { + SpanSource::Process { cpu_uj, gpu } => format!( + "measured on this process's CPU ({cpu_uj} µJ) and GPU ({} µJ), the whole inference", + gpu.uj + ), + SpanSource::Fallback { meter, why } => { + format!("{} by {meter}: this process's counters gave no figure ({why})", how(s.provenance)) + } + SpanSource::CpuOnly { why, .. } => format!("this process's CPU ({why})"), + }; + (reading(s.uj, map_provenance(s.provenance)), note) + } + Own::Process(Ok(s)) => { + let why = match &s.source { + SpanSource::CpuOnly { why, .. } => why.clone(), + _ => "a span that covers part of the inference".to_string(), + }; + let note = format!( + "estimated: this process's CPU read {} µJ, not all of the inference ({why}), plus {} µJ {rest_note}", + s.uj, rest.estimated_uj + ); + (estimate(s.uj.saturating_add(rest.estimated_uj)), note) + } + Own::Process(Err(e)) => (rest, format!("{rest_note} (this process's counters: {e})")), + Own::Child { reading: Some(Ok(r)), self_contained } => { + let cpu_uj = r.cpu_nj.div_ceil(1_000); + let mut because: Vec = r.gaps.iter().map(ToString::to_string).collect(); + if !self_contained { + because.push("not declared self-contained".to_string()); + } + if because.is_empty() { + let note = format!("measured on the model's own process tree ({cpu_uj} µJ of CPU), whole"); + return (reading(cpu_uj, Provenance::HwShunt), note); + } + let note = format!( + "estimated: the model's own process read {cpu_uj} µJ of CPU, not all of the inference ({}), plus {} \ + µJ {rest_note}", + because.join("; "), + rest.estimated_uj + ); + (estimate(cpu_uj.saturating_add(rest.estimated_uj)), note) + } + Own::Child { reading: Some(Err(e)), .. } => (rest, format!("{rest_note} (the model's own process: {e})")), + Own::Child { reading: None, .. } => (rest, rest_note), + } +} + +/// How a grade is said: a reading was measured, an estimate estimated. +fn how(p: JcProvenance) -> &'static str { + match p { + JcProvenance::HwShunt => "measured", + JcProvenance::ModelBased => "host-metered (energy model)", + JcProvenance::Estimator => "estimated", + } +} + +/// The engine that runs the model. +enum Engine { + Runtime(Box), + Llama(LlamaCompletionBackend), +} + +impl Engine { + fn model_name(&self) -> &str { + match self { + Engine::Runtime(b) => b.model_name(), + Engine::Llama(b) => b.model_name(), + } + } +} + +/// Run `request` on `engine`, read on the engine's own process: the in-process +/// engine by `span` (built before any window opens: its first construction +/// probes the counters), the llama engine by its subprocess's tree. +fn infer( + engine: &Engine, + span: Option<&ProcessSpanMeter>, + self_contained: bool, + request: &LlmRequest, +) -> (Result, Own) { + match (engine, span) { + (Engine::Runtime(b), Some(meter)) => { + let (response, energy) = meter.measure(SpanUses::CpuGpu, || b.complete(request)); + (response, Own::Process(energy)) + } + (Engine::Runtime(b), None) => (b.complete(request), Own::Process(Err(EnergyError::NotCurrent))), + (Engine::Llama(b), _) => { + let (response, reading) = b.complete_metered(request); + (response, Own::Child { reading, self_contained }) + } + } +} + fn main() { let mut args = std::env::args().skip(1); // Model path: first arg, else $JOULE_CODE_L3_MODEL. @@ -113,17 +264,22 @@ fn main() { }); // Backend: in-process from-scratch runtime by default; llama.cpp on request. - let engine = std::env::var("JOULE_CODE_L3_ENGINE").unwrap_or_else(|_| "runtime".to_string()); - let backend: Box = match engine.as_str() { - "llama" => Box::new(LlamaCompletionBackend::new(&model)), + let engine_name = std::env::var("JOULE_CODE_L3_ENGINE").unwrap_or_else(|_| "runtime".to_string()); + let engine = match engine_name.as_str() { + "llama" => Engine::Llama(LlamaCompletionBackend::new(&model)), _ => match JouleclawRuntimeBackend::from_path(&model) { - Ok(b) => Box::new(b), + Ok(b) => Engine::Runtime(Box::new(b)), Err(e) => { eprintln!("failed to load model on jouleclaw-runtime: {e}"); std::process::exit(1); } }, }; + let self_contained = std::env::var("JOULE_CODE_L3_SELF_CONTAINED").as_deref() == Ok("1"); + // The in-process engine's meter, built (and its counters probed) before any + // window opens. + let span = matches!(engine, Engine::Runtime(_)) + .then(|| ProcessSpanMeter::new(jouleclaw_backend_apple::gpu_liveness_load)); let counter = host_counter(); let counter_name = counter.as_ref().map_or("none", |c| c.name()); @@ -137,7 +293,8 @@ fn main() { let _ = read(&counter); // prime let before = read(&counter); - let response = match backend.complete(&LlmRequest::from_prompt(&intent, 256)) { + let (response, own) = infer(&engine, span.as_ref(), self_contained, &LlmRequest::from_prompt(&intent, 256)); + let response = match response { Ok(r) => r, Err(e) => { eprintln!("L3 inference failed: {e}"); @@ -162,14 +319,17 @@ fn main() { }; let op_id = op.op_id().expect("op_id"); - // Receipt energy: the host counter's figure at its provenance, else the - // token-based estimate (honest fallback). - let (energy, from_counter) = receipt_energy( + // Receipt energy: the engine's own reading, graded by how much of the + // inference it covers, with the host counter's figure (else the token-based + // estimate) for the rest. + let (rest, from_counter) = receipt_energy( before, after, response.input_tokens as u64, response.output_tokens as u64, ); + let rest_note = energy_note(&rest, counter_name, from_counter); + let (energy, note) = grade(&own, (rest, rest_note)); let receipt = Receipt { op_id: op_id.clone(), tier: Tier::L3, @@ -179,7 +339,8 @@ fn main() { }; println!("== Joule Code L3 (local model) =="); - println!("model : {}", backend.model_name()); + println!("model : {}", engine.model_name()); + println!("engine : {engine_name}"); println!("intent : {intent}"); println!("--- generated ---"); println!("{}", response.text); @@ -188,7 +349,7 @@ fn main() { println!("edits : {} (GENERATED.rs)", op.edits.len()); println!("tier : {}", receipt.tier); println!( - "meter : {counter_name} (IOReport: {:?})", + "host meter : {counter_name} (IOReport: {:?})", jouleclaw_energy::ioreport_status() ); println!( @@ -196,10 +357,9 @@ fn main() { response.input_tokens, response.output_tokens ); println!( - "energy : {:.3} J ({:?}, {})", + "energy : {:.3} J ({:?}, {note})", receipt.energy.estimated_uj as f64 / 1_000_000.0, receipt.energy.provenance, - energy_note(&receipt.energy, counter_name, from_counter) ); } @@ -251,6 +411,209 @@ mod tests { assert_eq!(energy_note(&measured, "rapl", true), "measured by rapl"); } + fn est(uj: u64) -> (Energy, String) { + (Energy { measured_uj: None, estimated_uj: uj, provenance: Provenance::Estimator }, "the rest".into()) + } + + fn span(uj: u64, provenance: JcProvenance, coverage: Coverage, source: SpanSource) -> Own { + Own::Process(Ok(SpanEnergy { uj, provenance, coverage, source })) + } + + fn tree(cpu_nj: u64, gaps: Vec) -> Option> { + Some(Ok(TreeReading { + cpu_uj: cpu_nj.div_ceil(1_000), + cpu_nj, + processes: 1, + pids: vec![7], + wall: std::time::Duration::from_secs(1), + gaps, + })) + } + + /// The grading, pure: a reading keeps its hardware grade only when it covers + /// the whole inference; a partial one is an estimate, the reading plus the + /// rest; no reading is the rest as it is. The receipt never seals a partial + /// reading as `HwShunt`. + #[test] + fn a_reading_keeps_its_hardware_grade_only_when_it_covers_the_whole_inference() { + use jouleclaw_energy::process_gpu::GpuShare; + let gpu = GpuShare { uj: 700, self_ns: 9, rail_uj: Some(710), all_ns: Some(9) }; + let whole = span(1_000, JcProvenance::HwShunt, Coverage::Whole, SpanSource::Process { cpu_uj: 300, gpu }); + let (e, note) = grade(&whole, est(40_000)); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (Some(1_000), 1_000, Provenance::HwShunt)); + assert!(note.starts_with("measured on this process's CPU (300 µJ) and GPU (700 µJ)"), "{note}"); + + let cpu_only = SpanSource::CpuOnly { cpu_uj: 300, why: "process GPU: shared".into() }; + let (e, note) = grade(&span(300, JcProvenance::HwShunt, Coverage::Partial, cpu_only), est(40_000)); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 40_300, Provenance::Estimator)); + assert!(note.contains("shared") && note.starts_with("estimated"), "{note}"); + e.check_provenance().unwrap(); + + let fallback = SpanSource::Fallback { meter: "apple-silicon", why: "process CPU: not live".into() }; + let (e, note) = grade(&span(5_000, JcProvenance::Estimator, Coverage::Whole, fallback), est(40_000)); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 5_000, Provenance::Estimator)); + assert!(note.starts_with("estimated by apple-silicon"), "{note}"); + + let (e, note) = grade(&Own::Process(Err(EnergyError::NotAdvanced)), est(40_000)); + assert_eq!(e, est(40_000).0); + assert!(note.starts_with("the rest"), "{note}"); + + // The llama engine's subprocess. + let exited = vec![sandbox_meter::TreeGap::Exited { pid: 7 }]; + let child = |reading, self_contained| Own::Child { reading, self_contained }; + let (e, _) = grade(&child(tree(9_000_001, vec![]), true), est(40_000)); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (Some(9_001), 9_001, Provenance::HwShunt)); + for (reading, declared) in [(tree(9_000_000, vec![]), false), (tree(9_000_000, exited.clone()), true)] { + let (e, note) = grade(&child(reading, declared), est(40_000)); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 49_000, Provenance::Estimator), "{note}"); + assert!(note.contains("not all of the inference"), "{note}"); + } + // A hardware host figure does not make the sum a reading. + let hw = (Energy { measured_uj: Some(40_000), estimated_uj: 40_000, provenance: Provenance::HwShunt }, "r".into()); + let (e, _) = grade(&child(tree(9_000_000, exited), true), hw.clone()); + assert_eq!((e.measured_uj, e.provenance), (None, Provenance::Estimator)); + for reading in [None, Some(Err(TreeError::RootGone { pid: 7 }))] { + let (e, _) = grade(&child(reading, true), hw.clone()); + assert_eq!(e, hw.0, "no reading: the host meter's figure as it is"); + } + } + + /// This process's own CPU energy count, nJ: `proc_pid_rusage(getpid(), + /// RUSAGE_INFO_V6)`'s `ri_energy_nj`, read with a bare buffer of the struct's + /// 464 bytes at its SDK offset, 336. Written out here, sharing no code with + /// the meters it checks. + #[cfg(target_os = "macos")] + fn own_energy_nj() -> u64 { + extern "C" { + fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut std::ffi::c_void) -> i32; + } + let mut buf = [0u64; 58]; + // SAFETY: `buf` is 464 writable bytes, the size of `rusage_info_v6`. + let rc = unsafe { proc_pid_rusage(std::process::id() as i32, 6, buf.as_mut_ptr().cast()) }; + assert_eq!(rc, 0, "proc_pid_rusage on this process"); + buf[336 / 8] + } + + #[cfg(not(target_os = "macos"))] + fn own_energy_nj() -> u64 { + 0 + } + + /// A fixed amount of CPU work: about a second of one core, unoptimized. + fn burn() -> u64 { + let mut x = 1u64; + for i in 0..150_000_000u64 { + x = std::hint::black_box(x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(i)); + } + x + } + + /// The in-process engine's meter on CPU work, against an oracle sharing no code + /// with it: this process's own count, read around the same work. The work uses + /// no GPU, so its GPU share is an exact zero and the span covers it whole, a + /// hardware reading of this process — the oracle's window holds the span's, so + /// the reading is at most the oracle and short of it only by the meter's own + /// snapshots. Twice. Where the counters are not live it is the fallback's. + #[test] + fn the_in_process_engine_is_read_on_this_process_as_its_own_count_says() { + let meter = ProcessSpanMeter::new(jouleclaw_backend_apple::gpu_liveness_load); + let metered = meter.is_metered(); + for round in 1..=2 { + let before = own_energy_nj(); + let (x, energy) = meter.measure(SpanUses::CpuGpu, burn); + std::thread::sleep(std::time::Duration::from_millis(20)); + let oracle_uj = (own_energy_nj() - before) / 1_000; + std::hint::black_box(x); + let own = Own::Process(energy); + let (e, note) = grade(&own, est(1)); + let Own::Process(energy) = own else { unreachable!() }; + eprintln!("in-process round {round}: oracle {oracle_uj} µJ; span {energy:?}; receipt {e:?} ({note})"); + if metered { + let s = energy.expect("a metered span has a figure"); + let (cpu_uj, gpu) = s.parts().expect("this process's own counters"); + assert_eq!((gpu.uj, gpu.self_ns), (0, 0), "CPU work spent no GPU time"); + assert_eq!((s.coverage, s.provenance), (Coverage::Whole, JcProvenance::HwShunt)); + let ratio = cpu_uj as f64 / oracle_uj as f64; + assert!(oracle_uj > 100_000, "setup: the burn read {oracle_uj} µJ"); + assert!((0.8..=1.02).contains(&ratio), "the span read {cpu_uj} µJ of {oracle_uj} µJ"); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (Some(cpu_uj), cpu_uj, Provenance::HwShunt)); + } else { + assert_ne!(e.provenance, Provenance::HwShunt, "no live counter, yet a reading: {e:?}"); + } + } + } + + /// The stand-in model's marker and report, in its working directory. + const STAND_IN_MARKER: &str = ".llama-stand-in"; + const STAND_IN_REPORT: &str = ".llama-stand-in-report"; + + /// Not a test of its own: the stand-in llama engine, run as a subprocess by + /// [`the_llama_engine_is_read_on_its_own_process_as_its_own_count_says`] (this + /// test binary, filtered to this test, behind a script that takes the engine's + /// arguments). It does a fixed amount of CPU work, leaves the core (which folds + /// its count), and writes its own count to its working directory. Anywhere + /// without its marker it returns at once. + #[test] + #[ignore = "a subprocess stand-in, run by the llama engine's energy test"] + fn llama_stand_in() { + if !std::path::Path::new(STAND_IN_MARKER).exists() { + return; + } + let x = burn(); + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(STAND_IN_REPORT, format!("{} {x}", own_energy_nj())).expect("the report"); + } + + /// The llama engine's subprocess, read on its own process: the stand-in burns a + /// fixed amount of CPU and reports its own count from inside before it exits — + /// the oracle, sharing no code with the tree meter. Where this host reads the + /// process (macOS, the per-process counter live) the reading agrees with that + /// count to within the shells' start-up and the stand-in's tear-down, and the + /// receipt is an estimate (the process exited in its window), never `HwShunt`. + #[test] + fn the_llama_engine_is_read_on_its_own_process_as_its_own_count_says() { + let dir = std::env::temp_dir().join(format!("l3-llama-stand-in-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(STAND_IN_MARKER), "").unwrap(); + let exe = std::env::current_exe().expect("this test binary"); + let script = dir.join("stand-in-llama"); + std::fs::write( + &script, + format!("#!/bin/sh\ncd '{}' || exit 1\nexec '{}' tests::llama_stand_in --exact --ignored\n", dir.display(), exe.display()), + ) + .unwrap(); + assert!(std::process::Command::new("chmod").arg("+x").arg(&script).status().unwrap().success()); + let reads_processes = + cfg!(target_os = "macos") && sandbox_meter::process_energy_status() == sandbox_meter::CounterStatus::Live; + let engine = Engine::Llama(LlamaCompletionBackend::new(dir.join("model.gguf")).with_binary(script.display().to_string())); + + for self_contained in [false, true] { + let (response, own) = infer(&engine, None, self_contained, &LlmRequest::from_prompt("fib", 16)); + let response = response.expect("the stand-in ran"); + assert!(response.text.contains("1 passed"), "{}", response.text); + let report = std::fs::read_to_string(dir.join(STAND_IN_REPORT)).expect("the stand-in's report"); + let _ = std::fs::remove_file(dir.join(STAND_IN_REPORT)); + let oracle_uj = report.split_whitespace().next().unwrap().parse::().unwrap() / 1_000; + let (e, note) = grade(&own, est(40_000)); + eprintln!("llama engine (declared={self_contained}): oracle {oracle_uj} µJ; receipt {e:?} ({note})"); + let Own::Child { reading, .. } = own else { panic!("the llama engine is read as a child") }; + if reads_processes { + let r = reading.expect("a tree meter on this host").expect("a reading of the model's process"); + let read = r.cpu_nj.div_ceil(1_000); + assert!(oracle_uj > 100_000, "setup: the stand-in's work read {oracle_uj} µJ"); + let ratio = read as f64 / oracle_uj as f64; + assert!((0.8..=1.25).contains(&ratio), "read {read} µJ of a process that counted {oracle_uj} µJ"); + assert!(!r.is_whole(), "an exited process is never read whole: {r:?}"); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, read + 40_000, Provenance::Estimator)); + } else { + assert!(reading.is_none()); + assert_eq!(e, est(40_000).0); + } + } + let _ = std::fs::remove_dir_all(&dir); + } + /// 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 diff --git a/joule-code/examples/l3-jouleclaw/src/llama_backend.rs b/joule-code/examples/l3-jouleclaw/src/llama_backend.rs index b3060446a..123318d44 100644 --- a/joule-code/examples/l3-jouleclaw/src/llama_backend.rs +++ b/joule-code/examples/l3-jouleclaw/src/llama_backend.rs @@ -3,18 +3,35 @@ //! datacenter, no network. This is the production-shaped alternative to //! `EchoBackend`: it implements the same JouleClaw trait, so it drops into the //! same cascade slot. +//! +//! [`LlamaCompletionBackend::complete_metered`] also reads the subprocess's own +//! CPU energy where this host counts each process's ([`sandbox_meter::TreeMeter`]: +//! macOS, the per-process counter live): the model's process, not this one and +//! not the machine. The reading covers that process's CPU only — llama.cpp built +//! for Apple offloads to the GPU by default, and a process that exits in its +//! window lacks its last time on a core — so it is never a reading of the whole +//! inference; the `run` binary grades it. use std::path::PathBuf; -use std::process::Command; +use std::process::{Command, Stdio}; use jouleclaw_llm::{FinishReason, LlmBackend, LlmError, LlmRequest, LlmResponse}; +use sandbox_meter::{TreeError, TreeMeter, TreeReading}; /// Drives a local GGUF model via the `llama-completion` binary. pub struct LlamaCompletionBackend { model_path: PathBuf, model_name: String, + /// The binary run: `llama-completion` on `PATH` unless set otherwise. + bin: String, } +/// What the model's process runs as: `sh` waits for a gate line on its stdin, +/// then `exec`s the binary (`$0`) with its arguments (`$@`, passed as argv, +/// never re-parsed), so the model is the very process the tree meter is rooted +/// at and the shell's own start-up is outside the meter's span. +const GATED_EXEC: &str = "IFS= read -r l3_gate || exit 1; exec \"$0\" \"$@\""; + impl LlamaCompletionBackend { /// Back the L3 tier with the GGUF at `model_path`. pub fn new(model_path: impl Into) -> Self { @@ -27,17 +44,37 @@ impl LlamaCompletionBackend { Self { model_path, model_name, + bin: "llama-completion".to_string(), } } -} -impl LlmBackend for LlamaCompletionBackend { - fn model_name(&self) -> &str { - &self.model_name + /// Run `bin` instead of `llama-completion` (a build of it elsewhere, or a + /// stand-in that takes its arguments). + pub fn with_binary(mut self, bin: impl Into) -> Self { + self.bin = bin.into(); + self } - fn complete(&self, request: &LlmRequest) -> Result { - let output = Command::new("llama-completion") + /// [`LlmBackend::complete`], and the model process's own CPU-energy reading + /// over the call: `None` where this host has no per-process counter (off + /// macOS, or not live), else the [`TreeMeter`]'s span from the moment the + /// process is let go to its exit. + /// + /// The process is spawned waiting on its gate ([`GATED_EXEC`]); the tree + /// meter is rooted at it and reads it at rest; the gate line lets it go and + /// its stdin closes. Its stdout and stderr are drained to the end, it is + /// waited for without being reaped, so its exited count is still readable, + /// the meter's span is stopped on it, and then it is reaped. + pub fn complete_metered( + &self, + request: &LlmRequest, + ) -> (Result, Option>) { + use std::io::{Read, Write}; + // The per-process counter's liveness probe burns about 300 ms of CPU on + // this thread the first time: here, before anything is spawned. + let _ = sandbox_meter::process_energy_status(); + let spawned = Command::new("sh") + .args(["-c", GATED_EXEC, &self.bin]) .arg("-m") .arg(&self.model_path) .arg("-p") @@ -46,45 +83,119 @@ impl LlmBackend for LlamaCompletionBackend { .arg(request.max_tokens.to_string()) .arg("--temp") .arg("0") - .output() - .map_err(|e| LlmError::Unavailable(format!("spawn llama-completion: {e}")))?; - if !output.status.success() { - return Err(LlmError::Upstream(format!( - "llama-completion exited {:?}", - output.status.code() - ))); + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + let mut child = match spawned { + Ok(c) => c, + Err(e) => return (Err(LlmError::Unavailable(format!("spawn {}: {e}", self.bin))), None), + }; + let pid = child.id(); + let tree = TreeMeter::for_process(pid); + let start = tree.as_ref().map(TreeMeter::start); + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(b"\n"); } + // stderr on a thread of its own: llama.cpp logs there while it writes + // stdout, and a full pipe would stall it. + let stderr = child.stderr.take().map(|mut e| { + std::thread::spawn(move || { + let mut v = Vec::new(); + let _ = e.read_to_end(&mut v); + v + }) + }); + let mut stdout = Vec::new(); + if let Some(mut out) = child.stdout.take() { + let _ = out.read_to_end(&mut stdout); + } + let stderr = stderr.and_then(|t| t.join().ok()).unwrap_or_default(); + let reading = match (&tree, start) { + (Some(tree), Some(start)) => { + let _ = wait_exited_unreaped(pid); + Some(tree.stop(start)) + } + _ => None, + }; + let status = child.wait(); + let response = match status { + Err(e) => Err(LlmError::Unavailable(format!("wait for {}: {e}", self.bin))), + Ok(s) if !s.success() => Err(LlmError::Upstream(format!("{} exited {:?}", self.bin, s.code()))), + Ok(_) => Ok(parse_completion(request, &stdout, &stderr)), + }; + (response, reading) + } +} - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); +/// Wait for child `pid` to exit WITHOUT reaping it (`waitid` with `WNOWAIT`), +/// so its count stays readable for the tree meter's stop walk. The caller reaps +/// it afterwards. `false` if the wait failed. +#[cfg(target_os = "macos")] +fn wait_exited_unreaped(pid: u32) -> bool { + loop { + // SAFETY: an all-zero `siginfo_t` is a valid out-parameter. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is writable for the call; `pid` is this process's child. + let rc = unsafe { libc::waitid(libc::P_PID, pid as libc::id_t, &mut info, libc::WEXITED | libc::WNOWAIT) }; + if rc == 0 { + return true; + } + if std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + return false; + } + } +} - // The chat template wraps output as "...assistant\n{completion}\n> EOF...". - let after = stdout - .rsplit_once("assistant\n") - .map(|(_, c)| c) - .unwrap_or(&stdout); - let text = after - .split("\n> EOF") - .next() - .unwrap_or(after) - .trim() - .to_string(); +/// No tree meter exists off macOS, so there is nothing to read before the reap. +#[cfg(not(target_os = "macos"))] +fn wait_exited_unreaped(_pid: u32) -> bool { + false +} - // Real token counts from llama.cpp's timing lines; fall back to a coarse - // char/4 estimate if the format shifts. - let input_tokens = - count_before(&stderr, "tokens").unwrap_or((request.prompt.len() / 4) as u32); - let output_tokens = count_before(&stderr, "runs").unwrap_or((text.len() / 4) as u32); +impl LlmBackend for LlamaCompletionBackend { + fn model_name(&self) -> &str { + &self.model_name + } - Ok(LlmResponse { - text, - finish_reason: FinishReason::Stop, - input_tokens, - output_tokens, - // The backend itself does not meter joules; the caller measures host - // energy over the call (see the `run` binary), keeping provenance honest. - energy_joules: None, - }) + fn complete(&self, request: &LlmRequest) -> Result { + self.complete_metered(request).0 + } +} + +/// The completion in llama-completion's output. +fn parse_completion(request: &LlmRequest, stdout: &[u8], stderr: &[u8]) -> LlmResponse { + let stdout = String::from_utf8_lossy(stdout); + let stderr = String::from_utf8_lossy(stderr); + + // The chat template wraps output as "...assistant\n{completion}\n> EOF...". + let after = stdout + .rsplit_once("assistant\n") + .map(|(_, c)| c) + .unwrap_or(&stdout); + let text = after + .split("\n> EOF") + .next() + .unwrap_or(after) + .trim() + .to_string(); + + // Real token counts from llama.cpp's timing lines; fall back to a coarse + // char/4 estimate if the format shifts. + let input_tokens = + count_before(&stderr, "tokens").unwrap_or((request.prompt.len() / 4) as u32); + let output_tokens = count_before(&stderr, "runs").unwrap_or((text.len() / 4) as u32); + + LlmResponse { + text, + finish_reason: FinishReason::Stop, + input_tokens, + output_tokens, + // The backend puts no joule figure on the response: its reading of the + // model's process ([`LlamaCompletionBackend::complete_metered`]) is + // partial, and the caller grades it with the rest (see the `run` + // binary), keeping provenance honest. + energy_joules: None, } } -- 2.54.0 (Apple Git-157) From 03a1c1a59ffa678ca7ce78883e1f282a1c5978d6 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 10:07:07 -0400 Subject: [PATCH 04/12] l3-jouleclaw: test the in-process meter through the path main runs The in-process engine's span was opened inline in infer(), and the energy test opened its own: the test exercised the meter but not the call main makes. Both now go through in_process(), so the oracle test covers what the binary does (SpanUses::CpuGpu included). Co-Authored-By: Claude Opus 5.5 --- .../examples/l3-jouleclaw/src/bin/run.rs | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/joule-code/examples/l3-jouleclaw/src/bin/run.rs b/joule-code/examples/l3-jouleclaw/src/bin/run.rs index 8eb1a8ee1..df08c1387 100644 --- a/joule-code/examples/l3-jouleclaw/src/bin/run.rs +++ b/joule-code/examples/l3-jouleclaw/src/bin/run.rs @@ -225,6 +225,14 @@ impl Engine { } } +/// Run in-process `work` — the engine's decode, its CPU threads and the Metal +/// kernels it dispatches and waits on — under `meter`, as running on this +/// process's CPU and GPU and nothing else. +fn in_process(meter: &ProcessSpanMeter, work: impl FnOnce() -> T) -> (T, Own) { + let (out, energy) = meter.measure(SpanUses::CpuGpu, work); + (out, Own::Process(energy)) +} + /// Run `request` on `engine`, read on the engine's own process: the in-process /// engine by `span` (built before any window opens: its first construction /// probes the counters), the llama engine by its subprocess's tree. @@ -235,10 +243,7 @@ fn infer( request: &LlmRequest, ) -> (Result, Own) { match (engine, span) { - (Engine::Runtime(b), Some(meter)) => { - let (response, energy) = meter.measure(SpanUses::CpuGpu, || b.complete(request)); - (response, Own::Process(energy)) - } + (Engine::Runtime(b), Some(meter)) => in_process(meter, || b.complete(request)), (Engine::Runtime(b), None) => (b.complete(request), Own::Process(Err(EnergyError::NotCurrent))), (Engine::Llama(b), _) => { let (response, reading) = b.complete_metered(request); @@ -508,8 +513,9 @@ mod tests { x } - /// The in-process engine's meter on CPU work, against an oracle sharing no code - /// with it: this process's own count, read around the same work. The work uses + /// The in-process engine's meter ([`in_process`], as `main` runs the engine) on + /// CPU work, against an oracle sharing no code with it: this process's own + /// count, read around the same work. The work uses /// no GPU, so its GPU share is an exact zero and the span covers it whole, a /// hardware reading of this process — the oracle's window holds the span's, so /// the reading is at most the oracle and short of it only by the meter's own @@ -520,11 +526,10 @@ mod tests { let metered = meter.is_metered(); for round in 1..=2 { let before = own_energy_nj(); - let (x, energy) = meter.measure(SpanUses::CpuGpu, burn); + let (x, own) = in_process(&meter, burn); std::thread::sleep(std::time::Duration::from_millis(20)); let oracle_uj = (own_energy_nj() - before) / 1_000; std::hint::black_box(x); - let own = Own::Process(energy); let (e, note) = grade(&own, est(1)); let Own::Process(energy) = own else { unreachable!() }; eprintln!("in-process round {round}: oracle {oracle_uj} µJ; span {energy:?}; receipt {e:?} ({note})"); -- 2.54.0 (Apple Git-157) From 5f9639420c07c805b4f8acc712875b94530fe439 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 10:27:38 -0400 Subject: [PATCH 05/12] jouleclaw-loader-gguf: calibrate the fabric on this process's own counters fabric_calibrate and mpp_energy priced members only on the IOReport SoC counter, and refused - writing nothing - wherever its CPU rail is frozen, which is every macOS 27.0.1 host checked: the placer's calibration file could not be produced at all. Where IOReport declines they now do what tune_shape_live_process does: each member's loop is one ProcessSpanMeter span, this process's CPU energy plus its GPU energy, no idle to subtract. marginal_energy::process_figure is the rule, pure and in one place: a figure only from a span metered whole on this process's own counters (not the host-meter fallback), a CPU member's GPU share an exact zero, a GPU member's GPU time spent (its energy the rail while this process was the GPU's sole user); anything else is refused, saying why. Rows are written at the span's grade through calibration_row, with a method naming the new domain (process CPU + GPU, not the SoC marginal) and, for AMX, that its Accelerate work is counted as the kernel attributes it to the calling threads, unchecked against a rail. fabric_calibrate refuses member by member and writes the rest (exit 2 when any was refused, 1 when none was written); a member whose last shape was refused gets no row. Both examples now merge into the file instead of clobbering it. The IOReport path is unchanged where its rail is live. Tested: the rule's every refusal, pure; and a live CPU member against an oracle sharing no code (this process's ri_energy_nj read at the first op's start and the last op's end, inside the span): equal, twice. On this host the examples wrote the NEON and AMX rows (Measured) and refused the GPU members: the GPU was shared. Co-Authored-By: Claude Opus 5.5 --- .../examples/fabric_calibrate.rs | 264 +++++++++++++----- .../examples/mpp_energy.rs | 122 ++++++-- .../src/marginal_energy.rs | 241 ++++++++++++++++ .../tests/process_calibration.rs | 82 ++++++ 4 files changed, 611 insertions(+), 98 deletions(-) create mode 100644 jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/process_calibration.rs diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs index 8c4a6a8c1..2952bce13 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs @@ -2,33 +2,52 @@ //! //! Runs the same BF16 matmul workloads on every serving //! local member — NEON, AMX (Accelerate), Metal GPU -//! (resident-weight MPS) — under the IOReport SoC energy -//! counter, and writes the measured µJ/GFLOP table the -//! placer loads at discovery -//! (`~/.jouleclaw/fabric-calibration.json`). +//! (resident-weight MPS) — under an energy meter, and +//! writes the measured µJ/GFLOP table the placer loads at +//! discovery (`~/.jouleclaw/fabric-calibration.json`). //! -//! Honesty about the method, printed and recorded: the SoC -//! counter is machine-wide. Marginal energy per op = -//! ΔE_busy − P_idle·Δt, with P_idle sampled immediately -//! before the runs — an idle-subtraction MODEL on top of a -//! hardware reading, the same caveat the joule-fix fleet -//! dataset records. Run this on a quiet machine or the -//! numbers are someone else's workload. +//! Two meters, chosen as the live autotuner chooses +//! (`fabric_dispatch::tune_shape_live`): //! -//! 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. +//! - **The IOReport SoC counter, where its CPU energy rail +//! is live.** It is machine-wide, so marginal energy per +//! op = ΔE_busy − P_idle·Δt, with P_idle sampled +//! immediately before the runs — an idle-subtraction MODEL +//! on top of a hardware reading, the same caveat the +//! joule-fix fleet dataset records. Run this on a quiet +//! machine or the numbers are someone else's workload. +//! Every figure must be a measurement or nothing is +//! written: 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. +//! - **This process's own CPU and GPU counters, where it is +//! not** (macOS 27.0.1: the rail is frozen for an +//! unprivileged reader): each member's loop is one +//! `ProcessSpanMeter` span, its figure this process's CPU +//! energy plus its GPU energy, with no idle to subtract +//! (`marginal_energy::measure_member_process`). A member is +//! written only from a span metered whole: a CPU member's +//! GPU share an exact zero, a GPU member's the GPU rail +//! while this process was the GPU's sole user. Any other +//! member is refused, saying why, and the rest are still +//! written. The domain is this process's CPU and GPU, not +//! the SoC: DRAM is outside it, and AMX work through +//! Accelerate is counted as the kernel attributes it to the +//! calling threads' CPU energy, which is not checked +//! against a rail here — the row's method says so. //! //! 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. +//! live 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. Rows are merged +//! into the file: a member written now replaces its row, and +//! every other row stays as it was. +//! +//! Exit status: 0 when every member was written; 2 when +//! some were and others were refused; 1, having written +//! nothing, when none could be. //! //! ```bash //! cargo run --release --features "rayon fabric-meter" \ @@ -36,10 +55,13 @@ //! ``` use jouleclaw_energy::apple_ioreport::AppleIoReportCounter; +use jouleclaw_energy::span::ProcessSpanMeter; use jouleclaw_energy::EnergyCounter; use jouleclaw_loader_gguf::marginal_energy::{ - calibration_row, idle_draw_uw, marginal_uj, refusal, NotMeasured, + calibration_row, idle_draw_uw, marginal_uj, measure_member_process, process_method, refusal, MemberRuns, + NotMeasured, }; +use std::collections::BTreeMap; use std::time::Instant; /// Write nothing: say why, with the IOReport status, on stderr and exit non-zero. @@ -53,6 +75,15 @@ fn read_uj(counter: &AppleIoReportCounter) -> Result { counter.read().map(|r| r.uj).map_err(|e| NotMeasured::ReadFailed(e.to_string())) } +/// The meter every member is priced on. +enum Meter { + /// The machine-wide IOReport SoC counter, its CPU rail live, and the idle draw + /// sampled before the runs, µW. + IoReport { counter: AppleIoReportCounter, idle_uw: f64 }, + /// This process's own CPU and GPU counters. + Process(ProcessSpanMeter), +} + struct Shape { label: &'static str, m: usize, @@ -60,6 +91,11 @@ struct Shape { k: usize, } +/// A member's row, for the AMX member's method: what this process's CPU counter +/// covers of work it hands to Accelerate. +const AMX_NOTE: &str = "; AMX work through Accelerate counted as the kernel attributes it to the calling \ + threads' CPU energy, not checked against a rail"; + fn main() { // Decode GEMV at the lm_head class (wave 66's open // question) and a prefill batch shape. @@ -73,21 +109,41 @@ fn main() { let path = format!("{dir}/fabric-calibration.json"); let counter = AppleIoReportCounter::new(); - if !counter.is_available() { - refuse(&path, NotMeasured::CounterUnavailable("apple-ioreport declined".into())); - } - - // Idle baseline: 3 s of nothing. - let idle_uw = { - 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 = 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)) + let meter = if counter.is_available() { + // Idle baseline: 3 s of nothing. + let idle_uw = { + 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 = 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); + Meter::IoReport { counter, idle_uw } + } else { + let span = ProcessSpanMeter::new(jouleclaw_backend_apple::gpu_liveness_load); + if !span.is_metered() { + refuse( + &path, + NotMeasured::CounterUnavailable( + "apple-ioreport declined, and this process's own CPU and GPU counters are not both live".into(), + ), + ); + } + println!( + "IOReport declined ({:?}): pricing each member on this process's own CPU and GPU counters", + jouleclaw_energy::ioreport_status() + ); + Meter::Process(span) }; - println!("idle draw: {:.2} W (machine-wide SoC)", idle_uw / 1e6); - let mut entries: Vec = Vec::new(); + // Per member, the row from its LAST shape (the batch shape for + // matrix members reflects their serving regime; the decode + // shape stays in the printout for the record). A member whose + // last shape was refused has no row: an earlier shape's figure + // never stands in for it. + let mut latest: BTreeMap = BTreeMap::new(); + let mut refused: BTreeMap = BTreeMap::new(); for shape in &shapes { let (m, n, k) = (shape.m, shape.n, shape.k); @@ -108,9 +164,10 @@ fn main() { let mut out = vec![0f32; m * n]; type Runner<'a> = Box bool + 'a>; - let members: Vec<(&str, Runner)> = vec![ + let members: Vec<(&str, MemberRuns, Runner)> = vec![ ( "local/cpu-neon", + MemberRuns::Cpu, Box::new(|out: &mut [f32]| { jouleclaw_loader_gguf::matmul_bf16::matmul_bf16_neon(&a, &w, &[], m, n, k, out); true @@ -118,19 +175,21 @@ fn main() { ), ( "local/amx", + MemberRuns::Cpu, Box::new(|out: &mut [f32]| { jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_amx(&a, &w, &[], m, n, k, out) }), ), ( "local/metal-gpu", + MemberRuns::Gpu, Box::new(|out: &mut [f32]| { jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_gpu(&a, &w, &[], m, n, k, out) }), ), ]; - for (member, run) in &members { + for (member, runs, run) in &members { // Warm up (and for the GPU: pay the residency // upload OUTSIDE the measured window — calibration // prices the steady state; the upload amortizes). @@ -139,48 +198,99 @@ fn main() { continue; } run(&mut out); - // Measure ≥ 2 s of work. - let mut iters = 0u64; - 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; + match &meter { + Meter::IoReport { counter, idle_uw } => { + // Measure ≥ 2 s of work. + let mut iters = 0u64; + 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 = 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, + ); + refused.remove(*member); + latest.insert( + member.to_string(), + calibration_row( + member, + "matmul", + uj_per_gflop, + counter.provenance(), + format!( + "IOReport SoC ΔE − idle({:.2} W)·Δt over {} ops of {} — \ + idle-subtraction model on a hardware reading; IOReport {:?}", + idle_uw / 1e6, iters, shape.label, jouleclaw_energy::ioreport_status(), + ), + ), + ); + } + Meter::Process(span) => { + // ≥ 2 s of work, one span. + match measure_member_process(span, *runs, gflop, 2.0, || { + run(&mut out); + }) { + Ok(f) => { + println!( + "{member:>18}: {:>5} ops in {:.2}s → {:.0} µJ/op, {:.0} µJ/GFLOP ({:.1} ms/op; \ + CPU {} µJ + GPU {} µJ)", + f.ops, + f.secs, + f.uj_per_gflop * gflop, + f.uj_per_gflop, + f.secs * 1e3 / f.ops as f64, + f.cpu_uj, + f.gpu_uj, + ); + let mut method = process_method(&f, *runs, shape.label); + if *member == "local/amx" { + method.push_str(AMX_NOTE); + } + refused.remove(*member); + latest.insert( + member.to_string(), + calibration_row(member, "matmul", f.uj_per_gflop, f.provenance, method), + ); + } + Err(why) => { + println!("{member:>18}: refused — {why}"); + latest.remove(*member); + refused.insert(member.to_string(), format!("{}: {why}", shape.label)); + } + } + } } - let dt = t0.elapsed().as_secs_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(calibration_row( - member, - "matmul", - uj_per_gflop, - counter.provenance(), - format!( - "IOReport SoC ΔE − idle({:.2} W)·Δt over {} ops of {} — \ - idle-subtraction model on a hardware reading; IOReport {:?}", - idle_uw / 1e6, iters, shape.label, jouleclaw_energy::ioreport_status(), - ), - )); } } - // Keep the LAST measurement per member (the batch shape - // for matrix members reflects their serving regime); the - // decode shape stays in the printout for the record. - let mut latest: std::collections::BTreeMap = - Default::default(); - for e in entries { - latest.insert(e.member_id.clone(), e); + if latest.is_empty() { + let why: Vec = refused.iter().map(|(m, w)| format!("{m} ({w})")).collect(); + refuse(&path, format!("no member was measured whole: {}", why.join("; "))); } - let out_entries: Vec<_> = latest.into_values().collect(); + // Merge: a member written now replaces its row; every other row stays. + let mut rows: Vec = std::fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + rows.retain(|e| !latest.contains_key(&e.member_id)); + let written: Vec = latest.keys().cloned().collect(); + rows.extend(latest.into_values()); std::fs::create_dir_all(&dir).expect("mkdir"); - std::fs::write(&path, serde_json::to_vec_pretty(&out_entries).unwrap()).expect("write"); - println!("\nwrote {path} — Fabric::discover() now places on measured figures."); + std::fs::write(&path, serde_json::to_vec_pretty(&rows).unwrap()).expect("write"); + println!("\nwrote {} to {path} — Fabric::discover() now places on measured figures.", written.join(", ")); + if !refused.is_empty() { + for (member, why) in &refused { + eprintln!("not written: {member} — {why}"); + } + std::process::exit(2); + } } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/mpp_energy.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/mpp_energy.rs index 32e6c3192..657944639 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/mpp_energy.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/mpp_energy.rs @@ -1,19 +1,30 @@ //! Honest µJ/GFLOP of the Metal-4 matmul2d prefill path vs the -//! threaded-NEON CPU path, via the IOReport SoC energy counter. -//! Run on a QUIET box: marginal energy = ΔE_busy − P_idle·Δt. +//! threaded-NEON CPU path. //! `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. +//! Two meters, chosen as the live autotuner chooses (`fabric_dispatch::tune_shape_live`): +//! +//! - **The IOReport SoC counter, where its CPU energy rail is live.** Run on a QUIET +//! box: marginal energy = ΔE_busy − P_idle·Δt. It writes nothing, and exits non-zero +//! saying why, unless both figures are measurements: 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. +//! - **This process's own CPU and GPU counters, where it is not** (macOS 27.0.1: the +//! rail is frozen for an unprivileged reader): each run is one `ProcessSpanMeter` +//! span, this process's CPU energy plus its GPU energy, no idle to subtract +//! (`marginal_energy::measure_member_process`). The GPU run's figure is written only +//! from a span metered whole — the GPU rail while this process was the GPU's sole +//! user — else nothing is written. The CPU run (its GPU share must be an exact zero) +//! stands on its own span and gives the ratio; its refusal is printed and does not +//! void the GPU figure, which needs no idle baseline to be this process's alone. #[cfg(all(feature = "fabric", feature = "fabric-meter"))] fn main() { + use jouleclaw_energy::span::ProcessSpanMeter; use jouleclaw_energy::EnergyCounter; use jouleclaw_loader_gguf::marginal_energy::{ - calibration_row, energy_ratio, idle_draw_uw, marginal_uj, refusal, NotMeasured, + calibration_row, energy_ratio, idle_draw_uw, marginal_uj, measure_member_process, process_method, refusal, + MemberRuns, 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 } @@ -28,7 +39,7 @@ fn main() { }); let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new(); if !counter.is_available() { - refuse(&path, NotMeasured::CounterUnavailable("apple-ioreport declined".into())); + return process_counters(&path); } let read_uj = || counter.read().map(|r| r.uj).map_err(|e| NotMeasured::ReadFailed(e.to_string())); let idle_uw = { @@ -109,11 +120,6 @@ fn main() { // 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 mut entries: Vec = 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"); let row = calibration_row( "local/metal-mpp", "matmul", @@ -125,13 +131,87 @@ fn main() { 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); + write_row(&path, row); + + /// Merge `row` into the calibration file under its member, replacing that member's + /// row and leaving every other row as it was (merge, don't clobber). + fn write_row(path: &str, row: jouleclaw_fabric::CalibrationEntry) { + let mut entries: Vec = std::fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + entries.retain(|e| e.member_id != row.member_id); + let (member, ujpgf, grade) = (row.member_id.clone(), row.uj_per_gflop, 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 {member} = {ujpgf:.0} µJ/GFLOP ({grade:?}) → {path}"); + } } - if std::fs::write(&path, serde_json::to_vec_pretty(&entries).unwrap()).is_ok() { - println!("wrote local/metal-mpp = {:.0} µJ/GFLOP ({grade:?}) → {path}", gpu_ujpgf); + + /// The same two runs on this process's own CPU and GPU counters, each one span + /// ([`measure_member_process`]): the GPU run's figure is written only from a span + /// metered whole, and the CPU run, on its own span, gives the ratio. + fn process_counters(path: &str) { + let meter = ProcessSpanMeter::new(jouleclaw_backend_apple::gpu_liveness_load); + if !meter.is_metered() { + refuse( + path, + NotMeasured::CounterUnavailable( + "apple-ioreport declined, and this process's own CPU and GPU counters are not both live".into(), + ), + ); + } + println!( + "IOReport declined ({:?}): each run metered on this process's own CPU and GPU counters", + jouleclaw_energy::ioreport_status() + ); + let (m, n, k) = (256usize, 4096usize, 4096usize); + let a: Vec = (0..m * k).map(|i| ((i % 101) as f32 / 50.0) - 1.0).collect(); + let w: Vec = (0..n * k) + .flat_map(|i| f32_to_bf16(((i % 97) as f32 / 48.0 - 1.0) * 0.1).to_le_bytes()) + .collect(); + let gflop = 2.0 * m as f64 * n as f64 * k as f64 / 1e9; + let mut out = vec![0f32; m * n]; + let gpu_run = |o: &mut [f32]| { + let ok = jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_mpp_bt(&a, &w, m, n, k, o); + assert!(ok, "mpp_bt fell back — set JOULECLAW_FABRIC=1 on a Metal-4 box"); + }; + gpu_run(&mut out); // warm, outside the span + let gpu = measure_member_process(&meter, MemberRuns::Gpu, gflop, 3.0, || gpu_run(&mut out)); + let cpu_run = |o: &mut [f32]| { + // 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, &w, &[], m, n, k, o) + .expect("threaded-NEON matmul refused this shape"); + }; + cpu_run(&mut out); + let cpu = measure_member_process(&meter, MemberRuns::Cpu, gflop, 3.0, || cpu_run(&mut out)); + + println!("shape {m}x{n}x{k}"); + for (label, r) in [("matmul2d (metal-gpu)", &gpu), ("threaded-NEON (cpu) ", &cpu)] { + match r { + Ok(f) => println!( + " {label}: {:>9.0} µJ/GFLOP {:>7.1} GFLOP/s (CPU {} µJ + GPU {} µJ)", + f.uj_per_gflop, + gflop * f.ops as f64 / f.secs, + f.cpu_uj, + f.gpu_uj + ), + Err(e) => println!(" {label}: not measured — {e}"), + } + } + match (&gpu, &cpu) { + (Ok(g), Ok(c)) => match energy_ratio(c.uj_per_gflop, g.uj_per_gflop) { + Some(ratio) => println!(" energy ratio (CPU/GPU per GFLOP): {ratio:.2}×"), + None => println!(" energy ratio (CPU/GPU per GFLOP): n/a"), + }, + _ => println!(" energy ratio (CPU/GPU per GFLOP): n/a — both runs must be measured"), + } + let g = gpu.unwrap_or_else(|e| refuse(path, format!("the matmul2d (metal-gpu) run: {e}"))); + let method = process_method(&g, MemberRuns::Gpu, &format!("matmul2d {m}×{n}×{k}")); + write_row(path, calibration_row("local/metal-mpp", "matmul", g.uj_per_gflop, g.provenance, method)); } } #[cfg(not(all(feature = "fabric", feature = "fabric-meter")))] diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs index 3915913c2..4f57f668e 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs @@ -1,5 +1,11 @@ //! Marginal energy on a machine-wide cumulative energy counter, and when there is none. //! +//! Where there is none — an Apple Silicon host whose IOReport CPU rail is frozen for an +//! unprivileged reader, as on macOS 27.0.1 — a member is priced on this process's own +//! counters instead ([`measure_member_process`], the `fabric-meter` feature): one span +//! of the member's work, this process's CPU energy plus its GPU energy, with no idle to +//! subtract, every figure being this process's alone. +//! //! 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 @@ -47,6 +53,24 @@ pub enum NotMeasured { /// Operations in the window. ops: u64, }, + /// This process's span gave no figure; the payload says why. + SpanFailed(String), + /// This process's span covered only part of the member's work: the CPU alone, its + /// GPU energy unmeasured (the GPU was shared with another process, a GPU client + /// vanished, the GPU counter is not live). The payload says why. + NotWhole(String), + /// This process's own counters gave no figure, and the span fell back to the host + /// meter, whose figure is the machine's, not the member's. The payload says why. + NotOwnCounters(String), + /// A member that runs on the CPU spent GPU time over its span: its figure would + /// hold GPU energy that is not its own. + CpuMemberUsedGpu { + /// This process's GPU time over the span, ns. + self_ns: u64, + }, + /// A member that runs on the GPU spent no GPU time over its span: what ran was not + /// the member's GPU work. + GpuMemberUsedNoGpu, } impl fmt::Display for NotMeasured { @@ -80,6 +104,22 @@ impl fmt::Display for NotMeasured { Self::BelowResolution { marginal_uj, ops } => { write!(f, "{marginal_uj:.0} µJ over {ops} ops is below 1 µJ per op") } + Self::SpanFailed(why) => write!(f, "this process's span gave no figure: {why}"), + Self::NotWhole(why) => write!( + f, + "this process's span covered only part of the work, so it is no figure of it: {why}" + ), + Self::NotOwnCounters(why) => write!( + f, + "this process's own counters gave no figure, and the host meter's is the machine's: {why}" + ), + Self::CpuMemberUsedGpu { self_ns } => write!( + f, + "a CPU member spent {self_ns} ns of GPU time over its span, so its figure is not its own" + ), + Self::GpuMemberUsedNoGpu => { + f.write_str("a GPU member spent no GPU time over its span: its work did not run on the GPU") + } } } } @@ -208,6 +248,143 @@ pub fn calibration_row( } } +/// Where a fabric member's work runs, for [`process_figure`]: on this process's CPU +/// threads (NEON; AMX through Accelerate), or on the GPU. +#[cfg(feature = "fabric-meter")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemberRuns { + /// On this process's CPU threads: its GPU share must be an exact zero. + Cpu, + /// On the GPU, dispatched and waited on by this process: it must have spent GPU + /// time, and had the GPU to itself for the span to be whole. + Gpu, +} + +/// A fabric member's figure on this process's own CPU and GPU counters +/// ([`process_figure`]). +#[cfg(feature = "fabric-meter")] +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessFigure { + /// The span's energy over its operations' GFLOPs, µJ/GFLOP. + pub uj_per_gflop: f64, + /// The span's grade, which the calibration row is written at + /// ([`calibration_row`]): `HwShunt` for this process's live counters. + pub provenance: jouleclaw_energy::Provenance, + /// This process's CPU energy over the span, µJ. + pub cpu_uj: u64, + /// This process's GPU energy over the span, µJ: an exact zero for a CPU member; + /// for a GPU member, the GPU rail's advance over a span this process all but owned. + pub gpu_uj: u64, + /// Operations in the span. + pub ops: u64, + /// The span's length, s. + pub secs: f64, +} + +/// A member's figure from one span of its work on this process's own counters +/// (`jouleclaw_energy::span::ProcessSpanMeter`, [`SpanUses::CpuGpu`]), `ops` operations +/// of `gflop_per_op` GFLOP each over `secs` — or why there is none. A figure is given +/// only for a span metered **whole** on this process's own counters: +/// +/// - the span must have a figure, cover the whole work ([`Coverage::Whole`]: CPU and +/// GPU both read), and come from this process's counters, not the host meter it +/// falls back to; +/// - a CPU member's GPU share must be an exact zero (no GPU time at all), and a GPU +/// member must have spent GPU time — its GPU figure then the rail's advance over a +/// span this process was the GPU's effective sole user of. +/// +/// No idle is subtracted: both counters are this process's alone. The figure is the +/// span's energy per GFLOP, at the span's grade. Pure, so every refusal is tested +/// without hardware. +/// +/// [`SpanUses::CpuGpu`]: jouleclaw_energy::span::SpanUses::CpuGpu +/// [`Coverage::Whole`]: jouleclaw_energy::Coverage::Whole +#[cfg(feature = "fabric-meter")] +pub fn process_figure( + span: Result, + runs: MemberRuns, + ops: u64, + secs: f64, + gflop_per_op: f64, +) -> Result { + use jouleclaw_energy::span::SpanSource; + if ops == 0 || !(secs.is_finite() && secs > 0.0) || !(gflop_per_op.is_finite() && gflop_per_op > 0.0) { + return Err(NotMeasured::EmptyWindow); + } + let s = span.map_err(|e| NotMeasured::SpanFailed(e.to_string()))?; + if s.coverage != jouleclaw_energy::Coverage::Whole { + return Err(NotMeasured::NotWhole(match &s.source { + SpanSource::CpuOnly { why, .. } => why.clone(), + _ => "the work used what the meter does not read".to_string(), + })); + } + let (cpu_uj, gpu) = match &s.source { + SpanSource::Process { cpu_uj, gpu } => (*cpu_uj, gpu), + SpanSource::Fallback { meter, why } => { + return Err(NotMeasured::NotOwnCounters(format!("{why}; fell back to `{meter}`"))) + } + SpanSource::CpuOnly { why, .. } => return Err(NotMeasured::NotWhole(why.clone())), + }; + match runs { + MemberRuns::Cpu if gpu.uj != 0 || gpu.self_ns != 0 => { + return Err(NotMeasured::CpuMemberUsedGpu { self_ns: gpu.self_ns }) + } + MemberRuns::Gpu if gpu.self_ns == 0 => return Err(NotMeasured::GpuMemberUsedNoGpu), + _ => {} + } + if s.uj == 0 { + return Err(NotMeasured::SpanFailed("it read 0 µJ".to_string())); + } + Ok(ProcessFigure { + uj_per_gflop: s.uj as f64 / (ops as f64 * gflop_per_op), + provenance: s.provenance, + cpu_uj, + gpu_uj: gpu.uj, + ops, + secs, + }) +} + +/// Run a member's work `run` (one operation of `gflop_per_op` GFLOP a call) for at +/// least `secs` as one span on `meter`, and take its figure ([`process_figure`]). The +/// span holds everything this process runs meanwhile, so nothing else of this +/// process should run then. +#[cfg(feature = "fabric-meter")] +pub fn measure_member_process( + meter: &jouleclaw_energy::span::ProcessSpanMeter, + runs: MemberRuns, + gflop_per_op: f64, + secs: f64, + mut run: impl FnMut(), +) -> Result { + let start = meter.start(); + let t0 = std::time::Instant::now(); + let mut ops = 0u64; + while t0.elapsed().as_secs_f64() < secs { + run(); + ops += 1; + } + let dt = t0.elapsed().as_secs_f64(); + process_figure(meter.finish(start, jouleclaw_energy::span::SpanUses::CpuGpu), runs, ops, dt, gflop_per_op) +} + +/// A calibration row's `method` for a [`ProcessFigure`] of `workload`: the domain (this +/// process's CPU and GPU energy, not the SoC's marginal draw), how the GPU part was +/// read, and that nothing was subtracted. +#[cfg(feature = "fabric-meter")] +pub fn process_method(fig: &ProcessFigure, runs: MemberRuns, workload: &str) -> String { + let gpu = match runs { + MemberRuns::Cpu => "an exact GPU zero (no GPU time)".to_string(), + MemberRuns::Gpu => format!("{} µJ of GPU rail while this process was its sole user", fig.gpu_uj), + }; + format!( + "this process's own energy over {} ops of {workload} in {:.2} s: {} µJ of CPU (the per-process CPU \ + counter) + {gpu}; no idle subtraction, every figure this process's alone (ProcessSpanMeter, CpuGpu, \ + whole)", + fig.ops, fig.secs, fig.cpu_uj + ) +} + /// 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 { @@ -388,6 +565,70 @@ mod tests { assert_eq!(rfc3339_utc(4_102_444_799), "2099-12-31T23:59:59Z"); } + /// A member's process-span figure, pure: given only for a span metered whole on this + /// process's own counters — a CPU member's GPU share an exact zero, a GPU member's + /// GPU time spent — and refused, saying why, for every other span. + #[cfg(feature = "fabric-meter")] + #[test] + fn a_member_is_priced_on_this_process_only_from_a_whole_span_of_its_own_work() { + use jouleclaw_energy::process_gpu::GpuShare; + use jouleclaw_energy::span::{SpanEnergy, SpanSource}; + use jouleclaw_energy::{Coverage, EnergyError, Provenance}; + let zero = GpuShare { uj: 0, self_ns: 0, rail_uj: None, all_ns: None }; + let sole = GpuShare { uj: 700, self_ns: 9_000, rail_uj: Some(710), all_ns: Some(9_010) }; + let whole = |cpu_uj: u64, gpu: GpuShare| -> Result { + Ok(SpanEnergy { + uj: cpu_uj + gpu.uj, + provenance: Provenance::HwShunt, + coverage: Coverage::Whole, + source: SpanSource::Process { cpu_uj, gpu }, + }) + }; + + // 4 ops of 0.5 GFLOP: 2 GFLOP. + let cpu = process_figure(whole(3_000, zero), MemberRuns::Cpu, 4, 2.0, 0.5).unwrap(); + assert_eq!((cpu.uj_per_gflop, cpu.provenance, cpu.cpu_uj, cpu.gpu_uj), (1_500.0, Provenance::HwShunt, 3_000, 0)); + let gpu = process_figure(whole(300, sole), MemberRuns::Gpu, 4, 2.0, 0.5).unwrap(); + assert_eq!((gpu.uj_per_gflop, gpu.gpu_uj), (500.0, 700)); + let method = process_method(&gpu, MemberRuns::Gpu, "m=16"); + assert!(method.contains("4 ops of m=16") && method.contains("700 µJ of GPU rail") && method.contains("no idle"), "{method}"); + + // A CPU member that spent GPU time, a GPU member that spent none. + assert_eq!( + process_figure(whole(3_000, sole), MemberRuns::Cpu, 4, 2.0, 0.5), + Err(NotMeasured::CpuMemberUsedGpu { self_ns: 9_000 }) + ); + assert_eq!(process_figure(whole(3_000, zero), MemberRuns::Gpu, 4, 2.0, 0.5), Err(NotMeasured::GpuMemberUsedNoGpu)); + + // The GPU shared: the CPU alone, partial. + let shared = Ok(SpanEnergy { + uj: 300, + provenance: Provenance::HwShunt, + coverage: Coverage::Partial, + source: SpanSource::CpuOnly { cpu_uj: 300, why: "process GPU: shared 115 ns of 1000 ns".into() }, + }); + let why = process_figure(shared, MemberRuns::Gpu, 4, 2.0, 0.5).unwrap_err(); + assert!(matches!(&why, NotMeasured::NotWhole(w) if w.contains("shared")), "{why}"); + assert!(why.to_string().contains("only part of the work"), "{why}"); + + // The host meter's fallback: whole for its own domain, which is the machine's. + let fallback = Ok(SpanEnergy { + uj: 4_000, + provenance: Provenance::Estimator, + coverage: Coverage::Whole, + source: SpanSource::Fallback { meter: "apple-silicon", why: "process CPU: not live".into() }, + }); + let why = process_figure(fallback, MemberRuns::Cpu, 4, 2.0, 0.5).unwrap_err(); + assert!(matches!(&why, NotMeasured::NotOwnCounters(w) if w.contains("apple-silicon")), "{why}"); + + let why = process_figure(Err(EnergyError::NotAdvanced), MemberRuns::Cpu, 4, 2.0, 0.5).unwrap_err(); + assert!(matches!(why, NotMeasured::SpanFailed(_)), "{why}"); + for (ops, secs, gflop) in [(0, 2.0, 0.5), (4, 0.0, 0.5), (4, 2.0, 0.0), (4, f64::NAN, 0.5)] { + let empty = process_figure(whole(3_000, zero), MemberRuns::Cpu, ops, secs, gflop); + assert_eq!(empty, Err(NotMeasured::EmptyWindow), "{ops} {secs} {gflop}"); + } + } + /// Only a hardware reading becomes a `Measured` row; nothing is upgraded. #[cfg(feature = "fabric-meter")] #[test] diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/process_calibration.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/process_calibration.rs new file mode 100644 index 000000000..dfa5dbfcb --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/process_calibration.rs @@ -0,0 +1,82 @@ +//! A fabric member priced on this process's own counters +//! (`marginal_energy::measure_member_process`, what `fabric_calibrate` and +//! `mpp_energy` fall back to where the IOReport CPU rail is frozen), against an +//! oracle sharing no code with the meter: this process's own CPU energy count, +//! read around the same span with a bare `proc_pid_rusage`. +//! +//! One test in its own binary, so no other test's thread runs in this process +//! during the span (the span, and the oracle, read every thread of it). + +#![cfg(feature = "fabric-meter")] + +use jouleclaw_energy::span::ProcessSpanMeter; +use jouleclaw_loader_gguf::marginal_energy::{measure_member_process, MemberRuns, NotMeasured}; + +/// This process's own CPU energy count, nJ: `proc_pid_rusage(getpid(), +/// RUSAGE_INFO_V6)`'s `ri_energy_nj`, read with a bare buffer of the struct's 464 +/// bytes at its SDK offset, 336. Written out here, sharing no code with the meter +/// it checks. +#[cfg(target_os = "macos")] +fn own_energy_nj() -> u64 { + unsafe extern "C" { + fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut std::ffi::c_void) -> i32; + } + let mut buf = [0u64; 58]; + // SAFETY: `buf` is 464 writable bytes, the size of `rusage_info_v6`. + let rc = unsafe { proc_pid_rusage(std::process::id() as i32, 6, buf.as_mut_ptr().cast()) }; + assert_eq!(rc, 0, "proc_pid_rusage on this process"); + buf[336 / 8] +} + +#[cfg(not(target_os = "macos"))] +fn own_energy_nj() -> u64 { + 0 +} + +/// A CPU member (the NEON BF16 matmul) priced on this process: where both of this +/// process's counters are live, the figure is given, its GPU part an exact zero, and +/// its CPU part agrees with the oracle, read inside the span at the first operation's +/// start and the last one's end — so the span holds the oracle's window and differs +/// from it only by the meter's last and first reads around it — and the per-GFLOP +/// figure is that energy over the work. Where they are not, the member is refused, +/// never priced on the host meter. Twice, three seconds each, however loaded the host. +#[test] +fn a_cpu_member_is_priced_on_this_process_as_its_own_count_says() { + use std::cell::Cell; + let meter = ProcessSpanMeter::new(jouleclaw_backend_apple::gpu_liveness_load); + let (m, n, k) = (16usize, 512usize, 512usize); + let gflop = 2.0 * (m * n * k) as f64 / 1e9; + let a: Vec = (0..m * k).map(|i| ((i % 101) as f32 / 50.0) - 1.0).collect(); + let w: Vec = (0..n * k) + .flat_map(|i| jouleclaw_loader_gguf::matmul_bf16::f32_to_bf16_rne(((i % 97) as f32 / 48.0 - 1.0) * 0.1).to_le_bytes()) + .collect(); + let mut out = vec![0f32; m * n]; + for round in 1..=2 { + let (first, last) = (Cell::new(None), Cell::new(0u64)); + let figure = measure_member_process(&meter, MemberRuns::Cpu, gflop, 3.0, || { + if first.get().is_none() { + first.set(Some(own_energy_nj())); + } + jouleclaw_loader_gguf::matmul_bf16::matmul_bf16_neon(&a, &w, &[], m, n, k, &mut out); + last.set(own_energy_nj()); + }); + let oracle_uj = (last.get() - first.get().expect("the work ran")) / 1_000; + eprintln!("CPU member round {round}: oracle {oracle_uj} µJ; metered={} {figure:?}", meter.is_metered()); + if meter.is_metered() { + let f = figure.expect("a CPU member on live counters is priced"); + assert_eq!(f.gpu_uj, 0, "the NEON matmul spent GPU energy: {f:?}"); + assert_eq!(f.provenance, jouleclaw_energy::Provenance::HwShunt); + assert!(oracle_uj > 100_000, "setup: the work read {oracle_uj} µJ"); + let ratio = f.cpu_uj as f64 / oracle_uj as f64; + assert!((0.9..=1.1).contains(&ratio), "the span read {} µJ of {oracle_uj} µJ", f.cpu_uj); + let total = f.uj_per_gflop * f.ops as f64 * gflop; + assert!((total - f.cpu_uj as f64).abs() <= 1.0, "{total} µJ priced of {} µJ read", f.cpu_uj); + } else { + let why = figure.expect_err("no live counters, yet a figure"); + assert!( + matches!(why, NotMeasured::NotWhole(_) | NotMeasured::NotOwnCounters(_) | NotMeasured::SpanFailed(_)), + "{why}" + ); + } + } +} -- 2.54.0 (Apple Git-157) From d134936e7ecb336d6df78a50139590edc61fba90 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 10:28:01 -0400 Subject: [PATCH 06/12] jouleclaw-loader-gguf: pin the process-figure refusal of a partial span of both counters A span can read both of this process's counters and still be partial (work declared to use the Neural Engine). process_figure refuses it by its coverage, which no test drove: the CPU-only case is refused by its source as well, so dropping the coverage check passed every test. Co-Authored-By: Claude Opus 5.5 --- .../jouleclaw-loader-gguf/src/marginal_energy.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs index 4f57f668e..7b7d9a4ac 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs @@ -611,6 +611,16 @@ mod tests { assert!(matches!(&why, NotMeasured::NotWhole(w) if w.contains("shared")), "{why}"); assert!(why.to_string().contains("only part of the work"), "{why}"); + // Both counters read, but the work declared Neural Engine use, which none reads. + let ane = Ok(SpanEnergy { + uj: 1_000, + provenance: Provenance::HwShunt, + coverage: Coverage::Partial, + source: SpanSource::Process { cpu_uj: 300, gpu: sole }, + }); + let why = process_figure(ane, MemberRuns::Gpu, 4, 2.0, 0.5).unwrap_err(); + assert!(matches!(why, NotMeasured::NotWhole(_)), "{why}"); + // The host meter's fallback: whole for its own domain, which is the machine's. let fallback = Ok(SpanEnergy { uj: 4_000, -- 2.54.0 (Apple Git-157) From 86f079b9c33d3fe6371cacc47f3bfb494e256987 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 17:50:22 -0400 Subject: [PATCH 07/12] jouleclaw-loader-gguf: fabric_calibrate replaces every row of the members it measures fabric_calibrate merged its rows by keeping every existing row of a member it did not write this run. A member refused this run (the GPU shared with another process) or skipped at warm-up kept the row an earlier run took, so the file came out mixing an old SoC-marginal row (no declared grade, loaded Benchmarked) with this run's per-process Measured rows, and the placer, which ranks on uj_per_gflop alone, compared the two as if they were one kind of figure, while the run said the fabric now placed on measured figures. A member skipped at warm-up also counted as neither written nor refused, so the run exited 0. marginal_energy::merge_calibration now drops every row of the members the run owns (FABRIC_CALIBRATE_MEMBERS: cpu-neon, amx, metal-gpu) before adding the rows it measured, so a refused or skipped member falls back to its seeded figure; rows it does not own (local/metal-mpp, which mpp_energy writes) stay as they were. A member skipped at warm-up is now refused (exit 2) and loses any row an earlier shape gave it. mpp_energy already writes its one row only when measured and nothing otherwise. Co-Authored-By: Claude Opus 5.5 --- .../examples/fabric_calibrate.rs | 40 +++++++---- .../src/marginal_energy.rs | 72 +++++++++++++++++++ 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs index 2952bce13..f9c4c5289 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs @@ -41,13 +41,21 @@ //! live 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. Rows are merged -//! into the file: a member written now replaces its row, and -//! every other row stays as it was. +//! at that grade and carries the rest along. The file's rows +//! for the three members this example measures +//! (`marginal_energy::FABRIC_CALIBRATE_MEMBERS`) are all +//! replaced (`marginal_energy::merge_calibration`): a member +//! written now by its new row, and a member refused or +//! skipped by none, so the fabric places it on its seeded +//! figure — never on a row an earlier run took, which may be +//! of another domain, grade or shape than the rows written +//! beside it. Rows of members it does not measure +//! (`local/metal-mpp`, which `mpp_energy` writes) stay as +//! they were. //! //! Exit status: 0 when every member was written; 2 when -//! some were and others were refused; 1, having written -//! nothing, when none could be. +//! some were and others were refused or skipped (absent at +//! warm-up); 1, having written nothing, when none could be. //! //! ```bash //! cargo run --release --features "rayon fabric-meter" \ @@ -58,8 +66,8 @@ use jouleclaw_energy::apple_ioreport::AppleIoReportCounter; use jouleclaw_energy::span::ProcessSpanMeter; use jouleclaw_energy::EnergyCounter; use jouleclaw_loader_gguf::marginal_energy::{ - calibration_row, idle_draw_uw, marginal_uj, measure_member_process, process_method, refusal, MemberRuns, - NotMeasured, + calibration_row, idle_draw_uw, marginal_uj, measure_member_process, merge_calibration, process_method, refusal, + MemberRuns, NotMeasured, FABRIC_CALIBRATE_MEMBERS, }; use std::collections::BTreeMap; use std::time::Instant; @@ -140,8 +148,8 @@ fn main() { // Per member, the row from its LAST shape (the batch shape for // matrix members reflects their serving regime; the decode // shape stays in the printout for the record). A member whose - // last shape was refused has no row: an earlier shape's figure - // never stands in for it. + // last shape was refused or skipped has no row: neither an + // earlier shape's figure nor an earlier run's stands in for it. let mut latest: BTreeMap = BTreeMap::new(); let mut refused: BTreeMap = BTreeMap::new(); @@ -195,6 +203,8 @@ fn main() { // prices the steady state; the upload amortizes). if !run(&mut out) { println!("{member:>18}: honestly absent / refused — skipped"); + latest.remove(*member); + refused.insert(member.to_string(), format!("{}: absent or refused at warm-up", shape.label)); continue; } run(&mut out); @@ -276,20 +286,20 @@ fn main() { let why: Vec = refused.iter().map(|(m, w)| format!("{m} ({w})")).collect(); refuse(&path, format!("no member was measured whole: {}", why.join("; "))); } - // Merge: a member written now replaces its row; every other row stays. - let mut rows: Vec = std::fs::read(&path) + // Every row of a member this example measures is replaced: by + // its row from this run, or by none. Other members' rows stay. + let existing: Vec = std::fs::read(&path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default(); - rows.retain(|e| !latest.contains_key(&e.member_id)); let written: Vec = latest.keys().cloned().collect(); - rows.extend(latest.into_values()); + let rows = merge_calibration(existing, &FABRIC_CALIBRATE_MEMBERS, latest.into_values().collect()); std::fs::create_dir_all(&dir).expect("mkdir"); std::fs::write(&path, serde_json::to_vec_pretty(&rows).unwrap()).expect("write"); - println!("\nwrote {} to {path} — Fabric::discover() now places on measured figures.", written.join(", ")); + println!("\nwrote {} to {path} — Fabric::discover() places them on these figures.", written.join(", ")); if !refused.is_empty() { for (member, why) in &refused { - eprintln!("not written: {member} — {why}"); + eprintln!("not written: {member} — {why}; its row was dropped, so it is placed on its seeded figure"); } std::process::exit(2); } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs index 7b7d9a4ac..6772c271c 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs @@ -248,6 +248,32 @@ pub fn calibration_row( } } +/// The members `fabric_calibrate` measures, and so owns the calibration rows of. +/// `local/metal-mpp` is not among them: `mpp_energy` writes that row. +#[cfg(feature = "fabric-meter")] +pub const FABRIC_CALIBRATE_MEMBERS: [&str; 3] = ["local/cpu-neon", "local/amx", "local/metal-gpu"]; + +/// The calibration file's rows after a run that owns the rows of the `owned` members +/// and measured `measured`: every row of an owned member is dropped and the measured +/// rows are added, so a member the run owns but refused, or skipped, has no row and is +/// placed on its seeded figure — never on a row an earlier run took, which may be of +/// another domain (the SoC's marginal draw, not this process's own energy), grade, +/// shape or OS than the rows written beside it. Rows of members the run does not own +/// stay as they were. A measured member outside `owned` still replaces its own row. +#[cfg(feature = "fabric-meter")] +pub fn merge_calibration( + existing: Vec, + owned: &[&str], + measured: Vec, +) -> Vec { + let mut rows: Vec<_> = existing + .into_iter() + .filter(|e| !owned.contains(&e.member_id.as_str()) && !measured.iter().any(|m| m.member_id == e.member_id)) + .collect(); + rows.extend(measured); + rows +} + /// Where a fabric member's work runs, for [`process_figure`]: on this process's CPU /// threads (NEON; AMX through Accelerate), or on the GPU. #[cfg(feature = "fabric-meter")] @@ -639,6 +665,52 @@ mod tests { } } + /// A calibration run's rows replace every row of the members it owns: a member it + /// refused (or skipped) is left with none, so it falls back to its seeded figure, + /// and an earlier run's SoC-marginal row (no declared grade, loaded `Benchmarked`) + /// never stands beside the per-process `Measured` rows written now. The shape of + /// this host's file before such a run, with `local/metal-mpp` (another example's + /// row) kept as it was. + #[cfg(feature = "fabric-meter")] + #[test] + fn a_calibration_run_drops_every_row_it_owns_and_keeps_the_rest() { + use jouleclaw_fabric::{CalibrationEntry, EnergyProvenance as Grade}; + let old = |member: &str, uj_per_gflop: f64| CalibrationEntry { + member_id: member.to_string(), + op: "matmul".to_string(), + uj_per_gflop, + method: "IOReport SoC ΔE − idle(100.17 W)·Δt over 1428 ops of prefill-batch (m=16)".to_string(), + provenance: None, + date: None, + os: None, + chip: None, + }; + let existing = vec![ + old("local/amx", 868_359.0), + old("local/cpu-neon", 3_774_424.8), + old("local/metal-gpu", 161_682.8), + old("local/metal-mpp", 90_000.0), + ]; + // This run: the two CPU members measured, the GPU member refused (shared GPU). + let now = |member: &str| calibration_row(member, "matmul", 246_741.4, jouleclaw_energy::Provenance::HwShunt, "m".into()); + let measured = vec![now("local/amx"), now("local/cpu-neon")]; + let rows = merge_calibration(existing, &FABRIC_CALIBRATE_MEMBERS, measured); + let ids: Vec<&str> = rows.iter().map(|r| r.member_id.as_str()).collect(); + assert_eq!(ids, ["local/metal-mpp", "local/amx", "local/cpu-neon"], "the refused GPU member's old row stayed"); + assert!( + rows.iter() + .filter(|r| FABRIC_CALIBRATE_MEMBERS.contains(&r.member_id.as_str())) + .all(|r| r.loaded_provenance() == Grade::Measured), + "a row this run owns is not one it wrote: {rows:?}" + ); + assert_eq!(rows[0].uj_per_gflop, 90_000.0, "a row it does not own stays as it was"); + + // A measured member outside the owned set still replaces its own row, once. + let rows = merge_calibration(rows, &[], vec![now("local/metal-mpp")]); + assert_eq!(rows.iter().filter(|r| r.member_id == "local/metal-mpp").count(), 1); + assert_eq!(rows.last().map(|r| r.uj_per_gflop), Some(246_741.4)); + } + /// Only a hardware reading becomes a `Measured` row; nothing is upgraded. #[cfg(feature = "fabric-meter")] #[test] -- 2.54.0 (Apple Git-157) From 8a2f1b1c0dd010f6309a31fcd50fb85e670c1b20 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 17:50:46 -0400 Subject: [PATCH 08/12] joule-code-remediate: drop JOULE_FIX_L3_SELF_CONTAINED, which could never take effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L3 window was attributed only for a tree reading with no gap and the operator's JOULE_FIX_L3_SELF_CONTAINED=1. But run_l3 reads the model at its exit (waited for, not reaped), and the tree meter marks every member that exited in the span (TreeGap::Exited): an exiting process's record lacks its last time on a core. The root always exits, so no reading was ever whole and the setting, the attributed field and the whole-reading branch were dead — while the docs offered the setting as the way to a whole reading. The setting and its plumbing are gone, and the docs now say plainly that a model subprocess is never read whole: its reading is a lower bound on its CPU energy, and the window is always an estimate, the reading plus the machine- wide figure. A reading whose tree showed no gap (only possible if the wait for the model's exit failed) says so (NOT_READ_AT_EXIT) rather than coming back with nothing to explain why it is partial. Co-Authored-By: Claude Opus 5.5 --- .../joule-code-remediate/src/bin/joule_fix.rs | 10 +- .../crates/joule-code-remediate/src/lib.rs | 156 +++++++----------- 2 files changed, 63 insertions(+), 103 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-code-remediate/src/bin/joule_fix.rs b/joule-code/joule-code-rs/crates/joule-code-remediate/src/bin/joule_fix.rs index 57d57d6d3..5c69b27db 100644 --- a/joule-code/joule-code-rs/crates/joule-code-remediate/src/bin/joule_fix.rs +++ b/joule-code/joule-code-rs/crates/joule-code-remediate/src/bin/joule_fix.rs @@ -45,15 +45,11 @@ fn main() { if let Some(l3) = &report.l3 { match l3.process_cpu_uj { Some(read) => println!( - " of which the L3 model's window: {:.3} J ({}; its own process tree read {:.3} J of CPU, {})", + " of which the L3 model's window: {:.3} J (an estimate: its own process tree read {:.3} J of CPU, \ + partial: {}; plus the machine-wide figure)", l3.figure_uj as f64 / 1e6, - if l3.attributed { "read whole" } else { "an estimate: that reading plus the machine-wide figure" }, read as f64 / 1e6, - if l3.partial_because.is_empty() { - "whole".to_string() - } else { - format!("partial: {}", l3.partial_because.join("; ")) - }, + l3.partial_because.join("; "), ), None => println!( " of which the L3 model's window: {:.3} J (machine-wide only{})", diff --git a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs index f1b362171..b6cdf48d5 100644 --- a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs @@ -80,8 +80,8 @@ struct MeterAcc { /// figure is not a reading of this workload. It is the meter's wall × TDP /// estimate where the host exposes no live CPU counter (every macOS 27.0.1 /// host checked), or where the counter held; or it is a machine-wide figure -/// the meter could not apportion. The L3 window is attributed only when its -/// model's own process tree was read whole ([`grade_l3`]). The run is +/// the meter could not apportion. The L3 window is never attributed: its +/// model's own process tree is never read whole ([`grade_l3`]). The run is /// `ModelBased` only when every window was attributed, because a sum is only /// as good as its weakest part. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -192,10 +192,10 @@ pub struct L3Energy { /// exits in the window always has. pub process_cpu_uj: Option, /// Why `process_cpu_uj` is not the model's whole energy over the window: - /// each gap the tree meter saw, as it words it, then - /// [`NOT_DECLARED_SELF_CONTAINED`] where the operator has not declared - /// the command self-contained ([`Harness::l3_self_contained`]); or why - /// there is no reading at all. Empty only for a whole reading. + /// each gap the tree meter saw, as it words it — a model that exits in + /// its window always has one, so its reading is never whole — or, where + /// it saw none, [`NOT_READ_AT_EXIT`]; or why there is no reading at all. + /// Never empty for a reading. pub partial_because: Vec, /// Whether the command ran as the shell's `exec` — a plain argv — so the /// model's process IS the process the tree meter is rooted at, and its @@ -203,24 +203,21 @@ pub struct L3Energy { /// a redirect) runs as the shell's script: what the shell forks and /// reaps is a gap, its energy in no counter. pub direct_child: bool, - /// What the window adds to the run's figure when the run is not - /// attributed (see [`grade_l3`]), µJ. + /// What the window adds to the run's figure (see [`grade_l3`]), µJ: an + /// estimate, never attributed. pub figure_uj: u64, - /// Whether the window is attributed: a whole reading of the model's own - /// process tree. Only then does `process_cpu_uj` stand for the window. - pub attributed: bool, } -/// Why a reading of the L3 command's process tree is partial although -/// nothing was seen missing from it: the operator has not declared that the -/// command does all its work in its own tree (no server, no daemon, no -/// remote API), which no reader of the tree can see. The same rule, and the -/// same words, as the MCP gateway's upstream-tree reading. -pub const NOT_DECLARED_SELF_CONTAINED: &str = "not-declared-self-contained"; +/// Why a reading of the L3 command's process tree is partial although the +/// tree meter saw no gap in it: the model had not exited when it was read +/// (the wait for its exit failed), so the reading may stop short of its +/// work. A model read at its exit always carries the exit's own gap instead. +pub const NOT_READ_AT_EXIT: &str = + "the model had not exited when its process tree was read, so the reading may stop short of its work"; impl L3Energy { /// What the window's process reading added to the run's tree figures: - /// the reading itself, a whole one or a floor, else nothing. + /// the reading itself, a floor, else nothing. fn attributed_uj(&self) -> u64 { self.process_cpu_uj.unwrap_or(0) } @@ -229,11 +226,6 @@ impl L3Energy { fn describe(&self) -> String { let j = |uj: u64| uj as f64 / 1e6; match self.process_cpu_uj { - Some(read) if self.attributed => format!( - "L3 model read whole on its own process tree, {:.3} J of CPU (per-process HwShunt, sealed no higher than \ - the run's grade)", - j(read) - ), Some(read) => format!( "L3 model's own process tree read {:.3} J of CPU, partial ({}), so the window is an estimate: that \ reading plus the machine-wide {:.3} J, {:.3} J", @@ -255,48 +247,35 @@ impl L3Energy { } } -/// The L3 window's figure and grade: the one place they are decided. +/// The L3 window's figure: the one place it is decided. It is always an +/// estimate of the window, never attributed. /// -/// - **A whole reading** — the tree meter saw nothing missing AND the -/// operator declared the command self-contained: the reading, attributed. -/// It is a hardware reading of the model's own process (`HwShunt`, -/// per-process), which the run's receipts carry at their strongest grade, -/// `ModelBased`: graded down, never up. Its GPU part is an exact zero (a -/// whole reading held no GPU client). -/// - **A partial reading** — any gap (a model that exits in the window -/// always has one: its exiting thread's last time on a core is never -/// folded, and its GPU time is unknown), or no declaration: an estimate of -/// the window's whole energy, never a hardware figure of it. The figure is -/// the reading plus the machine-wide meter's figure over the same window — -/// never less than was read, and nothing the model drew outside its CPU -/// left out — the rule mesh's in-process meter bills a partial span by. -/// The machine-wide figure covers the model's CPU too, so this errs high, -/// by at most the reading. +/// - **A reading** — never whole. The model is read at its exit, and an +/// exiting process's record lacks its last time on a core (the kernel +/// never folds it), so the reading is a lower bound on the model's own CPU +/// energy; its GPU time is unknown, and work it hands outside its tree is +/// in no reading of it. The figure is the reading plus the machine-wide +/// meter's figure over the same window — never less than was read, and +/// nothing the model drew outside its CPU left out — the rule mesh's +/// in-process meter bills a partial span by. The machine-wide figure +/// covers the model's CPU too, so this errs high, by at most the reading. /// - **No reading** — no per-process counter (Linux, a counter that is not /// live) or the span's [`TreeError`]: the machine-wide figure, exactly as -/// a cargo window's, never attributed (it is the whole host's). -fn grade_l3( - reading: Option<&Result>, - self_contained: bool, - machine_uj: u64, - direct_child: bool, -) -> L3Energy { +/// a cargo window's (it is the whole host's). +fn grade_l3(reading: Option<&Result>, machine_uj: u64, direct_child: bool) -> L3Energy { match reading { Some(Ok(r)) => { let read = r.cpu_nj.div_ceil(1_000); let mut because: Vec = r.gaps.iter().map(ToString::to_string).collect(); - if !self_contained { - because.push(NOT_DECLARED_SELF_CONTAINED.to_string()); + if because.is_empty() { + because.push(NOT_READ_AT_EXIT.to_string()); } - let attributed = because.is_empty(); - let figure_uj = if attributed { read } else { read.saturating_add(machine_uj) }; L3Energy { machine_uj, process_cpu_uj: Some(read), partial_because: because, direct_child, - figure_uj, - attributed, + figure_uj: read.saturating_add(machine_uj), } } Some(Err(e)) => L3Energy { @@ -305,7 +284,6 @@ fn grade_l3( partial_because: vec![format!("no reading of the model's process tree: {e}")], direct_child, figure_uj: machine_uj, - attributed: false, }, None => L3Energy { machine_uj, @@ -313,7 +291,6 @@ fn grade_l3( partial_because: Vec::new(), direct_child, figure_uj: machine_uj, - attributed: false, }, } } @@ -605,15 +582,11 @@ pub struct Harness { /// is the shell's own process and its own energy count is readable. A /// command that needs the shell (a pipeline, a list, quoting) runs as the /// shell's script instead, and what the shell forks and reaps is energy - /// no counter holds: the reading says so. + /// no counter holds: the reading says so. The reading is never whole — + /// the model exits in its window, and an exiting process's record lacks + /// its last time on a core — so the window is always an estimate + /// ([`grade_l3`]). pub l3_cmd: Option, - /// The operator's declaration that the L3 command does all its work in - /// its own process tree: no model server it calls, no daemon, no remote - /// API. No reader of the tree can see work handed outside it, so without - /// this a reading of the model's process is never whole - /// ([`NOT_DECLARED_SELF_CONTAINED`]). Defaults from - /// `JOULE_FIX_L3_SELF_CONTAINED=1`. - pub l3_self_contained: bool, /// Where induced skills persist. Defaults from `JOULE_FIX_SKILLS`, else /// `~/.joule-fix/skills.json`; the store is created on first induction. pub skill_store: PathBuf, @@ -657,7 +630,6 @@ impl Harness { signing: ed25519_dalek::SigningKey::from_bytes(&[11u8; 32]), meter_signing: ed25519_dalek::SigningKey::from_bytes(&[12u8; 32]), l3_cmd: std::env::var("JOULE_FIX_L3_CMD").ok().filter(|s| !s.trim().is_empty()), - l3_self_contained: std::env::var("JOULE_FIX_L3_SELF_CONTAINED").as_deref() == Ok("1"), skill_store: std::env::var("JOULE_FIX_SKILLS") .map(PathBuf::from) .unwrap_or_else(|_| { @@ -1435,8 +1407,8 @@ impl Harness { }; let status = child.wait(); let m = machine.stop(&mut acc.meter); - let l3 = grade_l3(reading.as_ref(), self.l3_self_contained, m.total_uj(), direct_child); - acc.tally.add_window(l3.figure_uj, l3.attributed_uj(), l3.attributed); + let l3 = grade_l3(reading.as_ref(), m.total_uj(), direct_child); + acc.tally.add_window(l3.figure_uj, l3.attributed_uj(), false); acc.l3 = Some(l3); status.map_err(|e| HarnessError::Spawn(format!("L3 cmd: {e}")))?; Ok(String::from_utf8_lossy(&reply).into_owned()) @@ -1943,24 +1915,24 @@ fn answers() { assert_eq!(answer(), 42); } eprintln!("l3 window: {l3:?}; run total {} µJ ({})", report.total_uj(), report.meter_source); assert!(l3.machine_uj > 0 && l3.figure_uj >= l3.machine_uj, "{l3:?}"); assert!(l3.direct_child, "`./mock-l3.sh` is a plain argv"); - assert!(!l3.attributed, "a shell script that forks is never read whole: {l3:?}"); - assert!(!report.attributed); + assert!(!report.attributed, "an L3 window is never attributed"); assert!(report.total_uj() >= l3.figure_uj, "{} µJ total < the L3 window's {l3:?}", report.total_uj()); assert!(report.meter_source.contains("L3 model"), "{}", report.meter_source); if let Some(read) = l3.process_cpu_uj { assert_eq!(l3.figure_uj, read + l3.machine_uj, "{l3:?}"); assert!(l3.partial_because.iter().any(|w| w.contains("reaped")), "the forked `cat`: {l3:?}"); + assert!(l3.partial_because.iter().any(|w| w.contains("exited")), "the script exited: {l3:?}"); } let _ = std::fs::remove_dir_all(&dir); } /// The L3 window's grading, pure: a reading of the model's process tree - /// is attributed only with no gap AND the operator's declaration; any - /// other reading is an estimate of the window, the reading plus the - /// machine-wide figure; no reading is the machine-wide figure alone. + /// is never attributed — whatever the gaps, it is an estimate of the + /// window, the reading plus the machine-wide figure, and it always says + /// why it is partial; no reading is the machine-wide figure alone. #[test] - fn the_l3_window_is_attributed_only_when_read_whole_and_declared_self_contained() { + fn the_l3_window_is_never_attributed_and_a_reading_is_an_estimate() { use sandbox_meter::TreeGap; let reading = |cpu_nj: u64, gaps: Vec| TreeReading { cpu_uj: cpu_nj.div_ceil(1_000), @@ -1972,43 +1944,36 @@ fn answers() { assert_eq!(answer(), 42); } }; let machine = 40_000_000; - let whole = grade_l3(Some(&Ok(reading(3_000_000_001, vec![]))), true, machine, true); - assert!(whole.attributed && whole.partial_because.is_empty(), "{whole:?}"); - assert_eq!((whole.process_cpu_uj, whole.figure_uj), (Some(3_000_001), 3_000_001), "rounded up, never down"); - - let undeclared = grade_l3(Some(&Ok(reading(3_000_000_000, vec![]))), false, machine, true); - assert!(!undeclared.attributed); - assert_eq!(undeclared.partial_because, vec![NOT_DECLARED_SELF_CONTAINED.to_string()]); - assert_eq!(undeclared.figure_uj, 3_000_000 + machine); - - // The shape every model that exits in its window has, declared or not. - let exited = grade_l3(Some(&Ok(reading(3_000_000_000, vec![TreeGap::Exited { pid: 4242 }]))), true, machine, true); - assert!(!exited.attributed); + // The shape every model that exits in its window has. + let exited = grade_l3(Some(&Ok(reading(3_000_000_001, vec![TreeGap::Exited { pid: 4242 }]))), machine, true); assert_eq!(exited.partial_because, vec![TreeGap::Exited { pid: 4242 }.to_string()]); - assert_eq!((exited.process_cpu_uj, exited.figure_uj), (Some(3_000_000), 3_000_000 + machine)); + assert_eq!((exited.process_cpu_uj, exited.figure_uj), (Some(3_000_001), 3_000_001 + machine), "rounded up"); - let gpu = grade_l3(Some(&Ok(reading(5_000, vec![TreeGap::Gpu { ns: 9 }]))), false, machine, false); + // No gap seen: the model was not read at its exit. Still an estimate. + let no_gap = grade_l3(Some(&Ok(reading(3_000_000_000, vec![]))), machine, true); + assert_eq!(no_gap.partial_because, vec![NOT_READ_AT_EXIT.to_string()]); + assert_eq!(no_gap.figure_uj, 3_000_000 + machine); + + let gpu = grade_l3(Some(&Ok(reading(5_000, vec![TreeGap::Exited { pid: 1 }, TreeGap::Gpu { ns: 9 }]))), machine, false); assert_eq!(gpu.partial_because.len(), 2, "{gpu:?}"); assert_eq!(gpu.figure_uj, 5 + machine); assert!(!gpu.direct_child); - let no_reading = grade_l3(Some(&Err(TreeError::RootGone { pid: 4242 })), true, machine, true); - assert!(!no_reading.attributed); + let no_reading = grade_l3(Some(&Err(TreeError::RootGone { pid: 4242 })), machine, true); assert_eq!((no_reading.process_cpu_uj, no_reading.figure_uj), (None, machine)); assert!(no_reading.partial_because[0].contains("no reading"), "{no_reading:?}"); - let no_meter = grade_l3(None, true, machine, true); - assert_eq!((no_meter.process_cpu_uj, no_meter.figure_uj, no_meter.attributed), (None, machine, false)); + let no_meter = grade_l3(None, machine, true); + assert_eq!((no_meter.process_cpu_uj, no_meter.figure_uj), (None, machine)); assert!(no_meter.partial_because.is_empty()); - // In the tally: an estimate, unless whole. - for (l3, attributed) in [(&whole, true), (&exited, false), (&no_meter, false)] { + // In the tally: an estimate, as `run_l3` adds it. + for l3 in [&exited, &no_gap, &no_meter] { let mut t = EnergyTally::default(); - t.add_window(l3.figure_uj, l3.attributed_uj(), l3.attributed); - assert_eq!(t.attributed(), attributed, "{l3:?}"); + t.add_window(l3.figure_uj, l3.attributed_uj(), false); + assert!(!t.attributed(), "{l3:?}"); assert_eq!(t.figure_uj(), l3.figure_uj, "{l3:?}"); - let class = if attributed { EnergyClass::ModelBased } else { EnergyClass::Unaccounted }; - assert_eq!(t.energy_class(), class, "never above ModelBased, never a reading of a partial window"); + assert_eq!(t.energy_class(), EnergyClass::Unaccounted, "never a reading of a partial window"); } } @@ -2135,7 +2100,6 @@ fn answers() { assert_eq!(answer(), 42); } let ratio = read as f64 / oracle_uj as f64; assert!(oracle_uj > 100_000, "setup: the stand-in's work read {oracle_uj} µJ"); assert!((0.8..=1.25).contains(&ratio), "the window read {read} µJ of a model that counted {oracle_uj} µJ"); - assert!(!l3.attributed, "{l3:?}"); assert!(l3.partial_because.iter().any(|w| w.contains("exited")), "{l3:?}"); assert_eq!(l3.figure_uj, read + l3.machine_uj); } else { -- 2.54.0 (Apple Git-157) From 49c88f438797db4df95ca85bb9c739a6f11b634b Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 17:52:52 -0400 Subject: [PATCH 09/12] joule-code-remediate: seal the L3 window's device classes as separate records The L3 window's machine-wide figure was Measurement::total_uj(), every device class summed, and the tally sealed the run's whole figure as one DeviceClass::Cpu record. Where the machine meter reads a GPU (an Apple GPU rail, NVML on Linux), that GPU energy was sealed as CPU energy, which the sandbox schema forbids (a host must not meter accelerator work as undifferentiated Cpu work). L3Energy now carries the machine figure per class (machine_by_class; machine_uj is their sum), the tally keeps the non-CPU part of its estimate per class, and meter_records() seals one Estimator record per class: the CPU (cargo windows, the model's own reading, the machine's CPU) and each other class the meter read. The records sum to the run's figure, so the receipts' figure is unchanged. An attributed run is still one ModelBased CPU record: every window attributed means no L3 window. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-code-remediate/src/lib.rs | 149 ++++++++++++++---- 1 file changed, 121 insertions(+), 28 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs index b6cdf48d5..f147a919a 100644 --- a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs @@ -39,6 +39,7 @@ use joulecontract_schema::{ Rigor, }; use sandbox_meter::{run_attributed, Attribution, PowerMeter, TreeError, TreeMeter, TreeReading}; +use sandbox_schema::DeviceClass; use serde::{Deserialize, Serialize}; // The formally-verified fix tier. `DischargeStatus`/`Formalization`/ // `ContractDischarge` come from the proof bridge; the bridge's own @@ -84,12 +85,16 @@ struct MeterAcc { /// model's own process tree is never read whole ([`grade_l3`]). The run is /// `ModelBased` only when every window was attributed, because a sum is only /// as good as its weakest part. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] struct EnergyTally { /// Σ each window's figure as an estimate of its work: over a cargo /// window the machine-wide SoC-CPU figure (an upper bound: every process /// on the host), over the L3 window [`L3Energy::figure_uj`]. soc_uj: u64, + /// The part of `soc_uj` in device classes other than the CPU, per class: + /// what the machine-wide meter read of them over an L3 window (a GPU + /// rail, NVML's GPUs). A cargo window's figure is the CPU's alone. + non_cpu_uj: std::collections::BTreeMap, /// Σ figure apportioned or read for this run's process trees. attr_uj: u64, windows: u64, @@ -101,6 +106,19 @@ impl EnergyTally { self.add_window(a.soc_cpu_uj, a.attributed_uj, a.attributed); } + /// The L3 window: its figure, an estimate, never attributed, with the + /// machine-wide meter's figure for each class other than the CPU kept + /// apart, so the sealed record names the class it was read on. + fn add_l3(&mut self, l3: &L3Energy) { + self.add_window(l3.figure_uj, l3.attributed_uj(), false); + for &(class, uj) in &l3.machine_by_class { + if class != DeviceClass::Cpu { + let sum = self.non_cpu_uj.entry(class).or_default(); + *sum = sum.saturating_add(uj); + } + } + } + /// One window: its figure as an estimate (`estimate_uj`), its figure as /// a reading or apportionment of its own process tree (`attributed_uj`), /// and whether that one is the window's honest figure (`attributed`). @@ -133,20 +151,29 @@ impl EnergyTally { } } - /// The sandbox JCR-1 meter record for the figure. It is `ModelBased` for - /// an attribution, and otherwise the `Estimator` grade with no measured - /// figure. - fn meter_record(&self) -> sandbox_meter::DeviceEnergy { - sandbox_meter::DeviceEnergy { - class: sandbox_schema::DeviceClass::Cpu, + /// The sandbox JCR-1 meter records for the figure, one per device class, + /// summing to it. An attribution is one `ModelBased` CPU record (every + /// window attributed, so no L3 window and nothing off the CPU). An + /// estimate is an `Estimator` record with no measured figure for the CPU + /// and one for each other class the L3 window's machine-wide meter read: + /// a GPU's energy is never sealed as the CPU's. + fn meter_records(&self) -> Vec { + let record = |class, estimated_uj, provenance| sandbox_meter::DeviceEnergy { + class, measured_uj: None, - estimated_uj: self.figure_uj(), - provenance: if self.attributed() { - sandbox_meter::Provenance::ModelBased - } else { - sandbox_meter::Provenance::Estimator - }, + estimated_uj, + provenance, + }; + if self.attributed() { + return vec![record(DeviceClass::Cpu, self.attr_uj, sandbox_meter::Provenance::ModelBased)]; } + let non_cpu: u64 = self.non_cpu_uj.values().fold(0, |a, &b| a.saturating_add(b)); + let mut records = + vec![record(DeviceClass::Cpu, self.soc_uj.saturating_sub(non_cpu), sandbox_meter::Provenance::Estimator)]; + records.extend( + self.non_cpu_uj.iter().map(|(&class, &uj)| record(class, uj, sandbox_meter::Provenance::Estimator)), + ); + records } /// The JouleContract energy class for the figure. It is `ModelBased`, @@ -182,6 +209,10 @@ pub struct L3Energy { /// estimate where it has none (every macOS 27.0.1 host checked). Like a /// cargo window's, it is the whole host's, other processes included. pub machine_uj: u64, + /// The same figure per device class, as the meter read them (the CPU, + /// and a GPU where it reads one); `machine_uj` is their sum. The run's + /// sealed records keep the classes apart. + pub machine_by_class: Vec<(DeviceClass, u64)>, /// The CPU energy of the model's own process tree over the window, µJ /// (the [`TreeReading`]'s nanojoules rounded up), where the host counts /// it. `None` off macOS, where the per-process counter is not live, and @@ -262,7 +293,12 @@ impl L3Energy { /// - **No reading** — no per-process counter (Linux, a counter that is not /// live) or the span's [`TreeError`]: the machine-wide figure, exactly as /// a cargo window's (it is the whole host's). -fn grade_l3(reading: Option<&Result>, machine_uj: u64, direct_child: bool) -> L3Energy { +fn grade_l3( + reading: Option<&Result>, + machine_by_class: Vec<(DeviceClass, u64)>, + direct_child: bool, +) -> L3Energy { + let machine_uj = machine_by_class.iter().fold(0u64, |a, &(_, uj)| a.saturating_add(uj)); match reading { Some(Ok(r)) => { let read = r.cpu_nj.div_ceil(1_000); @@ -272,6 +308,7 @@ fn grade_l3(reading: Option<&Result>, machine_uj: u64, d } L3Energy { machine_uj, + machine_by_class, process_cpu_uj: Some(read), partial_because: because, direct_child, @@ -280,6 +317,7 @@ fn grade_l3(reading: Option<&Result>, machine_uj: u64, d } Some(Err(e)) => L3Energy { machine_uj, + machine_by_class, process_cpu_uj: None, partial_because: vec![format!("no reading of the model's process tree: {e}")], direct_child, @@ -287,6 +325,7 @@ fn grade_l3(reading: Option<&Result>, machine_uj: u64, d }, None => L3Energy { machine_uj, + machine_by_class, process_cpu_uj: None, partial_because: Vec::new(), direct_child, @@ -1012,7 +1051,7 @@ impl Harness { // as a reading. let (tally, meter_sources, l3) = { let acc = self.meter.borrow(); - (acc.tally, acc.meter.sources(), acc.l3.clone()) + (acc.tally.clone(), acc.meter.sources(), acc.l3.clone()) }; let (soc_cpu_uj, attributed_uj, attributed) = (tally.soc_uj, tally.attr_uj, tally.attributed()); let wall = wall_start.elapsed(); @@ -1038,7 +1077,7 @@ impl Harness { let meter_receipt = sandbox_meter::Receipt::build( sandbox_schema::ContentId::of_bytes(prior_rev.as_bytes()), sandbox_schema::Tier::S0, - vec![tally.meter_record()], + tally.meter_records(), 1, [3u8; 8], ) @@ -1407,8 +1446,9 @@ impl Harness { }; let status = child.wait(); let m = machine.stop(&mut acc.meter); - let l3 = grade_l3(reading.as_ref(), m.total_uj(), direct_child); - acc.tally.add_window(l3.figure_uj, l3.attributed_uj(), false); + let by_class = m.devices.iter().map(|d| (d.class, d.measured_uj.unwrap_or(d.estimated_uj))).collect(); + let l3 = grade_l3(reading.as_ref(), by_class, direct_child); + acc.tally.add_l3(&l3); acc.l3 = Some(l3); status.map_err(|e| HarnessError::Spawn(format!("L3 cmd: {e}")))?; Ok(String::from_utf8_lossy(&reply).into_owned()) @@ -1795,7 +1835,9 @@ fn answers() { assert_eq!(answer(), 42); } assert!(!t.attributed()); assert_eq!(t.figure_uj(), 60_000_000, "the unapportioned estimate"); assert_eq!(t.energy_class(), EnergyClass::Unaccounted); - let r = t.meter_record(); + let records = t.meter_records(); + assert_eq!(records.len(), 1, "cargo windows are the CPU's alone: {records:?}"); + let r = &records[0]; assert_eq!(r.provenance, sandbox_meter::Provenance::Estimator); assert_eq!((r.measured_uj, r.estimated_uj), (None, 60_000_000)); r.check_provenance().unwrap(); @@ -1854,15 +1896,17 @@ fn answers() { assert_eq!(answer(), 42); } assert!(all.attributed()); assert_eq!(all.figure_uj(), 10_000_000, "the apportioned reading"); assert_eq!(all.energy_class(), EnergyClass::ModelBased); - assert_eq!(all.meter_record().provenance, sandbox_meter::Provenance::ModelBased); - all.meter_record().check_provenance().unwrap(); + let records = all.meter_records(); + assert_eq!(records.len(), 1, "{records:?}"); + assert_eq!((records[0].class, records[0].provenance), (DeviceClass::Cpu, sandbox_meter::Provenance::ModelBased)); + records[0].check_provenance().unwrap(); let mut mixed = all; mixed.add(&window(20_000_000, 0, false)); assert!(!mixed.attributed()); assert_eq!(mixed.figure_uj(), 60_000_000, "the unapportioned estimate"); assert_eq!(mixed.energy_class(), EnergyClass::Unaccounted); - assert_eq!(mixed.meter_record().provenance, sandbox_meter::Provenance::Estimator); + assert_eq!(mixed.meter_records()[0].provenance, sandbox_meter::Provenance::Estimator); // Nothing metered is not an attribution either. assert!(!EnergyTally::default().attributed()); @@ -1943,40 +1987,89 @@ fn answers() { assert_eq!(answer(), 42); } gaps, }; let machine = 40_000_000; + let cpu = || vec![(DeviceClass::Cpu, machine)]; // The shape every model that exits in its window has. - let exited = grade_l3(Some(&Ok(reading(3_000_000_001, vec![TreeGap::Exited { pid: 4242 }]))), machine, true); + let exited = grade_l3(Some(&Ok(reading(3_000_000_001, vec![TreeGap::Exited { pid: 4242 }]))), cpu(), true); assert_eq!(exited.partial_because, vec![TreeGap::Exited { pid: 4242 }.to_string()]); assert_eq!((exited.process_cpu_uj, exited.figure_uj), (Some(3_000_001), 3_000_001 + machine), "rounded up"); // No gap seen: the model was not read at its exit. Still an estimate. - let no_gap = grade_l3(Some(&Ok(reading(3_000_000_000, vec![]))), machine, true); + let no_gap = grade_l3(Some(&Ok(reading(3_000_000_000, vec![]))), cpu(), true); assert_eq!(no_gap.partial_because, vec![NOT_READ_AT_EXIT.to_string()]); assert_eq!(no_gap.figure_uj, 3_000_000 + machine); - let gpu = grade_l3(Some(&Ok(reading(5_000, vec![TreeGap::Exited { pid: 1 }, TreeGap::Gpu { ns: 9 }]))), machine, false); + let gpu = grade_l3(Some(&Ok(reading(5_000, vec![TreeGap::Exited { pid: 1 }, TreeGap::Gpu { ns: 9 }]))), cpu(), false); assert_eq!(gpu.partial_because.len(), 2, "{gpu:?}"); assert_eq!(gpu.figure_uj, 5 + machine); assert!(!gpu.direct_child); - let no_reading = grade_l3(Some(&Err(TreeError::RootGone { pid: 4242 })), machine, true); + let no_reading = grade_l3(Some(&Err(TreeError::RootGone { pid: 4242 })), cpu(), true); assert_eq!((no_reading.process_cpu_uj, no_reading.figure_uj), (None, machine)); assert!(no_reading.partial_because[0].contains("no reading"), "{no_reading:?}"); - let no_meter = grade_l3(None, machine, true); + let no_meter = grade_l3(None, cpu(), true); assert_eq!((no_meter.process_cpu_uj, no_meter.figure_uj), (None, machine)); assert!(no_meter.partial_because.is_empty()); // In the tally: an estimate, as `run_l3` adds it. for l3 in [&exited, &no_gap, &no_meter] { let mut t = EnergyTally::default(); - t.add_window(l3.figure_uj, l3.attributed_uj(), false); + t.add_l3(l3); assert!(!t.attributed(), "{l3:?}"); assert_eq!(t.figure_uj(), l3.figure_uj, "{l3:?}"); assert_eq!(t.energy_class(), EnergyClass::Unaccounted, "never a reading of a partial window"); } } + /// The L3 window's machine-wide figure is sealed one record per device + /// class: a GPU the meter read (an Apple GPU rail, NVML on Linux) is a + /// `Gpu` record, never folded into the CPU's. The records sum to the + /// run's figure, and the receipt builds from them. + #[test] + fn the_l3_windows_device_classes_are_sealed_apart() { + use sandbox_meter::TreeGap; + let reading = TreeReading { + cpu_uj: 3_000_000, + cpu_nj: 3_000_000_000, + processes: 1, + pids: vec![7], + wall: Duration::from_secs(1), + gaps: vec![TreeGap::Exited { pid: 7 }], + }; + let machine = vec![(DeviceClass::Cpu, 30_000_000), (DeviceClass::Gpu, 10_000_000)]; + let l3 = grade_l3(Some(&Ok(reading)), machine, true); + assert_eq!((l3.machine_uj, l3.figure_uj), (40_000_000, 43_000_000), "{l3:?}"); + + let mut t = EnergyTally::default(); + t.add(&window(20_000_000, 5_000_000, true)); + t.add_l3(&l3); + assert_eq!(t.figure_uj(), 63_000_000); + let records = t.meter_records(); + let by_class: Vec<(DeviceClass, Option, u64, sandbox_meter::Provenance)> = + records.iter().map(|r| (r.class, r.measured_uj, r.estimated_uj, r.provenance)).collect(); + let est = sandbox_meter::Provenance::Estimator; + assert_eq!( + by_class, + [(DeviceClass::Cpu, None, 53_000_000, est), (DeviceClass::Gpu, None, 10_000_000, est)], + "the CPU: the cargo window, the reading and the machine's CPU; the GPU apart" + ); + let receipt = sandbox_meter::Receipt::build( + sandbox_schema::ContentId::of_bytes(b"prior"), + sandbox_schema::Tier::S0, + records, + 1, + [3u8; 8], + ) + .expect("one record per class"); + assert_eq!(receipt.total.estimated_uj, t.figure_uj()); + + // A second L3 window adds to its class's record. + t.add_l3(&l3); + assert_eq!(t.meter_records()[1].estimated_uj, 20_000_000); + assert_eq!(t.meter_records().iter().map(|r| r.estimated_uj).sum::(), t.figure_uj()); + } + /// A plain argv is `exec`ed after the gate, so the model is the meter's /// root process; anything that needs the shell runs as its script. #[test] -- 2.54.0 (Apple Git-157) From e325ab5d9ab580e712be0ec4e85690ada4a9b9c7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 17:55:04 -0400 Subject: [PATCH 10/12] jouledesk-github: a model process is never read whole; a fallback carries no model reading JOULEDESK_LLAMA_SELF_CONTAINED could never take effect. A reading was sealed HwShunt only with no gap and that declaration, but run_model reads the model at its exit, and the tree meter marks every member that exited in the span (TreeGap::Exited: an exiting process's record lacks its last time on a core). The model always exits, so no reading was ever whole. The setting, the Llama struct that carried it and the whole-reading branch are gone; the docs say plainly that the reading is a lower bound and the work is always sealed as an estimate (the reading plus the host meter's figure). A reading whose tree showed no gap says why it is still partial (NOT_READ_AT_EXIT). When the model binary is missing, sh's exec fails (127), the answer falls back to the deterministic extract, yet the work still carried the reading of that sh, so the reply named deterministic-extract beside a "model process ... read on its own process tree" line. A fallback now carries no model reading, as the field's docs said; a test runs a missing binary. The lines this branch added that named a third-party engine now name the capability (a model binary that offloads to the GPU, the binary's log). Co-Authored-By: Claude Opus 5.5 --- .../crates/jouledesk-github/src/lib.rs | 225 +++++++++--------- 1 file changed, 109 insertions(+), 116 deletions(-) diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs index b81096b05..632cb73c0 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs @@ -12,12 +12,12 @@ //! //! The model runs in a subprocess (`llama-completion`), and where this host counts each //! process's CPU energy (macOS, its per-process counter live) that process is read on -//! its own ([`sandbox_meter::TreeMeter`]) beside the host meter. The reading covers the -//! model's CPU only: a model that exits in its window always lacks its last time on a -//! core, and llama.cpp built for Apple offloads to the GPU by default, which the reading -//! does not cover. An `efficiency/1` receipt has no coverage field, so such a reading is -//! never sealed as a hardware figure of the work: it is sealed as an estimate, the -//! reading plus the host meter's figure ([`grade_work`]). +//! its own ([`sandbox_meter::TreeMeter`]) beside the host meter. The reading is never +//! whole: it covers the model's CPU only, not the GPU a model binary may offload to, and +//! the model exits in its window, so its record lacks its last time on a core — a lower +//! bound on its CPU energy. An `efficiency/1` receipt has no coverage field, so the +//! reading is never sealed as a hardware figure of the work: it is sealed as an +//! estimate, the reading plus the host meter's figure ([`grade_work`]). use std::time::{Duration, Instant}; @@ -51,32 +51,20 @@ fn configured_model() -> String { panic!("a unit test reached the configured model ({DEFAULT_MODEL} unless JOULEDESK_MODEL); pass a model path") } -/// The binary that runs a model, `JOULEDESK_LLAMA`, else `llama-completion`; and the -/// operator's declaration, `JOULEDESK_LLAMA_SELF_CONTAINED=1`, that it does all its work -/// in its own process tree (no model server it calls, no daemon, no remote API), which no -/// reader of the tree can see. Read only when a model file is there to run. +/// The binary that runs a model, `JOULEDESK_LLAMA`, else `llama-completion`. Read only +/// when a model file is there to run. #[cfg(not(test))] -fn configured_llama() -> Llama { - Llama { - bin: std::env::var("JOULEDESK_LLAMA").unwrap_or_else(|_| "llama-completion".into()), - self_contained: std::env::var("JOULEDESK_LLAMA_SELF_CONTAINED").as_deref() == Ok("1"), - } +fn configured_llama() -> String { + std::env::var("JOULEDESK_LLAMA").unwrap_or_else(|_| "llama-completion".into()) } /// Unit tests give the binary themselves ([`do_work_via`]): none may read the /// process-global environment, which tests running in parallel would race on. #[cfg(test)] -fn configured_llama() -> Llama { +fn configured_llama() -> String { panic!("a unit test reached the configured llama binary; pass one to do_work_via") } -/// How a model is run: the binary, and whether the operator declared it self-contained. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct Llama { - pub(crate) bin: String, - pub(crate) self_contained: bool, -} - /// 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 { @@ -94,8 +82,9 @@ pub struct Work { /// Why the meter produced no figure for the work, when `provenance` is `unmetered`. pub unmetered: Option, /// The model's own process, read where this host counts it: its CPU energy over the - /// run and why that is not the work's whole energy. `None` for the fallback, off - /// macOS, where the per-process counter is not live, or when the span gave no reading. + /// run and why that is not the work's whole energy. `None` for the fallback (no model + /// file, or a model that gave no answer, its binary missing among them), off macOS, + /// where the per-process counter is not live, or when the span gave no reading. pub model_process: Option, } @@ -103,23 +92,26 @@ pub struct Work { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelProcess { /// The CPU energy of the model's process and its descendants over the run, µJ (the - /// reading's nanojoules rounded up). Its CPU only: not its GPU or Neural Engine - /// work, not work handed to a process outside its tree, and not the last time on a - /// core of a thread that exits, which a model that exits in its window always has. + /// reading's nanojoules rounded up): a lower bound. Its CPU only: not its GPU or + /// Neural Engine work, not work handed to a process outside its tree, and not the + /// last time on a core of a thread that exits, which a model that exits in its + /// window always has. pub cpu_uj: u64, /// Why `cpu_uj` is not the model's whole energy: each gap the meter saw, as it words - /// it, then [`NOT_DECLARED_SELF_CONTAINED`] where the operator has not declared the - /// model self-contained. Empty only for a whole reading. + /// it — a model that exits in its window always has one, so its reading is never + /// whole — or, where it saw none, [`NOT_READ_AT_EXIT`]. Never empty. pub partial_because: Vec, } -/// Why a reading of the model's process tree is partial although nothing was seen -/// missing from it: the operator has not declared that the model does all its work in -/// its own tree, which no reader of the tree can see. -pub const NOT_DECLARED_SELF_CONTAINED: &str = "not-declared-self-contained"; +/// Why a reading of the model's process tree is partial although the tree meter saw no +/// gap in it: the model had not exited when it was read (the wait for its exit failed), +/// so the reading may stop short of its work. A model read at its exit always carries +/// the exit's own gap instead. +pub const NOT_READ_AT_EXIT: &str = + "the model had not exited when its process tree was read, so the reading may stop short of its work"; -/// The meter name a work's figure carries when it was read on the model's own process -/// tree; joined with the host meter's name when that figure is in it too. +/// The meter name a work's figure carries when the model's own process tree was read: +/// joined with the host meter's name, whose figure is in it too. const MODEL_PROCESS_TREE: &str = "model-process-tree"; /// `Work::provenance` for work whose meter produced no figure for it. @@ -135,12 +127,14 @@ pub fn do_work(question: &str) -> Work { /// [`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 llama = std::path::Path::new(model).exists().then(configured_llama); - do_work_via(question, model, llama.as_ref()) + let bin = std::path::Path::new(model).exists().then(configured_llama); + do_work_via(question, model, bin.as_deref()) } /// [`do_work_with`] with the binary that runs the model given: `None` answers with the -/// deterministic fallback, and runs no subprocess. +/// deterministic fallback, and runs no subprocess. A model that gives no answer (its +/// binary missing, for one) also answers with the fallback, which carries no reading of +/// the model's process: the reply names no model, so it shows no model's energy. /// /// Two meters span the work. The host's, `jouleclaw_energy::meter::detect_meter`, and /// never the in-process meter (`detect_in_process_meter`): the model runs in a @@ -148,25 +142,24 @@ pub(crate) fn do_work_with(question: &str, model: &str) -> Work { /// alone, so it would bill the inference nothing. And, where this host counts each /// process's CPU energy, the model's own process tree ([`run_model`]). [`grade_work`] /// grades the two. -pub(crate) fn do_work_via(question: &str, model: &str, llama: Option<&Llama>) -> Work { +pub(crate) fn do_work_via(question: &str, model: &str, bin: Option<&str>) -> Work { // The per-process counter's liveness probe burns about 300 ms of CPU on this thread // the first time: here, before the host meter's window opens. - if llama.is_some() { + if bin.is_some() { let _ = sandbox_meter::process_energy_status(); } let meter = jouleclaw_energy::meter::detect_meter(); - let ((answer, label, reading), figure) = metered(&meter, || match llama { - Some(llama) => { - let run = run_model(question, model, &llama.bin); + let ((answer, label, reading), figure) = metered(&meter, || match bin { + Some(bin) => { + let run = run_model(question, model, bin); match run.answer { Some(a) => (a, model_label(model), run.reading), - None => (fallback_answer(question), "deterministic-extract".to_string(), run.reading), + None => (fallback_answer(question), "deterministic-extract".to_string(), None), } } None => (fallback_answer(question), "deterministic-extract".to_string(), None), }); - let self_contained = llama.is_some_and(|l| l.self_contained); - let graded = grade_work(figure, reading.as_ref(), self_contained, meter.active_name()); + let graded = grade_work(figure, reading.as_ref(), meter.active_name()); Work { answer, measured_uj: graded.measured_uj, @@ -192,25 +185,22 @@ struct Graded { /// decided. `figure` is the host meter's ([`window_energy`]), `reading` the model's own /// process tree's where there is one. /// -/// - **A whole reading** — the meter saw nothing missing AND the operator declared the -/// model self-contained: the reading, `HwShunt`, metered by `model-process-tree`. Its -/// GPU part is an exact zero (a whole reading held no GPU client). -/// - **A partial reading** — any gap (a model that exits in its window always has one, -/// and llama.cpp offloading to Metal another: GPU time, unmeasured), or no -/// declaration: an estimate of the work, never a hardware figure of it, because an -/// `efficiency/1` receipt has no coverage field and its figure stands for the whole. -/// The figure is the reading plus the host meter's figure over the same window (the -/// rule mesh's in-process meter bills a partial span by): never less than was read, -/// and nothing the model drew outside its CPU left out; it errs high, by at most the -/// reading, the host figure covering the model's CPU too. `Estimator`, metered by -/// `model-process-tree+`. Where the host meter has no figure, the reading -/// alone cannot stand for the work: no figure, and the work is not sealed. +/// - **A reading** — never whole: the model exits in its window, so its record lacks +/// its last time on a core, and a model that offloads to the GPU leaves GPU time the +/// reading does not cover. So it is an estimate of the work, never a hardware figure +/// of it, because an `efficiency/1` receipt has no coverage field and its figure +/// stands for the whole. The figure is the reading plus the host meter's figure over +/// the same window (the rule mesh's in-process meter bills a partial span by): never +/// less than was read, and nothing the model drew outside its CPU left out; it errs +/// high, by at most the reading, the host figure covering the model's CPU too. +/// `Estimator`, metered by `model-process-tree+`. Where the host meter +/// has no figure, the reading alone cannot stand for the work: no figure, and the +/// work is not sealed. /// - **No reading** — no per-process counter (off macOS, a counter that is not live), /// the span's [`TreeError`], or the fallback: the host meter's figure as it is. fn grade_work( figure: Result<(u64, Provenance), String>, reading: Option<&Result>, - self_contained: bool, host_meter: &str, ) -> Graded { let host = |figure: Result<(u64, Provenance), String>, model_process| match figure { @@ -232,19 +222,10 @@ fn grade_work( let Some(Ok(r)) = reading else { return host(figure, None) }; let cpu_uj = r.cpu_nj.div_ceil(1_000); let mut partial_because: Vec = r.gaps.iter().map(ToString::to_string).collect(); - if !self_contained { - partial_because.push(NOT_DECLARED_SELF_CONTAINED.to_string()); + if partial_because.is_empty() { + partial_because.push(NOT_READ_AT_EXIT.to_string()); } let process = ModelProcess { cpu_uj, partial_because }; - if process.partial_because.is_empty() { - return Graded { - measured_uj: cpu_uj, - provenance: Provenance::HwShunt.to_string(), - meter: MODEL_PROCESS_TREE.to_string(), - unmetered: None, - model_process: Some(process), - }; - } match figure { Ok((uj, _)) => Graded { measured_uj: cpu_uj.saturating_add(uj), @@ -359,20 +340,16 @@ fn unmetered_line(work: &Work) -> String { const NOT_SEALED: &str = "not sealed (no energy figure)"; /// A reply's line for the model's own process reading, when there is one: what it read, -/// and that it is whole or why it is not. +/// and why it is not all of the work. fn model_process_line(work: &Work) -> Option { let p = work.model_process.as_ref()?; let mj = p.cpu_uj as f64 / 1000.0; - Some(if p.partial_because.is_empty() { - format!("{mj:.1} mJ ({} µJ) of CPU read on its own process tree, whole", p.cpu_uj) - } else { - format!( - "{mj:.1} mJ ({} µJ) of CPU read on its own process tree, not all of its work ({}), so the energy \ - above is an estimate", - p.cpu_uj, - p.partial_because.join("; ") - ) - }) + Some(format!( + "{mj:.1} mJ ({} µJ) of CPU read on its own process tree, not all of its work ({}), so the energy above is \ + an estimate", + p.cpu_uj, + p.partial_because.join("; ") + )) } /// The model a reply names: the file name of its path. @@ -401,7 +378,7 @@ const GATED_EXEC: &str = "IFS= read -r jouledesk_gate || exit 1; exec \"$0\" \"$ /// the per-process counter live) is rooted at it and reads it at rest; the gate line /// lets it go, and its stdin closes. Its stdout is read to the end, it is waited for /// without being reaped, so its exited count is still readable, the meter's span is -/// stopped on it, and then it is reaped. Its stderr (llama.cpp's log) is discarded. +/// stopped on it, and then it is reaped. Its stderr (the binary's log) is discarded. fn run_model(question: &str, model: &str, bin: &str) -> ModelRun { use std::io::{Read, Write}; use std::process::{Command, Stdio}; @@ -889,35 +866,33 @@ pub(crate) mod tests { TreeReading { cpu_uj: cpu_nj.div_ceil(1_000), cpu_nj, processes: 1, pids: vec![4242], wall: SECOND, gaps } } - /// The grading, pure: a reading of the model's process is sealed as a hardware - /// reading only when whole and declared self-contained; a partial one is an estimate, - /// the reading plus the host meter's figure, or no figure where the host has none; no - /// reading leaves the host meter's figure as it is. + /// The grading, pure: a reading of the model's process is never sealed as a hardware + /// reading — it is an estimate, the reading plus the host meter's figure, or no figure + /// where the host has none — and it always says why it is partial; no reading leaves + /// the host meter's figure as it is. #[test] - fn a_model_process_reading_is_a_hardware_figure_only_when_whole_and_declared() { + fn a_model_process_reading_is_never_a_hardware_figure() { use sandbox_meter::TreeGap; let est = Ok((40_000, Provenance::Estimator)); - let whole = grade_work(est.clone(), Some(&Ok(tree_reading(9_000_001, vec![]))), true, "apple-silicon"); - assert_eq!((whole.measured_uj, whole.provenance.as_str(), whole.meter.as_str()), (9_001, "hw_shunt", "model-process-tree")); - assert_eq!(whole.model_process, Some(ModelProcess { cpu_uj: 9_001, partial_because: vec![] }), "rounded up"); + // A model on the GPU, exited in its window. + let gpu = vec![TreeGap::Exited { pid: 4242 }, TreeGap::Gpu { ns: 2_000_000 }]; + let g = grade_work(est.clone(), Some(&Ok(tree_reading(9_000_001, gpu.clone()))), "apple-silicon"); + assert_eq!((g.measured_uj, g.provenance.as_str(), g.unmetered), (49_001, "estimator", None), "rounded up"); + assert_eq!(g.meter, "model-process-tree+apple-silicon"); + let because: Vec = gpu.iter().map(ToString::to_string).collect(); + assert_eq!(g.model_process, Some(ModelProcess { cpu_uj: 9_001, partial_because: because })); - let undeclared = grade_work(est.clone(), Some(&Ok(tree_reading(9_000_000, vec![]))), false, "apple-silicon"); - assert_eq!((undeclared.measured_uj, undeclared.provenance.as_str()), (49_000, "estimator")); - assert_eq!(undeclared.meter, "model-process-tree+apple-silicon"); - assert_eq!(undeclared.model_process.unwrap().partial_because, vec![NOT_DECLARED_SELF_CONTAINED.to_string()]); + // No gap seen: the model was not read at its exit. Still an estimate, saying so. + let no_gap = grade_work(est.clone(), Some(&Ok(tree_reading(9_000_000, vec![]))), "apple-silicon"); + assert_eq!((no_gap.measured_uj, no_gap.provenance.as_str()), (49_000, "estimator")); + assert_eq!(no_gap.model_process.unwrap().partial_because, vec![NOT_READ_AT_EXIT.to_string()]); - // llama.cpp on Metal, exited in its window: declared or not, an estimate. - let metal = vec![TreeGap::Exited { pid: 4242 }, TreeGap::Gpu { ns: 2_000_000 }]; - let g = grade_work(est.clone(), Some(&Ok(tree_reading(9_000_000, metal.clone()))), true, "apple-silicon"); - assert_eq!((g.measured_uj, g.provenance.as_str(), g.unmetered), (49_000, "estimator", None)); - let because: Vec = metal.iter().map(ToString::to_string).collect(); - assert_eq!(g.model_process.as_ref().unwrap().partial_because, because); // A hardware machine-wide figure does not make the sum a hardware reading. - let hw = grade_work(Ok((40_000, Provenance::HwShunt)), Some(&Ok(tree_reading(9_000_000, metal.clone()))), true, "rapl"); + let hw = grade_work(Ok((40_000, Provenance::HwShunt)), Some(&Ok(tree_reading(9_000_000, gpu.clone()))), "rapl"); assert_eq!((hw.measured_uj, hw.provenance.as_str()), (49_000, "estimator")); - // A partial reading with no host figure is no figure: nothing is sealed. - let short = grade_work(Err("too short".into()), Some(&Ok(tree_reading(9_000_000, metal))), true, "apple-silicon"); + // A reading with no host figure is no figure: nothing is sealed. + let short = grade_work(Err("too short".into()), Some(&Ok(tree_reading(9_000_000, gpu))), "apple-silicon"); assert_eq!((short.measured_uj, short.provenance.as_str()), (0, UNMETERED)); let why = short.unmetered.unwrap(); assert!(why.contains("9000 µJ of CPU") && why.contains("too short"), "{why}"); @@ -925,11 +900,11 @@ pub(crate) mod tests { // No reading: the host meter's figure, as it is. for reading in [None, Some(&Err(TreeError::RootGone { pid: 4242 }))] { - let g = grade_work(est.clone(), reading, true, "apple-silicon"); + let g = grade_work(est.clone(), reading, "apple-silicon"); assert_eq!((g.measured_uj, g.provenance.as_str(), g.meter.as_str()), (40_000, "estimator", "apple-silicon")); assert_eq!(g.model_process, None); } - let g = grade_work(Ok((40_000, Provenance::HwShunt)), None, false, "rapl"); + let g = grade_work(Ok((40_000, Provenance::HwShunt)), None, "rapl"); assert_eq!(g.provenance, "hw_shunt", "a live machine-wide counter with no process reading is unchanged"); } @@ -938,7 +913,7 @@ pub(crate) mod tests { #[test] fn a_partial_model_process_figure_is_sealed_and_posted_as_an_estimate() { let partial = Some(&Ok(tree_reading(9_000_000, vec![sandbox_meter::TreeGap::Exited { pid: 7 }]))); - let g = grade_work(Ok((40_000, Provenance::Estimator)), partial, false, "apple-silicon"); + let g = grade_work(Ok((40_000, Provenance::Estimator)), partial, "apple-silicon"); let w = Work { answer: "an answer".into(), measured_uj: g.measured_uj, @@ -953,7 +928,7 @@ pub(crate) mod tests { assert!(body.contains("49.0 mJ (49000 µJ) — estimated by `model-process-tree+apple-silicon`"), "{body}"); let line = body.lines().find(|l| l.starts_with("- **model process:** ")).expect("a model-process line"); assert!(line.contains("9.0 mJ (9000 µJ) of CPU read on its own process tree, not all of its work"), "{line}"); - assert!(line.contains("pid 7 exited") && line.contains(NOT_DECLARED_SELF_CONTAINED) && line.contains("estimate"), "{line}"); + assert!(line.contains("pid 7 exited") && line.contains("estimate"), "{line}"); assert!(!body.contains("measured"), "{body}"); let slack = slack_compose(&w); assert!(slack.contains("• *model process:* 9.0 mJ (9000 µJ) of CPU"), "{slack}"); @@ -1014,8 +989,7 @@ pub(crate) mod tests { /// within the shells' start-up and the stand-in's tear-down; it is partial (the model /// exited in its window), so the work is sealed and posted as an estimate, the /// reading plus the host meter's figure. Elsewhere the host meter's figure stands. - /// Twice: undeclared, then declared self-contained, which an exited model's reading - /// is partial either way. + /// Twice. #[test] fn a_model_subprocess_is_read_on_its_own_process_as_its_own_count_says() { // About a second of one core, unoptimized; a fixed amount of work. @@ -1037,14 +1011,14 @@ pub(crate) mod tests { let reads_processes = cfg!(target_os = "macos") && sandbox_meter::process_energy_status() == sandbox_meter::CounterStatus::Live; - for self_contained in [false, true] { - let llama = Llama { bin: script.display().to_string(), self_contained }; - let w = do_work_via("what changed?", model.to_str().unwrap(), Some(&llama)); + let bin = script.display().to_string(); + for round in 1..=2 { + let w = do_work_via("what changed?", model.to_str().unwrap(), Some(&bin)); let report = std::fs::read_to_string(dir.join(STAND_IN_REPORT)).expect("the stand-in's report"); let _ = std::fs::remove_file(dir.join(STAND_IN_REPORT)); let oracle_nj: u64 = report.split_whitespace().next().unwrap().parse().unwrap(); eprintln!( - "model subprocess (declared={self_contained}): oracle {oracle_nj} nJ; {} µJ {} by {} ({:?})", + "model subprocess round {round}: oracle {oracle_nj} nJ; {} µJ {} by {} ({:?})", w.measured_uj, w.provenance, w.meter, w.model_process ); assert!(w.answer.contains("1 passed"), "the stand-in did not run: {}", w.answer); @@ -1056,7 +1030,6 @@ pub(crate) mod tests { let ratio = p.cpu_uj as f64 / oracle_uj as f64; assert!((0.8..=1.25).contains(&ratio), "read {} µJ of a model that counted {oracle_uj} µJ", p.cpu_uj); assert!(p.partial_because.iter().any(|g| g.contains("exited")), "{p:?}"); - assert_eq!(p.partial_because.iter().any(|g| g == NOT_DECLARED_SELF_CONTAINED), !self_contained); assert_eq!(w.provenance, Provenance::Estimator.to_string(), "a partial reading sealed above an estimate"); assert!(w.meter.starts_with("model-process-tree+"), "{}", w.meter); assert!(w.measured_uj > p.cpu_uj, "the host meter's figure is in the sum: {} µJ", w.measured_uj); @@ -1074,6 +1047,26 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A model binary that cannot be run answers with the deterministic fallback, and the + /// fallback carries no reading of the model's process: `sh` failing to `exec` the + /// binary is no model's energy, and the reply, which names no model, shows none. + #[test] + fn a_model_binary_that_cannot_run_falls_back_with_no_model_reading() { + let dir = std::env::temp_dir().join(format!("jouledesk-missing-binary-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let model = dir.join("model.gguf"); + std::fs::write(&model, b"").unwrap(); // a file that exists: the "model" + let w = do_work_via("what changed?", model.to_str().unwrap(), Some("/nonexistent/llama-completion")); + eprintln!("missing binary: model={} model_process={:?} meter={}", w.model, w.model_process, w.meter); + assert_eq!(w.model, "deterministic-extract"); + assert_eq!(w.model_process, None, "the fallback carried a model reading"); + assert!(!w.meter.starts_with(MODEL_PROCESS_TREE), "{}", w.meter); + let body = compose_github("dana", &w, "completed", true); + assert!(!body.contains("model process"), "{body}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn ignores_comments_that_dont_address_the_handle() { let event = json!({ "comment": { "user": { "login": "x" }, "body": "just chatting" } }); -- 2.54.0 (Apple Git-157) From cbcc27f1f7f918a421a586afe2b6f07af661d8bb Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 17:57:53 -0400 Subject: [PATCH 11/12] l3-jouleclaw: grade a fallback span as no reading; never read the llama engine whole The docs said a host with no per-process counter gets the token-count estimate off macOS, but there ProcessSpanMeter falls back to detect_meter() (the 28.6 W x wall estimator in this build), which it marks Whole, and grade() kept that figure. A fallback span is the machine's figure, not the engine's, so grade() now treats it as no reading and uses the rest (host_counter, else the token estimate): one way of reading the host whichever engine ran, and the docs hold. That is simpler than documenting a second host figure that only the in-process engine would get. JOULE_CODE_L3_SELF_CONTAINED could never take effect: the llama engine is read at its exit, which the tree meter always marks (TreeGap::Exited), so no reading was ever whole. The setting and its plumbing are gone and the docs say the reading is a lower bound, always graded as an estimate. A missing llama binary had become LlmError::Upstream("... exited 127"): the binary is exec'd by the gating sh, whose failed exec reads as the model's own failure. The binary is now resolved first (a path as given, else PATH; a regular executable file) and one that cannot run is Unavailable, as a failed spawn was, with nothing spawned. The README says the oracle tests run only locally on Apple Silicon (CI tests this workspace on Linux, where they take the fallback), and lines this branch added name the capability, not a third-party engine. Co-Authored-By: Claude Opus 5.5 --- joule-code/examples/l3-jouleclaw/README.md | 30 +++-- .../examples/l3-jouleclaw/src/bin/run.rs | 124 +++++++++--------- .../l3-jouleclaw/src/llama_backend.rs | 86 +++++++++++- 3 files changed, 161 insertions(+), 79 deletions(-) diff --git a/joule-code/examples/l3-jouleclaw/README.md b/joule-code/examples/l3-jouleclaw/README.md index 47453edd1..fdcad1334 100644 --- a/joule-code/examples/l3-jouleclaw/README.md +++ b/joule-code/examples/l3-jouleclaw/README.md @@ -53,16 +53,18 @@ the reading covers: figure over the same window. - **llama engine** — the subprocess is read on its own process tree (`sandbox_meter::TreeMeter`) where the host counts each process's CPU energy - (macOS). That is its CPU only: llama.cpp offloads to the GPU, and a process that - exits in its window lacks its last time on a core. So it is an estimate of the - whole, the reading plus the host meter's figure — `HwShunt` only for a whole - reading of an engine declared self-contained (`JOULE_CODE_L3_SELF_CONTAINED=1`). -- **No reading of the engine's own process** — the host meter's figure as it is: - JouleClaw's IOReport counter where its CPU energy rail is live (`HwShunt`), its - load-average estimator where it is not (macOS 27.0.1 for an unprivileged - process, `Estimator`), and the token-count estimate anywhere else (Linux and - Windows included; this binding builds `jouleclaw-energy` with the `ioreport` - feature only). + (macOS). That reading is never whole: it is the process's CPU only, not the GPU + the binary may offload to, and the process exits in its window, so its record + lacks its last time on a core — a lower bound. So it is always an estimate of + the whole, the reading plus the host meter's figure, never `HwShunt`. +- **No reading of the engine's own process** — no per-process counter, or, for + the in-process engine, this process's counters not live (the span then falls + back to a host meter, whose figure is the machine's, not the engine's): the + host meter's figure as it is. That is JouleClaw's IOReport counter where its + CPU energy rail is live (`HwShunt`), its load-average estimator where it is not + (macOS 27.0.1 for an unprivileged process, `Estimator`), and the token-count + estimate anywhere else (Linux and Windows included; this binding builds + `jouleclaw-energy` with the `ioreport` feature only). The receipt has no coverage field, so a partial reading is never sealed as a hardware reading of the whole. The printout names the engine, the host meter and @@ -81,3 +83,11 @@ cargo test # EchoBackend path — no model required JOULE_BONSAI=/path/to/ternary-bonsai-1.7b-q2_0.gguf \ cargo test --release --test runtime_l3 -- --ignored --nocapture ``` + +The `run` binary's meter tests check each engine's reading against an oracle +sharing no code with the meter — the process's own energy count, read from +inside it — and assert it only where this host counts each process's energy +(Apple Silicon macOS, the per-process counter live); elsewhere they check the +fallback. CI tests this workspace on Linux only, where they take the fallback, +so the oracle assertions are not in CI and run only locally: run +`cargo test -- --nocapture` on an Apple Silicon Mac to see them. diff --git a/joule-code/examples/l3-jouleclaw/src/bin/run.rs b/joule-code/examples/l3-jouleclaw/src/bin/run.rs index df08c1387..6a256e825 100644 --- a/joule-code/examples/l3-jouleclaw/src/bin/run.rs +++ b/joule-code/examples/l3-jouleclaw/src/bin/run.rs @@ -20,18 +20,20 @@ //! process, so the CPU alone — is an estimate of the whole, never a reading. //! - **The llama engine** runs in a subprocess, which this process's counters do //! not see; its own process is read instead (`sandbox_meter::TreeMeter`), where -//! the host counts each process's CPU energy. That is its CPU only — llama.cpp -//! offloads to the GPU, and a process that exits in its window lacks its last -//! time on a core — so it is an estimate of the whole, unless whole and -//! declared self-contained (`JOULE_CODE_L3_SELF_CONTAINED=1`). +//! the host counts each process's CPU energy. That reading is never whole: it is +//! the process's CPU only, not the GPU the binary may offload to, and the +//! process exits in its window, so its record lacks its last time on a core (a +//! lower bound). It is always an estimate of the whole. //! //! An estimate of the whole is the reading plus the host meter's figure over the //! same window (or, with no host counter, the token-count estimate): never less //! than was read. The receipt has no coverage field, so a partial reading is -//! never sealed as a hardware reading. Where neither engine's own reading gives a -//! figure, the host meter's (a measurement where it is a live counter, an -//! estimate from host load where it is not) or the token-count estimate stands. -//! The printed receipt says which. +//! never sealed as a hardware reading. Where the engine's own process gives no +//! figure — no per-process counter, or this process's counters not live (the +//! span then falls back to a host meter, whose figure is the machine's, not the +//! engine's) — the host meter's figure (a measurement where it is a live +//! counter, an estimate from host load where it is not) or, with no host +//! counter, the token-count estimate stands. The printed receipt says which. use jouleclaw_energy::meter::EnergyMeter; use jouleclaw_energy::span::{ProcessSpanMeter, SpanEnergy, SpanSource, SpanUses}; @@ -122,28 +124,34 @@ enum Own { /// ([`ProcessSpanMeter`], [`SpanUses::CpuGpu`]). Process(Result), /// The llama engine's subprocess: its own process tree, `None` where the - /// host has no per-process counter; and whether the operator declared it - /// self-contained (no server it calls, no remote API), which no reader of - /// the tree can see. - Child { reading: Option>, self_contained: bool }, + /// host has no per-process counter. + Child { reading: Option> }, } +/// Why a reading of the llama engine's process tree is partial although the +/// tree meter saw no gap in it: the process had not exited when it was read +/// (the wait for its exit failed). One read at its exit always carries the +/// exit's own gap instead. +const NOT_READ_AT_EXIT: &str = "the model had not exited when its process tree was read"; + /// The receipt's energy for the inference, and how it was obtained: the one /// place it is graded. `rest` is the host meter's figure over the same window, /// or the token-count estimate ([`receipt_energy`]), with its note. /// -/// - **Whole** — the in-process span covers the whole inference (this process -/// the GPU's effective sole user, or no GPU time at all), or the child's tree -/// was read with no gap and declared self-contained: the reading at its own -/// grade, `HwShunt` with `measured_uj` for a hardware reading. A span that -/// fell back to the host meter keeps that meter's grade. -/// - **Partial** — the CPU alone (the GPU shared, a client vanished), or a child -/// with a gap (one that exits in its window always has one) or undeclared: an +/// - **Whole** — the in-process span covers the whole inference on this +/// process's own counters (this process the GPU's effective sole user, or no +/// GPU time at all): the reading at its own grade, `HwShunt` with +/// `measured_uj` for a hardware reading. +/// - **Partial** — the CPU alone (the GPU shared, a client vanished), or the +/// child's tree, which is never read whole (it exits in its window): an /// estimate of the whole, `Estimator`, the reading plus `rest` — never less /// than was read, nothing outside the reading left out; it errs high by at most /// the reading, `rest` covering the host. The rule mesh's in-process meter /// bills a partial span by. -/// - **None** — no figure from the engine's own process: `rest` as it is. +/// - **None** — no figure from the engine's own process, including a span that +/// fell back to a host meter because this process's counters gave none (that +/// figure is the machine's, not the engine's): `rest` as it is, so the host +/// figure is read the one way, [`host_counter`], whichever engine ran. fn grade(own: &Own, rest: (Energy, String)) -> (Energy, String) { let reading = |uj: u64, provenance: Provenance| Energy { measured_uj: (provenance == Provenance::HwShunt).then_some(uj), @@ -153,16 +161,19 @@ fn grade(own: &Own, rest: (Energy, String)) -> (Energy, String) { let estimate = |uj: u64| Energy { measured_uj: None, estimated_uj: uj, provenance: Provenance::Estimator }; let (rest, rest_note) = rest; match own { + Own::Process(Ok(SpanEnergy { source: SpanSource::Fallback { why, .. }, .. })) => { + (rest, format!("{rest_note} (this process's counters gave no figure: {why})")) + } Own::Process(Ok(s)) if s.coverage == Coverage::Whole => { let note = match &s.source { SpanSource::Process { cpu_uj, gpu } => format!( "measured on this process's CPU ({cpu_uj} µJ) and GPU ({} µJ), the whole inference", gpu.uj ), - SpanSource::Fallback { meter, why } => { - format!("{} by {meter}: this process's counters gave no figure ({why})", how(s.provenance)) + // (A fallback is graded as no reading, above.) + SpanSource::CpuOnly { why, .. } | SpanSource::Fallback { why, .. } => { + format!("this process's CPU ({why})") } - SpanSource::CpuOnly { why, .. } => format!("this process's CPU ({why})"), }; (reading(s.uj, map_provenance(s.provenance)), note) } @@ -178,15 +189,11 @@ fn grade(own: &Own, rest: (Energy, String)) -> (Energy, String) { (estimate(s.uj.saturating_add(rest.estimated_uj)), note) } Own::Process(Err(e)) => (rest, format!("{rest_note} (this process's counters: {e})")), - Own::Child { reading: Some(Ok(r)), self_contained } => { + Own::Child { reading: Some(Ok(r)) } => { let cpu_uj = r.cpu_nj.div_ceil(1_000); let mut because: Vec = r.gaps.iter().map(ToString::to_string).collect(); - if !self_contained { - because.push("not declared self-contained".to_string()); - } if because.is_empty() { - let note = format!("measured on the model's own process tree ({cpu_uj} µJ of CPU), whole"); - return (reading(cpu_uj, Provenance::HwShunt), note); + because.push(NOT_READ_AT_EXIT.to_string()); } let note = format!( "estimated: the model's own process read {cpu_uj} µJ of CPU, not all of the inference ({}), plus {} \ @@ -196,17 +203,8 @@ fn grade(own: &Own, rest: (Energy, String)) -> (Energy, String) { ); (estimate(cpu_uj.saturating_add(rest.estimated_uj)), note) } - Own::Child { reading: Some(Err(e)), .. } => (rest, format!("{rest_note} (the model's own process: {e})")), - Own::Child { reading: None, .. } => (rest, rest_note), - } -} - -/// How a grade is said: a reading was measured, an estimate estimated. -fn how(p: JcProvenance) -> &'static str { - match p { - JcProvenance::HwShunt => "measured", - JcProvenance::ModelBased => "host-metered (energy model)", - JcProvenance::Estimator => "estimated", + Own::Child { reading: Some(Err(e)) } => (rest, format!("{rest_note} (the model's own process: {e})")), + Own::Child { reading: None } => (rest, rest_note), } } @@ -239,7 +237,6 @@ fn in_process(meter: &ProcessSpanMeter, work: impl FnOnce() -> T) -> (T, Own) fn infer( engine: &Engine, span: Option<&ProcessSpanMeter>, - self_contained: bool, request: &LlmRequest, ) -> (Result, Own) { match (engine, span) { @@ -247,7 +244,7 @@ fn infer( (Engine::Runtime(b), None) => (b.complete(request), Own::Process(Err(EnergyError::NotCurrent))), (Engine::Llama(b), _) => { let (response, reading) = b.complete_metered(request); - (response, Own::Child { reading, self_contained }) + (response, Own::Child { reading }) } } } @@ -280,7 +277,6 @@ fn main() { } }, }; - let self_contained = std::env::var("JOULE_CODE_L3_SELF_CONTAINED").as_deref() == Ok("1"); // The in-process engine's meter, built (and its counters probed) before any // window opens. let span = matches!(engine, Engine::Runtime(_)) @@ -298,7 +294,7 @@ fn main() { let _ = read(&counter); // prime let before = read(&counter); - let (response, own) = infer(&engine, span.as_ref(), self_contained, &LlmRequest::from_prompt(&intent, 256)); + let (response, own) = infer(&engine, span.as_ref(), &LlmRequest::from_prompt(&intent, 256)); let response = match response { Ok(r) => r, Err(e) => { @@ -454,31 +450,35 @@ mod tests { assert!(note.contains("shared") && note.starts_with("estimated"), "{note}"); e.check_provenance().unwrap(); - let fallback = SpanSource::Fallback { meter: "apple-silicon", why: "process CPU: not live".into() }; + // A span that fell back to a host meter (off macOS, the 28.6 W × wall + // estimator) is no reading of the engine: the rest stands, as it is. + let fallback = SpanSource::Fallback { meter: "estimation", why: "process CPU: not live".into() }; let (e, note) = grade(&span(5_000, JcProvenance::Estimator, Coverage::Whole, fallback), est(40_000)); - assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 5_000, Provenance::Estimator)); - assert!(note.starts_with("estimated by apple-silicon"), "{note}"); + assert_eq!(e, est(40_000).0, "the fallback's figure stood in for the rest"); + assert!(note.starts_with("the rest") && note.contains("process CPU: not live"), "{note}"); + let hw_fallback = SpanSource::Fallback { meter: "rapl", why: "process CPU: not live".into() }; + let (e, _) = grade(&span(5_000, JcProvenance::HwShunt, Coverage::Whole, hw_fallback), est(40_000)); + assert_eq!(e, est(40_000).0, "a machine-wide reading is not the engine's own"); let (e, note) = grade(&Own::Process(Err(EnergyError::NotAdvanced)), est(40_000)); assert_eq!(e, est(40_000).0); assert!(note.starts_with("the rest"), "{note}"); - // The llama engine's subprocess. + // The llama engine's subprocess: never read whole. let exited = vec![sandbox_meter::TreeGap::Exited { pid: 7 }]; - let child = |reading, self_contained| Own::Child { reading, self_contained }; - let (e, _) = grade(&child(tree(9_000_001, vec![]), true), est(40_000)); - assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (Some(9_001), 9_001, Provenance::HwShunt)); - for (reading, declared) in [(tree(9_000_000, vec![]), false), (tree(9_000_000, exited.clone()), true)] { - let (e, note) = grade(&child(reading, declared), est(40_000)); - assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 49_000, Provenance::Estimator), "{note}"); - assert!(note.contains("not all of the inference"), "{note}"); - } + let child = |reading| Own::Child { reading }; + let (e, note) = grade(&child(tree(9_000_001, exited.clone())), est(40_000)); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 49_001, Provenance::Estimator), "{note}"); + assert!(note.contains("not all of the inference") && note.contains("pid 7 exited"), "{note}"); + let (e, note) = grade(&child(tree(9_000_000, vec![])), est(40_000)); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 49_000, Provenance::Estimator), "{note}"); + assert!(note.contains(NOT_READ_AT_EXIT), "{note}"); // A hardware host figure does not make the sum a reading. let hw = (Energy { measured_uj: Some(40_000), estimated_uj: 40_000, provenance: Provenance::HwShunt }, "r".into()); - let (e, _) = grade(&child(tree(9_000_000, exited), true), hw.clone()); + let (e, _) = grade(&child(tree(9_000_000, exited)), hw.clone()); assert_eq!((e.measured_uj, e.provenance), (None, Provenance::Estimator)); for reading in [None, Some(Err(TreeError::RootGone { pid: 7 }))] { - let (e, _) = grade(&child(reading, true), hw.clone()); + let (e, _) = grade(&child(reading), hw.clone()); assert_eq!(e, hw.0, "no reading: the host meter's figure as it is"); } } @@ -593,16 +593,16 @@ mod tests { cfg!(target_os = "macos") && sandbox_meter::process_energy_status() == sandbox_meter::CounterStatus::Live; let engine = Engine::Llama(LlamaCompletionBackend::new(dir.join("model.gguf")).with_binary(script.display().to_string())); - for self_contained in [false, true] { - let (response, own) = infer(&engine, None, self_contained, &LlmRequest::from_prompt("fib", 16)); + for round in 1..=2 { + let (response, own) = infer(&engine, None, &LlmRequest::from_prompt("fib", 16)); let response = response.expect("the stand-in ran"); assert!(response.text.contains("1 passed"), "{}", response.text); let report = std::fs::read_to_string(dir.join(STAND_IN_REPORT)).expect("the stand-in's report"); let _ = std::fs::remove_file(dir.join(STAND_IN_REPORT)); let oracle_uj = report.split_whitespace().next().unwrap().parse::().unwrap() / 1_000; let (e, note) = grade(&own, est(40_000)); - eprintln!("llama engine (declared={self_contained}): oracle {oracle_uj} µJ; receipt {e:?} ({note})"); - let Own::Child { reading, .. } = own else { panic!("the llama engine is read as a child") }; + eprintln!("llama engine round {round}: oracle {oracle_uj} µJ; receipt {e:?} ({note})"); + let Own::Child { reading } = own else { panic!("the llama engine is read as a child") }; if reads_processes { let r = reading.expect("a tree meter on this host").expect("a reading of the model's process"); let read = r.cpu_nj.div_ceil(1_000); diff --git a/joule-code/examples/l3-jouleclaw/src/llama_backend.rs b/joule-code/examples/l3-jouleclaw/src/llama_backend.rs index 123318d44..bb5a9b197 100644 --- a/joule-code/examples/l3-jouleclaw/src/llama_backend.rs +++ b/joule-code/examples/l3-jouleclaw/src/llama_backend.rs @@ -7,10 +7,10 @@ //! [`LlamaCompletionBackend::complete_metered`] also reads the subprocess's own //! CPU energy where this host counts each process's ([`sandbox_meter::TreeMeter`]: //! macOS, the per-process counter live): the model's process, not this one and -//! not the machine. The reading covers that process's CPU only — llama.cpp built -//! for Apple offloads to the GPU by default, and a process that exits in its -//! window lacks its last time on a core — so it is never a reading of the whole -//! inference; the `run` binary grades it. +//! not the machine. The reading covers that process's CPU only — not the GPU the +//! binary may offload to — and the process exits in its window, so its record +//! lacks its last time on a core: it is never a reading of the whole inference, +//! and the `run` binary grades it as an estimate. use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -65,16 +65,29 @@ impl LlamaCompletionBackend { /// its stdin closes. Its stdout and stderr are drained to the end, it is /// waited for without being reaped, so its exited count is still readable, /// the meter's span is stopped on it, and then it is reaped. + /// + /// A binary that cannot be run — not on `PATH`, not a file, not executable + /// — is [`LlmError::Unavailable`], as a failed spawn of it would be, and + /// nothing is spawned: `sh` failing to `exec` it would otherwise exit 127, + /// which reads as the model's own failure. pub fn complete_metered( &self, request: &LlmRequest, ) -> (Result, Option>) { use std::io::{Read, Write}; + let Some(bin) = executable(&self.bin) else { + return ( + Err(LlmError::Unavailable(format!("spawn {}: no executable file by that name", self.bin))), + None, + ); + }; // The per-process counter's liveness probe burns about 300 ms of CPU on // this thread the first time: here, before anything is spawned. let _ = sandbox_meter::process_energy_status(); let spawned = Command::new("sh") - .args(["-c", GATED_EXEC, &self.bin]) + .arg("-c") + .arg(GATED_EXEC) + .arg(&bin) .arg("-m") .arg(&self.model_path) .arg("-p") @@ -97,7 +110,7 @@ impl LlamaCompletionBackend { if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(b"\n"); } - // stderr on a thread of its own: llama.cpp logs there while it writes + // stderr on a thread of its own: the binary logs there while it writes // stdout, and a full pipe would stall it. let stderr = child.stderr.take().map(|mut e| { std::thread::spawn(move || { @@ -128,6 +141,32 @@ impl LlamaCompletionBackend { } } +/// The file `bin` names, as `exec` would find it: a path (any name with a `/`) +/// as given, else the first match on `PATH`; `None` unless it is a regular +/// file this process may execute. +fn executable(bin: &str) -> Option { + fn runnable(p: &std::path::Path) -> bool { + let Ok(meta) = std::fs::metadata(p) else { return false }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.is_file() && meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + meta.is_file() + } + } + if bin.is_empty() { + return None; + } + if bin.contains('/') { + let p = PathBuf::from(bin); + return runnable(&p).then_some(p); + } + std::env::split_paths(&std::env::var_os("PATH")?).map(|dir| dir.join(bin)).find(|p| runnable(p)) +} + /// Wait for child `pid` to exit WITHOUT reaping it (`waitid` with `WNOWAIT`), /// so its count stays readable for the tree meter's stop walk. The caller reaps /// it afterwards. `false` if the wait failed. @@ -180,7 +219,7 @@ fn parse_completion(request: &LlmRequest, stdout: &[u8], stderr: &[u8]) -> LlmRe .trim() .to_string(); - // Real token counts from llama.cpp's timing lines; fall back to a coarse + // Real token counts from the binary's timing lines; fall back to a coarse // char/4 estimate if the format shifts. let input_tokens = count_before(&stderr, "tokens").unwrap_or((request.prompt.len() / 4) as u32); @@ -214,3 +253,36 @@ fn count_before(stderr: &str, unit: &str) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + + /// A binary that cannot be run is `Unavailable`, as a failed spawn of it is, + /// with no reading: never the `Upstream` failure of a model that ran, which + /// `sh` exiting 127 on a failed `exec` would read as. + #[test] + fn a_binary_that_cannot_run_is_unavailable_not_a_model_failure() { + let dir = std::env::temp_dir().join(format!("l3-unrunnable-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let not_executable = dir.join("llama-completion"); + std::fs::write(¬_executable, "#!/bin/sh\necho hi\n").unwrap(); + let bins = [ + "/nonexistent/llama-completion".to_string(), + "l3-jouleclaw-no-such-binary-on-path".to_string(), + not_executable.display().to_string(), + dir.display().to_string(), + ]; + for bin in bins { + let backend = LlamaCompletionBackend::new(dir.join("model.gguf")).with_binary(bin.clone()); + let (response, reading) = backend.complete_metered(&LlmRequest::from_prompt("fib", 4)); + match response { + Err(LlmError::Unavailable(why)) => assert!(why.contains(&bin), "{why}"), + other => panic!("{bin}: {other:?}"), + } + assert!(reading.is_none(), "{bin}: nothing ran, yet a reading"); + } + let _ = std::fs::remove_dir_all(&dir); + } +} -- 2.54.0 (Apple Git-157) From 68aa43bafdc6b8e213a9767d13c44304cf9965c5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 17:59:34 -0400 Subject: [PATCH 12/12] ci: run the L3 and fabric-meter oracle tests on the macOS energy job No job ran the oracle assertions this branch added. joule-code-rs's matrix entry runs on linux-arm64, where joule-fix's L3 energy test has no tree meter and takes its fallback branch; jouleclaw-loader-gguf's calibration tests need the fabric-meter feature, which no job passed, and their oracle needs macOS. The macOS energy job now runs both, each step in the job's style (pipefail, a tee'd log, a grep gate on a line the test prints only where its gated assertion ran): - joule-code-remediate `l3_`: 'l3 model process:' (the model's reading checked against the stand-in's own count). The stand-in itself stays #[ignore]: it is the subprocess the oracle test runs (filtered to it, --ignored), not a test of its own, and the oracle test is not ignored. - jouleclaw-loader-gguf --features "rayon fabric-meter": the marginal_energy tests ('calibration row stamped on macOS:') and tests/process_calibration ('process calibration: CPU member'); this also builds the fabric-meter examples. joule-code-rs's target dir and Cargo.lock join the job's cache. The l3-jouleclaw example workspace stays out of this job (a heavy separate build on the one macOS runner); its README says its oracle tests run locally. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/standards-build.yml | 32 ++++++++++++++++++- .../crates/joule-code-remediate/src/lib.rs | 2 ++ .../src/marginal_energy.rs | 2 ++ .../tests/process_calibration.rs | 2 ++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index b5272685d..71cc4dc77 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -347,7 +347,8 @@ jobs: eoc/eoc-rs/target jouleclaw/jouleclaw-rs/target sandbox/sandbox-rs/target - key: standards-macos-energy-${{ hashFiles('jcp/jcp-rs/Cargo.lock', 'proof/proof-rs/Cargo.lock', 'eoc/eoc-rs/Cargo.lock', 'jouleclaw/jouleclaw-rs/Cargo.lock', 'sandbox/sandbox-rs/Cargo.lock', 'rust-toolchain.toml') }} + joule-code/joule-code-rs/target + key: standards-macos-energy-${{ hashFiles('jcp/jcp-rs/Cargo.lock', 'proof/proof-rs/Cargo.lock', 'eoc/eoc-rs/Cargo.lock', 'jouleclaw/jouleclaw-rs/Cargo.lock', 'sandbox/sandbox-rs/Cargo.lock', 'joule-code/joule-code-rs/Cargo.lock', 'rust-toolchain.toml') }} restore-keys: | standards-macos-energy- @@ -448,3 +449,32 @@ jobs: log=$(mktemp) cargo test -p sandbox-proof -- --nocapture 2>&1 | tee "$log" grep -q 'local engine tree:' "$log" + + # joule-fix meters its L3 model on the model's own process tree. The + # joule-code-rs matrix entry runs on linux-arm64, where there is no tree + # meter, so its oracle test (the model's reading against the stand-in's + # own energy count) takes its fallback branch there and runs nowhere + # else. It prints this line only where it checked the reading. + - name: joule-code-remediate (L3 model process tree) + shell: bash + working-directory: joule-code/joule-code-rs + run: | + set -o pipefail + log=$(mktemp) + cargo test -p joule-code-remediate l3_ -- --nocapture 2>&1 | tee "$log" + grep -q 'l3 model process:' "$log" + + # Feature AND platform gated: `fabric-meter`, which no other job passes, + # and macOS. The fabric calibration rows' macOS stamp, and a CPU member + # priced on this process's own counters against its own energy count + # (tests/process_calibration.rs), each print their line only there. + - name: jouleclaw-loader-gguf (fabric-meter, process calibration) + shell: bash + working-directory: jouleclaw/jouleclaw-rs + run: | + set -o pipefail + log=$(mktemp) + cargo test -p jouleclaw-loader-gguf --features "rayon fabric-meter" marginal_energy -- --nocapture 2>&1 | tee "$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 'process calibration: CPU member' "$log" diff --git a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs index f147a919a..afe26b72f 100644 --- a/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-code-remediate/src/lib.rs @@ -2195,6 +2195,8 @@ fn answers() { assert_eq!(answer(), 42); } assert!((0.8..=1.25).contains(&ratio), "the window read {read} µJ of a model that counted {oracle_uj} µJ"); assert!(l3.partial_because.iter().any(|w| w.contains("exited")), "{l3:?}"); assert_eq!(l3.figure_uj, read + l3.machine_uj); + // CI's macOS energy job greps for this line: the oracle ran. + eprintln!("l3 model process: window {window} read {read} µJ of a model that counted {oracle_uj} µJ"); } else { assert_eq!((l3.process_cpu_uj, l3.figure_uj), (None, l3.machine_uj), "{l3:?}"); } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs index 6772c271c..ea61241c7 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs @@ -729,6 +729,8 @@ mod tests { 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"); + // CI's macOS energy job greps for this line: the macOS stamp was checked. + eprintln!("calibration row stamped on macOS: os={:?} chip={:?}", row.os, row.chip); } eprintln!("a calibration row on this host: date={:?} os={:?} chip={:?}", row.date, row.os, row.chip); } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/process_calibration.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/process_calibration.rs index dfa5dbfcb..4bb226f3b 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/process_calibration.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/tests/process_calibration.rs @@ -71,6 +71,8 @@ fn a_cpu_member_is_priced_on_this_process_as_its_own_count_says() { assert!((0.9..=1.1).contains(&ratio), "the span read {} µJ of {oracle_uj} µJ", f.cpu_uj); let total = f.uj_per_gflop * f.ops as f64 * gflop; assert!((total - f.cpu_uj as f64).abs() <= 1.0, "{total} µJ priced of {} µJ read", f.cpu_uj); + // CI's macOS energy job greps for this line: the oracle ran on live counters. + eprintln!("process calibration: CPU member round {round} read {} µJ of {oracle_uj} µJ", f.cpu_uj); } else { let why = figure.expect_err("no live counters, yet a figure"); assert!( -- 2.54.0 (Apple Git-157)