open-standards/mesh/tools/joules-per-decision
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Transaction Science 5208dc5936
All checks were successful
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m57s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m29s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m13s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m43s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 8m28s
standards workspaces build + test / aac-rs (pull_request) Successful in 3m42s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 3m18s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 1m20s
standards workspaces build + test / map-rs (pull_request) Successful in 1m56s
standards workspaces build + test / mesh-rs (pull_request) Successful in 1m6s
standards workspaces build + test / openpay (pull_request) Successful in 7m12s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m41s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 5m16s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 4m18s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 7m16s
standards workspaces build + test / sense-rs (pull_request) Successful in 4m7s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 5m30s
mesh: withdraw the GPU per-decision figure — another process held 99.5% of the GPU
I reported 0.104 J per three-question decision as GPU 0.0966 J plus process CPU
0.0075 J, and put it in SPEC §3.6's worked example. The GPU half does not hold.

`gpu_share.py` measures the thing I had assumed away: per-process GPU time from
`accumulatedGPUTime` on each IOUserClient in the IOAccelerator subtree, as a
**delta over the span** rather than the cumulative totals (which are useless —
WindowServer has days of accrued time). Over a 4.06 s span with an MLX matmul
loop running, **com.apple.Safari held 2.470 s of 2.482 s — 99.5%** — while this
process registered 0.000 s. The host was at load average 132 on 18 CPUs,
running another session's VVC/VTM encodes, which is this machine's normal state
rather than an unlucky moment.

A rail delta over that span is a reading of Safari. The rule already shipped
elsewhere in the fleet is the right one: attribute a rail reading to this
process only when it holds ≥99% of the GPU time advancing in the span, report
exactly zero when it holds none, and otherwise **issue no GPU figure**, marking
the span CPU-only and partial. By that rule this measurement issues none.

The 0.08% idle-bracket correction I cited as evidence of cleanliness proves
nothing, and I should not have read it that way: a bracket subtracts *steady*
background, and cannot subtract a contending process whose work overlaps the
span but not the brackets.

What stands, and why it is the more interesting half: the **CPU** figures are
untouched — Laya 0.486 J and GLiNER2.5-Decide 0.555 J per three-question
decision — because the kernel's per-process counter attributes to a principal,
so neither load average 132 nor Safari's GPU work enters them. That is exactly
what `Attribution::Process` is for, and it took a contaminated GPU figure to
demonstrate it. SPEC §3.6's worked example now uses the CPU figure (486 000 µJ,
162 mJ per answered question) and says why it is not the GPU one. Latency
figures are unaffected.

Also corrects the direction of my earlier framing. I presented the GPU path as
"4.7× cheaper, and the price is that it cannot be bound to a principal". The
truth is stronger: on a shared host it cannot be bound to a principal *and*
the number itself is not the workload's. A sound GPU figure needs a host with
no display server and no other GPU client — on this fleet, a headless Linux box
with NVML, not a Mac.

`gpu_rail_period.py` is also added, the harness behind 92449670's correction of
the rail's declared period.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 07:20:58 -04:00
..
gpu_share.py mesh: withdraw the GPU per-decision figure — another process held 99.5% of the GPU 2026-10-05 07:20:58 -04:00
joules_per_decision.py
README.md mesh: withdraw the GPU per-decision figure — another process held 99.5% of the GPU 2026-10-05 07:20:58 -04:00
two_fabrics.py

Joules per typed decision

A measurement harness, and what it found: Mesh cannot price, rate or report a typed decision, because every quantity in the schema is a token count and a decision emits no tokens.

Run it with uv:

uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python laya-mlx
.venv/bin/python joules_per_decision.py

What a decision model is

A decision model takes application state plus typed questions and returns calibrated probabilities over a bounded answer set. It generates no text. The category appeared between 2026-09-15 (TypeSafe's Jev) and 2026-10-01 (Cloudflare's Clef), converging on three primitives:

primitive returns
noul P(true) for a proposition
choice a probability per named option, 1–255 options
score a probability per level of an ordered rubric

The schema is published as System One (OpenAPI 3.1, api.typesafe.ai), and Jev, Liquid d1, Kev, Clef and Laya all implement it. Clef states that it scores schema choices non-autoregressively rather than generating tokens.

Method

The harness measures laya-mlx — an independent Apache-2.0 MLX port of Convai's Laya (ModernBERT-large, 421M, FP16) — on Apple Silicon.

Energy comes from ri_energy_nj in proc_pid_rusage(getpid(), RUSAGE_INFO_V6), the same counter jcp-energy's rung 3 uses, read in-process so the reading is Coverage::Whole for this process's CPU work. The ABI constants (SIZE = 464, ENERGY_NJ = 336) are lifted from jcp-energy, which verified them with sizeof/offsetof against the macOS 26, 26.5 and 27 SDKs; they are not re-derived here. The harness applies jcp-energy's own liveness rule before trusting the counter — at least five advancing sample intervals and at least 80% of them, over at least 300 ms — and reports the result rather than assuming it.

Because the counter is per-process, the energy figures are unaffected by other load on the host. The latency figures are not, so they are upper bounds.

Results — M5 Max, macOS 27, 2026-10-04

device questions p50 p95 J/decision coverage
CPU 1 (noul) 126.8 ms 636.2 ms 0.204 Whole (CPU), HwShunt
CPU 3 (choice+score+noul) 629.1 ms 1410.0 ms 0.486 Whole (CPU), HwShunt
GPU 1 21.1 ms 38.2 ms 0.0079 floor only
GPU 3 17.1 ms 22.8 ms 0.0082 floor only

Counter liveness: 25 advancing intervals of 25.

Three questions cost less wall time than one, because all three ride a single forward pass (batch_size defaults to 16) — the marginal cost of a question inside a batch is near zero, which no per-token model predicts.

The GPU rows are a floor, not the decision's energy. They are the host-side CPU energy only; the GPU's share is unmetered. jcp-energy has no whole-coverage rung for GPU-resident work on Apple Silicon and correctly refuses to pass off IOReport's machine-wide GPU rail as one task's energy, so no whole-task joule figure for the GPU path can be stated here. Every local inference on a Mac is GPU-resident, so this is the gap that matters most.

What the measurements show about this spec

Four findings, each checked against the code rather than inferred.

  1. uj_per_accepted_token is undefined for the whole category. MeshReceipt::uj_per_accepted_token is documented as "the wedge metric" and is checked_div by outcome.tokens_accepted. A decision's tokens_accepted is 0 — measured "output_tokens": 0 on every request — so the metric returns None. The receipt seals, and the number that makes it worth sealing does not exist.

  2. The unit of work is implicitly the accepted output token. Every field of ServeOutcome is a token count: tokens_in, tokens_out, tokens_accepted, cache_hit_tokens, spec_proposed, spec_accepted. A decision's unit is the answered question, and nothing in the outcome can carry it.

  3. The cost model prices the wrong thing. CostProfile::estimate_uj is charged tokens = prompt_tokens + max_tokens. With max_tokens = 0 a decision collapses to base_uj plus its prompt, which ignores what the pass actually does: score N questions × K options. And the split inside the prompt is invisible — in the measured 3-question request, 121 of 132 input tokens were the question schema and 11 were the state. A fixed schema is a cacheable prefix (laya-mlx exposes cache_prompts), so 92% of a real decision request is re-priced on every call as though it were new input.

  4. fidelity_met is a self-declaration sealed into a signed receipt. mesh-core computes fidelity_met = chosen_fidelity >= req.fidelity_floor, where chosen_fidelity is the level the backend advertises (SPEC §3.2: "ordered scale F0 … F4"). Energy in the same receipt carries a provenance tag — HwShunt | ModelBased | Estimator — with a normative coupling rule. Fidelity carries nothing. The receipt is honest about where its joules came from and silent about where its quality claim came from.

    Decision models are what makes this fixable rather than inherent: for a bounded question, calibration is measurable. Kev publishes Brier scores (27B: 0.225) alongside accuracy; Clef trains against a Brier loss. A fidelity rating can cite an evaluation instead of asserting a level.

Why a decision receipt is worth more than a generation receipt

A decision is a single forward pass with no sampling. Given the weight hash, revision, dtype and the fitted temperatures, it is reproducible — so a receipt over one is verifiable by re-execution, which a receipt over sampled text can never be. laya-mlx publishes pinned revisions and per-file checksums, and reports 63/63 validation questions matching upstream's selected answer in both FP32 and FP16, which is exactly the material a receipt would bind.

The precision matters and must be bound: probabilities differ across FP16 and FP32 even where the selected label agrees. A receipt that omits dtype does not reproduce.

The defect this category ships with today

Loading the published Laya checkpoint raises:

laya-mlx: this checkpoint ships temperatures outside [0.5, 5] which would
distort confidence; clamping choice:11+=0.1006. Treat confidence from the
affected buckets as uncalibrated.

Confirmed on the real weights: temperature_by_options_raw["choice:11+"] is 0.10058280825614929. A temperature of 0.1 sharpens logits roughly tenfold, so the shipped checkpoint reports near-certainty on what may be close to a coin flip, for every choice question with 11 or more options. The porter found it by reproducing upstream numerically and clamped it; the publisher ships it.

That is the argument for this work in one line: a calibrated probability is a claim, and this category ships claims with no receipt.

The attribution trade — two fabrics, one decision

two_fabrics.py measures a decision model on the CPU, where the kernel's per-process counter attributes energy to a principal, and compares it with the same class of work on the GPU, where the only counter is an SoC rail that counts the silicon and cannot tell one process from another.

Measured on an M5 Max, macOS 27, 2026-10-04. Both CPU rows are Attribution::Process, Coverage::Whole for CPU work, over spans far longer than the counter's declared 10 ms period:

model params runtime fabric J/decision p50 attribution
Laya 421M MLX CPU 0.486 629 ms Process, whole
GLiNER2.5-Decide 340M torch CPU 0.555 480 ms Process, whole
Laya 421M MLX GPU 0.104 11.9 ms Machine (GPU share)

With the fabric held constant the two models are close: GLiNER2.5-Decide spends 14% more energy per decision and returns it 24% faster — while scoring 60.1% against Laya's 46.6% on Fastino's 17-dataset suite, so on this evidence the better-scoring model is not the more expensive one in any meaningful sense.

Moving the same model to the GPU costs 4.7× fewer joules and is 53× faster — and the figure stops being attributable. IOReport's GPU Energy rail is a real reading of the whole GPU; it cannot say whose work it was. So the cheap path produces a number that cannot be bound to a principal, and the expensive path produces one that can.

That is the trade, and it is not a defect in either counter. It is why Attribution is a separate axis from Coverage: a receipt that wants to say this principal spent these joules has to choose the CPU path today, and a receipt that only needs this much energy was spent here can take the GPU.

The CPU figure is complete, not merely attributable. Over the GLiNER run the GPU rail, bracketed by idle baselines, moved by −0.0013 J per decision — negative, meaning the idle estimate slightly exceeded the real draw, so GPU involvement is below the measurement's own noise. The process-attributed CPU number is therefore the whole task, which is exactly what the GPU rows cannot claim.

⚠ Withdrawn: the GPU figure is not this decision's energy

gpu_share.py measures what the table above assumed away. accumulatedGPUTime per IOUserClient (I/O Registry, IOAccelerator subtree) gives each process's GPU time; the cumulative totals are useless because WindowServer has been running for days, so what matters is the delta over the measured span.

Measured on this host, over a 4.06 s span while an MLX matmul loop ran:

process GPU time in span share
com.apple.Safari 2.470 s 99.5%
WindowServer 0.007 s 0.3%
PhotosReliveWidget 0.005 s 0.2%
this process 0.000 s 0.0%

So a single unrelated application held essentially the whole GPU for the duration. A rail delta taken over that span is a reading of Safari, not of the decision. The host was at load average 132 on 18 CPUs at the time, running another session's VVC/VTM encodes — which is the normal state of this machine, not an unlucky moment.

Consequences for the table above:

  • The two CPU rows stand unchanged. The kernel's per-process counter attributes to a principal, so other processes' load does not enter them. That is the whole value of Attribution::Process, demonstrated the hard way.
  • The GPU row is withdrawn as a per-decision energy figure. 0.104 J is an upper bound on machine GPU energy over the span, and on this host the bound is dominated by other processes. Its latency (11.9 ms p50) is unaffected.
  • The idle-bracket correction of 0.08% that I read as evidence of cleanliness proves nothing. A bracket subtracts steady background; it cannot subtract a contending process whose work overlaps the span and not the brackets.

The rule to apply, already shipped elsewhere in the fleet: a rail reading may be attributed to this process only when this process holds ≥99% of the GPU time advancing in the span; where it holds none, its GPU energy is exactly zero; and otherwise no GPU figure is issued and the span is CPU-only and partial. By that rule this measurement issues no GPU figure at all.

This is also why time-share apportionment cannot rescue it: apportioning a rail by GPU-time share was falsified elsewhere at 3–15× error, because a contending job can draw an order of magnitude more watts per GPU-second than a decode, and GPU time itself inflates ~5.4× under contention.

To get a sound GPU figure, re-measure on a host with no display server and no other GPU client — which on this fleet means a headless Linux box with NVML, not a Mac.