diff --git a/jcp/README.md b/jcp/README.md index 0ac850d4d..011474d60 100644 --- a/jcp/README.md +++ b/jcp/README.md @@ -43,7 +43,7 @@ jcp/ - **`jcp-orchestrate`** — a **cross-harness orchestration profile**: a deterministic reconcile loop (poll → route → dispatch → reconcile → retry) that runs work items across a *heterogeneous* fleet of `Harness` backends, where every dispatch is **capability-gated** (a harness is granted only the task kinds it may handle), **joule-budgeted** (an exhausted harness fails over to the next one with capacity), and **sealed** as a JCR-1 decision receipt *before* it runs. A task whose kind no harness is granted is denied; a flaky harness retries up to a bound; the whole run is an ordered, verifiable ledger of sealed decisions. The compose-many-harnesses shape a meta-harness offers — with the capability, energy budget, and audit it leaves to the platform. - **`jcp-prompt`** — a **signed, content-addressed prompt registry**: immutable prompt **versions** (double-brace `{{ variable }}` templates) with mutable **aliases** (`dev` / `staging` / `production`) resolved by `prompts:/@` (or `prompts://`) URIs — the registry shape the serving ecosystem already uses. The difference: each registered version and each alias **promotion** seals as a hash-chained **`prompt/1`** receipt, and a version's id *is* its template, so swapping the text under a name changes the id and re-pointing an alias is a signed entry in an ordered log. The exact instructions an agent ran under — and which version was live when — become tamper-evident artifacts, not a row someone with write access can edit. - **`jcp-trace`** — ingests an agent run's **OpenTelemetry GenAI trace** (`gen_ai.*` semantic conventions: `chat` / `tool` / `retriever` spans with token usage) and its **evaluation assessments** (judge / human / code feedback), attaches **per-span joules** (measured where a meter is present, else `Estimator` from token counts — provenance never silently upgrades), and seals one **`eval/1` JCR-1 receipt**: the per-span token + joule breakdown with the headline `uj_per_accepted_token` at the **weakest** provenance present, the assessments, a deterministic **quality-gate** verdict (the sealed, declarative analog of a CI assert), and a **readiness projection** an ARL validation-axis claim consumes. The observability stacks *record* a trace and *log* a judge score in a table; this is the interop projection that **seals** the same trace, puts a joule on it, and hash-chains it via `EvalLog` — no new cryptography. The **gate is content-addressed**, so the sealed receipt commits to exactly which bar it was judged against (a tamper-evident artifact, not a mutable in-code `assert`), and **`align_threshold` / `Judge`** calibrate a judge's decision threshold to human pass/fail labels — judge alignment as a reproducible number, not a UI click. Behind the `server` feature, a std-only **OTLP `/v1/traces` receiver** lets any OpenTelemetry exporter POST a `gen_ai.*` trace (with its assessments) and get the sealed `eval/1` receipt back the instant the run ends. -- **`jcp-intercept`** — **attested interceptor chains**: a composable, ordered pipeline of transforms *around* each model and tool call — the `before_model` / `after_model` / `before_tool` seams an agent's middleware stack runs, composed onion-order (forward in, reverse out) and stopping at the first refusal or human-approval hold. An interceptor may inspect or **rewrite** the request (redact a secret before it reaches the model, trim stale context), **refuse** it, or **hold** it for a human. The difference from the incumbent's arbitrary in-process hooks: every step seals an **`intercept/1`** JCR-1 receipt carrying the request digest **before and after** the step (so a redaction or a trim is provable — the content itself never sealed, only its hash), the seam, the outcome, and the **metered joules** at their honest provenance, hash-chained — the exact transform pipeline that produced a model request becomes reconstructable and verifiable by anyone, and a step cannot run un-sealed. Three reference interceptors — `Redact` (a content transform), `CallLimit` (a guardrail), `Approve` (a human-in-the-loop hold) — each reuse a primitive the family already holds rather than reinventing it. +- **`jcp-intercept`** — **attested interceptor chains**: a composable, ordered pipeline of transforms *around* each model and tool call — the `before_model` / `after_model` / `before_tool` seams an agent's middleware stack runs, composed onion-order (forward in, reverse out) and stopping at the first refusal or human-approval hold. An interceptor may inspect or **rewrite** the request (redact a secret before it reaches the model, trim stale context), **refuse** it, or **hold** it for a human. The difference from the incumbent's arbitrary in-process hooks: every step seals an **`intercept/1`** JCR-1 receipt carrying the request digest **before and after** the step (so a redaction or a trim is provable — the content itself never sealed, only its hash), the seam, the outcome, and the **metered joules** at their honest provenance (where the meter reads a counter, a seam pass is one reading divided among its steps by each step's thread CPU time, so the steps sum to it exactly and each share is graded `ModelBased`), hash-chained — the exact transform pipeline that produced a model request becomes reconstructable and verifiable by anyone, and a step cannot run un-sealed. Three reference interceptors — `Redact` (a content transform), `CallLimit` (a guardrail), `Approve` (a human-in-the-loop hold) — each reuse a primitive the family already holds rather than reinventing it. - **`jcp-efficiency`** — the **energy-efficiency spec**, surfaced as a sealed receipt. The dark-factory economics turn on four figures — **joules per accepted token, tokens per second, value per token, and the tokens/joules/dollars spent per COMPLETED, VERIFIED unit of work** — and the incumbents publish, at best, a *rate* (cost per token) that can't tell you whether the spend bought a result. An **`efficiency/1`** JCR-1 receipt records all four at honest provenance, hash-chained, with the one thing the rates omit: the **outcome anchor** (`completed` + a verdict). A measurement that didn't complete is spend that bought nothing, and the receipt says so. `aggregate` then splits value-delivered from total spend across a chain — charging *every* attempt (including the failed ones) against the results actually delivered — so `tokens_per_completed_unit` / `uj_per_completed_unit` / `value_per_joule` become the honest efficiency a "cost per token" number is designed to hide. Integers and strings only (JCR-1 forbids bools/floats); the rate figures recompute from the sealed inputs on read. - **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`, only while every package counter is live — `rapl_status()`), NVML GPU energy behind the `nvml` feature (`HwShunt` and `Partial`, since it does not read the CPU's share; only while live — `nvml_status()`), the macOS kernel's per-process CPU energy count (`proc_pid_rusage`'s `ri_energy_nj`; `HwShunt`, whole for in-process CPU work, only while live — `process_energy_status()`; it covers this process's threads, not children, the GPU or DRAM, so a span that reaps a child process or ends with one is `Partial`; each end of a span is read only once the metering thread is seen to have left its core, because the kernel counts a thread's energy into its process's only then, and a span with an end not so checked is the estimate, never a reading; it is live on macOS 27.0.1 where IOReport's CPU rails are not), Apple Silicon IOReport (`HwShunt`, only while every CPU energy rail, each die's on a multi-die part, is live — on macOS 27.0.1 that rail is republished in lumps, so there the rung is declined and `ioreport_status()` says why; `Meter::detect_explained()` names the rung selected and why each one above it was declined), a time×power model at a power the host calibrated (`ModelBased`, `Meter::time_power`), and estimates no host calibrated (`Estimator`): time × the documented default power — the rung `Meter::detect()` falls to, always available — and a constant per-op figure. A vendor counter's reading is real but vendor-opaque and relayed by the platform, and it counts as a reading only while the counter is live (SPEC §5). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the default-power estimate. - **`jcp-gateway`** — the **energy-native MCP gateway**: a JCP enforcement point that sits in front of an MCP server so an existing client/server pair keeps its transport while every `tools/call` becomes capability-scoped, joule-budgeted, and sealed. The novel mechanic is two-phase metered enforcement: the call is *authorized* before it runs (no debit, because the cost isn't yet known), the upstream call is **metered** by the sibling Sandbox standard's energy probe (Apple Silicon IOReport, RAPL, NVML — vendor energy counters, whose readings are real but vendor-opaque and relayed by the platform, and which count as readings only while the counter is live for the work, SPEC §5; otherwise the figure is an estimate and is graded as one), and the metered joules are debited from the grant — so the budget that stops a runaway agent is denominated in joules, not a token estimate. Where the upstream server is a local process whose CPU energy the host's kernel counts live (macOS `proc_pid_rusage`), each call is read on that server's own process tree, the work's own process rather than the machine (SPEC §5.1, rung 3): sealed `HwShunt`, and `Whole` only when nothing was seen missing from the tree (no descendant reaped or exited in the span, no GPU client held, no in-process Neural Engine memory, every member read and quiet at both ends) and the operator has declared, with `--upstream-self-contained`, that the server hands no work to a process outside its tree; otherwise `Partial`, with the response's `_meta` naming why. Every decision, allow and deny, seals into a **hash-chained receipt log**: excise, reorder, or splice a receipt and the chain breaks at that point. A denied call never reaches the server (the live 2025–26 MCP exploits land on tool *output*, which the gateway frames as untrusted `data` that can never exercise a capability), and the `jcp-gateway` binary runs it over stdio in front of any MCP-server subprocess. This is the chokepoint the AI-gateway field occupies, on the two axes nobody there holds: the unit of account is the joule, and the audit is cryptographic and tamper-evident rather than a dollar figure and a span. diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index b5ec2fed0..4f29664be 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -657,6 +657,7 @@ dependencies = [ "jcp-energy", "jcp-receipt", "jcp-schema", + "libc", "serde_json", "thiserror 2.0.20", ] diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index 06a6c1872..8c596f58c 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -5527,6 +5527,16 @@ impl Ladder<'static> { } } +/// CPU time the calling thread has used, user and system +/// (`clock_gettime(CLOCK_THREAD_CPUTIME_ID)`), or `None` where the clock cannot +/// be read. Exact to the nanosecond and live: unlike a process's energy count, +/// which a thread's work joins only when the thread leaves its core, the +/// thread's CPU time is computed at the read. So it divides a span's reading +/// among work that ran in turn on one thread, at no cost to the span. +pub fn thread_cpu_time() -> Option { + liveness::thread_cpu_time() +} + impl Meter { /// Pick the best backend this environment exposes. Never fails: when no /// hardware interface exists the result is the time×power figure at the diff --git a/jcp/jcp-rs/crates/jcp-intercept/Cargo.toml b/jcp/jcp-rs/crates/jcp-intercept/Cargo.toml index d14132315..5e8734d0b 100644 --- a/jcp/jcp-rs/crates/jcp-intercept/Cargo.toml +++ b/jcp/jcp-rs/crates/jcp-intercept/Cargo.toml @@ -18,3 +18,8 @@ thiserror = { workspace = true } [lints] workspace = true + +# The real-counter test reads this process's energy count with its own FFI: +# an oracle that shares no code with the meter. +[target.'cfg(target_os = "macos")'.dev-dependencies] +libc = "0.2" diff --git a/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs b/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs index a93e3fe3c..6cd6cafdd 100644 --- a/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs @@ -22,6 +22,18 @@ //! and a figure that covers only part of the step says so (`"coverage": //! "Partial"`), rather than being sealed as the whole step's energy. //! +//! Where the meter reads a counter, a seam is metered as one span around its +//! whole pass, not one per step ([`Chain::before_model`]). A counter read for +//! one process first takes the reading thread off its core, so each read +//! costs tens of microseconds and its own wake-up; an interceptor step is +//! often a few microseconds, which a span of its own would read as mostly the +//! meter. The pass's reading is divided among its steps by each step's own +//! thread CPU time ([`jcp_energy::thread_cpu_time`], exact and free to +//! read), so the steps' figures sum to the reading exactly; a step's share of +//! a reading is `ModelBased`, and a pass with one step keeps the reading's +//! own grade. A meter that reads nothing (a model or a constant) costs +//! nothing to run and still meters each step on its own. +//! //! A [`Chain`] runs its interceptors forward through the `before_*` seams and in //! reverse through `after_model` (the onion unwinds), stopping at the first //! [`Step::Halt`] (a guardrail refused) or [`Step::Hold`] (a human must approve). @@ -32,7 +44,8 @@ use ed25519_dalek::SigningKey; use jcp_energy::Meter; use jcp_receipt::jcr1; -use jcp_schema::{ContentId, Coverage, Energy}; +use jcp_schema::{ContentId, Coverage, Energy, Provenance}; +use std::time::Duration; use serde_json::{json, Value}; // ───────────────── the request/response/tool an interceptor transforms ───────────────── @@ -269,59 +282,98 @@ impl Chain { /// Run the request through every interceptor's `before_model` seam, forward /// order, sealing each step. Stops at the first refusal/hold. + /// + /// Where the meter reads a counter, the whole pass is one span and each + /// step is sealed with its share of it by thread CPU time (see the crate + /// docs); otherwise each step is metered on its own. pub fn before_model(&mut self, req: &mut ModelRequest) -> ChainOutcome { - for idx in 0..self.interceptors.len() { + let order: Vec = (0..self.interceptors.len()).collect(); + self.run_seam(Seam::BeforeModel, &order, |interceptor| { let before = req.digest(); - // Split-borrow: the meter (shared) and one interceptor (mut) are - // disjoint fields, so the metered call touches no other chain state. - let (step, energy) = { - let meter = &self.meter; - let interceptor = &mut self.interceptors[idx]; - meter.measure(|| interceptor.before_model(req)) - }; - let after = req.digest(); - let name = self.interceptors[idx].name().to_string(); - self.seal(&name, Seam::BeforeModel, &step, before, after, energy); - if let Some(outcome) = terminal(&name, &step) { - return outcome; - } - } - ChainOutcome::Proceed + let step = interceptor.before_model(req); + (step, before, req.digest()) + }) } /// Run the response through every interceptor's `after_model` seam, **reverse** /// order (the onion unwinding), sealing each step. pub fn after_model(&mut self, req: &ModelRequest, resp: &mut ModelResponse) -> ChainOutcome { - for idx in (0..self.interceptors.len()).rev() { + let order: Vec = (0..self.interceptors.len()).rev().collect(); + self.run_seam(Seam::AfterModel, &order, |interceptor| { let before = resp.digest(); - let (step, energy) = { - let meter = &self.meter; - let interceptor = &mut self.interceptors[idx]; - meter.measure(|| interceptor.after_model(req, resp)) - }; - let after = resp.digest(); - let name = self.interceptors[idx].name().to_string(); - self.seal(&name, Seam::AfterModel, &step, before, after, energy); - if let Some(outcome) = terminal(&name, &step) { - return outcome; - } - } - ChainOutcome::Proceed + let step = interceptor.after_model(req, resp); + (step, before, resp.digest()) + }) } /// Run a tool call through every interceptor's `before_tool` seam, forward /// order, sealing each step. Stops at the first refusal/hold. pub fn before_tool(&mut self, call: &mut ToolCall) -> ChainOutcome { - for idx in 0..self.interceptors.len() { + let order: Vec = (0..self.interceptors.len()).collect(); + self.run_seam(Seam::BeforeTool, &order, |interceptor| { let before = call.digest(); - let (step, energy) = { + let step = interceptor.before_tool(call); + (step, before, call.digest()) + }) + } + + /// Run one seam over the interceptors in `order`, stopping after the + /// first step that halts or holds, and seal every step that ran. + /// + /// A meter that reads a counter meters the pass as one span; the steps are + /// sealed after it closes, each with its [`apportion`]ed share by its own + /// thread CPU time, in the order they ran. Any other meter meters each step + /// as it runs, as one span of its own. + fn run_seam( + &mut self, + seam: Seam, + order: &[usize], + mut step_at: impl FnMut(&mut dyn Interceptor) -> (Step, ContentId, ContentId), + ) -> ChainOutcome { + if order.is_empty() { + return ChainOutcome::Proceed; + } + if self.meter.provenance() == Provenance::HwShunt { + let (ran, pass) = { + let meter = &self.meter; + let interceptors = &mut self.interceptors; + meter.measure(|| { + let mut ran = Vec::with_capacity(order.len()); + for &idx in order { + let cpu_before = jcp_energy::thread_cpu_time(); + let (step, before, after) = step_at(interceptors[idx].as_mut()); + let cpu = match (cpu_before, jcp_energy::thread_cpu_time()) { + (Some(a), Some(b)) => Some(b.saturating_sub(a)), + _ => None, + }; + let stops = !matches!(step, Step::Continue); + ran.push((idx, step, before, after, cpu)); + if stops { + break; + } + } + ran + }) + }; + let cpu: Vec> = ran.iter().map(|r| r.4).collect(); + let mut outcome = ChainOutcome::Proceed; + for ((idx, step, before, after, _), energy) in ran.into_iter().zip(apportion(&pass, &cpu)) { + let name = self.interceptors[idx].name().to_string(); + self.seal(&name, seam, &step, before, after, energy); + if let Some(stopped) = terminal(&name, &step) { + outcome = stopped; + } + } + return outcome; + } + for &idx in order { + let ((step, before, after), energy) = { let meter = &self.meter; let interceptor = &mut self.interceptors[idx]; - meter.measure(|| interceptor.before_tool(call)) + meter.measure(|| step_at(interceptor.as_mut())) }; - let after = call.digest(); let name = self.interceptors[idx].name().to_string(); - self.seal(&name, Seam::BeforeTool, &step, before, after, energy); + self.seal(&name, seam, &step, before, after, energy); if let Some(outcome) = terminal(&name, &step) { return outcome; } @@ -371,6 +423,59 @@ impl Chain { } } +/// Divide one pass's figure among the steps that ran in it, by each step's +/// thread CPU time (`cpu`, in run order): integer micro-joules that sum to the +/// pass's figure exactly (largest remainder; a tie goes to the earlier step). +/// +/// The shares are weighted by CPU time only where every step's was read and +/// they are not all zero; otherwise equally. A share of a reading is a model of +/// how the reading divides, so a pass graded `HwShunt` gives its steps +/// `ModelBased` shares; any other grade is kept. A pass of one step is not +/// divided and keeps its figure as it is. Coverage is the pass's: a `Partial` +/// reading gives `Partial` shares. The pass's span also holds the chain's own +/// work between steps (digests) and the meter's closing wake-up; all of it is +/// divided among the steps, so no step is under its own energy. +fn apportion(pass: &Energy, cpu: &[Option]) -> Vec { + let n = cpu.len(); + if n == 0 { + return Vec::new(); + } + if n == 1 { + return vec![*pass]; + } + let total = pass.uj(); + let weights: Vec = match cpu.iter().map(|c| c.map(|d| d.as_nanos())).collect::>>() { + Some(w) if w.iter().any(|&x| x > 0) => w, + _ => vec![1; n], + }; + let sum: u128 = weights.iter().sum(); + let mut shares: Vec = weights.iter().map(|&w| (u128::from(total) * w / sum) as u64).collect(); + let mut left = total - shares.iter().sum::(); + // Largest remainder first; equal remainders go to the earlier step. + let mut order: Vec = (0..n).collect(); + order.sort_by_key(|&i| std::cmp::Reverse(u128::from(total) * weights[i] % sum)); + for &i in &order { + if left == 0 { + break; + } + shares[i] += 1; + left -= 1; + } + let provenance = match pass.provenance { + Provenance::HwShunt => Provenance::ModelBased, + other => other, + }; + shares + .into_iter() + .map(|uj| Energy { + measured_uj: None, + estimated_uj: uj, + provenance, + coverage: pass.coverage, + }) + .collect() +} + fn terminal(name: &str, step: &Step) -> Option { match step { Step::Continue => None, @@ -791,6 +896,58 @@ mod tests { assert!(verify(&env, c.steward(), &c.steward_pubkey()).is_err()); } + fn ns(n: u64) -> Option { + Some(Duration::from_nanos(n)) + } + + /// A pass's reading divides among its steps by thread CPU time, in whole + /// micro-joules that sum to the reading exactly, as `ModelBased` shares. + #[test] + fn a_reading_divides_among_its_steps_by_cpu_time_and_sums_exactly() { + let pass = Energy::measured(1_000); + let shares = apportion(&pass, &[ns(1_000), ns(2_000), ns(7_000)]); + assert_eq!(shares.iter().map(Energy::uj).collect::>(), vec![100, 200, 700]); + for e in &shares { + assert_eq!((e.provenance, e.coverage, e.measured_uj), (Provenance::ModelBased, Coverage::Whole, None)); + e.check_provenance().unwrap(); + } + // Remainders: 1000 µJ over thirds is 333/333/334, never 999 or 1002. + for (cpu, want) in [ + (vec![ns(1), ns(1), ns(1)], vec![334, 333, 333]), + (vec![ns(3), ns(3), ns(1)], vec![429, 428, 143]), + (vec![ns(5), ns(0), ns(5)], vec![500, 0, 500]), + ] { + let got: Vec = apportion(&pass, &cpu).iter().map(Energy::uj).collect(); + assert_eq!(got, want, "{cpu:?}"); + assert_eq!(got.iter().sum::(), 1_000); + } + } + + /// One step is not divided: it keeps the reading, grade and all. + #[test] + fn a_pass_of_one_step_keeps_its_reading() { + let pass = Energy::measured(1_234); + assert_eq!(apportion(&pass, &[ns(9)]), vec![pass]); + assert!(apportion(&pass, &[]).is_empty()); + } + + /// Without every step's CPU time, or with none of it non-zero, the + /// reading divides equally; a partial reading gives partial shares; a + /// grade below a reading is kept. + #[test] + fn shares_fall_back_to_equal_and_keep_the_passs_coverage_and_lower_grade() { + let got: Vec = apportion(&Energy::measured(10), &[ns(5), None]).iter().map(Energy::uj).collect(); + assert_eq!(got, vec![5, 5]); + let got: Vec = apportion(&Energy::measured(10), &[ns(0), ns(0)]).iter().map(Energy::uj).collect(); + assert_eq!(got, vec![5, 5]); + for e in apportion(&Energy::measured_partial(9), &[ns(1), ns(2)]) { + assert_eq!((e.provenance, e.coverage), (Provenance::ModelBased, Coverage::Partial)); + } + for e in apportion(&Energy::estimator(9), &[ns(1), ns(2)]) { + assert_eq!(e.provenance, Provenance::Estimator); + } + } + #[test] fn energy_is_metered_and_accumulates_honestly() { let mut c = chain() diff --git a/jcp/jcp-rs/crates/jcp-intercept/tests/one_span_per_pass.rs b/jcp/jcp-rs/crates/jcp-intercept/tests/one_span_per_pass.rs new file mode 100644 index 000000000..1dcf6fb2b --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-intercept/tests/one_span_per_pass.rs @@ -0,0 +1,115 @@ +//! A seam metered on this process's own energy count, as one span around the +//! whole pass, against an oracle that shares no code with the meter. +//! +//! Three interceptors each burn a different amount of their thread's CPU time +//! in one `before_model` pass. Each step is sealed with its share of the +//! pass's reading by thread CPU time (`ModelBased`), the shares sum to the +//! chain's figure, and that figure is the process's own energy over the pass: +//! no more than an oracle's window around it, and most of it. The oracle takes +//! its thread off its core its own way before each read (sleeps until the +//! process's context-switch count moves), then reads `ri_energy_nj` into a raw +//! buffer at the SDK's offset. + +#![cfg(target_os = "macos")] + +use ed25519_dalek::SigningKey; +use jcp_energy::Meter; +use jcp_intercept::{verify, Chain, Interceptor, ModelRequest, Step}; +use jcp_schema::Provenance; +use std::time::Duration; + +/// Burn `cpu` of this thread's own CPU time. +fn burn(cpu: Duration) { + let start = jcp_energy::thread_cpu_time().expect("the thread clock"); + let mut x = 1u64; + while jcp_energy::thread_cpu_time().expect("the thread clock") - start < cpu { + for _ in 0..10_000 { + x = std::hint::black_box(x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1)); + } + } +} + +struct Burner { + name: &'static str, + cpu: Duration, +} + +impl Interceptor for Burner { + fn name(&self) -> &str { + self.name + } + fn before_model(&mut self, _req: &mut ModelRequest) -> Step { + burn(self.cpu); + Step::Continue + } +} + +/// This process's `ri_energy_nj` (`proc_pid_rusage`, `RUSAGE_INFO_V6`, the +/// u64 at byte 336), after this thread has left its core: sleep 1, 2, 4, 8 ms +/// until the process's context-switch count (`pti_csw` would do; the rusage +/// `ri_user_time` is not enough) has moved. `ru_nvcsw + ru_nivcsw` of this +/// process is read with `getrusage`. +fn oracle_nj() -> u64 { + fn switches() -> i64 { + // SAFETY: `ru` is a valid, writable `rusage`. + let mut ru: libc::rusage = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) }, 0); + ru.ru_nvcsw + ru.ru_nivcsw + } + let before = switches(); + for ms in [1, 2, 4, 8] { + std::thread::sleep(Duration::from_millis(ms)); + if switches() > before { + break; + } + } + let mut buf = [0u64; 58]; + // SAFETY: `buf` is 464 writable bytes, the size of `rusage_info_v6`. + let rc = unsafe { libc::proc_pid_rusage(libc::getpid(), 6, buf.as_mut_ptr() as *mut libc::rusage_info_t) }; + assert_eq!(rc, 0, "proc_pid_rusage"); + buf[336 / 8] +} + +#[test] +fn a_pass_is_one_reading_divided_among_its_steps_by_cpu_time() { + let meter = Meter::detect(); + assert_eq!(meter.provenance(), Provenance::HwShunt, "this host's meter does not read a counter: {:?}", meter.rung()); + let steward = SigningKey::from_bytes(&[7u8; 32]); + let mut misses = 0; + for round in 0..10 { + let mut c = Chain::new(steward.clone(), "did:jcp:test-intercept-pass") + .with_meter(Meter::detect()) + .with(Box::new(Burner { name: "one", cpu: Duration::from_millis(2) })) + .with(Box::new(Burner { name: "two", cpu: Duration::from_millis(4) })) + .with(Box::new(Burner { name: "four", cpu: Duration::from_millis(8) })); + let before = oracle_nj(); + assert!(c.before_model(&mut ModelRequest::default()).proceeds()); + let oracle = oracle_nj() - before; + + let recs: Vec<_> = + c.receipts().iter().map(|e| verify(e, c.steward(), &c.steward_pubkey()).expect("a sealed step verifies")).collect(); + assert_eq!(recs.iter().map(|r| r.interceptor.as_str()).collect::>(), ["one", "two", "four"]); + let figures: Vec = recs.iter().map(|r| r.energy_uj).collect(); + let sum: u64 = figures.iter().sum(); + eprintln!( + "intercept pass: round {round}: steps {figures:?} µJ ({:?}), sum {sum} µJ, chain {} µJ, oracle window {:.0} µJ", + recs.iter().map(|r| r.provenance.as_str()).collect::>(), + c.energy_uj(), + oracle as f64 / 1e3 + ); + assert_eq!(sum, c.energy_uj(), "the steps' shares are not the chain's figure"); + if recs.iter().any(|r| r.provenance != "ModelBased") { + // A pass whose ends could not be read is the estimate, divided as one. + assert!(recs.iter().all(|r| r.provenance == "Estimator"), "{recs:?}"); + misses += 1; + continue; + } + // The shares follow the steps' own CPU time: 2 : 4 : 8 ms. + assert!(figures[0] < figures[1] && figures[1] < figures[2], "{figures:?}"); + // The pass is the process's own energy over it: within the oracle's + // window, and most of it. + assert!(sum * 1_000 <= oracle + 1_000, "the pass ({sum} µJ) exceeds the oracle's window ({oracle} nJ)"); + assert!(sum * 1_000 * 3 >= oracle * 2, "the pass ({sum} µJ) is under two thirds of the oracle's window ({oracle} nJ)"); + } + assert!(misses <= 1, "{misses} of 10 passes could not be read"); +}