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

+75 -13
View File
@@ -253,9 +253,10 @@ Two lines of the self-check output are assertions in disguise:
seconds, and the rest the channel's unit. `switch_on` and `reset_sensitive` are the panel's *frame* read back: the switch widget against
`measure_stats_on`, which the code drives, and the Reset button that follows it. Either disagreeing with
`[stats-probe] on=` is a `.ui` that lost the widget, or lost the wiring to it; `pane_rows=R` is what
proves the readout rows landed in the frame's `measures_list` rather than nowhere. `marks_shown` counts two per shown measurement
(a min/max showing both its marks counts two of its own). `measures window:` is the
timeliness assertion -- the window the numbers were computed over against the one on screen.
`marks_shown` counts two per shown measurement
(a min/max showing both marks counts two of its own). `measures window:` is the
timeliness assertion -- the window the numbers were computed over against the one that *should*
be on screen.
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
@@ -263,15 +264,22 @@ Two lines of the self-check output are assertions in disguise:
(`App::measure_view_window`) and is deliberately not read back here: the dump runs from the
destroy handler, by which point the exit probe has switched the trigger on, and
`stream_at_x0()` then answers for a mode no frame was ever drawn in -- which read as a 45000 %
drift on a run whose every frame was exact. And the count is against `win`, not against
`measure_window`, which contains every mark by construction: read that way it was a tautology
drift on a run whose every frame was exact. `pin` is the same tick's pin (`App::measure_pin_window`,
`off` when nothing was pinned) and it is the **target** while the view is pinned: a measurement
on a still frame describes the frame, so `drift` there is `results_for` against the *pin*, and a
zoom that leaves the screen showing a slice of that frame is not drift. Read against `win` -- the
old rule -- a zoomed held event would report the intended behaviour as the defect it was once
reported as. The count is still against `win`, the window **on screen**, not against
`measure_window`/`pin`, which contain every mark by construction: read that way it was a tautology
that printed `2/2` while the pane described a window a forty-fold zoom away from the one
displayed. On a window **shorter than the refresh interval** the count rides the same sawtooth
displayed. A *time* mark may legitimately sit outside `win` on a pinned view that has been zoomed
into the pin (the mark is a position in the window the result describes, and that window is the
frame), so the strict `K/K` reading belongs to a live view and to a pinned view left at its pin.
On a window **shorter than the refresh interval** the count rides the same sawtooth
`drift` does -- the 50 ms window the zoom sweep leaves behind can lag far enough that *every*
time mark is off screen, so `0/K` there is read beside `drift` and not alone. The strict
reading belongs to the documented 2 s run, and to a **held** window in any run: a latched
event does not scroll, so `[measure-trace]` must show `for=` equal to `swin=` exactly for as
long as the mode stays `held`, zoom and pan included.
time mark is off screen, so `0/K` there is read beside `drift` and not alone. `0/0` is a pane
whose types carry no time marks at all (`average`, `top`, `amplitude` and `peak to peak` draw
rulers only), and is not a reading about marks.
`drift` itself is a **single sample of a sawtooth** and is not an assertion on its
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
@@ -284,9 +292,11 @@ Two lines of the self-check output are assertions in disguise:
is a dump that landed just after a pass, not a better state. Read it against the recompute count:
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one
survive a frame. **`marks_selectable` must be 0**: the
recomputed when the window **it describes** moves (2 % of its span) or the held/live state
changes, never on a wall-clock timer: a timer is what let a marker sit outside the viewport and a
dragged one survive a frame, and it is the window it describes -- the viewport live, the pin while
pinned -- so that a gesture on a still frame spends no recompute at all. **`marks_selectable`
must be 0**: the
measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the
library gained for this), read back out of the plot here -- re-placing a marker every frame
does **not** stop a drag, because the controller holds it for the gesture's life and the
@@ -1152,6 +1162,58 @@ Four line kinds, and the reading is the third:
most and `0.986495` on the rest. **A line's `--` on a *lagging* trace is the trace's, not the
reading's** — the pane is refreshed on a cadence, so quote an earlier line beside its own `view=`.
## `[measurepin-probe]`
`UDPSCOPE_PROBE_MEASUREPIN_MS`. The reading for **what window a measurement describes**: on a still
frame the pane is about the *frame*, so a pan or a zoom changes no number, and on a live view the
numbers still follow the window. It installs `measure_probe`'s own four types on the first *channel*
(`average`, `top`, `peak to peak`, `amplitude`), arms the trigger in **single** mode, and runs three
legs — each one reading the pane before and after a **gesture** written on the row's own axis, which
is what a drag, a wheel or a box zoom writes. Nothing here takes the window-control path: that one
*calls off* the pin (`set_time_window()` clears `pinned_`), so measuring it would test a different
question.
Run it against the fast mock, and mind the datagram limit: `--batch 2000` is 80 kB of payload for the
mock's five signals and dies with `OSError: [Errno 90] Message too long`, so the command here is
`--rate 1300000 --batch 1500 --freq 1`. Its sequence is about 12 s: it waits for a latch, then
~1.5 s per leg, so `UDPSCOPE_EXIT_AFTER_MS=20000` covers it with room.
```
[measurepin-probe] 4 measurements on 'sine', trigger single
[measurepin-probe] wait mode=waiting pinned=0 at_pin=0 for=[-1.419616..0.580384] pin=[0.000000..1.000000] passes=14
[measurepin-probe] wait mode=capturing pinned=1 at_pin=0 for=[-1.419616..0.580384] pin=[0.000000..1.000000] passes=14
[measurepin-probe] wait mode=capturing pinned=1 at_pin=1 for=[0.881155..2.881155] pin=[0.881155..2.881155] passes=15
[measurepin-probe] wait mode=held pinned=1 at_pin=1 for=[0.881155..2.881155] pin=[0.881155..2.881155] passes=15
[measurepin-probe] held/before pinned=1 mode=held view=[-1.000000..1.000000] pin=[-1.000000..1.000000] for=[0.881155..2.881155] passes=16 ev=2600001 evx=[-1.000000..1.000000] average=0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915950
[measurepin-probe] held/after pinned=1 mode=held view=[-0.250000..0.250000] pin=[-1.000000..1.000000] for=[0.881155..2.881155] passes=16 ev=2600001 evx=[-1.000000..1.000000] average=0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915950 same=4/4 for_same=1 pass_delta=0 span_before=2.000000 span_after=2.000000 follows=0
[measurepin-probe] paused/before pinned=1 mode=paused view=[-0.250000..0.250000] pin=[-0.250000..0.250000] for=[1.631155..2.131155] passes=17 ev=650001 evx=[-0.250000..0.250000] average=0.000003 top=0.957944 peak to peak=2.000000 amplitude=1.915906
[measurepin-probe] paused/after pinned=1 mode=paused view=[-0.062500..0.062500] pin=[-0.250000..0.250000] for=[1.631155..2.131155] passes=17 ev=650001 evx=[-0.250000..0.250000] average=0.000003 top=0.957944 peak to peak=2.000000 amplitude=1.915906 same=4/4 for_same=1 pass_delta=0 span_before=0.500000 span_after=0.500000 follows=0
[measurepin-probe] live/before pinned=0 mode=live view=[-2.000000..0.000000] pin=[off] for=[1.666922..3.666922] passes=22 ev=0 evx=[0.000000..0.000000] average=-0.000000 top=0.957975 peak to peak=2.000000 amplitude=1.915948
[measurepin-probe] live/after pinned=0 mode=live view=[-1.062500..-0.937500] pin=[off] for=[2.746345..2.871345] passes=24 ev=0 evx=[0.000000..0.000000] average=-0.427665 top=-0.083224 peak to peak=0.687725 amplitude=0.644726 same=0/4 for_same=0 pass_delta=2 span_before=2.000000 span_after=0.125000 follows=1
```
- `view`/`pin`/`for`/`passes`/`ev`/`evx` are the state: the axis, the pin's own extent, the window
the printed values were computed over, the pane's pass count, and the **bound event series'** own
sample count and extent — the frame the numbers are about. `ev=0` while a view is pinned is an
*empty* frozen frame (every value `--`), which is the reading that named the pause ordering defect
(`on_pause_toggled()` read its display offset after setting the pause state).
- `same=K/4` — the values that are **bit-identical** before and after the gesture. On a pinned view
it must be `4/4` with `for_same=1` and `pass_delta=0`: the frame's samples did not change, so
neither may a number, and the pane may not even spend a recompute on the gesture. `for_same` is
the app's own 2 %-of-span criterion (the rule that decides a recompute is not owed), not equality:
the pin is re-derived from the snapshot's stamps every tick and the pane keeps the window its last
pass used, so the two differ in the last digits without anything being owed.
- `follows` — the live leg's assertion, and the case the still-frame rule must *not* swallow:
`for_same=0`, a positive `pass_delta` and the results window's span narrowed from the axis'
2.000000 to 0.125000, with `average`, `top` and `peak to peak` all moving. `follows=1` with `same=4/4` on a
pinned leg, or `follows=1` while pinned at all, is the defect in one direction or the other.
- The `before`/`after` pair is taken only once the pinned frame has **settled** (a `wait` line per
*change* of the state being waited on until then, then nothing): the frames that bind a snapshot or freeze a pause re-state the
pin from the request and then from the snapshot's own stamps, and a gesture made in one of those
frames is reverted by that re-statement — the `[zoom-probe]`'s own gesture sweep loses its first
two steps to it, which is visible in its `ges/x1`, `ges/x2` lines. A `gave up waiting after N
polls` line means no latch (or no pin) was ever reached, and is a reading about the stream.
## `[search-probe]`
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is