Typed decisions, per-fabric energy, and two figures I had to withdraw #154

Merged
dcharlot merged 7 commits from feat/decision-models into main 2026-10-05 08:54:07 -04:00
Owner

Decision models — state plus typed questions in, a calibrated probability per allowed answer out, no text generated — went from nonexistent to a category between 2026-09-15 and 2026-10-01 (Jev, Kev, Clef, Laya, Liquid d1, GLiNER2.5-Decide, SemIf), converging on three primitives (noul, choice, score) and one schema. Every quantity in Mesh is a token count, and a decision emits none, so the spec could neither price one, rate one, nor report one.

Mesh §3.6 — a unit of work the spec could not measure

Three defects, each checked in the code rather than inferred:

  • MeshReceipt::uj_per_accepted_token, documented as "the wedge metric", is checked_div by tokens_accepted. A decision's is 0 — measured "output_tokens": 0 on every request — so the headline efficiency figure returned None for the whole category. The receipt sealed with no number.
  • estimate() charged prompt_tokens + max_tokens, so a caller leaving max_tokens at a generation's default was priced for an essay the backend will never write, and refused at a ceiling that would have covered the work.
  • Nothing could express the dimension a decision's cost actually follows: the schema it scores. One question over 20 options is more work than one over 2 at identical token counts.

Added DecisionShape / DecisionOutcome, CostProfile::with_option_cost + estimate_decision_uj, and uj_per_decision / uj_per_option_scored. Additive throughout, following the pattern CostProfile::calibration already set here: new fields are Option, skipped when absent, and a declared option cost of zero reproduces the token-only model exactly. The sealed conformance vectors in conformance/v1/reference still verify byte-identically, which is the real test that this is compatible.

jcp-energy — per-fabric energy, and a refusal that has teeth

Heterogeneous compute is the premise, so Fabric covers Cpu, Gpu, Npu, Fpga, Dsp, Dram, Package, and FabricSource names 13 counters (ProcRusage, IoReport, Powermetrics, Rapl, Nvml, RocmSmi, LevelZero, TegraIna, QualcommQpt, AscendSmi, HabanaSmi, CambriconCnmon, ExternalMeter). A receipt can now say which counter produced a figure.

Two defects fixed on the way. ANE energy was counted nowhere: counts_once decides by rail name and looks for an ANE Energy aggregate no Apple part here publishes, so ANE0 was rejected as a summand of a nonexistent parent. And a live counter was being discarded because a sibling rail was frozen: the ladder admits one rung, so it declined IOReport whole and fell back to a per-process counter that documents it excludes GPU and ANE.

The fix splits two axes the ladder conflated: Coverage (whole task?) and new Attribution (whose task?). A GPU rail is a real reading of the whole GPU that cannot attribute → Machine. survey() returns every (fabric, source) pair with a status, and the five negative statuses are the substance — Frozen, Absent, NeedsPrivilege, NoCounterExists, NotImplemented. Only NoCounterExists is the hardware's fault, which is what makes an energy-disclosure exemption legitimate; presence is probed so an unread accelerator says NotImplemented, never the false Absent.

FabricStatus::may_grade_span enforces what min_window_ns never did: a span shorter than the counter's update period is not a measurement of it, and an undeclared period can never grade a span at all. That rule immediately indicted our own best figure, so the counter got characterised rather than the rule weakened — ri_energy_nj advanced in 0% of intervals at 50 µs, 35% at 1 ms, 87% at 5 ms, 100% at 10 ms, batching in ~20 mJ lumps below 1 ms. Declared 10 ms, not the 5 ms where it clears our 80% criterion, because over-declaring refuses work that would have fit while under-declaring admits work the budget cannot cover.

⚠️ Two self-corrections in this branch

The GPU rail's declared period was wrong by two orders, in the unsafe direction. I first declared 10 ms. Measured properly, the gap between actual changes is p50 12.7 ms / p90 91 ms / max 992 ms at idle, and p50 13.1 ms / max 42 ms under sustained load — the rail republishes when there is activity and holds when there is not. Declared 1 s, the idle worst case, since a span inside a hold reads zero and would have been graded as a measurement of zero.

The GPU per-decision figure is withdrawn. I reported 0.104 J per three-question decision as GPU 0.0966 J + process CPU 0.0075 J. Measuring per-process GPU time as a delta over the span showed com.apple.Safari holding 99.5% of GPU time in a 4 s window while this process held 0.000 s, at host load average 132 on 18 CPUs. A rail delta over that span is a reading of Safari. The 0.08% idle-bracket correction I cited as evidence of cleanliness proves nothing — a bracket subtracts steady background, not a contender overlapping the span but not the brackets.

What stands is the better half: Laya 0.486 J and GLiNER2.5-Decide 0.555 J per three-question decision, process-attributed, immune to that contention. SPEC §3.6's worked example uses the CPU figure (486 000 µJ, 162 mJ per answered question) and says why it is not the GPU one. It took a contaminated GPU figure to demonstrate what Attribution::Process is for.

eoc/standards — proposed SCI-for-AI annex on the provenance of E

SCI = (E × I + M) per R is well specified in every term but one. I has a location-based methodology, M a lifecycle one, R must be stated and reproducible — E has no comparable requirement, so a figure can be labelled "measured" with no counter, span, silicon or boundary named.

Five machine-checkable requirements: name the counter; declare its period and refuse a measured claim for a shorter span; declare principal-vs-machine attribution with the idle-correction magnitude; break down per fabric; and say why a fabric is uncovered. The last is the lever — the EU AI Act GPAI exemption for lacking "critical information from compute or hardware provider" is a claim of no-counter-exists, and an unprivileged on-die counter narrows where that is honest.

No change to R is proposed, and that corrects me: I had read "tokens are a natural functional unit" as the unit being token-shaped. SCI-for-AI supports diverse units — tokens for language models, inferences for classifiers, FLOPs for training — so R = one inference already covers these models, as our own prior draft already recorded. The Mesh defect was real; the standards-level version of it was not.

Verification

mesh: 52 tests, 0 clippy. jcp-energy: 99 tests. Rebased onto current main; no file here was modified on main since the branch point. Measurement harnesses are in-tree (mesh/tools/joules-per-decision/, jcp/tools/counter-resolution/) so every number above is reproducible rather than quoted.

🤖 Generated with Claude Code

**Decision models** — state plus typed questions in, a calibrated probability per allowed answer out, **no text generated** — went from nonexistent to a category between 2026-09-15 and 2026-10-01 (Jev, Kev, Clef, Laya, Liquid d1, GLiNER2.5-Decide, SemIf), converging on three primitives (`noul`, `choice`, `score`) and one schema. Every quantity in Mesh is a token count, and a decision emits none, so the spec could neither price one, rate one, nor report one. ### Mesh §3.6 — a unit of work the spec could not measure Three defects, each checked in the code rather than inferred: - `MeshReceipt::uj_per_accepted_token`, documented as "the wedge metric", is `checked_div` by `tokens_accepted`. A decision's is 0 — measured `"output_tokens": 0` on every request — so the headline efficiency figure returned `None` for the whole category. The receipt sealed with no number. - `estimate()` charged `prompt_tokens + max_tokens`, so a caller leaving `max_tokens` at a generation's default was priced for an essay the backend will never write, and refused at a ceiling that would have covered the work. - Nothing could express the dimension a decision's cost actually follows: the schema it scores. One question over 20 options is more work than one over 2 at identical token counts. Added `DecisionShape` / `DecisionOutcome`, `CostProfile::with_option_cost` + `estimate_decision_uj`, and `uj_per_decision` / `uj_per_option_scored`. Additive throughout, following the pattern `CostProfile::calibration` already set here: new fields are `Option`, skipped when absent, and a declared option cost of zero reproduces the token-only model exactly. **The sealed conformance vectors in `conformance/v1/reference` still verify byte-identically**, which is the real test that this is compatible. ### `jcp-energy` — per-fabric energy, and a refusal that has teeth Heterogeneous compute is the premise, so `Fabric` covers `Cpu`, `Gpu`, `Npu`, `Fpga`, `Dsp`, `Dram`, `Package`, and `FabricSource` names 13 counters (`ProcRusage`, `IoReport`, `Powermetrics`, `Rapl`, `Nvml`, `RocmSmi`, `LevelZero`, `TegraIna`, `QualcommQpt`, `AscendSmi`, `HabanaSmi`, `CambriconCnmon`, `ExternalMeter`). A receipt can now say *which* counter produced a figure. Two defects fixed on the way. **ANE energy was counted nowhere**: `counts_once` decides by rail name and looks for an `ANE Energy` aggregate no Apple part here publishes, so `ANE0` was rejected as a summand of a nonexistent parent. And **a live counter was being discarded because a sibling rail was frozen**: the ladder admits one rung, so it declined IOReport whole and fell back to a per-process counter that documents it excludes GPU *and* ANE. The fix splits two axes the ladder conflated: `Coverage` (whole *task*?) and new `Attribution` (*whose* task?). A GPU rail is a real reading of the whole GPU that cannot attribute → `Machine`. `survey()` returns every `(fabric, source)` pair with a status, and **the five negative statuses are the substance** — `Frozen`, `Absent`, `NeedsPrivilege`, `NoCounterExists`, `NotImplemented`. Only `NoCounterExists` is the hardware's fault, which is what makes an energy-disclosure exemption legitimate; presence is probed so an unread accelerator says `NotImplemented`, never the false `Absent`. `FabricStatus::may_grade_span` enforces what `min_window_ns` never did: a span shorter than the counter's update period is not a measurement of it, and an **undeclared** period can never grade a span at all. That rule immediately indicted our own best figure, so the counter got characterised rather than the rule weakened — `ri_energy_nj` advanced in 0% of intervals at 50 µs, 35% at 1 ms, 87% at 5 ms, 100% at 10 ms, batching in ~20 mJ lumps below 1 ms. Declared 10 ms, not the 5 ms where it clears our 80% criterion, because over-declaring refuses work that would have fit while under-declaring admits work the budget cannot cover. ### ⚠️ Two self-corrections in this branch **The GPU rail's declared period was wrong by two orders, in the unsafe direction.** I first declared 10 ms. Measured properly, the gap between actual changes is p50 12.7 ms / p90 91 ms / **max 992 ms at idle**, and p50 13.1 ms / max 42 ms under sustained load — the rail republishes when there is activity and holds when there is not. Declared **1 s**, the idle worst case, since a span inside a hold reads zero and would have been graded as a measurement of zero. **The GPU per-decision figure is withdrawn.** I reported 0.104 J per three-question decision as GPU 0.0966 J + process CPU 0.0075 J. Measuring per-process GPU time as a delta over the span showed **com.apple.Safari holding 99.5% of GPU time** in a 4 s window while this process held 0.000 s, at host load average 132 on 18 CPUs. A rail delta over that span is a reading of Safari. The 0.08% idle-bracket correction I cited as evidence of cleanliness proves nothing — a bracket subtracts *steady* background, not a contender overlapping the span but not the brackets. What stands is the better half: **Laya 0.486 J and GLiNER2.5-Decide 0.555 J** per three-question decision, process-attributed, immune to that contention. SPEC §3.6's worked example uses the CPU figure (486 000 µJ, 162 mJ per answered question) and says why it is not the GPU one. It took a contaminated GPU figure to demonstrate what `Attribution::Process` is for. ### `eoc/standards` — proposed SCI-for-AI annex on the provenance of E `SCI = (E × I + M) per R` is well specified in every term but one. `I` has a location-based methodology, `M` a lifecycle one, `R` must be stated and reproducible — **`E` has no comparable requirement**, so a figure can be labelled "measured" with no counter, span, silicon or boundary named. Five machine-checkable requirements: name the counter; declare its period and refuse a measured claim for a shorter span; declare principal-vs-machine attribution with the idle-correction magnitude; break down per fabric; and say *why* a fabric is uncovered. The last is the lever — the EU AI Act GPAI exemption for lacking "critical information from compute or hardware provider" is a claim of `no-counter-exists`, and an unprivileged on-die counter narrows where that is honest. **No change to `R` is proposed**, and that corrects me: I had read "tokens are a natural functional unit" as the unit being token-shaped. SCI-for-AI supports diverse units — tokens for language models, **inferences for classifiers**, FLOPs for training — so `R = one inference` already covers these models, as our own prior draft already recorded. The Mesh defect was real; the standards-level version of it was not. ### Verification mesh: 52 tests, 0 clippy. jcp-energy: 99 tests. Rebased onto current `main`; no file here was modified on `main` since the branch point. Measurement harnesses are in-tree (`mesh/tools/joules-per-decision/`, `jcp/tools/counter-resolution/`) so every number above is reproducible rather than quoted. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Decision models — state plus typed questions in, a calibrated probability per
allowed answer out, no text generated — went from nonexistent to a category
between 2026-09-15 and 2026-10-01, converging on three primitives (`noul`,
`choice`, `score`) and one schema. Every quantity in Mesh is a token count, and
a decision emits no tokens, so the spec could neither price one, rate one, nor
report one. Three defects, each checked in the code rather than inferred:

  - `MeshReceipt::uj_per_accepted_token`, documented as "the wedge metric", is
    `checked_div` by `tokens_accepted`. A decision's is 0 — measured
    `"output_tokens": 0` on every request — so the headline efficiency figure
    returned `None` for the whole category. The receipt sealed with no number.
  - `estimate()` charged `prompt_tokens + max_tokens`, so a caller that left
    `max_tokens` at a generation's default was priced for an essay the backend
    will never write, and refused at a ceiling that would have covered the work.
  - Nothing could express the dimension a decision's cost actually follows: the
    schema it scores. One question over 20 options is more work than one over 2
    at identical token counts.

Added `DecisionShape` on the request and `DecisionOutcome` on the outcome
(questions, options scored, and how much of the input was schema rather than
state), `CostProfile::with_option_cost` + `estimate_decision_uj`, and
`uj_per_decision` / `uj_per_option_scored` on the receipt. SPEC §3.6 states the
three MUSTs; §4's estimate formula and §6's field table are updated.

Additive throughout, following the pattern `CostProfile::calibration` already
set here: the new fields are `Option`, skipped when absent, and a declared
option cost of zero reproduces the token-only model exactly. So an existing
request's `request_id` is unchanged, a generation's signed core gains no keys,
and **the sealed conformance vectors in `conformance/v1/reference` still verify
byte-identically** — which is the real test that this is compatible, and it
passes. 52 tests, 0 failures, 0 clippy findings.

Two of the four new tests are the defects stated as invariants: a decision
receipt now has an efficiency figure where the token metric has none, and a
decision is not priced for tokens it never emits. Both were falsified by
reverting the fix — removing the `estimate()` branch fails two router tests,
making `uj_per_decision` read `tokens_accepted` fails two receipt tests.

`tools/joules-per-decision/` is the evidence: a harness that meters a real
Apache-2.0 decision model (Laya, ModernBERT-large 421M, via MLX) per fabric on
Apple silicon, and the numbers in §3.6 come from it. On an M5 Max a
three-question decision costs **0.104 J** — 0.0966 J on the GPU rail plus
0.0075 J of process CPU energy — at an 11.85 ms p50, which is ~35 mJ per
answered question. Idle baselines bracket the run and correct it by 0.08%, so
the figure is measurement, not modelling. The energy counters are read
unprivileged; the CPU-rail/GPU-rail asymmetry on macOS is noted there and is
the subject of follow-up work on `jcp-energy`.

The harness also reproduced a live defect in the category it measures: the
published Laya checkpoint ships a fitted temperature of 0.1006 for choice
questions with 11+ options, which sharpens logits roughly tenfold and reports
near-certainty on what may be a coin flip. A third-party porter found it by
reproducing upstream numerically and clamps it with a warning; the publisher
ships it. A calibrated probability is a claim, and this category ships claims
with no receipt — which is the argument for §3.6 in one line.

Co-Authored-By: Claude Opus 5 <[email protected]>
Two defects, both found by measuring a real GPU-resident inference on an
M5 Max and neither visible from the ladder's own tests.

**ANE energy was counted nowhere.** `counts_once` decides by rail name, and on
every Apple part seen here there is no `ANE Energy` aggregate to recognise — the
only ANE channel is a single `ANE0` in mJ, which the name rule rejects as a
summand of an aggregate that does not exist. A crate test pinned that state and
said in as many words that changing it should be "a decision, not an accident".
This is the decision: a Neural Engine workload metered as though it cost
nothing, and under-reporting at a ceiling is the direction this crate already
calls unsafe, because it admits work the budget cannot cover. `sum_energy_uj`
can see the whole sample, so it takes the aggregate where a part publishes one
and falls back to the per-engine `ANE<n>` rails where it does not — never both,
so nothing is counted twice. The name-level gate stays exactly as it was, which
is what keeps the two paths from colliding.

**A live counter was being discarded because another rail was frozen.** The
ladder admits one rung and reports one number. For an unprivileged reader on
macOS 27 the `CPU Energy` rails hold their value while `GPU Energy` advances
continuously in nanojoules, so the IOReport rung is declined whole — and with
it the only counter that reads the fabric every local inference on a Mac
actually runs on. The kernel's per-process counter that wins instead documents
that it excludes the GPU and the Neural Engine. So the platform's best figure
covered neither.

The fix separates two axes the ladder conflated. `Coverage` says whether a
figure covers the whole task; new `Attribution` says *whose* task. A GPU rail
reading is a real reading of the whole GPU that cannot tell one process's share
from another's — `Attribution::Machine`, not a lesser `Coverage`. The kernel's
CPU figure is `Attribution::Process`. `FabricMeter` composes the two counters
rather than choosing between them, because together they cover the fabrics the
work used, and `total_uj` returns the sum *and the fabrics it is a sum of*, so a
caller states its coverage instead of implying it.

`FabricEnergy` carries `span_ns` deliberately. A vendor energy counter has an
update period, and a figure taken over a shorter span is not a measurement of
that span: a published study of 588 runs found one such counter under-reporting
integrated power by 95.4% at 0.5 ms sampling, recovering to 1.6% only at 1 s.
That is why the figures below are the mean of 200 decisions over a 2.6 s span
and not 200 per-decision counter reads — an 11.85 ms span is unmeasurable by
this class of counter. Enforcing that bound at the grade boundary is the
follow-up; this commit makes the span visible so a caller can apply it.

Measured on an M5 Max, per three-question typed decision to a 421M decision
model: **GPU 96 591 µJ + process CPU 7 536 µJ = 104 127 µJ**, idle baselines
bracketing the run and correcting it by 0.08%. The harness is
`mesh/tools/joules-per-decision/` from ed0c07b5, and those are the numbers in
Mesh SPEC §3.6. The host test proves the composition live rather than asserting
it: 8M iterations of pure CPU work reported CPU 191 563 µJ as `Process` beside
GPU 7 234 156 µJ as `Machine` over the same 912 ms span — the GPU drawing 7.9 W
during a CPU-only test being exactly the reason `Machine` has to exist, and
exactly what a single-number ladder would have attributed to this process.

91 tests pass. The two clippy findings in this workspace are pre-existing in
`jcp-schema::flow` on main and are untouched.

Co-Authored-By: Claude Opus 5 <[email protected]>
Heterogeneous compute is the premise, so the taxonomy had to stop being three
Apple rails. `Fabric` now covers `Cpu`, `Gpu`, `Npu`, `Fpga`, `Dsp`, `Dram` and
`Package` — `Ane` is gone, since Apple's Neural Engine, a Qualcomm or Intel
NPU, a TPU, Ascend, Gaudi and Cambricon are one class of silicon and the vendor
belongs in the source, not the fabric.

`FabricSource` names the counter: `ProcRusage`, `IoReport`, `Powermetrics`,
`Rapl`, `Nvml`, `RocmSmi`, `LevelZero`, `TegraIna`, `QualcommQpt`, `AscendSmi`,
`HabanaSmi`, `CambriconCnmon`, `ExternalMeter`. A receipt can now say *which*
counter produced a figure: two readings graded `HwShunt` from different sources
are not interchangeable, because they have different domains, different update
periods and different attribution.

`survey()` returns every `(fabric, source)` pair with this host's status, and
the **five negative statuses are the substance**:

  - `Frozen` — silicon and counter both present, counter not advancing.
  - `Absent` — no such silicon here.
  - `NeedsPrivilege` — present, needs root we do not have.
  - `NoCounterExists` — present, and exposes no energy counter at all, so any
    figure is modelled by anyone. A property of the hardware.
  - `NotImplemented` — a counter exists and we have no reader. Our gap, named.

Blurring those is what makes an energy survey worthless, and one of them is
load-bearing outside this crate: a disclosure regime that excuses a provider
who "lacks critical information from the compute or hardware provider" is
relying on `NoCounterExists`, and this distinguishes it from the three statuses
that are nobody's hardware's fault. Presence is probed (`/sys/class/kfd`,
`/dev/accel/accel0`, `/dev/davinci0`, `/dev/hl0`, `/dev/cambricon_dev0`,
`/sys/bus/i2c/drivers/ina3221`, …) so an unread accelerator reports
`NotImplemented` rather than the false claim `Absent`. Apple's rails are
probed live — two reads with a CPU burn between them — because which rails a
macOS version exposes to an unprivileged reader is a fact to observe on the
host, not to assume.

**And the refusal is now real.** `FabricStatus::may_grade_span` enforces what
`min_window_ns` never did: a span shorter than the counter's update period is
not a measurement of that span, and a counter whose period is **undeclared**
can never grade a span at any length, because an unknown error direction at a
ceiling must be read as possibly-under-reporting.

That rule immediately indicted our own best figure, which is how it earned its
place: the per-process CPU counter had an undeclared period, so the only
process-attributed reading on Apple silicon could not be graded. Rather than
weaken the rule, `tools/counter-resolution/` measures the counter. Burning this
thread's CPU and sampling 60 times per interval, the fraction of intervals in
which `ri_energy_nj` advanced: **0% at 10 µs and 50 µs, 2% at 100 µs, 5% at
500 µs, 35% at 1 ms, 63% at 2 ms, 87% at 5 ms, 100% at 10 ms**. The crate's
liveness criterion is 80%, so 5 ms is where it first clears — and **10 ms is
declared**, because over-declaring refuses work that would have fit while
under-declaring admits work the budget cannot cover.

Below 1 ms it does not lag, it **batches**: on the few intervals where it moved
at all the median step was ~20 mJ, two orders above the 1 ms step. A sampler
reading it every 500 µs would see nothing most of the time and a 20 mJ jump
occasionally, and would report both as measurements — the same pathology the
crate already refuses on IOReport's CPU rails, and the same shape as the
published 95.4% under-read at 0.5 ms sampling.

With the period declared, this morning's figure stands for the reason it was
taken that way: 2.606 s ≫ 10 ms, which is why it is the mean of 200 decisions
over one long span and not 200 counter reads.

98 tests pass, 0 clippy findings. Six new invariants: each pair named once, a
source with no backend is never `Live`, an undeclared period grades nothing at
any span, source names are stable and distinct, only `ProcRusage` may claim
per-process attribution, and the declared period refuses exactly the intervals
the measurement showed it cannot resolve.

Co-Authored-By: Claude Opus 5 <[email protected]>
The cheapest fabric and the attributable one are not the same fabric, and this
measures the gap instead of asserting it.

On an M5 Max, the same three-question bounded decision:

  - Laya 421M on **CPU**: 0.486 J, 629 ms
  - GLiNER2.5-Decide 340M on **CPU**: 0.555 J, 480 ms
  - Laya 421M on **GPU**: 0.104 J, 11.9 ms

Holding the fabric constant, the two models are close — GLiNER2.5-Decide
spends 14% more per decision and answers 24% faster, while scoring 60.1% to
Laya's 46.6% on Fastino's 17-dataset suite. Worth recording because the Mesh
SPEC's §3.6 figures come from Laya, which is the **weakest** member of this
family on the only cross-model suite published for it; nothing here should
imply it is the best available.

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 that cannot say whose work it was. So the cheap path
yields a number that cannot be bound to a principal and the expensive path
yields one that can — which is the concrete reason `Attribution` is a separate
axis from `Coverage` rather than a weaker grade of it.

The CPU figure is also *complete*, not just attributable: over the GLiNER run
the GPU rail, bracketed by idle baselines either side, moved by −0.0013 J per
decision. Negative — the idle estimate slightly exceeded the real draw — so GPU
involvement sits below the measurement's own noise and the process-attributed
CPU number is the whole task. That is the claim the GPU rows cannot make.

`two_fabrics.py` is the harness. It needs `gliner2`, `peft` and `torch`; the
model is Apache-2.0 and 340M, and it runs on CPU without a GPU at all, which
is what makes the attributable path available on any host rather than only on
one with a counter we can read per principal.

Co-Authored-By: Claude Opus 5 <[email protected]>
`SCI = (E × I + M) per R` is well specified in every term but one. `I` has a
location-based methodology, `M` a lifecycle one, and `R` must be stated and
reproducible. **`E` has no comparable requirement**: a figure can be labelled
"measured" without saying what measured it, over what span, on which silicon,
or on whose behalf.

Five proposed requirements, all machine-checkable: E1 name the counter (not the
word "measured"); E2 declare its update period and **refuse a measured claim
for a span shorter than that period**, with an undeclared period never
supporting one; E3 declare whether the figure counts one principal's work or
the whole machine, and if machine-wide, report the magnitude of the idle
correction; E4 break down per fabric and state which fabrics are uncovered;
E5 say *why* a fabric is uncovered, as one of frozen / absent /
needs-privilege / no-counter-exists / not-implemented.

E5 is the one with teeth outside accounting. The EU AI Act's GPAI Code of
Practice requires energy documentation for training and inference while
permitting an exemption where a provider lacks "critical information from
compute or hardware provider" — which is a claim of `no-counter-exists`. E5
distinguishes it from the four statuses that do not justify it, and an
unprivileged on-die counter needs no cooperation from the compute provider at
all, so it narrows the set of accelerators for which the claim is honest.

The evidence is three independent observations plus our own: instrumented
AI-energy studies are GPU-rail-only and none declares a sampling interval; a
reference implementation in this ecosystem stamps `measured` on a lifecycle
estimator's output; per-call energy is being read from provider SDKs with no
stated boundary. And counters do take the unsafe direction by large factors —
a 588-run study found one under-reporting integrated power by 95.4% at 0.5 ms
sampling, a second paper built its per-token decomposition on that same counter
for the opposite reason without citing the first, and a third observation has
the same counter class reading 4–30× the board's draw. We reproduced the shape
on a fourth counter and characterised it (0% advancing at 50 µs through 100% at
10 ms, with ~20 mJ batched steps below 1 ms), which is what let us declare a
period instead of assuming one.

**A correction this annex makes explicitly: no change to `R` is proposed.** I
had taken SCI-for-AI's "tokens are a natural functional unit" to mean the unit
was token-shaped, and therefore undefined for the zero-token decision models
that appeared in Sept–Oct 2026. That is wrong: SCI-for-AI supports diverse
functional units — tokens for language models, **inferences for classifiers**,
FLOPs for training — so `R = one inference` already covers them. The gap is in
the energy, not the unit of work. Our own Mesh defect was real and is fixed
(ed0c07b5); the standards-level version of it was not.

Complements `sci-for-ai-submission.md`, which maps EOC receipts onto the
formula and takes E as given. Cross-linked both ways. Proposed URN
`urn:gsf:sci-ai:ext:energy-provenance:v1`, additive, and imposing nothing on
deployments that report estimates — which stay conformant provided they say so.

Co-Authored-By: Claude Opus 5 <[email protected]>
I declared the Apple `GPU Energy` rail's update period as 10 ms yesterday
(ed00cab1) on the strength of "~10 ms refresh on the parts measured here". That
was an assumption dressed as an observation, and it is wrong the dangerous way:
too short a declared period lets `may_grade_span` grade a span the counter
never covered, and a span landing entirely inside one hold reads **zero** and
would have been graded as a measurement of zero.

Measured properly, sampling every 2 ms on an M5 Max, the gap between actual
changes in the rail:

| condition | p50 | p90 | max | median step |
|---|---:|---:|---:|---:|
| idle | 12.7 ms | 91.0 ms | **992.2 ms** | 1.06 mJ |
| sustained MLX matmul | 13.1 ms | 15.0 ms | 42.0 ms | 73.50 mJ |

The rail has no fixed period. It republishes when there is activity to report
and holds when there is not, so the tight figure is only available to a
measurer who can guarantee the fabric stays busy — and a measurer cannot.
**Declared 1 s**, the idle worst case, because that is the condition a short
span is most likely to fall inside.

This also reconciles a fleet note that put the hold at "300–600 ms under load",
measured from a GGUF decode. That decode is dispatch-bound with idle gaps
between tokens, so it was sampling the idle behaviour rather than the loaded
one; under genuinely sustained load the rail is tight (max 42 ms). The two
observations agree once the cause is named, and the cause is what decides which
number to declare.

The headline figure survives, for the reason it was taken that way: the Laya
GPU span was 2.606 s, above even the idle worst case. A single 11.85 ms
decision could never have been one counter read, which is pinned in the test
alongside the 42 ms and 992 ms refusals so the period cannot drift back.

**Also corrects an overclaim in ed00cab1's message**, which said this "stop[s]
metering the Neural Engine as free". The code change there was right — count
the `ANE Energy` aggregate where a part publishes one, else the per-engine
rails — but on this hardware it changes nothing, because `ANE0` is frozen for
an unprivileged reader. The survey already reported `npu/ioreport` as `Frozen`;
it now also records the only route that exists: `sudo powermetrics --samplers
ane_power`, machine-wide, `NeedsPrivilege`. ANE work runs in `aned`, so it is
not this process's CPU energy either, and the load-balancer clients carry no
statistics — a span that used the ANE is CPU+GPU only and Partial.

Co-Authored-By: Claude Opus 5 <[email protected]>
mesh: withdraw the GPU per-decision figure — another process held 99.5% of the GPU
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
5208dc5936
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]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!154
No description provided.