Files
udpscope/docs/claude/invariants-measurements.md
T
Martino FerrariandClaude Opus 4.6 1627abcd4a docs: the sample budget in samples per signal, the memory limit and the planner probe lines
Converts every --buffer-mb / buffer_mb mention to --buffer-samples / buffer_samples,
records the unit change as a deviation (README, SPECS, CLAUDE.md), documents the
buffer: and planner: dump lines and the new Preferences row, and updates the help
pages and the help-shot script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:49:21 +01:00

340 lines
29 KiB
Markdown

# Invariants: the measurements pane
Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted, and which mean over a split is which.
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
- **The measurements pane is refreshed on a cadence, and its cost is the *window's*, not the
frame's.** A measurement is a statistic of the visible window and a live view moves that window
every frame, so "recompute when the window moves" is "recompute every frame" at O(visible
samples) *per measurement*. With the pane holding four measurements over a 4.7M-sample window
that measured **13.66 ms of tick mean** (baseline 0.41 ms) and cost the run a third of its
frames. Three things fixed it, and all three matter: `update_measures()` recomputes at most
every `kMeasureRefreshUs` (100 ms; 400 ms when a pass costs more than `kMeasureHeavyUs`), once
when the window settles and once per mode change -- and reading *that* backwards is what made
it worse first, 457 recomputes in one run, because a pinned window then recomputed every
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
first positions, the sum and the count, so average/min/max/peak-to-peak need no pass of their
own -- `top`/`bottom`/`amplitude` take a second read of that same **bucket** list and never of
the window (the flat-level invariant below). **Adding a type must not add a pass** -- that is
what `compute_measure()`'s `stats` argument is for.
**The window a value is computed over is the *pin* while the view is pinned, and the viewport
otherwise.** A pinned view -- a held event, a paused stream, an opened file -- is a *still
frame*: the data behind it does not change, so a pan or a zoom only looks closer. Measuring the
pin (`RowSet::pinned_range()`, the same accessor `hold_pinned_window()` clamps into, so "what
frames this view" has one expression) is what makes a gesture change **no value at all** --
and it is decided in `update_measures()`, at the call site, rather than inside
`MeasureWindowReader`, because the decision is also *booked*: the window
`MeasureWindowReader` is handed is the same one the "it moved" test compares against
`App::measure_window`, so a gesture inside the pin spends no recompute either. A reader that
chose its own window would leave a pinned view recomputing on every gesture frame for a number
that cannot change (457 passes in one run is the shape that mistake took).
This test has been wrong in both directions and the reasoning is worth keeping. Suppressing it
while held (`moved = ... && !held`) left the pane describing the whole 2 s event
(`for=[0.47..2.47]`) while a 50 ms slice of it was on screen; *removing* the exemption was the
opposite mistake, and it is the reported "when measuring a still frame (a triggered event, or a
paused stream) moving the zoom makes measurements such as average or top change, which should
never happen". The two are one question asked of two different windows: **which window is this
pane about** (the pin, while pinned) and **has it moved** (asked of that window in every mode).
Answers: a pinned view still recomputes when it should -- a fresh latch, a re-stated pin, a new
snapshot, the held/live change -- and a settled one satisfies `swin == measure_window` exactly
and so reads `moved == false` on its own.
`[measurepin-probe]` is the reading: on a latched event zoomed to a quarter of itself,
`same=4/4 for_same=1 pass_delta=0` with the values bit-identical and `for=` still at the pin;
the same on a pause taken from a held event. Live is the case that must **not** be swallowed,
and it reads `same=0/4 for_same=0` with a positive `pass_delta`, `span_before=2.000000`,
`span_after=0.125000` and `follows=1` -- the results window narrowed with the viewport and the
values moved with it. The
one path that is *not* this rule is the window control: `apply_time_window()` unpins
(`set_time_window()` clears `pinned_`), so for the frames before the held branch re-states the
pin the view really is live-framed and the pane measures the viewport -- `[measure-trace]`
prints `pin=off` for exactly those frames, which is what makes them legible rather than looking
like the defect again.
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
stroke -- it is created in the same loop, so it does; a new *instrument* should not.
The **marks** the pane draws (`set_measure_marks()` -- full-width `h_ruler`s and full-height
cursors, i.e. the "lines across the plot") are re-placed every frame from the result, so a
throttled pass is a *stepped* ruler: acceptable, because the ruler and the number beside it
come from the same pass and agree. What is *not* acceptable is a ruler that appears and
disappears, so an invalid pass falls back to the last valid result for its **level** marks and
only drops its **time** marks: a level is a property of the signal (a mean, a top, an
amplitude) and outlives the window it was measured in, while a time mark is a position *inside*
that window and may not. Both are emitted in **one pass after the numbers, never inside the
loop that computes them**: that loop has three `continue`s (an unresolvable signal, a channel
with no window) and each one was a way for the rulers to blink off and back. Fixing one and not
the others is how that bug survived a round of "fixes". `clamp fires: N` in the dump is the other half of this: `clamp_to_data()`
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
clamp.
- **A measurement must not read its window raw.** Both passes go through
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
pane inherits the summary of the new samples rather than computing one, which is what the
"evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded *how
often*; this bounds *how much*, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6
ms, reading **0.1 %** of a 2 M-sample window. Pass one (`measure_scan_buckets()`) adds whole
level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and
locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it
to a sample, two buckets at most whatever the window's size. Pass two
(`measure_accumulate_bucket()`) folds a bucket's **sums** only when it lies **strictly** on one
side of its level (`k.lo > level` or `k.hi < level`): then every sample falls in the same bin and
none is *at* the level, so the fold is exact and not an approximation. It asks **separately**
whether the bucket enters a side of the hysteresis band (see the invariant below), because a
single test against `level ± h` refuses exactly the buckets a crossing passes through. A bucket
wholly past a threshold holds that crossing at its first sample, exactly -- which is where the raw
walk would have found it. It refuses a bucket that straddles the level, one that enters a side
somewhere inside itself, and one holding a non-finite sample (the raw walk takes the first
*finite* sample of the new side and an aggregate cannot say which that is); the caller then opens
that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to
`measure_accumulate()`, which adds the same samples again, and sums added above the `return false`
double-counted every opened bucket. That read as `top=0.614304` on a unit sine against the 2/pi a
mean over a half then gave; a *bucketed* window no longer reports `top` from those sums at all
(see the flat-level invariant below), which makes the rule easier to break silently rather than
less binding -- the sums are the fallback a window with no histogram reads, so a wrong one is a
wrong answer waiting for that window. Four things this rests on, each of which breaks it silently:
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
exactly `2` on a unit sine, and it and `average` still come straight out of the one shared
`measure_stats()` pass. A bucket's sum is a sum. (`top` has no *exact* reading to protect any
more: it is a density estimate and not a mean, so the assertion that used to be
`top == 0.636620` on a sine now belongs to `above 50`, which *is* a mean over a side and reads
exactly 2/pi again -- see the band-average invariant below, and the flat-level one for what
`top` reads instead. Everything above is about the *bucket* path being a fast path and not an
approximation, and that is unchanged.)
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so
its space is the snapshot's own. `MeasureWindowReader::origin_` is what turns one into a
window offset, and a mismatch there measures the wrong samples rather than failing.
* **The raw path stays, and stays exact**, for the window's partial edge buckets, for the
samples produced since this frame's `acquire_all()` (one frame of production, so bounded), and
for the straddling buckets. A window shorter than a bucket degrades to O(window) and must
still be right.
* **Both passes walk the same fetched bucket list** (`App::measure_bucket_scratch`, reused
across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of
the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
- **A pass costs the window's buckets times the pane's *measured channels* and its *distinct
levels*, and the window's own size multiplies all of it.** The cadence above bounds *how
often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at
the user's scale -- a **15 s** window, `--buffer-samples 100M` (so `time_stride` stays 1 and the
window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with
`[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading
`recomputes`/`each`/`covered`/`buckets`/`raw`:
| pane | `each` | `raw` | tick mean |
|---|---|---|---|
| empty | -- | -- | 1.34 ms |
| 1 measurement, 1 level | 6.58 ms | 0.1 % | 1.99 ms |
| 4 measurements, 4 shares on **one** channel | 10.28 ms | 0.4 % | 2.38 ms |
| 16 measurements, 16 shares on one channel | 24.55 ms | 1.2 % | 3.86 ms |
| 4 measurements, 1 share each on **four** channels | 35.69 ms | 2.6 % | 4.58 ms |
So a pane is priced per measured **channel** (one `MeasureWindowReader`, one fetch, one scan)
and per **distinct share** (one accumulator), and the window's samples are the multiplier on
both: the same pane over a 2 M-sample window is the 1.2--1.6 ms a recompute this pane's
budget was fixed at, and a machine's ten noisy channels at a 15 s window is **tens of ms a
pass**, several times a second. That lands on the **tick** -- `update_measures()` runs inside
it -- so `frame cost:`'s tick mean is where a pane's cost shows (1.34 -> 4.58 ms above), and
it is *not* an ingest problem: `gaps=`/`packets=` stay exactly clean even though each
`MeasureWindowReader` holds `model->mutex()` for its whole life, because a socket buffer
absorbs a few tens of ms of producer. Read the tick mean and `each` before blaming the
producer.
Inside a pass the terms are, instrumented per window at that scale: the **fetch** 3.3 ms
(three out-of-line `MinMaxPyramid` accessors and a 48-byte `MeasureBucket` per bucket),
`measure_scan_buckets()` 0.8 ms, and **pass two** 7.5 ms -- dominated by the per-(bucket,
level) fold. That fold is why `measure_accumulate_bucket()` is `inline` in `measure.hpp`: it
is the innermost loop of a walk whose iteration count is the window's buckets *times* the
pane's levels, and as an out-of-line call in its own translation unit it cost **30 % of the
pass** (sixteen shares on one channel: `24.55 -> 17.25 ms` a recompute). A caller adding a
per-bucket type keeps it in the header, and a caller adding a bulk one may not let it grow a
call per bucket either.
The third term is `raw`, and it is the **signal's**: a level that falls inside a bucket's
`[lo, hi]` has to be walked sample by sample (the fold cannot split a bucket), so a signal
whose local range covers the level everywhere -- white noise, a noisy machine channel --
degrades the pass to O(window) *per level*: measured `raw=4.6 %` on the mock's `noise` channel
against `0.1 %` on its `sine` at the same window. It is the field to read before blaming the
pane for a slow run, and no cheaper walk fixes it.
- **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets',
not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30
harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or
a mean over a side of a level, reports the ringing rather than the level the signal dwells at:
that is what made this change (`amplitude` read `1.179350` and `top` `0.986496` on the rippled
square, against `1.179350` of overshoot where the plateau is `1`). A flat level is a *density*
question: the plateau holds most of the samples and the ringing a few percent. So
`measure_scan_buckets()` folds a 64-bin histogram of the whole buckets' *midpoints* while it has
the list in hand -- one observation per bucket, each bin carrying its buckets' **exact** sum and
finite count -- and `measure_flat_levels()` takes, either side of the split, the densest bin that
holds at least half the densest one's buckets, **the outermost of those**, and the exact mean of
the samples of the buckets within **5 % of the window's peak-to-peak** of it. 5 % is *under* the
~9 %-of-swing Gibbs overshoot, so the ringing's excursion falls outside the band while a
plateau's own ripple stays inside it; the outermost-of-the-equally-dense rule is what makes a
waveform with *no* plateau -- a sine, a ramp -- read its peak instead of an arbitrary mid-range
bin that a shifted window would move. `Amplitude` is then `top - bottom`, a bench scope's
`Vamplitude`, and **not** the half-swing it used to be, nor the peak-to-peak swing.
The readings, fast mock, 2 s window, `[measure-trace]`, harmonics `0` → `30`: `top` `1.000000` →
`0.999983`, `bottom` `-1.000000` → `-0.999983`, `amplitude` `2.000000` → `1.999966`, against
`peak to peak` `2.000000` → `2.358701` -- the extremes keep the overshoot, and that separation
*is* the measurement. On the probe's 1 Hz ±1 sine: `top` `0.957988` (it was `0.636620`, the
half-wave mean), `amplitude` `1.915983`, and `peak to peak` still **exactly `2`**; `[stats-probe]`
reads `top` `mean=0.957905 sd=0.0014 lo=0.951967 hi=0.962348` over a scrolling window, which is
the stability the value band buys -- the answer does not follow the window's alignment.
Five things this rests on, each of which breaks something silently:
* **Nothing may be read raw for it.** The histogram is the *same* fetched bucket list, walked a
second time inside `measure_scan_buckets()`; a histogram of the window's *samples* would be a
second O(window) walk, which is exactly what the pyramid path exists to avoid. `measure cost:`
is the guard and its `raw` does not move: the probe's pane reads
`covered=59684972 buckets=932446 raw=47640 (0.1%)` against the baseline's
`covered=61183221 buckets=955856 raw=47402 (0.1%)` -- the same `raw`, 0.44 → 0.50 ms a pass
(and `--noise 0.1`, `2.1%` both sides, 0.52 → 0.65 ms).
* **It is gated, and the gate is what keeps an unrelated pane free.** `measure_scan_buckets()`'s
`flat_hist` comes from `App::MeasureWindow::flat_wanted`, set by the first loop over the pane
and only for one of the three types. A pane of means and extremes pays nothing.
* **The bin range is the *buckets'*, not the window's.** The window's partial edge buckets and the
samples produced since `acquire_all()` are read raw and are not in the list, so
`measure_scan_buckets()` tracks its own `blo`/`bhi` over the **non-empty** buckets only -- an
all-non-finite bucket is an empty aggregate whose `lo`/`hi` say nothing, and taking them would
put a bucket at some arbitrary value. A degenerate range (`bhi == blo`) leaves `folded` at 0,
which is the caller's "no histogram at all".
* **The *window's* peak-to-peak is what the band is a share of**, and the *split* is sized exactly
as `measure_set_level()` sizes a level, so "the top" and "the level a period is counted about"
are placed by one rule. The band is a *value* band, which is why the reading does not move when
the window does.
* **The fallback is the split, and it is what a filtered or sub-bucket window reads.** `hf_fc > 0`
disables the pyramid (above) and a window shorter than a bucket has no bucket to fold, so
`top`/`bottom` fall back to the mean of each side of the split -- and `amplitude` to the
window's own peak to peak, which is the same number on a waveform with nothing to exclude.
There is no flat level in that mode to subtract, so `amplitude == top - bottom` is *not* an
invariant to lean on there. Measured on the rippled `square` at `hf_fc=100`:
`top=0.973824 bottom=-0.991731 amplitude=2.354173 peak to peak=2.354173`, `buckets=0
raw=200.0%` -- two raw walks and no density estimate, which is the honest reading of a window
the pyramid cannot summarize.
- **`Above`/`Below`/`Between` are the *means over a side of a share*, and they are the readings
`Top`/`Bottom` gave before a flat level replaced them.** That change kept the split's two means as
its *internal fallback* for a window the pyramid cannot summarize (above); these three types are
that fallback, exposed and named, which is what lets the two different questions about one split be
asked side by side -- on a unit sine `above 50` reads `0.636620` (2/pi) where `top` reads
`0.957988`, and on the mock's band-limited square `above 50` reads `0.986496` where the plateau is
`1.000000` and `top` `0.999983`: the edge's ring is *inside* this mean and not inside that level.
Five rules, and the first two are what a reader is most likely to assume wrongly:
* **A band is not a wider split, and needs no walk of its own.** `Above`/`Below` are
`MeasureLevel::sum[1]`/`count[1]` and `sum[0]`/`count[0]` at the measurement's own share.
`Between` is the **difference of two levels' upper sides** -- `sum[1](lo) - sum[1](hi)` over
`count[1](lo) - count[1](hi)`, i.e. the samples above the lower edge and *not* above the upper
one -- and no single level's two sums can bound a band, because a band's two edges are two
thresholds. Both accumulators come out of one pass two over one window (the pane registers one
level per edge, `w.pct`, and both are deduplicated into it), so the difference is exact rather
than approximate. `v == level` is the below side, as everywhere, so the band is `(lo, hi]`.
* **It rides the bucket fast path unchanged.** A bucket wholly inside the band is strictly above
the lower level *and* strictly below the upper one, so both accumulators fold it whole; only the
buckets that straddle either edge are opened, exactly as for one level. `measure cost:` is the
guard: adding all three types to a pane of every type left `raw` at `0.1%` of `covered`
(`113966` of `240719031` with the ten types, `134978` of `255372033` with the thirteen), and a
band is *two entries of the level list*, not a second pass.
* **`measure_level_pcts()` is the only place the shares are read**, and it clamps both to `[0,100]`
and puts `Between`'s pair **in order**: "between 80 % and 20 %" is the band between 20 % and
80 %. Normalized rather than refused because the shares name two *edges*, and the band does not
depend on which the user typed first; the pane's dedup key and both accumulators are keyed on the
ordered pair, so one band is one walk however it was written.
* **Nothing to measure reads `--`**, exactly as for every other type: a side holding no sample (a
share at `100 %`, whose level is the window's own maximum) and an *interior* holding none are
both `invalid`/`--` and never `0`. The ideal `--square-harmonics 0` square is where the second
shows -- every sample is exactly +/-1, so the band `(0, 0.5]` holds nothing -- against the same
band on the band-limited square, which holds the edges' transitions (`between 50 75` =
`0.302744`, `between 25 75` = `0.000000`: the symmetric band's two halves cancel, the transition
being odd about its own midpoint).
* **The reading is a mean over the window's *samples*, so its sixth decimal follows the window's
boundary.** A live window is closed at both ends, so its oldest and newest sample share a phase
and the mean carries one sample that is not in an open interval -- about one unit in the sixth
decimal out of 2.6 M samples. Measured over one fast-mock run, `[measure-trace]`: `above 50` on
the sine printed `0.636620` on 48 lines and `0.636621` on 2, `below 50` `-0.636620` on 37 and
`-0.636619` on 18, `above 50` on the square `0.986496` on 32 and `0.986495` on 18, and
`between 50 75` on the ramp `0.250000`/`0.250001`/`0.249999`. The old `Top`/`Bottom` spread the
same way -- it is a property of a mean over a finite window, not of these types, and a reading
that has to be exact to six figures is read off `[bandavg-probe]`'s nine.
Measured on the fast mock, 2 s window, the mock's default 30-harmonic square, `[bandavg-probe]`
(nine significant digits, then what the pane prints):
`above 50` on the sine `0.636620262` -> `636.62 mV`; `below 50` on the sine `-0.636619283`;
`between 50 75` on the sine `0.255873597` -- the analytic `3(2-sqrt 3)/pi = 0.255873`, which is
neither the window's mean (`0`) nor the band's edge (`0.5`); `above 50` on the square
`0.986494746` / `0.986495505` (one or two samples at the window's end); `between 50 75` on the
`ramp` `0.250000385` and `above 50` `0.500000` -- a uniform density makes every band's mean that
band's own centre, so a quarter and a half of the range are the shape's prediction and not a
coincidence; `above 100` on the `ramp` `--`; and the ideal square's `between 50 75` `--`.
Editing the sine's band through the dialog to `50/90` takes it to `0.431361697`
(`0.8 / (2 asin 0.8)`), which is the check that the second share is the one the engine reads.
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
sums still split at the bare level, which is the *fallback* `Top`/`Bottom` read on a window with no
bucket histogram -- a band that moved that split would change what a filtered or sub-bucket window
reports, for no gain to a crossing count.
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
the last a different filter state.
- **A non-zero `hf_fc` disables *both* bucket fast paths**, with the single
`if (!filtered && reader.bucket_range(...))`. An empty `buckets` makes `bulk_from == bulk_to == n` *and*
leaves `lo_search` / `hi_search` at 0, so `pin()` becomes a no-op -- and that is the right answer, because a
bucket is an aggregate of unfiltered samples and cannot be mixed into a filtered walk in either direction.
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
nothing. Without it `period`, `frequency` and `pulse duration` are unusable on anything real --
the mid level is exactly where a waveform moves *fastest*, so an interferer faster than the
fundamental recrosses a single level several times per true crossing. Measured on a 5 Hz sine with
a 500 Hz tone at a tenth of the amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true
crossings counted as **79**, `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz, against
`period=0.200188` with the band. Four rules, each of which was wrong first:
* **`measure_set_level()` is the only place the band is sized**, and `src/ui.cpp` calls it rather
than assigning `.level` itself: two expressions for the width is how the raw walk and the bucket
path end up disagreeing about where a crossing was. `h = min(0.10 * (hi - lo), 0.5 * min(hi -
level, level - lo))`. The 0.10 is measured -- 5 % reads a 0.1 interferer and fails at 0.2
(`period=0.075`), 10 % holds to 0.3 and to white noise at sigma=0.05.
* **The clamp is load-bearing.** `PulseDuration` lets the user put the split near an extreme, and
a fixed tenth of the span there puts the upper threshold *above every sample*: the walk never
decides that side and the measurement reads nothing at all, which is worse than reading
something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band,
`enters=4 period=0.200000` with the clamp.
* **The `sum`/`count` split stays at the bare `level`.** Four things read it and none may be
moved by a band: a crossing's *side* is decided by the sample's own value against the level
(`v == level` is the below side, as it always was), `Top`/`Bottom` fall back to the mean of
each side on a window with no bucket histogram, and `Above`/`Below`/`Between` read it on
*every* window -- their whole definition is that split, so a band folded into it would move a
documented exact reading (2/pi on a sine) for no gain to any crossing count. The band is about
*deciding a crossing*, not about where a sample lives.
* **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the
band is a parameter of the engine rather than a mode of it.
The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at
1.41 ms a recompute and with the 0.1 interferer `raw=2.0%` at 1.96 ms. A
`/tmp/check_measure.cpp`-style check is how the walk itself was verified (`g++ -std=c++20 -O2 -I
src ... src/measure.cpp`): over a clean sine, sines with 0.1 and 0.3 interferers, 10 %/50 % duty
squares and a 90 % threshold, the **bucket walk must equal the raw walk exactly** -- same
`enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion
that the fast path is a fast path and not an approximation. The app's reading is
`[measures-probe]` against `--noise 0.1`.
- **A statistic's observation is one *recompute*, not one sample.** The pane already refreshes on
a cadence (at most one pass per 100 ms while the window scrolls, exactly once when it
settles), and the accumulator is fed from that pass's result — so `n` counts windows measured.
Feeding it per sample would make `n` a sample count and the mean a mean of *samples*, which is
not what any of these measurements measure: a frequency over a window is not a property of a
sample. The reading that names this defect is `[stats-probe]`'s `n` in the thousands rather
than the tens.
- **The accumulator is Welford's, and the reason is the same one the time ring is `f64` for.** A
measurement's values are routinely large with a small spread — an absolute stream time is the
obvious case — and `Σx² - (Σx)²/n` cancels to noise there. The standalone check is that the
spread of `{2,4,4,4,5,5,7,9}` survives a `+1e9` offset unchanged.
- **What resets the statistics is "what is being measured changed", not "what is in view
changed".** An explicit Reset, an add/edit/delete (all three funnel through
`rebuild_measures_pane()`), a CONFIG, and a change of display window each change the
*question*, so the history is cleared. A pause, a resume, a trigger latch, a pan and a zoom do
not: on a **live** view they change which samples answer it, which is precisely what averaging
over many windows is for -- and on a **pinned** one they change *nothing*, not even a value
(the window invariant above), so there is nothing for the history to see either way. The rule
is about the *accumulator* only; it is not a claim that a gesture leaves a value alone, which is
a property of the window a value is computed over and is the other half of this change. The
pane-wide toggle does not reset either — it only shows and hides labels that are always created
and always fed, so reading the plot for a moment does not cost the history.