# Probe readings: what each line asserts The per-line reference for the self-check's output — what every probe and dump line means, the value it must read, and the defect a wrong reading names. Part of [CLAUDE.md](../../CLAUDE.md). Two lines of the self-check output are assertions in disguise: - `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or two after the first measured frame, and the figure serves its floor out of the label strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation converges over two layouts rather than one. A `y-gutter 88..104` dragging `axes.x 88..104`/`axes.w 707..782` with it is that settle, not a twitch — confirm it with the `changes` line below. A range that keeps moving is the "twitching axis" bug: the plot being re-measured every frame. **A range that keeps *climbing* to 200 is the other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which already includes the floor imposed last frame, so slack added to it is added again every frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200 / axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks like a settle. The reservation's own padding is the only slack there may be. `axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not a zero. **`x-desync` must read 0**: it counts frames in which any row's x range differed from row 0's by more than a pixel of data, i.e. frames the x link group did not reach. `x-ticks` is content, not layout, and legitimately varies by one as a live window slides over a round number. The companion `axes.w changes: N (last at frame F, side panel up=S)` line separates the two readings: `0`, or a handful near the start, is a settled layout; a count near the frame count is the twitch. Runs far longer than the documented probes have shown one ~320 px step — the side panel's width — that reverts; it is intermittent and unexplained, so read `changes` before calling a range the twitch bug. - `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr, `UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`, `live(capturing)` or `nothing`, **never plain `live`**, except for the single interval that contains the toggle itself — recognise it by `latches=0` *and* the next probe reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named `live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while `data=`'s upper bound climbs towards `+post` — an axis that moves instead means the capture is being treated as a live view (`clamp_to_data()` reaching a pinned window). `epoch=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing left to do. Note the probe reads `pending=` fresher than `display=` (which is last tick's decision), so a single `pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect. And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly the post-trigger share: that is the "post window is empty" defect's observable. **`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a series that breaks the "gather emits x relative to `x0()`" contract shows up here as the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left of its axis, invisible once the view is narrower than the shift, while the crosshair keeps reporting correct values. - `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)` (stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and `dx_ratio` must stay within about a percent of `1.0`. `got` is the index window `Renderer::draw_traces` gathers *today* — `neighbours()`, one sample of slack either side — and `want` is the same window from the same call, so read this line for the **rate**: `dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the samples in between in the buffer and readable by the crosshair but never drawn) was the live view whose older part went blank while its newest part looked fine. That is a *fixed* defect: the renderer no longer walks the grid at all, because on a ring-backed series it also lost the two end segments and left the left and right margins of a zoomed-in view empty. The guard for it is the library's own pixel test (`a_sparse_window_between_samples_still_inks_both_margins`), not this line — a probe that recomputes the renderer's formula cannot see a renderer that changed it. Also read `axis=` in the companion line against the `window control:` line in the dump — a live axis narrower than the requested window is the same symptom from the other cause (see the `clamp_to_data()` invariant in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to show up, so run them under `--buffer-mb 1`, which wraps in a few seconds. - `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal `want` and `N` must be exactly the window times the stream's real rate, plus one (2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left* means the edge was accepted before the history reached back a full pre window (the gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched early or the cut lost the newest samples. An `N` one or two short of the exact count is the same class of bug, not rounding: `publish_event()` clamps the slice to every value ring's `produced()`, so publishing before each channel of the latching sample has been pushed silently drops it. - `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display` must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far the post window had filled, with `n` that span times the stream's real rate plus one. `display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture makes the event completely disappear" defect. **`seq` must advance by exactly one while `events` does not move**: no event fired, so the partial publish deliberately leaves `trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at the disarm` line in the dump meaning what it says. - `[trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M enabled=1 armed=1 ch=C channels=N`, then `capture open: t0=T ch=C -- enabling signal 1 now`, `after enable: trigger ch=C' channels=N'`, and `latched t0=T' opened on T t0_held=D` (stderr, `UDPSCOPE_PROBE_TRIGRENUM_MS`) — the first line is the geometry the real widgets produced (`window=1.0000 event_span=1.0000 pre_pct=10.0 post=0.9000` for the reported setup); it is context, not the assertion. The third must show the index **actually moving** (`ch=0` → `ch=1`), or the probe tested nothing — the sine has to be a channel that sorts before the square. **`t0_held=1` is the assertion**: the capture that opened on `T` latched on `T`, so the renumber neither abandoned it nor let a later edge inside the post window become t0. With the engine's invalidation keyed on the channel's index instead of its name this read `t0_held=0`, with `pending` back to 0 and `holdoff_until` *behind* `now_t` on the engine line — i.e. no hold-off was set at all, which is the whole defect. The `[udpscope] trigger: edge at ... got [--0.0199..0.9000]` beside it is expected: the rebuild replaces the `BufferStore`, so the surviving capture has no pre side left and reports a negative `pre_s`. - `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr, `UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export is the *visible* range at the *stored* rate, so a count far short of that is a table cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on the wrong clock (the live branch puts the window back on the stream's clock with `stream_at_x0()` before searching the ring, and that offset is a whole window while streaming — not the 0 it once was). **`abs - time` must be a single constant across both ends of a line**, and that constant is where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in `mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on the second column, and it replaces the old "`stream=` must be the edge ± the window" reading, which only ever held because the display clock *was* the stream clock. A `mode=live` line whose `abs - time` differs between the two ends is the two columns computed from different epochs — the defect `abs_base` exists as a single `const` to prevent. An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far the post window has filled — the export is what is on screen, and during a capture that is a partly filled window. Pin the probe outside a capture when checking the `rows=` count. The two `bytes=` lines are the writers: the `.npy` is `64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing either writer. - `[window-probe] label=.. entry=.. unit=U window=W event_span=E cap=C decim=M stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal `event_span`** on every line (the trigger's event span is the display window; a capture cut to a different length than the axis frames is the "post window is empty" defect's cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches the feedback loop: changing the unit re-interprets the typed number, so "500" in ms becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit` invariant in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md). `label` is a reading to three decimals in a natural unit and is *expected* to be lossy (500 s reads `8.333 min`); it is the `window=` figure that has to be exact. For the resampling: **`buffered` must be ≥ `window`** on every line — that is what the decimation is for, and a `buffered` short of the window is the old "the control is capped to what the ring holds" behaviour coming back. `decim` must be a power of two, `stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s decimate; at 200 MB nothing inside `--max-window` needs to. - **The anti-alias check** is the same probe run against a waveform above the stored Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives `decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's `ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes 2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is the only check that distinguishes averaging from striding. - `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and `A == B`): the tick reads a row only while its controller holds a cursor, and this line is the guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The *adoption* half -- a gesture that does move a cursor, and the shared value reaching every row -- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a synthetic press here cannot reach the adapter's own event pump.) - `[cursors-probe] mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]` (stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` → `mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is what a drag does) and reports again. **The times must survive every mode change**: they are the `RowSet`'s one pair, and a mode that parks the markers must not read them back (a parked cursor reports a real number, so a read-back would adopt it as a gesture — the bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for `horizontal`/`both` (and the time row exists only in the modes that show the vertical pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound** trace for `mix` — a slot with a name *and* a channel the model built, never a name alone (see the bound-slot invariant in [invariants-plot-and-layout.md](invariants-plot-and-layout.md); `lines=6 marks=3` over three real traces is that defect). Each line reports as `= `, so the names are the reading: a row claiming a signal that is not on a plot is the phantom. In `mix` the values must be the traces' own (`--` when a marker is past the newest sample, `partial` when only one marker is over samples). After the move, `t0` must have changed and every row's marker must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix` mode's crossing annotations: **0 in every other mode, and one per bound trace for each marker that is over samples in `mix`** (so a move that brings the second marker into the data doubles it). The `first mark at x=.. y=..` line that follows must agree with the pane's *first* value for the same trace (the pane's lines are `chip name Y1 Y2 dY` -- the chip carries the signal's name, since a chip alone leaves the user to match a colour against the plot by eye -- with the values formatted as measurements, two decimals, an SI prefix, the channel's unit, so the comparison is against the first number): both are read through the same `Plot::crosshair_value()`, and that line is the check that they stay the same number. A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks the first ruler to 80% of its y range), because nothing shares a ruler and the read-back in `RowSet::sync_cursors()` is the only thing that can notice. The dump's `cursors in plot 0:` line is the other half: it reads the markers back out of the plot (`cursor(id).x/.visible`), which is what the renderer draws from — the pane's numbers and the plot's state agreeing is the difference between "the app placed them" and "the app placed them and they are on screen". - `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..] [duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W pane_h=H` (stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first plottable signal and lets the tick compute them, so what it prints is what a user reads. On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and `bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS` ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`. **Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy -- the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96 ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**: `level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value *and* a marker at the first sample that reached it — two level marks at both extremes was the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration` seconds, and the rest the channel's unit. `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. **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 would read no drift whatever the data did. `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 counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a 50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling for the window the run actually used before calling a figure large. A `0.0000%` 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 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 marker follows the pointer between frames. The library's own controller test (`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last step still drags a mark *programmatically* and requires the next frame to put it back (`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr= side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the split nor the side row (they compute from the mid level's rising crossings, and an option there is one the user can only get wrong), `average`/`min`/`max` no split either, and only `pulse duration` a side. A measurement whose signal is not acquired, or a window with too few crossings, must read `invalid`/`--` rather than a made-up number (`frequency` on a window holding one edge is the case: `duty` is reported only when a period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must be unchanged after the save and one lower after the delete, with the process still alive. That is the check for the two things a dialog of this shape gets wrong: two buttons owning one context (a double free aborts on edit/delete, and only those paths have two buttons) and a `const char*` array built from a `std::vector` as it grew (every pointer dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third: the list must be the **plotted traces** (real names, deduplicated, in signal-list order — *not* every channel, and never a stream's timestamp signal, which is what an acquired-but- unplotted list put first and auto-selected), and `drop_h` must be a combo box's height (tens of pixels — a scrolled-window wrapper asked it to grow to 160). - `[timelabels-probe] relative=D distinct=D window=.. origin=.. note=[..] ticks=[..] first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr, `UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through `apply_time_window()`: **under the figure's x link group the group owns the window, so a row-level `set_time_window()` does nothing** (a probe that changes the window must use the app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two major labels on the axis are the same string, which is the one thing the relative form exists to buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than a change to the rule: the form is decided by the tick step against the label resolution at the axis' **magnitude** (see [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and no display mode carries a large magnitude any more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s, 2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads `-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on the strength of this reading — but what the probe asserts is `distinct`, not the form. The **caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after `UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis that is already edge-relative re-anchors the ticks away from the trigger, which is the one position the axis exists to show. The probe *races the paint* — it reads the axis on a timer, and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus the axis' origin) is a fraction of a step; the dump runs after the last paint and is the race-free reading. Either way the assertion that matters is the *sum*: `first_value` must equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()` floors the window there, so the 20 ms step is clamped. - `[fit-probe] pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr, `UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare `RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed `[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter of it the way a gesture does — on the *member* axis — and fits. Four assertions. **`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit is a plot interaction and may not restate the time scale (see [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and they are the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put `span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion: the follow is re-armed on the newest data, which is what the whole window means there. Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`), which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_` — that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads back as the zoomed range one frame later. - `[layout-probe] rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]` (stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the buttons exist only while the editor does. `done` follows the same rule for the editor's own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the area's top-right corner: `x + W` within a few pixels of the area width and `y` within a few of 0 (the on-switch line reports a box from before the first frame, so read the `placed` one). The last probe step leaves the editor by **clicking that button** — the toolbar toggle is still exercised by the first step — so `buttons=0 done=0` with the rows and traces untouched is the assertion that the way out works. **`traces=` is the real assertion**: an insert or a remove moves the rows' *contents* with them, so `add above 0` must read `[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row — and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0 means `carry_row()` was skipped and the rows were renumbered under the user. Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and `add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than `kRowGap` means the figure and the overlay disagree about where a row is, which is how a `×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the floor are the two refusals, and because the probe emits `::clicked` straight at the widget it reaches the handler even when the button is insensitive, so `add above 0` there must leave `rows=` and `traces=` untouched rather than growing a ninth row. - `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be a small constant and must not track `B`**: the window control is a header-bar child, and an expanding one is measured into the title's centre box, so it stretched from the label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that grows with the window width is that bug back. - `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=... menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource` compiled into the binary — see the toolbar-icon invariant in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)), and a `(MISSING)` on one of those means the resource path was never registered. That failure is otherwise *silent*: GTK substitutes a missing-image placeholder, the button still works, and every other line of the dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this is the only check that it names something that exists. - `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump, beside `toolbar icons:`) — the application icon, named and present at the size its own root `` element declares. **Both entries must read `ok` at those sizes.** `ok` is `gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a `MISSING` is a resource path that was never registered or a file that was renamed, and a size other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with `toolbar icons:`, and trust that line further for the registration**: the resource-path registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons, so a `MISSING` on either line is the same defect — but on a machine where `--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the **installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads `ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:` reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok` until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` / `MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and the declared sizes are read from the binary and nothing else can satisfy them — but a developer machine is the one where it is least sensitive. **It deliberately does not claim the icon renders**, because no runtime GTK call can: `gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes `image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules. - `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr, `UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before `gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named signal may appear**: a development machine accumulates one session file per probe run on a throwaway port (140 of them when this was written), and the filter in `known_sources()` is what keeps the list the sources a user configured. Order is newest first by file mtime. - `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) — shows the chooser, then activates row 0 the way a click does and continues into the real application, so a full run with **no `--server`** must reach the scope and emit every other dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the assertion that a multicast session reconnects to its group, and it needs **no multicast networking**: write the session file by hand and read the line. With `UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open, connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a dialog built and destroyed as it is a state check. An empty list must print `picked nothing: the list is empty` rather than dereferencing a null row. - **The session layout round trip** (`~/.config/udpscope/_.toml`) — run once with a drop and a window change, then again with no interaction: the second run's dump must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0 -> channel ... samples=N` line). Delete the file before a run whose layout you did not mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). - `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last second ...` (stderr, once per second while the state holds, plus a status-line notice) — "connected but nothing is arriving after a reconnect", which over unicast is the second instance taking the source's single session. Check it does **not** fire against a live source. The status line carries ` | N reconnects` only when N > 0, which is the same counter without the prose. **The same stall test has two outcomes, split on `have_config`, and they are different diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints `[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the stream ...` and the notice reads *trying to connect* — nothing was taken away, because with no signal list nothing can be enabled and no DATA frame is owed, and the old wording blamed a second reader that does not exist. That state is also named *without* waiting for the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the first frame (see `badge frames:`). The way to see it with no second client is to point the app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of the unicast-contention wording needs a real second reader, or a source that configures and then goes quiet. - **The export's `time`/`abs_seconds` columns must be strictly increasing and evenly spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and `rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence — the two ways this has broken: a per-frame choice of time source (now sticky, see the README) and a per-sample step derived from the client's `lost` counter (now the stream's measured arrival cadence). A `rows=` count far below the span times the rate is the other signature, that of a table cut from the LOD pyramid. **The step must hold to a few ULP of the column's own magnitude**, and this is the check that needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds exactly one value", and that was only true while `time` *was* the stored stamp. Both columns are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s. The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime (see the `f64` time-ring invariant in [invariants-data-path.md](invariants-data-path.md)). The figure to compute is the ULP of `t[0]` and of `abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude above the arithmetic noise, so the two readings cannot be confused. - `badge frames: lit=L dim=D idle=I connecting=C` — the activity badge's phase per frame. Over a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it blinks" means, and a badge stuck lit is indistinguishable from a working one in a screenshot. One or two `idle` frames at the start are the frames before the socket connected; a large `idle` is correct only when the run paused or disarmed (a `UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run against a live source** — it is the steady *yellow* phase, "the socket answered but the stream has sent no CONFIG", and a non-zero count against a working mock means a signal list that arrived late or not at all. It is the *only* non-zero phase on a dead-port run (measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the first CONNECT), which is also the check that a source that has sent nothing never gets the blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and `last event ... ago` are checked there. - `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and `frame path: raw=R lod=L columns=C graded=G frames, segments S/T (mean/max), visible max V samples` — the drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame clock's `before-paint` to the end of the host's `render` handler) and the whole GTK paint. **`segments` max must stay in the low thousands and `lod` must dominate `raw` on any run holding a large capture**: a max in the *millions* with `lod=0` is the empty-pyramid defect (see the `MinMaxPyramid` invariant in [invariants-rendering.md](invariants-rendering.md)) — the renderer drawing every raw sample of a held event on every frame, which measured 6,000,073 segments and 100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per pixel, which is where `level_for()`'s own gate puts the boundary. **`columns` must equal the frames that drew a trace at all on any run at a high rate**, and it is the reading that says *why* `segments` is small: a dense gathered window is reduced onto the pixel grid upstream (one snapped vertical segment per column — see the moiré invariant in [invariants-rendering.md](invariants-rendering.md)), so the geometry is bounded by `axes.w`, and a `columns=0` beside a large `segments` is that reduction gone rather than a narrower window. Measured on `--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames, segments 2041/2237` over 1201 painted frames, against `columns=0 ... segments 10960/216093` over 263 before it existed — the same run at 4.6× the frame rate. **`graded` must equal `columns`**: this app turns `Style::intensity_grading` on, and grading is a way of *drawing* the reduction rather than a third path, so a `graded` below `columns` is the style not reaching a frame. **`UDPSCOPE_NO_PHOSPHOR=1` is the A/B** and the only way to read either of these two lines against the ungraded path, since a frame mean cannot attribute its own cost: measured on `--rate 1300000 --batch 2000`, `columns=1166 graded=1166` with `draw 3.24/21.43 ms` and `segments 1340/4157` against `graded=0`, `draw 2.30/25.76 ms` and `segments 131/4306` over the same `lod=453` frames. The segment rise *is* the gradient (alpha is quantised into steps, one `LineBatch` and one draw call each, because the line pipeline's colour is a uniform) and the ~1 ms is its cost; a `raw` that *grows* when the grading is on is the level-floor invariant in [invariants-rendering.md](invariants-rendering.md) gone, which measured `raw=1154 lod=0` and 6.36 ms. **`draw`'s max must sit at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything — `tools/mock_udpstreamer.py --rate 1300000 --batch 2000` — since at the default 10 kS/s a whole window fits in the raw path and the publish is too short to lose a race. (Do not raise `--batch` to chase the rate: a batch of 20000 exceeds the datagram limit and the mock dies with `OSError: [Errno 90] Message too long`.) - `publish cost: N events, A/B ms (mean/worst, blocks the UI); M pauses, C/D ms (mean/worst, blocks the producer)` — what *freezing a view* costs, and the line that attributes the stall `frame cost:`'s `tick` max can only hint at. The two publishes are counted apart because they stall **opposite threads**, and nothing else in the dump distinguishes them: `publish_event()` runs on the producer with `mutex()` already held, so its cost lands on the UI as a long tick; `publish_pause_view()` runs on the UI thread and takes the lock, so its cost lands on the producer and can only surface as `gaps`. Read the event figure against `tick`'s max and the pause figure against `gaps`. Both are **wide**: on the fast mock the event publish has read **69, 89, 92 and 115 ms** and the pause publish **22 and 86 ms** across runs, so treat the line as an order of magnitude and not a figure to compare a change against at the millisecond. The event reading matters anyway because it is far above the ~40 ms the `frame cost:` entry above quotes — treat that 40 as the old reading and this line as the authority. It is also **the number to judge a second live ring against**, which is the one design question it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a `ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at the price of a second budget's worth of memory (README records that one live ring is why `--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line says about that trade is that the stall is real but **costs no packets**: an A/B of the same run with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring; it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.) - `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst drawn-vs-quoted D px over R raw paints` plus a `[ink] paint N mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=.. ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..] back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**, and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads). This is the only check that can see a frame whose *waveform* vanished: `staged_segments` counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose points are then collapsed, so both read healthy through the flicker. The probe runs on every painted frame that drew a trace and recomputes what the renderer did, through the same calls — `neighbours()` for the index window, the 64-spp gate for the level, `gather()`, then the increasing-suffix filter — and reports where the ink actually landed against the axis. `collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase` is the filter dropping over three quarters of the points, `short` is ink reaching under a quarter of the axis (reported only once the data spans the window, since a filling ring is *meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the one that names a *defect of position* rather than of presence: at a raw level a gathered pair **is** sample `first + k`, so the line differences where a sample is drawn (`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the gather-origin invariant in [invariants-rendering.md](invariants-rendering.md) read from the screen's side; it was over a hundred pixels at a deep zoom before the origin became the caller's. It is measured on the raw levels only — above them a gathered point is a bucket's envelope and has no single sample to be off from — so `R` is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read (see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an `idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a local of the renderer's now, so that failure mode is structurally impossible and the field is gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race, so it needs load and a reason for long frames — `tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the measurements *and* cursors panes up: `UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500 UDPSCOPE_EXIT_AFTER_MS=35000`, which reported three staircase frames in each of three runs while the defect was live and none after. `frame path:`'s own numbers move with it (`segments` max 156078 → 19681), since a refused `neighbours()` gathers the whole ring. - `traceless paints: N (M after the first bind, last at paint P)` and `worst side/pane min width: W px`, plus `[gen] frame F generation A -> B (bound=.. empty=..)` and `[width-probe] ...` lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and each guard a documented invariant from the side the existing lines cannot see. **`M` must be only the frames in a state that draws nothing**: a traceless paint is legitimate in `DisplayMode::Waiting`, which is why the line carries `mode=` — `UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so a tail of them at the end of a run is expected. `[gen]` names each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's history, so the frames after one legitimately draw an empty plot, and `blank view frames` cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt ring holds none either) — so an unexplained `[gen]` burst is a structural change firing when nothing structural happened. `worst side/pane min width` is the "layout must not depend on content" invariant read from the **demand** side: a `GtkLabel`'s minimum width is its text and a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an allocation wider than the window; `axes.w` sees that a frame later and cannot name the cause, so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read. - `[help] pages=14 parsed=14 shortest='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape. - `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row, then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B` and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr, `UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all seven — a menu whose checkable rows indent their text and whose others do not is exactly what "the text is not left-aligned" looks like), every shortcut ends at the row's own right edge (`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34, and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu` trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a `GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed the bubble's height, because "below the button" is off the window's bottom edge — the bubble's top edge stayed at the button with its body clipped away, which reads as an arrow disconnected from its body. Run it **alone**: another probe that opens a window dismisses the popover, and every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores it afterwards. - `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `: : ->` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). - `[color] '' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. - `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires). - `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for ` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it. ## `[condition-probe]` `UDPSCOPE_PROBE_CONDITION_MS`, with the mock's `--glitch-at`/`--glitch-width` (legs 0 and 1) or `--quiet-at` (leg 3). Four legs, selected by `UDPSCOPE_PROBE_CONDITION_SHAPE`. Every leg prints one line of the form: ``` [condition-probe] type=T cmp=C width=W t0=T0 measured=M at0=A earlier=E level2=L2 mode=D ``` Measured readings (`--rate 10000 --freq 5`, each on a fresh port): ``` shape=0 type=1 cmp=0 width=0.010000 t0=1.842100 measured=0.002000 at0=nan earlier=nan shape=1 type=1 cmp=1 width=0.010000 t0=2.160100 measured=0.200000 at0=nan earlier=nan shape=2 type=2 cmp=0 width=0.010000 t0=1.706700 measured=nan at0=-0.500907 earlier=-0.498185 shape=3 type=3 cmp=0 width=0.010000 t0=4.120200 measured=0.300100 at0=nan earlier=nan ``` `measured` is the reading, not a phase. It is the pulse width read back from the held snapshot's own stamps, which are edge-relative: the snapshot is the ground truth, not the mock's absolute clock. Three things are worth knowing before calling a reading wrong: - **Leg 0**: `measured=0.002` is the injected glitch width. A `measured` near the signal's own half-period means the width qualifier did not qualify — the trigger caught a normal edge instead of the runt. - **Leg 1**: `measured=0.200` is an ordinary square half-period; every square pulse satisfies `> 0.010`. - **Leg 2**: `at0` must lie **outside** `[-0.5, +0.5]` and `earlier` **inside** it — those are the two sample values around the band crossing. The probe prints `nan` for any field a leg cannot measure. **This leg uses `sine`**, not `square`: the square is exactly ±1 and has no sample inside the `[-0.5, +0.5]` band, so a window condition on the square can never fire. **Use a 1.0 s window with 50 % pre** or the probe cannot reach the opening edge of a 0.2 s pulse. - **Leg 3**: `measured=0.300` is the dropout timeout. **This leg also uses `sine`**. A timeout of 0.050 s against a sine crossing zero every 0.1 s latches on a healthy signal, so use 0.300 s or longer; **use a 1.0 s window with 50 % pre** as for leg 2. The channel-selection line `[condition-probe] = of channels` is printed before the trigger fires; if the channel is not found this reads `-1` and the probe fails immediately. A failure line `[condition-probe] no latch after N polls` means the condition never fired — a wrong shape, a missing mock flag, or the wrong signal selected. ## `[stats-probe]` `UDPSCOPE_PROBE_STATS_MS`. Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements. Run against the fast mock (`--rate 1300000 --batch 2000 --freq 1`) for readable figures. Three phases, one line each. Every line carries `phase=`, `on=` (the toggle), `passes=` (the pane's recompute count since start) and one `| n=… mean=… sd=… lo=… hi=…` group per measurement. Phase 1 adds `dn=` per group. Measured readings (fast mock, 1 Hz sine, with `UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`): ``` phase=0 on=1 passes=36 | n=30 mean=-0.0370313 sd=0.0953509 lo=-0.246367 hi=0.105167 | n=30 mean=0.633511 … | n=30 mean=1.99567 … hi=2 | n=30 mean=0.997834 … hi=1 phase=1 … dn=0 (on all four measurements) phase=2 … n=1 mean=-4.84467e-14 | n=1 mean=0.63662 | n=1 mean=2 | n=1 mean=1 ``` - `phase=0` — the running count. **`n` must be in the low tens**, not thousands: a count in the thousands means the accumulator is fed per sample, not per recompute. `mean=0.633511` (≈ 2/π) for the `top` measurement on a unit sine is the check that the threshold types are correct, not merely plausible. `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`. - `phase=1` — after a two-second pause. **`dn` must be 0 or 1**: a pinned window recomputes once when it settles and then never again. A `dn` near 120 is the second defect — accumulating per frame, which adds a hundred-odd observations of the identical window and collapses `sd` towards zero. Note that `n` is **not** reset by the pause or the resume; `passes` continues growing. - `phase=2` — after Reset. **`n` must be 0 or 1** (0 if the report ran before the next recompute, 1 if after); anything larger means Reset cleared nothing. ## `[search-probe]` `UDPSCOPE_PROBE_SEARCH_MS`, scheduled after `UDPSCOPE_PROBE_CONDITION_MS`. Run with `--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004` and `UDPSCOPE_EXIT_AFTER_MS=12000`. Measured output (port 8419): ``` [condition-probe] type=1 cmp=0 width=0.010000 t0=1.710000 measured=0.004000 at0=nan earlier=nan level2=0.500000 mode=2 [search-probe] started on channel 0, armed t0=5.710000 [search-probe] state=2 hits=12 scanned=4.5115 total=4.5115 chunks=2 worst=0.106ms total=0.116ms armed t0=5.710000 vs scanned first=5.710000 err=0 agree=1 [search-probe] jumped to hit 0 t=1.710000 mode=4 axis=[-0.1000..0.1000] pos=1 [search-probe] marks placed=1 of 1 in view (12 found) locked=1 mode=4 axis=[-0.1000..0.1000] [udpscope] search marks: placed=1 hits=12 locked=1 ``` - **`agree=1` is the load-bearing line of the whole feature.** The search and the live trigger run the same `EventDetector` over the same stamps, so `agree=0` means one of the two is reading a different clock — which is exactly the class of defect a search that merely "returns plausible times" hides. Here `err=0`: exact agreement, not within tolerance. - `state=2` is `BufferSearch::State::Done` (the enum's ordinal). `state=1` would be `Running`. - `mode=4` after the jump is `DisplayMode::Paused` (the enum's ordinal). `mode=2` would be `Capturing`. - **`placed=1 of 1 in view` is the correct answer, not an under-count.** The jump frames a 0.2 s window and the glitches are 0.4 s apart (one per square period at 5 Hz / square half-frequency), so only one hit can be in view at a time. `1 of 1` against `(12 found)` is the marks agreeing with the window, not with the hit list. - **`total=4.5115` far short of the run is not a defect.** `condition_probe` enables the channel (which starts acquisition) and then calls `apply_time_window()`, and each of those rebuilds the buffer and drops history. 4.51 s was the genuinely retained span. - `locked=1` says the marks are out of the pointer's reach, like every other readout. The exit dump's `search marks: placed=1 hits=12 locked=1` is the same assertion from the dump path; its `placed=` counts marks in the **visible window at exit**, which is the live view after the probe disarmed the trigger — so `placed=0` there is legitimate and asserts nothing about the search. The load-bearing `placed=` is the `[search-probe] marks` line, emitted a frame after the jump, which quotes the window the scope has actually moved to.