Three defects, all of them a window describing data that is not there.
A math edit refilled the derived ring with `clear()`, which renumbers it
from 0, leaving `produced()` short by the inputs' retained base. Since
`live_window_locked()` clamps against every channel's `produced()`, that
one lagging channel answered `n = 0` for the live view, every pause view
and every trigger capture until a whole ring of fresh data had gone by --
the reported freeze, invisible until the ring first wraps, hence its
intermittence. It now restarts with `clear_to(base)`, and
`ChannelBuffer::republish_from(base)` puts the published window back:
`acquire()` is incremental, so a rewrite in place published nothing and
the pyramid went on describing the samples the edit replaced.
The trigger's channel is an index, re-resolved by name after a renumbering
-- but only in `rebuild_enabled_locked()`, so a math add or delete went
through `set_math_channels()` with no restore, the index was clamped to
-1, and `process()` returned at once while the panel still named the
channel. The restore moves into `rebuild_channels()`, which *is* the
renumbering, so no caller can forget it.
And `update_measures()` suppressed its "the window moved" test while an
event was held -- the one mode where a gesture is the only thing that can
move it. Zooming a latched event left the pane describing the whole
captured event. A pinned window needs no exemption: it lands on the range
it already measured and fails the 2 % test by itself.
The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a
graph whose ring has already wrapped, which `math_probe` structurally
cannot reach; `[measure-trace]` now prints the mode, the window on screen
and the window the numbers describe; and the dump's `marks_in_window`
counts against the window on screen instead of against the one the marks
were computed over, where it was a tautology.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
**The clock.** Trigger mode showed a blank plot and never latched when the session defined a math
channel, because such a session gives the model a plan *before* the stream's own signals are in it:
one derived channel, declared rate 0, so `plan_partition()`'s 1 kHz fallback is the interval those
early frames are stamped at -- 1300x too fast for a 1.3 MS/s stream -- and they have nowhere to go,
since the value loop skips derived channels. Relative mode then stamped a *position* times the plan's
*current* interval, so when the CONFIG's channels landed and the plan took the real rate (769 ns) the
same positions produced times 1300x *smaller* than what was already in the ring, and the monotonic
clamp held the clock at the old value for the rest of the run: one repeated stamp, `history=0`, no
latch, and a window the renderer cannot draw. The same family as the restarted-client poisoning
fixed in 620473a, through a different door. The filter's own output was finite and correct
throughout, and disabling the math channel only appeared to cure it because that is a structural
change which rebuilds the store -- the third time that channel has looked guilty while being a
bystander.
Relative mode now accumulates *seconds* (`rel_base_s_ += n * dt`), so a re-plan is a slope change and
the clock cannot jump backwards by construction; a frame is only stamped into a model that has a
*streamed* channel; and the epoch is adopted once per connection, with "unset" a state rather than
the value zero. A one-shot warning names the family if it returns: `the clock stalled at ...: N
samples stamped [a..b] are all behind it`.
**The dialog.** The Configurations window's Close button and the window-manager close both did
nothing: `close-request` was connected to a bare `void` lambda, and that signal is `gboolean` -- TRUE
means "handled" and suppresses the default handler that destroys the window, while `G_CALLBACK` is a
cast, so GTK read whatever the compiler left in the return register. A standalone GTK program of that
shape reproduces it: `void` handler -> `window_alive=1` after `gtk_window_close()`, `gboolean FALSE`
-> 0. The same audit found `gtk_window_present(GTK_WINDOW(...))` on an `AdwDialog`, a type assertion
and a no-op; it is `adw_dialog_present()` now. The menu row stays: it is the only way to switch or
save a configuration without restarting, and what made it look useless was that it could not close.
Verified: trigger mode with that session's graph reads `ring=[0.0058..0.2446] -> [0.0058..1.9969]`,
`history 0.237 -> 2.0 s`, `edge at 1.120 s, latched at 2.120 s`, `display=event(frozen) latches=1`,
`traceless paints 266` against 1519 before the fix. Baselines hold: 0 errors, the documented three
warnings, test-ui 13 ok, test-help green, `gtk warnings: 0`.
The compact line -- the pane's locked, "what is it set to" mode -- showed the arm state in words and gave
no way to change it, so arming from there meant unfolding the panel first, which is the thing the lock
exists to avoid. The control is built into the pane's *heading*, the row that stays on screen when the
body goes, and it is wired to the same `on_arm_clicked` the body's button uses, so both do one thing.
Its label, sensitivity and visibility are stated by `sync_arm_buttons()`, called from the four places any of
its inputs can move: the click, the mode toggle, the curator's per-tick re-read (a single-shot latch disarms
by itself) and `apply_pane_lock()`. The label comes from the producer's `trigger_armed()` atomic, the other
two from the toolbar toggle that owns `enabled`, so no caller needs the model lock. Visibility is
`enabled && locked`: with trigger mode off the line beside it already reads "stream (trigger off)" and there
is no engine to arm, and with the pane open the body's own button is the one on screen. Never both -- two
arm buttons would be two controls for one state.
Verified with the arm probe, which locks the pane the way a user does (the [edit] click):
compact arm with the trigger off: visible=0; with the trigger on and the pane locked:
visible=1 sensitive=1 width=51 label=Disarm
[udpscope] compact arm probe: locked=1 enabled=1 own=1 effective=1 width=51 panel_width=-1 label=Disarm
`panel_width=-1` is the "not both" assertion: the body's button is not on screen while the compact one is.
The width cost a defect to read. The control is created hidden and shown for the first time inside the
handler, so a same-tick `gtk_widget_get_width()` reads 0 while `gtk_widget_is_visible()` already says 1 --
and the body button's *stale* allocation (270 px, from when the body was still up) reads as a live one,
because `gtk_widget_get_visible()` is the widget's own flag and stays true under a hidden parent. The
reading is therefore taken one frame later, by a step of its own, and the control is compared with
`is_visible()`. Both defects are in the invariants, because the next control read this way will meet them.
0 Gtk-CRITICAL; clean build with the documented four warnings (the help resource re-measured); test-ui
14/14. Docs: the rule and both readings in `invariants-trigger-and-pinning.md`, the probe's entry in
`probe-readings.md`, and the user-facing sentence in `src/help/08-trigger.txt`.
The symptom was "view a recorded event, click `<live>`, then toggle the trigger a few times before it
starts streaming again". The mechanism was the restore's own gate: `restore_live_state()` waits for the
*stream's* channel list, because the trigger's stored channel is a **name** and a file view that renamed its
channels (`square (A)`) must be gone before the name means the right signal — and that wait was gated on
`generation()` having moved since *Go live* was pressed.
That gate could never be satisfied in the common case. A **single** event keeps the stream's own channel
names, and the restarted client's CONFIG is identical — which the model treats as *no change on purpose*
(`same_stream_signals()`: "the honest recovery is nothing") — so nothing bumped the generation and the
restore sat there. Toggling the trigger *is* a structural change and moved it, which is exactly the
workaround the report describes.
The gate is now the question it was actually asking: is the snapshot's channel name resolvable against the
live channels (or did the snapshot name none)? For a single event that is true as soon as the client has a
channel list at all, so going live is immediate; for a group the names are renamed, so it waits for the
stream's own CONFIG exactly as before — which is what the gate was for. `live_restore_generation` is still
captured and printed (`gen=a/b`) as information, not as the gate.
Verified against the documented reading, both ways into a file view, from a session with a window and a
trigger so the paired fields have something to disagree with:
[events] live restored (after replay): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=6/3
[events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=16/12
Every want satisfied, `pending=0` in both, `make test-ui` 14 files, zero `Gtk-CRITICAL`. Docs: the
invariants' bullet rewritten around what the restore waits for and why the generation was the wrong
question, and `probe-readings.md` gaining the thing that cost me two runs to find — those four paired
fields are the probe's capture of the live view, so a bare first run reads the application's defaults and
is not a defect; reproduce the documented line from a session that has a window and a trigger.
`set_data_pin()` takes a range and `pin_event_window()` a pre/post
split; the file view handed the split to both. On a recording whose
stamps start before zero that stored a pin that no longer held the
data, so every second frame found the pin wrong and stated it in full
again -- rewriting the group's x range and discarding the user's
gesture with it. The view alternated between the file's span and the
corrupted one at the frame rate: a flicker in which no pan, zoom or
click survived. `[drop-probe]` now reports the view and the count of
pin statements, which must stay at two for a whole run.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.
GTK4 and libadwaita. It receives one UDP stream through the vendored
udpsclient, buffers it in a large ring, and draws it with the vendored
plotter: a trigger with a pre/post event window, a pause that freezes the
window, cursors, measurements, math channels in a node editor, an event cache,
named configurations, preferences, and an export of the visible window to
.npy/.csv. Waveform files can be opened and analysed in place.
The interface is two threads -- a producer that owns the socket and the GTK
main thread that owns every widget and the GL context -- sharing one model
under one mutex, with samples crossing through lock-free rings.
deps/plotter is a submodule pinned to the commit this tree builds against;
deps/udpsclient is vendored in place. docs/claude/ documents the rules that
cost a defect to learn, and SPECS.md is the requirement document.
Two defects behind one report ("a trigger during a capture overrides the first,
and the hold-off does not apply"); the second is also what made a spike in one
channel look like the acquisition resetting.
The trigger engine keyed its invalidation on the trigger channel's *index*, which
every rebuild of the channel list renumbers -- enabling a signal that sorts before
it is enough. Any rebuild therefore abandoned an open capture and set no hold-off,
so the next edge inside the post window became t0. It keys on the channel's name
now, which changes only when the source genuinely does.
A packet whose stamps jump more than 256 packet spans ahead is now rejected as a
fault and *bridged* at the declared cadence, instead of poisoning the monotonic
mark (which pinned every later packet flat at the glitched value) and condemning
the next good packet. Only four consecutive failures abandon the declared time
signal, and that fallback no longer wipes the rings: the arrival clock is anchored
to continue from the monotonic mark, so the history is still on one timeline --
the wipe is what made one glitching packet look like the stream restarting from 0.
The declared unit is resolved before those checks, since they weigh the stamps
against a cadence in seconds.
Guards: UDPSCOPE_PROBE_TRIGRENUM_MS prints t0_held=1 through the real widgets,
and the mock's --time-glitch-at/--time-glitch-by injects the clock fault.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The file was 1967 lines and is auto-loaded into every context, so every
session paid for the whole of it to reach one invariant. It now carries the
project's goal, the three facts that are always relevant, and a table of
contents; the body is fourteen files under docs/claude/ reached by link
rather than by import, so nothing is loaded until it is asked for.
The content is moved verbatim -- the only edits are each file's own header
and the cross-references that became cross-file ("see the invariant below"
is now a link to the file that owns it).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>