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

+39 -14
View File
@@ -20,17 +20,38 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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 "it moved" test is asked in every mode, a held event included.** Suppressing it while
held (`moved = ... && !held`) is the shape the pinned-window fix first took, and it is wrong
for the one mode where a *gesture* is the only thing that can move the window: a latched event
zoomed forty times over left the pane describing the whole 2 s event with `passes` frozen,
which is the reported "measurements move when zooming or panning on data" -- the numbers stand
still while the data under them moves. Nothing is needed for the pinned case anyway, because a
window that is merely re-pinned satisfies `swin == measure_window` exactly and fails the move
test on its own; a pause is the standing proof, since `display_mode()` reports `Paused` rather
than `Held` for one and the statistics probe already requires `n` to advance by at most one
across a two-second pause. The held/live state is still a recompute of its own, so a mode
change is not missed.
**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
@@ -268,6 +289,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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: they change which samples answer it, which is precisely what averaging over many windows
is for. 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.
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.
+20 -1
View File
@@ -81,13 +81,20 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
trigger is not supposed to see.
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
**A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
While an event is held, a capture is filling, or the view is paused, `tick()` calls
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
clamps the position into it (and states `Off`, since a following group would re-derive from a
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
in single trigger mode: an acquired event could not be examined at all.
**What a gesture inside the pin may *not* move is a measurement**, and it is not this function
that holds that: `pinned_range()` is the one expression of the pin's extent, and
`update_measures()` measures *it* rather than the viewport while the view is pinned, so a pan
or a zoom on a still frame changes no value and spends no recompute (see
[invariants-measurements.md](invariants-measurements.md); `[measurepin-probe]` is the reading,
`pin=`/`for=` in `[measure-trace]` the field to watch). The pin is therefore load-bearing twice
over, and a change to what `pinned_range()` says changes both the framing and the numbers.
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
@@ -148,6 +155,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
pause now agrees.)
- **The pause reads the display offset *before* it sets the pause state, and a pause taken on a
held event depends on that order.** `on_pause_toggled()` reads the row's axis and
`stream_at_x0()`, cuts `view + offset` out of the ring, and hands the same offset to the
snapshot as its `x_offset`. `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` view is **0**
-- so the cut searched the ring at the event's edge-relative x, found nothing, and froze an
**empty** picture: `ev=0` on the probe's line, every measurement `--` and every ruler gone,
which is a much louder symptom than the empty margin above and reaches a user the same way
(pause while an event is on screen). Read first, it is the offset the axis is actually
displaying, which is what the whole scheme requires. The live case is unchanged -- there the
epoch does not depend on `paused()`, which is why the pause probe, which pauses a *live* view,
never saw it.
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
+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
+34 -14
View File
@@ -96,20 +96,25 @@ precedent, not an exception.
overshoot, kept by the extremes and excluded by the levels. The per-type values, the reason for
each, and the cost of the histogram are in
[invariants-measurements.md](invariants-measurements.md).
`[measure-trace] mode=M view=[..] s0=S swin=[..] for=[..] passes=N <type>=<value>... marks=N
level:V/time:V...` is a repeating line (250 ms) from the probe: the pane's numbers beside the
*current* window and the marks actually placed, which is the only way a stale result or a mark
that should not be drawn shows up as a number. `swin` is `view` moved onto the stream's clock by
`s0` (`stream_at_x0()`), and `for` is the window the printed numbers were computed over, in the
same clock -- so a reading that moved while `view` stood still is a change of `s0` (a new
snapshot under the same mode) and not a stale pass, and only these three fields tell the two
apart. `passes` beside them says whether a recompute answered the move. **While `mode=held`,
`for` must equal `swin` exactly**, through any number of zooms and pans: a latched event does
not scroll, so there is nothing for a recompute to be late for. That reading is where
the measurements standing still under a gesture was found -- `for=[0.47..2.47]` against a
`swin=[1.417..1.467]` with `passes` frozen, the pane describing the whole 2 s event while a 50 ms
slice of it was on screen. The line must also never print `marks=0` while measurements with
`show_on_plot` are on and the data is there -- that was the rulers blinking off at a mode change.
`[measure-trace] mode=M view=[..] s0=S swin=[..] pin=[..|off] for=[..] passes=N
<type>=<value>... marks=N level:V/time:V...` is a repeating line (250 ms) from the probe: the
pane's numbers beside the *current* window and the marks actually placed, which is the only way a
stale result or a mark that should not be drawn shows up as a number. `swin` is `view` moved onto
the stream's clock by `s0` (`stream_at_x0()`), `pin` is the pin's own extent in that clock (`off`
when nothing is pinned) and `for` is the window the printed numbers were computed over -- so a
reading that moved while `view` stood still is a change of `s0` (a new snapshot under the same
mode) and not a stale pass, and only these fields tell those apart. `passes` beside them says
whether a recompute answered the move. **While the view is pinned, `for` must equal `pin` -- and
`swin` is free to sit anywhere inside it** -- through any number of zooms and pans: a latched
event's numbers are the *frame's*, so a gesture inside the pin changes neither them nor `passes`.
Read the other way round it is the defect: `for` following `swin` into the zoom while the values
move is the reported "on a still frame the measurements change when I zoom". Neither reading is
the old "measurements stand still while the data moves", which was `for=[0.47..2.47]` against a
`swin=[1.417..1.467]` with `passes` frozen -- the pane describing a window the plot had left.
The line must also never print `marks=0` while measurements with `show_on_plot` are on and the
data is there -- that was the rulers blinking off at a mode change. `pin=off` while the mode is
`held` is not a stale reading but the window control: `apply_time_window()` unpins, and for those
frames the view is live-framed and measuring the viewport is right.
`measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B
raw=R (F%), N ns/covered sample` follows it: **read it against a run with
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
@@ -168,6 +173,21 @@ precedent, not an exception.
`--square-harmonics 30` for the `0.986496` and the `0.302744`, `--square-harmonics 0` for the
`1.000000` and the empty band.
- `[measurepin-probe]` (stderr, `UDPSCOPE_PROBE_MEASUREPIN_MS`) — **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. Three legs — a latched event, a
pause taken from it, and a live view after the trigger is switched off — each read the pane
before and after a *gesture* written on the row's own axis (never the window control, which
calls off the pin). The assertions, in order: the pinned legs read **`same=4/4 for_same=1
pass_delta=0`** with `for=` still at the pin (the values bit-identical and no recompute spent on
the gesture), and the live leg reads **`follows=1`** with `for_same=0`, `pass_delta>0` and the
results window's span narrowed to the axis'. Its sequence is about 12 s, so
`UDPSCOPE_EXIT_AFTER_MS=20000`; run it on the fast mock with a batch the datagram limit allows
(`--rate 1300000 --batch 1500 --freq 1`, **not** `--batch 2000`, which is 80 kB of payload and
dies with `Message too long`). The lines, the fields and the settle wait are in
[probe-readings.md](probe-readings.md); the window rule itself is in
[invariants-measurements.md](invariants-measurements.md).
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window