Files
udpscope/docs/claude/invariants-readouts-and-cursors.md
T
Martino Ferrari 6492bb910f test: a probe reads the hidden tab's accumulator, and the docs follow the tabs
`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the design's §5 rule -- the statistics
accumulator keeps running on the hidden page -- which nothing tested before. It switches to
Measure, forces one pass so there is a baseline, switches away to Signals and reads again two
seconds later: `advanced=4` is the assertion, and `advanced=0` is the defect where the
accumulation is gated on the visible tab, which would leave a gap in min/max/avg for exactly as
long as the trigger was on screen.

Two things the probe has to do for itself, and getting either wrong makes `advanced=0` a lie
about the tab:

- it **enables its own signal**, because a signal starts disabled (enabling is what starts
  acquisition) and a probe that waited for one to be on would be reading another probe's side
  effects -- and `UDPSCOPE_ENABLE_ALL_MS` is not part of this run;
- it **assigns that channel to a plot**, because a channel with no trace leaves the live window
  *still*, and a still window is precisely the state `update_measures()` is supposed to skip.
  Measured the hard way: without the assignment the probe read `advanced=0 min_n=0` with the view
  pinned and only four passes in twelve seconds, none of them the tab's doing.

Docs: `probe-readings.md` gains the probe's entry and the signals panel's new shape
(`rows == signals + 1 + math`, `divider=0` a list that lost its `+`), loses the whole `math
panel:` entry, and follows `pane_sensitive=`, the gone `revealed=` field, the six toolbar
controls and the tab-reading `m`/`g` key pairs. `verification.md` gains the probe's bullet and
the re-measured warning figures. The invariants record the sidebar as a switcher above a stack
with a scroller per page, the hidden-page trap that replaces the unrevealed-revealer one for it,
that a feature's on/off is sensitivity rather than visibility, that a math channel is a row of
the signal list, and that a cursors-off readout blanks to `--` with the panel left on screen.
`shortcuts.ui` and the help page's window and sidebar bullets stop describing five revealers.
The README records the deviation from `SPECS.md` that names the panes as separate collapsible
sections.

Verified: `[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=16`;
`signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1`; `panels: … (page_current=1)
(sensitive=0) switcher=1`; `toolbar icons:` six controls with every name `(ok)`, with the
Preferences dialog opened in the same run; zero `Gtk-CRITICAL` and no missing icon.
2026-10-14 12:56:43 +02:00

89 lines
7.7 KiB
Markdown

# Invariants: readouts, cursors and the value formatter
What a readout is allowed to quote, how the cursors are read back, and the one formatter both panes share.
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.
- **A readout quotes a stored sample, never a value between two of them.** The hover
crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it
reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream
interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar
average* over `time_stride` samples, so a number between two stored samples is a second
estimate laid over the first that nothing on screen or in an export holds; and past the sample
spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The
change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host
whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and
**udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a
regression, not a feature.
**Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns
the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is
*snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's
`mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not
`crosshair_value()`) are both placed from it. Two consequences that look like defects and are
not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the
dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own
vertical cursor line** — up to half a sample period, invisible at a whole-window view and the
width of the panel at a few samples across, which is the only zoom at which anyone is asking
which point it is. The line says where the *marker* is; the dot says which *datum* is quoted.
The hover mark is also deliberately *larger* than a trace's own sample dots
(`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them
and at their size reads as one more sample. The guards are the library's
`crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`,
`crosshair_interpolated_mode_blends_the_two_samples` and
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
y=..` agreeing with the pane's first value.
- **A measurement with a non-zero `hf_fc` is the one exception**, and it is inherent rather than a rounding
error. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered
copy of the window, so the quoted value need not equal any stored sample -- and with `show_on_plot` its
ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering *is*; it must
not be "fixed" by snapping the readout to the nearest sample. Every measurement with `hf_fc == 0`, which is
the default, still quotes a stored sample exactly as the rule above says. The filter is seeded with the
window's **first sample** and never from history before the window, so a measurement never depends on data
outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming
from earlier samples.
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse —
and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal
whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not
see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes
**`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching
this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against
the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
that range falls back to `%.3g`.
- **With the instruments off the readout blanks, and the panel stays on screen.** Cursors-off makes the
pane *insensitive* rather than hiding it -- a panel you can still see is a panel you can read, and the
settings you are about to switch back on must not vanish while you look for them -- and every cell in
it is written `"--"`, the spelling an unavailable cell already uses. A readout quotes a stored sample;
with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting
a sample the cursors no longer name. Two spellings of one state in one grid would be the defect.
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
every frame therefore adopts its own echo back, and -- because the vertical pair is one value
shared by every row -- a row that has not been re-written yet hands the *previous* shared
time straight back. That is the "drag it and it springs back" defect, and the fix is
`RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports
`grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed
back with `CursorPlacement::Free` for the same reason -- the controller snaps at the end of
the gesture (`Controller::end_drag()`), and snapping the echo fights the hand that is still
moving the cursor.
- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one
piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something
`insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is
how a row added while the cursors were already on came up with its instruments parked
while every other row showed them. The same goes for the vertical pair's `x`: it is one
stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is
genuinely per row (the rulers' values, the traces, the colours).
- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and
the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor
is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside
the axis is not enough**: the controller holds a marker for the gesture's life and follows the
pointer between frames, so a readout parked at `kParked` could still be dragged. The trigger
marker says where the scope put the edge and a measurement mark says what a measurement measured;
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.