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

+56 -17
View File
@@ -264,10 +264,13 @@ void place_measure_marks(App& app) {
app.rows->set_measure_marks(app.measure_marks_display);
}
/// Recomputes every measurement over the visible window and writes the results into the pane,
/// collecting the marks the ones asking to be shown need.
/// Recomputes every measurement over the window it describes and writes the results into the
/// pane, collecting the marks the ones asking to be shown need.
///
/// A measurement is a statistic of the window, so it is recomputed when the *window* moves --
/// **The window is the viewport live and the pin while the view is pinned** (see below): a still
/// frame is measured as a frame, so on a held event or a pause a pan or a zoom changes no value.
///
/// A measurement is a statistic of that window, so it is recomputed when the *window* moves --
/// not on a timer. A timer decouples the two: with the window scrolling under a 5 Hz cadence
/// the stored marks describe a range the plot has already left, which is how a min/max marker
/// ended up outside the viewport, and how a dragged marker survived until the next tick. The
@@ -283,19 +286,46 @@ void update_measures(App& app, bool force, bool accumulate) {
// `[-window, 0]` that never changes while the data under it does.
const double s0 = app.model->stream_at_x0();
const plot::Range view = r0.plot->x_range(r0.axis_x);
const plot::Range swin{view.min + s0, view.max + s0};
app.measure_view_window = swin;
// The window a statistic describes. A **pinned** view -- a held event, a paused view, an opened
// file -- is a *still frame*: the data behind it does not change, so a pan or a zoom only looks
// closer and no number may move because of one. What the pane describes there is the **pin's own
// extent**, which is the same accessor the framing itself uses (`hold_pinned_window()` clamps a
// pinned view into exactly this, so the pin *is* what frames it), and it is decided here rather
// than inside `MeasureWindowReader` because the decision is booked as well as read: `swin` below
// is what the "it moved" test compares against the last result's window, and a reader that chose
// its own window would leave a pinned view recomputing on every gesture frame for a number that
// cannot change -- the 457-pass run this pane's cadence was fixed for.
//
// A live view is the other case and is unchanged: there the window genuinely moves and the
// statistic follows it, and the accumulator's rule about what resets the history (`measure_*`
// stats) is about neither -- a pan or a zoom still changes *which samples* answer a live
// measurement, which is what averaging over many windows is for.
const plot::Range pin = app.rows->pinned_range();
const bool pinned_view = app.rows->window_pinned() && pin.span() > 0.0 &&
std::isfinite(pin.min) && std::isfinite(pin.max);
const plot::Range window = pinned_view ? pin : view;
const plot::Range swin{window.min + s0, window.max + s0};
// Both windows of the tick, on the stream's clock: what is **on screen**, and (while pinned)
// what the pin *is*. The dump reads them instead of calling `stream_at_x0()` itself, which by
// the time it runs answers for a mode no frame was drawn in.
app.measure_view_window = plot::Range{view.min + s0, view.max + s0};
app.measure_pin_valid = pinned_view;
if (pinned_view) app.measure_pin_window = swin;
const bool held = app.model->display_mode() == DisplayMode::Held;
// "It moved" is asked in **every** mode, a held event included. This test used to carry a
// `&& !held`, and a held event is precisely the mode where a *gesture* is the only thing that
// can move the window: with it, zooming a latched event forty times over left the pane
// describing the whole 2 s event (`for=[0.47..2.47]` against a `swin=[1.417..1.467]`, `passes`
// frozen), which is what "the measurements move when zooming or panning" was -- the numbers and
// the time marks belonged to a window the plot had left. The term was aimed at a *pinned*
// window recomputing every frame, and nothing is needed for that: a settled window satisfies
// `swin == measure_window` exactly and so reads `moved == false` 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.
// "It moved" is asked in **every** mode, a held event included -- but it is asked about the
// window the measurement *describes* (`swin`, the viewport live and the pin when pinned), never
// about the viewport. That distinction is the whole of a reported defect in both directions.
// This test used to compare the viewport, with a `&& !held` on it: exempting a held event left
// the pane describing the whole 2 s event (`for=[0.47..2.47]`) while a 50 ms slice of it was on
// screen, and *removing* the exemption made the opposite mistake -- the numbers followed the
// zoom into the slice, so an average or a top moved whenever the user moved the wheel on a
// frozen event, which is the report this pins down. Neither is a staleness question: on a
// pinned view the pin is what the pane is about, so a gesture cannot move it at all, and the
// numbers a gesture would have disturbed are the frame's and stay put. A pinned view still
// recomputes when it should -- a fresh latch, a re-stated pin, a new snapshot and the mode
// change below all move `swin` -- and a settled one satisfies `swin == measure_window` exactly
// and so reads `moved == false` on its own, which is what keeps it from recomputing every
// frame (457 passes in one run is the shape that mistake took).
const bool moved = app.measure_window_valid &&
std::fabs(swin.min - app.measure_window.min) +
std::fabs(swin.max - app.measure_window.max) >
@@ -445,7 +475,7 @@ void update_measures(App& app, bool force, bool accumulate) {
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
for (App::MeasureWindow& w : app.measure_samples) {
const gint64 fetch_start_us = g_get_monotonic_time();
MeasureWindowReader reader(app, static_cast<std::size_t>(w.channel));
MeasureWindowReader reader(app, static_cast<std::size_t>(w.channel), window);
const std::size_t n = reader.size();
const std::function<double(std::size_t)> time_of = [&reader](std::size_t i) {
return reader.time_of(i);
@@ -653,12 +683,21 @@ gboolean measure_trace(gpointer user) {
// the offset that converts between them: a reading that moved without the view moving is a
// change of `s0` (a new snapshot under the same mode), and only these three fields tell the two
// apart. `passes` beside them says whether a recompute answered it.
//
// `pin` is the fourth, and it is where a *pinned* view's `for` comes from: while the pin is what
// frames the plot the numbers describe it and not `swin`, so a gesture inside the pin leaves
// `for`, `passes` and every value exactly where they were. Both are captured on the tick, which
// is why `for` and `pin` are readable together; `view`/`s0` are read here, as they always were.
const double s0 = a->model->stream_at_x0();
std::string line = "[measure-trace] mode=" +
std::string(display_mode_name(a->model->display_mode())) + " view=[" +
std::to_string(view.min) + ".." + std::to_string(view.max) + "] s0=" +
std::to_string(s0) + " swin=[" + std::to_string(view.min + s0) + ".." +
std::to_string(view.max + s0) + "] for=[" +
std::to_string(view.max + s0) + "] pin=[" +
(a->measure_pin_valid ? std::to_string(a->measure_pin_window.min) + ".." +
std::to_string(a->measure_pin_window.max)
: std::string("off")) +
"] for=[" +
std::to_string(a->measure_window.min) + ".." +
std::to_string(a->measure_window.max) + "] passes=" +
std::to_string(a->measure_passes);