Files
udpscope/docs/claude/invariants-readouts-and-cursors.md
T
Martino Ferrari 749b34a2cb docs: record the three noise-rejection controls and their invariants
README gains the three controls where each lives -- the trigger's `band`/`reject`, the
measurement's `noise`/`hf`, and the math channel's `de-noise` node -- each with its unit, what
"off" is, and what it costs, plus the two deliberate deviations from the design spec next to the
features they belong to: the band applies to `edge` and `window` only (a pulse's width is measured
from a crossing to its return, so a band there changes the measured width rather than rejecting
noise; a dropout is an absence with no level to compare), and the band is verified through
`BufferSearch` hit counts rather than `[condition-probe]` latch times, because probe-readings.md
states that a `t0` is not an assertion.

The invariant docs gain the rules a later change must not break: `EventCondition`'s two new fields
and the once-per-packet `configure(dt)` that must not reset; the crossing rule that a change is
between two *decided* sides; the measurement path's exception to "a readout quotes a stored
sample", why the band moves the edge decision only, why `measure_accumulate` loops samples outer,
and why a filtered window disables both bucket fast paths; that `MathOp` is append-only (the index
*is* the serialisation), that the median window is pooled in the runtime rather than held per node,
why the pole reuses `b0`/`z1`, and that median-then-pole is the design; the session's named
`noise`/`hf` keys and its thirteen-field node string that still reads twelve; and the two GTK
traps -- positionally brace-initialised dialogs, and a control that cannot apply being hidden
rather than disabled.
2026-10-13 16:05:28 +02:00

7.1 KiB

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. 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.
  • 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.