eoc-cascade: a hit reads no meter and is charged a calibrated estimate; the span opens at the neural stage #233
Merged
dcharlot
merged 4 commits from 2026-10-13 09:31:02 -04:00
feat/eoc-cascade-meter-at-its-resolution into main 2026-10-13
eoc docs: what a resolve is charged, with the measured figures; the example prints the unmetered baseline
All checks were successful
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 57s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m42s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 55s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 1m32s
standards workspaces build + test / aac-rs (pull_request) Successful in 2m38s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 57s
standards workspaces build + test / map-rs (pull_request) Successful in 56s
standards workspaces build + test / mesh-rs (pull_request) Successful in 55s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 4m33s
standards workspaces build + test / openpay (pull_request) Successful in 3m27s
lockfiles / every Cargo.lock is current (pull_request) Successful in 37s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 4s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 7s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 6s
standards workspaces build + test / arl-rs (pull_request) Successful in 1m14s
standards workspaces build + test / eoc-rs (pull_request) Successful in 2m1s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m35s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 1m12s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m13s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m18s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m14s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m24s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m59s
The README said only that joule cost is the unit of accounting. It now has the rule and where the cascade reads its meter since 0d78becb: around the neural stage, never around a hit, which is charged a per-hit figure calibrated on the host. The example output shows the `hit` line, with and without a live counter. eoc-cascade's own docs carry the final figures from the build host (M3 Ultra, macOS 27.0.1, release, load average 5 to 14, three runs of `examples/hit_cost`): - 10,000 cache hits: 4.18 to 4.20 ms, 0.375 µs a resolve at the median (parent: 1,641 to 1,651 ms, 162 to 164 µs). A cascade given no meter at all: 3.55 to 3.60 ms, 0.333 µs. KV 0.667 µs, graph 0.75 to 0.79 µs. - calibrated: 1,413 to 1,716 nJ a cache hit, 2,672 to 2,961 nJ a KV hit, 3,007 to 3,163 nJ a graph hit, each from 416 to 3,328 lookups in a span 51 to 351 times the 12 to 19 µJ an empty span read; 0.85 to 2.3 ms for a first hit, calibration included, 0.96 to 1.35 ms for one asked again. - a neural stage that does nothing: 162.4 to 162.6 µs a resolve and 14.3 to 14.8 µJ measured, the cascade's two reads; 0.79 to 0.83 µs with no meter. The three misses before the opening read: 0.95 µJ and 220 ns together. `HitCalibration`'s docs say the figure is that of the core the batch ran on and of the build, and that the calibration's own energy is charged to no resolve. The module docs no longer link the private `cascade_cost`. eoc-meter's crate docs and `detect_in_process` say how eoc-cascade uses a counter now (it was the example of a caller that should bracket a batch, not each operation). eoc-core's `JouleCost::zero` and eoc-cache said a lookup's energy is below any counter's resolution; it is about a microjoule, readable as the mean of a batch, and less than a read costs, which is why no counter is read for a hit. `examples/hit_cost` also runs the same stages with no meter, the baseline a metered hit is now within 0.05 µs of. Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc: a hit prints its estimate and the calibration it came from
With 0d78becb a prompt the cache, the KV store or the graph answers is no
longer bracketed by two reads of the meter: it is charged the stage's own
figure plus what a hit on that tier costs on this host, calibrated over a
batch the first time the tier answers. The CLI resolves one prompt a process,
so its hit is always the one that calibrates.
`eoc query` therefore prints, for a hit, an estimate (it printed
`measured(the meter's own cost)` for a cache hit on a live counter, and the
stage's estimate plus that cost for a KV or graph hit), and a `hit` line that
says where the figure came from: the per-hit nanojoules, the batch, what the
meter read over it and over no work; or why the tier has no calibration and
the cost is the stage's own figure. On the build host (M3 Ultra, release):
$ eoc query ping
stage : kv
payload: pong
cost : 53 µJ (estimated)
hit : a kv hit is charged 3357 nJ on this host, on top of the stage's
own figure: process-energy read 6876 µJ over 2048 lookups in one
span (24 µJ over none)
A prompt that falls through to the neural stage prints as before: the echo
backend's 50 J estimate plus what the counter read around the stage, an
estimate; measured only where the stage estimated nothing, which the
existing in-process test pins. `eoc meter`'s `query` line says how the meter
is used.
Tests: the CLI's own path for `ping`, a KV hit and then a cache hit, is an
estimate on every host; where the tier was calibrated the cost is the stage's
figure plus the whole microjoules of the calibrated one, and where it was
not, the stage's figure and a reason this host's meter can give (the stub
holds, the powermetrics stand-in fails its reads). It takes the lock the
burning tests take, since the meter's domain is the whole process. The `hit`
line is pinned on its own.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-cascade: a hit's calibrated figure and a neural resolve's span, against an oracle on the real counter
0d78becb's rules were tested on scripted counters. These two hold them against this process's own CPU energy counter, each in a test binary of its own with one test, as eoc-meter's are: the counter's domain is every thread of the process, and the oracle's check is the process's context-switch count. Gated the same way: macOS only, and the counter must be live. The oracle (`tests/oracle`) shares no call and no number with the counter: `proc_pid_rusage` declared there and read at the SDK's offset, taken once the test's thread has left its core by the process's context-switch count rising across a sleep. A `Watched` counter puts that read on either side of every read the cascade takes, so a span lies between two windows of the oracle's: the one inside it and the one around it and both reads. That a reading lies between them holds for any count that only grows, so it is asserted every time; what is allowed to miss a tenth of the time is how far apart the two windows are, which is the two readers' own reads and wake-ups. `hit_calibration_process_energy`: - each tier calibrated 20 times through the watched counter: the span the calibration kept is its last two reads, it lies between the oracle's two windows around the same batch, the per-hit figure times the batch is that span, and the empty span it reports is the counter's first advance over no work. The inner window was 952/1000 of the outer at least, quiet and under 40 busy loops, so the figure is pinned within a twentieth; a fifth is asked. - on the bare counter, after a tier's first hit, 10,000 hits take no read of the meter (counted), every one is an estimate, and the microjoules charged are the calibrated nanojoules to the one microjoule still carried. - what the change removes: a span around one hit, two reads about one lookup, reads more than the hit is charged, every time; in an optimized build 5.8 to 17 times as much at the median, and three times is asked. In an unoptimized build the lookup itself costs about what the meter does (22 µJ a cache hit against 1.4 optimized), so no multiple is asked there. `neural_span_process_energy`, with a KV stage that burns 8 ms of the thread's CPU on its way to missing: - a neural stage that burns 2 ms: exactly two reads, the first after the KV lookup, and the measured figure between the oracle's window inside the span and its window around it, which opens after the burn. The inner window was 785/1000 of the outer at least under load (992 quiet); two thirds is asked. - a neural stage that only sleeps 2 ms, on the bare counter, nothing taking the thread off its core between the burn and the cascade's first read: the figure is under a quarter of the oracle's window over the whole resolve (at most 32/1000 of it). A span opened before the walk reads the burn. No joule figure and no timing is asserted. A calibration that lands on an efficiency core reads a fifth of what one on a performance core does (253 nJ a cache hit against 1,400): the figure follows the core the batch ran on. Co-Authored-By: Claude Opus 5.5 <[email protected]>
eoc-cascade: a hit reads no meter and is charged a calibrated estimate; the span opens at the neural stage
`Cascade::resolve` took one read of its meter before the walk and one after, whatever answered. Since0f6a9a3ba read of this process's own energy counter first sees the reading thread leave its core, so it costs about 80 µs, and a span holds its closing read's own wake-up. A cache, KV or graph hit is well under a microsecond. So a resolve one of them answered paid two reads to bracket a lookup three hundred times shorter than they are, and what it then sealed as the hit's cost, tagged measured (f84f0b2f's last case: an estimated zero on a live span is the span's reading), was the meter's own. Measured on the build host (M3 Ultra, macOS 27.0.1, load average 11 to 19, `examples/hit_cost`, release), parentac93d9e5against this commit, 10,000 resolves answered by the cache: - before: 1,647 ms in all, 164 µs a resolve at the median; every one tagged measured, 13 to 28 µJ, 141,447 µJ in all. A neural stage that does nothing read the same: 164 µs and 15 to 20 µJ. - after: 4.4 ms in all, 0.42 µs a resolve at the median; none tagged measured, 16,650 µJ in all, which is the 1,665 nJ a hit the tier was calibrated at. The rule is unchanged: nothing that was not read from a counter is measured. What changes is where the meter is read. - A resolve the neural stage answers is read as before, but the span opens once the three deterministic tiers have missed. The misses are spent before it and are not in the figure: 0.95 µJ and 223 ns for the three together, against the 13 to 24 µJ the span holds of the meter itself. So the measured figure is still not under the neural stage's energy. `cascade_cost` and `live_span` are as they were. A failed opening read is no longer followed by a closing one, which could not make a span. - A resolve a deterministic tier answers reads no meter and is never measured. It is charged the cost its stage reported plus a figure calibrated on this host: the first time a tier answers, the cascade reads the counter over no work until it moves (the meter's own cost), then repeats that tier's lookup inside one span, growing the batch until the span reads fifty times that (and 200 µJ at least) or 40 ms have gone, and keeps the span's reading per lookup, in nanojoules. It is an estimate: read by this counter on this host, but over a batch, and a mean is not a reading of any one hit. - Per-hit figures are around a microjoule and `JouleCost` is whole microjoules, so each tier carries the nanojoules not yet charged. The microjoules charged over any run of hits are the whole microjoules in what those hits accrued: never each hit rounded up, never a string of zeros. - The stage's own figure is added, as `cascade_cost` adds an estimate to a span and for its reason: it may be energy the counter never saw. A cached response that carries a measured figure is charged as an estimate too: it replays what was read for an earlier resolve. - No live counter, no calibration: the cascade given no meter (nothing is read to find the stub does not move), a failed read, a counter that holds for 2 ms of back-to-back reads or over every batch. The hit then keeps its stage's cost. The attempt is made once a tier; later hits read nothing. - `Cascade::calibration` reads what a tier holds (per-hit nJ, batch, span µJ, empty-span µJ, time taken) or why it holds nothing; `Cascade::calibrate` calibrates the tier that answers a query now, again if asked, and keeps the carry; `without_hit_calibration` switches a tier off, for a KV backend whose `get` must not be repeated. The three stages here are safe to repeat: the cache's lookup only refreshes a recency the hit already refreshed, the KV and graph lookups only read. Calibrated on that host: 1,356 to 1,665 nJ a cache hit, 2,669 to 3,250 nJ a KV hit, 2,946 to 3,152 nJ a graph hit, each from 224 to 4,032 lookups in a span of 0.7 to 5.9 mJ, 54 to 347 times the 12 to 29 µJ an empty span read, in 0.85 to 2.4 ms. Tests, on scripted counters and on a model host whose meter, clock and KV backend are one count: a hit takes no read once its tier is calibrated (not one in a thousand hits, each tier) and a neural resolve exactly two, the first after the KV lookup; the microjoules charged over 10,000 hits equal the calibrated nanojoules at every hit, for 139 nJ and 2,359 nJ a hit, and across eight threads; a calibration's batch is the lookups between its last two reads and its span their delta; a 1 ms lookup is repeated 39 times and a 100 ms one once; with no live counter every resolve keeps its stage's cost; and no hit is measured, on any counter, the calibrating hit included. Co-Authored-By: Claude Opus 5.5 <[email protected]>