fix: a still frame's measurements are the frame's, not the viewport's

A measurement is a statistic of the window the pane is *about*, and on a
pinned view -- a held event, a paused stream, an opened file -- that is the
frame, not the part of it on screen. The pane followed the viewport
instead, so a wheel on a latched event moved `average` and `top`: the
reported "when measuring a still frame, moving the zoom makes measurements
such as average or top change". Nothing about the data had changed; the
user was only looking closer.

The window is `RowSet::pinned_range()` while the pin frames the view and
the viewport otherwise, and the choice is made in `update_measures()` --
the call site -- rather than inside `MeasureWindowReader`, because it is
*booked* as well as read: that window is what the "it moved" test compares
against `App::measure_window`, so a gesture inside the pin spends no
recompute either, and a reader that chose its own would leave a pinned
view recomputing every gesture frame for a number that cannot change.
`pinned_range()` is now the one expression of the pin's extent, shared
with the framing (`hold_pinned_window()`, `fit_x()`), and the reader is
handed the window instead of reading the plot. Live is unchanged: there
the window really moves and the statistic follows it, and the
accumulator's rule about what resets the history is about neither.

`UDPSCOPE_PROBE_MEASUREPIN_MS` is the guard, three legs of before/after a
gesture on the row's own axis. On a latched event zoomed to a quarter
(`view=[-1..1]` -> `[-0.25..0.25]`, `pin` unchanged): `same=4/4
for_same=1 pass_delta=0`, every value bit-identical -- `average=0.000000
top=0.957975 peak to peak=2.000000 amplitude=1.915950` both sides. The
same on a pause taken from it. Since the key `for=` follows `pin=` now,
`[measure-trace]` gained the field, so a held run reads `for==pin` with
`swin` free to sit inside it. On a live view a zoom to an eighth reads
`same=0/4 for_same=0 pass_delta=3 span_after=0.125000 follows=1` with the
values moving -- the case the rule must not swallow.

Before the fix the same probe reads `same=1/4 for_same=0 pass_delta=1
span_after=0.500000` on the held event (`top` `0.957975` -> `0.957966`)
and `same=0/4 span_after=0.125000` on the pause (`top` `0.957944` ->
`0.358614`). Live reads the same either way.

Also fixed, found by the pause leg: pausing while an event is *held*
froze an **empty** picture. `on_pause_toggled()` read `stream_at_x0()`
after setting the pause state, and that accessor follows the display --
`paused` with no pause view published yet answers `-display_epoch()`,
which on a held event is 0, so the cut searched the ring at edge-relative
x, found nothing, and the frame bound an empty snapshot: every
measurement `--`, every ruler gone (`ev=650001` on the probe's line now,
`ev=0` before). The offset and the window are read first, which is the
offset the axis is actually displaying. The live case is unaffected, which
is why the pause probe, which pauses a live view, never saw it.

Docs corrected where they asserted the old half-truth: the invariants now
separate the accumulator's rule (unchanged) from the window a value is
computed over (this change), the trigger/pinning file names the pin as
load-bearing for both the framing and the numbers and records the pause
ordering rule, `verification.md`/`probe-readings.md` describe the new
probe and `pin=`, and the README and the manual say what a still frame
measures.

Checked: `cmake --build build --clean-first -j4` 0 errors and the
documented three warnings; `make -C build test-ui` 13 ok; `make -C build
test-help` green; an exit-dump run 0 Gtk-WARNING/Gtk-CRITICAL and `gtk
warnings: 0`; `[measurepin-probe]`, `[pan-probe]` (`held=1`,
`allowed=1`, `limited=1`), `[pause-probe]` (`event_size` non-zero exactly
while paused, `axis` inside `frozen_x`, `skips` 0 live / 30 paused),
`[stats-probe]` (`dn=1` across the pause), `[zoom-probe]` (`gap_px=0.0`
at every step) and `[measure-probe]` + the dump's `measures
window:` (all-types run `marks_in_window=4/4`, `pin=off` live) on the fast
mock. `measure cost:` is unchanged in kind: `raw=37298 (0.0%)` of
`covered=121704537`, 1.04 ms a pass -- the runs that read higher are the
probe's own zoomed-to-a-slice windows, where the window is legitimately
shorter than a bucket.
This commit is contained in:
Martino Ferrari committed 2026-10-16 06:10:24 +02:00
1 parent d91957a2fe
commit 0a209becb1
13 files changed
+719 -114

No files matched your search

+40 -13
View File
@@ -377,23 +377,37 @@ range") only for `between`, which is the one type whose two edges are both param
words on the split row name its side ("above % of the range" for `above`) — and the "show on plot"
line says what would be drawn.
The values are **window statistics over exactly the visible range, at the stored rate**: the
same cut the export makes, so a measurement describes the data on screen rather than the ring
behind it — and the marks it draws belong to that window, so they stay in the viewport.
The values are **window statistics over the window the pane is about, at the stored rate**: the
same cut the export makes, so a measurement describes data rather than the ring behind it — and
the marks it draws belong to that window.
They are recomputed when the **window moves**, not on a timer: a timer decouples the two, and
**Which** window, when the view is pinned, is the frame and not the viewport. A held event or a
paused stream is a *still frame*: the data behind it cannot change, so a pan or a zoom there is
only looking closer, and the pane goes on describing the **pin's own extent**
(`RowSet::pinned_range()` — the same statement the framing itself is clamped into, so "what this
view is a view of" has one expression). Zoom into a latched event and the numbers do not move:
that is what a still picture means, and it is the reported *"when measuring a still frame, moving
the zoom makes measurements such as average or top change"*. A live view is the other case and is
unchanged: there the window genuinely moves and the statistic follows it.
They are recomputed when that **window moves**, not on a timer: a timer decouples the two, and
with a scrolling window the stored marks then describe a range the plot has already left (the
min/max marker ends up outside the viewport, and a dragged marker survives until the next
tick). The trigger is 2 % of the span, so the numbers track the window within a few pixels
while the cost stays bounded by the window's motion rather than by the frame rate; one pass
copies the visible samples of each *channel* once, however many measurements read it.
"The window moved" is asked in **every** mode, including a held trigger event — which is the
one mode where a zoom or a pan is the only thing that can move it. Held views were once
exempted, on the grounds that a pinned window must not recompute every frame, and the result
was a pane that kept describing the whole captured event while you zoomed into a slice of it.
A pinned window needs no exemption: it lands on exactly the range it already measured and
fails the 2 % test by itself.
"The window moved" is asked in **every** mode, but it is asked about the window the pane
*describes*. Suppressing it while a view is held left the pane describing the whole captured
event while you zoomed into a slice of it; dropping the exemption made the opposite mistake — the
numbers followed the zoom, so a still frame's `average` moved whenever the wheel did. On a pinned
view neither can happen: the pin is what the pane is about, so a gesture moves no number and
spends no recompute, while a fresh latch, a re-stated pin or a new snapshot still recomputes it.
The one path that is *not* the pin is the window control, which unpins as it re-frames
(`apply_time_window()`), so for that frame the view really is live-framed and measuring the
viewport is right. `UDPSCOPE_PROBE_MEASUREPIN_MS` is the guard: a gesture on a held event and on a
pause must leave every value bit-identical (`same=4/4`, `pass_delta=0`), and on a live view the
results must still narrow with the axis (`follows=1`).
**Show on plot** marks the result on the row that plots the signal: a ruler at the level
(`top`/`bottom`), both rulers at the extremes (`min`/`max`), the pulse's or the cycle's two
@@ -534,9 +548,11 @@ loses that spread entirely.
**Reset** clears every accumulator. So does editing, adding or deleting a measurement, a CONFIG
that renames the signals, and a change of the display window — each of those changes *what is
being measured*, and a mean across the change describes nothing. A pause, a resume, a trigger
latch, a pan and a zoom do **not** reset: they change which samples are in view, which is
exactly what a statistic over many windows is meant to average. The toggle itself does not
reset either, so turning the line off to read the plot and back on again keeps the history.
latch, a pan and a zoom do **not** reset: on a live view they change which samples are in view,
which is exactly what a statistic over many windows is meant to average — and on a pinned one they
change nothing at all, values included (above), so the history has nothing to see either way. The
toggle itself does not reset either, so turning the line off to read the plot and back on again
keeps the history.
## Resequencing and relative timing
@@ -1556,6 +1572,17 @@ event pin already worked this way, and the pause agrees with it. `[pause-probe]`
beside `frozen_x=`, and while paused the axis must lie *inside* `frozen_x` — equal at the pause, a
subset after a zoom-in.
**The pause reads its display offset *before* it sets the pause state**, and pausing while an event
is held is the case that depends on the order. `stream_at_x0()` follows the *display*: the moment
`paused` is true with no pause view published yet it answers `-display_epoch()`, which on a held
event is `0` — so the cut searched the ring at the event's edge-relative x, found no samples, and
froze an **empty** picture: no trace, every measurement `--`, every ruler gone. Read first, the
offset is the one the axis is actually displaying, which is what hands the snapshot stamps back on
the coordinates the axis is already showing. The live case is unaffected (its epoch does not depend
on `paused()`), which is why the pause probe, which pauses a live view, never caught it;
`[measurepin-probe]`'s `paused/before` leg pauses a *held* event and prints the bound frame's own
sample count (`ev=650001` for a quarter-of-the-event zoom), which is `ev=0` when this regresses.
**Zooming into a held event clipped it further and further from the left**, and the cause was the
one the library's own margin fix is about, in the half of the code the fix could not reach.
`EventSeries::neighbours()` — the call the renderer maps the visible range onto sample indices with