measure: the split's own means are back as above/below/between

`top`, `bottom` and `amplitude` became the *flat levels* in e83ad86 -- the level the
signal dwells at, read from the density of the window's buckets -- and that change kept
the split's own two means as its internal fallback for a window the pyramid cannot
summarize. Those means are now three types of their own, so the two different questions
about one split can be asked side by side: `above %` and `below %` are the plain mean of
the samples on one side of a share -- the reading the old `top`/`bottom` gave -- and
`between % %` is the mean of the band between two shares, which no single split can
answer, because a band's two edges are two thresholds.

Nothing about the arithmetic is new, and nothing is a new pass. `Above`/`Below` are the
`sum[]`/`count[]` split `measure_accumulate()` already folds -- the very figures
`Top`/`Bottom` fall back to -- and `Between` is the **difference of two levels' upper
sides**, both of which come out of one pass two over one window. 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, and only the
buckets that straddle either edge are opened. `measure_level_pcts()` is the one place the
shares are read, and it clamps both and puts `between`'s pair **in order**, so "between
80 % and 20 %" is the band between 20 % and 80 % -- normalized rather than refused,
because the shares name two edges rather than a direction. Nothing to measure reads `--`,
as for every other type: a share at 100 % has no sample above it, and a band can hold
none at all.

Measured, `[bandavg-probe]`, fast mock at a 2 s window (`--rate 1300000 --batch 1000
--freq 1`):

  sine   `above 50` 0.636620 (2/pi), `below 50` -0.636620, `between 50 75` 0.255873
  square `above 50` 0.986496 (30 harmonics) / 1.000000 (--square-harmonics 0),
         `between 50 75` 0.302744 / --
  ramp   `between 50 75` 0.250000 and `above 50` 0.500000, `above 100` --

The first three are the figures the old `top`/`bottom` documented; the ramp's are what a
uniform density predicts (a band's mean is its own centre); the ideal square's empty band
is the nothing-to-measure rule. The pane offers the types and edits their percentages
through the dialog's second share -- which only `between` shows -- and a measurement's
pair survives add/edit/delete and the session file (`threshold2`), where a file written
before it existed loads at the default.

No raw pass: with the measurements probe adding one of every type at a 2 s window, `raw`
went from 113966 of 240719031 covered (0.0%) to 134978 of 255372033 (0.1%), 0.8 -> 1.1
ns a covered sample.

Docs: the band-average invariant (and the two assertions the flat-level change moved),
the probe readings for the new probe, the verification recipe, the manual's measurements
page and the README.
This commit is contained in:
Martino Ferrari committed 2026-10-15 07:56:37 +02:00
1 parent e83ad86b8f
commit 22e7a8efcc
14 files changed
+769 -98

No files matched your search

+65 -9
View File
@@ -1,6 +1,6 @@
# Invariants: the measurements pane
Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted.
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.
@@ -66,9 +66,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* **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, and the two places that used to assert
`top == 0.636620` on a sine now assert what the flat level actually reads -- see the flat-level
invariant below. Everything above is about the *bucket* path being a fast path and not an
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
@@ -139,6 +140,60 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`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
@@ -170,12 +225,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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`.** Two things read it and neither may be
* **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), and `Top`/`Bottom` fall back to the mean of
each side on a window with no bucket histogram. The band is about *deciding a crossing*, not
about where a sample lives, so folding it in would change a filtered window's `top` for no gain
to any crossing count.
(`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