Files
udpscope/docs/claude/invariants-trigger-and-pinning.md
T
Martino FerrariandClaude Opus 4.6 f179fbbb44 fix: a clock glitch is bridged and a channel is known by name, so a capture keeps its t0
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>
2026-10-07 10:29:05 +02:00

125 lines
10 KiB
Markdown

# Invariants: the trigger, the capture and a pinned view
The four display modes' framing rules: what the event window is counted in, what pins an axis, and what each pin is measured from.
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.
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
from "the ring has only just started filling", so `window_s_` — what the window control
states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a
requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there
grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the
other state instead — while the ring is filling the two are equal, but once it holds
more than the window the bound becomes the whole ring, and a wheel zoom-out walked the
view into data the window was never set to show. `window_s_` is right in both: it never
shrinks a window a caller asked for, and a partly filled buffer is left empty on its
left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS`
reports it as `zoom ... span=.. window=.. limited=1`.
Its pan clamping is likewise gated on the data being wider than the view, or the empty
left-hand side drags the live edge off the right-hand edge.
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
signal nobody acquires can never fire, so selecting/arming enables it.
- **The trigger's event window is separate from the display window**, and
`pretrigger_pct` is a share of the *event* span, not of the ring.
- **The event window is counted in stream seconds end to end; nothing converts it to
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
search over the time ring. There used to be a measured samples-per-second estimate
in `Acquisition` doing that conversion, and it is precisely why the post window came
out empty: any stream whose time signal advances in large steps drove it to its
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
itself. **Do not reintroduce a rate estimate on this path.**
- **The engine identifies its channel by *name*, never by index.** `TriggerEngine::configure()`
drops an open capture when the trigger moves to a different channel, and it is re-called from
`Acquisition::on_data()` on **every packet** out of `model_.trigger()`. `TriggerConfig::channel`
is an index into `channels()`, and that index is not stable: every rebuild of the channel list
renumbers it — enabling a signal that sorts before the trigger's own is enough — and
`ScopeModel::set_signal_enabled()` re-resolves the trigger by name and writes the *new* index
back. Keying the reset on the index therefore made any such rebuild abandon the capture **and
set no hold-off**, so the next edge inside the post window became t0: that is the "the trigger
re-triggers during a capture and the hold-off does not apply" defect, and it needed no glitch
and no user action beyond enabling a channel. The name changes when the source genuinely
changes, so a deliberate re-point still resets. The rebuild also replaces the `BufferStore`, so
the surviving capture's pre side is short and the snapshot reports its own `pre_s` — which may
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
While an event is held, a capture is filling, or the view is paused, `tick()` calls
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
clamps the position into it (and states `Off`, since a following group would re-derive from a
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
in single trigger mode: an acquired event could not be examined at all.
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
`group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers
what the clamp last wrote: a member that matches it is our own answer coming back, one that
differs is a gesture to honour -- which is also what keeps a *fresh* pin
(`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick)
from being reverted to the member's stale window. Without it nothing bounds a drag or
a zoom in those states: the library clamps a *following* group's offset only, and the live
clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
picture is equally immovable; resuming or leaving trigger mode clears it.
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
Two things had to agree for this, and both were wrong in the same direction:
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded
`pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds.
Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it,
which is what made it look as if zooming caused it. `tick()` now re-states the pin from
`RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held,
from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis
to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside.
A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side
is empty on purpose, and that is the capture filling in view, not a margin.
- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The
pause freezes the *visible window*, but `publish_pause_view()` cuts that window out of the ring
and the cut is clamped to what the ring holds -- so a window reaching back past the oldest
retained sample produces a *shorter* snapshot. Pinning the axis to the request left the shortfall
as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in
moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin
is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while
paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
pause now agrees.)
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
for a ±1 s request is correct there rather than a defect.
- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The
tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes
inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick
can read `Capturing`, block on the lock through `publish_event()`, and reach the framing
step with a new sequence number it is not bound to. (The other case is benign and was
always handled: a publication and the *next* edge in one frame, where the capture has
framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin
permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter
snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view —
so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a
frame is bound to the snapshot. Visible only at high rates, where the publish is long
enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard,
which means **running the disarm probe at 10 kS/s alone does not check this.**
- **A capture in progress is the one place the axis is pinned from the *request*.** There
is no snapshot to measure yet, so `DisplayMode::Capturing` frames
`[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own
`TriggerConfig::post_span_s()`, not a second expression that rounds differently, because
the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins
from the snapshot, and the two agreeing is why the picture does not jump at the latch.
- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.**
A capture is live data on a pinned axis — the exact combination the clamp was written
for and against: it would follow the live edge and drag the frozen window off the event.
- **`publish_event()` cuts one absolute index range and uses it for the time ring and
every value ring.** That is what makes `stamps` and each `values[c]` equal-length
and index-aligned by construction instead of by two truncations that can disagree —
`EventSeries::gather()` indexes both at the same `i`.
- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the
export.** Both need the same absolute index range over the time ring and every value
ring, or an export and a capture of the same window would hold different samples. Call
it under `mutex()`.