Files
udpscope/docs/claude/invariants-trigger-and-pinning.md
T
Martino FerrariandClaude Opus 4.6 a7702acb63 docs: CLAUDE.md is a goal and a table of contents, so a context pays for what it reads
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>
2026-10-07 01:49:28 +02:00

9.0 KiB

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. 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.
  • 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().