Files
udpscope/docs/claude/invariants-trigger-and-pinning.md
T

147 lines
12 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()`.
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
so the engine would not latch; but the real reason the samples are withheld is the other one:
feeding them would advance the hold-off clock across time nothing was sampled in. The probe
asserts `trigger_in_hole=0` rather than relying on NaN's arithmetic properties.
- **`EventDetector` owns every condition shape and holds no arming state; `TriggerEngine` owns
the arming, the hold-off and the post window and holds no condition.** The split is what lets
the buffer search ask the trigger's exact question without owning a trigger, and it is only
true as long as neither half leaks into the other: a detector that knew about the hold-off
would answer a search differently from the live trigger, which is the one thing the
`agree=1` cross-check in `[search-probe]` exists to catch.
- **The detector is fed on *every* sample, including during the hold-off and during a capture.**
A pulse width, a window excursion and a dropout timeout are all *stateful over several
samples*: skipping the feed while the engine is busy loses the opening edge, and the condition
then measures from whatever sample the engine happened to resume on. The hold-off is applied
to the detector's *answer*, not to its input — `process()` feeds first and tests
`now_s < holdoff_until_s_` afterwards.
- **An event's time is the detector's, not the sample's.** A pulse-width event is detected at
the closing edge but could in principle be timed at the opening one, and a dropout is detected
one sample after the timeout expires. `feed()` therefore returns the event's *own* time and
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
all three, or the marker, the cut and the gate would disagree with each other by the width of
a pulse.