| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
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
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]> |
||
| .. | ||
| gpu_share.py | ||
| joules_per_decision.py | ||
| README.md | ||
| 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.
-
uj_per_accepted_tokenis undefined for the whole category.MeshReceipt::uj_per_accepted_tokenis documented as "the wedge metric" and ischecked_divbyoutcome.tokens_accepted. A decision'stokens_acceptedis 0 — measured"output_tokens": 0on every request — so the metric returnsNone. The receipt seals, and the number that makes it worth sealing does not exist. -
The unit of work is implicitly the accepted output token. Every field of
ServeOutcomeis 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. -
The cost model prices the wrong thing.
CostProfile::estimate_ujis chargedtokens = prompt_tokens + max_tokens. Withmax_tokens = 0a decision collapses tobase_ujplus 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-mlxexposescache_prompts), so 92% of a real decision request is re-priced on every call as though it were new input. -
fidelity_metis a self-declaration sealed into a signed receipt.mesh-corecomputesfidelity_met = chosen_fidelity >= req.fidelity_floor, wherechosen_fidelityis the level the backend advertises (SPEC §3.2: "ordered scale F0 … F4").Energyin 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.