# 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()`. - **`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.