Files
udpscope/docs/claude/invariants-rendering.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

14 KiB
Raw Permalink Blame History

Invariants: rendering — index mapping, gather, LOD and grading

How the renderer maps a visible range onto samples, what gather() emits and relative to what, and why a dense trace is drawn one pixel column at a time.

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.

  • The renderer maps the visible range onto indices through the series' own x, never the dx() grid. deps/plotter's Renderer::draw_traces asks Series::neighbours() for the bracketing samples. A grid walk is wrong for exactly the series this app plots: dx() here is a nominal spacing (the LOD level, a coarse snap) while the real stamps are the time ring, so the window it computes lands inside the visible range and the two end segments — the ones that draw into the left and right margins — are never emitted. That is the "empty margin when zoomed in" defect. The library test is a_sparse_window_between_samples_still_inks_both_margins (a stub whose dx() lies, with the window strictly between two samples). It is the guard, not the probe: [trig-probe/idx] now computes got from the same call the renderer makes, so it cannot catch a renderer that went back to the grid — what it still reports is the drift that made the walk wrong (dx_ratio).
  • neighbours() is a binary search over the real stamps in every series here, the snapshot's included. EventSeries answered from the (x - x0()) / dx() grid — the walk the invariant above condemns: dx() is the mean spacing of the snapshot's stamps and the stamps are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the capture. The renderer picks its visible index window from this call, so the drift arrived as a constant offset in seconds between the axis and the gathered geometry — and a constant number of seconds is a doubling share of the screen on every zoom step: 8 ms over a 2 s capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming into a held event clips it more and more from the left while the crosshair still reads the right values" report; the crosshair was right because it reads x_at(), a real stamp. ChannelBuffer always searched; the two now agree. A perfectly uniform source cannot show this at all — three probe runs against the ideal mock were clean — so the guard needs --clock-jitter (see UDPSCOPE_PROBE_EVENTZOOM_MS above).
  • The Series grid contract does not hold for ChannelBuffer. x is a streamed timestamp, so dx()/x0() report a nominal grid (LOD level, coarse snap) while gather(), x_at(), neighbours() and x_bounds() use the real time ring. Nothing may walk that grid to find which samples a range covers — the renderer did, and it lost the two end segments (see the margin invariant above). gather() emits x relative to an origin because absolute epoch seconds do not survive f32.
  • gather() emits x relative to an origin the caller names, in every series here, EventSeries included, and the consumer adds that same origin back in f64. Two separate rules live here and both were learned from a defect. (1) It must be relative at all. An event's stamps are small (edge relative, [-pre, +post]), so emitting them absolute does not overflow anything and does not look like a bug — it just draws the whole capture x0() = one pre-trigger window to the left of the axis. That was the "half the plot is empty / the marker sits at the end of the data / nothing is drawn but the crosshair reads values" defect: the crosshair reads x_at(), which was right all along. [trig-probe]'s data= vs drawn= is the guard. (2) The origin is the view's minimum, not x0(). x0() is the start of the retained window, so the f32 had to resolve 2^-24 of the whole ring however little of it was on screen: 1.9 µs over 20 s of retained data, which at a view 10 µs wide across a 716 px axis is over a hundred pixels between where a sample is drawn and the x x_at() quotes for it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a point beside the data". Renderer::draw_traces passes x_range.min, making the error a fixed 4e-5 of a pixel at any zoom. The parameter sits before out_xy deliberately, so a call site that was not thought about is a compile error rather than silent drift. A caller that has no view in mind may still pass x0(), and [trig-probe]'s drawn=/data= does exactly that on purpose: it reads the series' whole span, which is the widest relative value a gather can be asked to stage. The two guards are upstream's a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes and the trace ink: line's drawn-vs-quoted figure. A probe that recomputes what the renderer gathers must pass the same origin the renderer does ([ink] and [zoom-probe] both pass the axis minimum), or its reconstruction stops being the renderer's and its pixel readings mean nothing.
  • Every series bound to a trace must carry a real MinMaxPyramid, including an immutable one. MinMaxPyramid::level_for() descends to level 0 — raw samples — on a pyramid with no levels, whatever n is, so a series that answers pyramid() with an empty one is re-gathered sample by sample on every frame it is bound. That was the "large data is laggy" defect: EventSeries had no pyramid, and a held 2 s capture at 1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The pyramid lives on EventSnapshot, not on EventSeries: the snapshot is immutable, so it is built once in publish_event() inside the O(n) pass that already computes the y bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per channel. frame path:'s segments is the guard. "Every" includes the pause, and that is the half this invariant was written without and which then went wrong for real: publish_pause_view() filled stamps, values and y and left pyramids empty, so a paused view was re-gathered raw on every frame. Both halves of the cost are readable, and the second is the one a performance line cannot show. frame path: read raw=21 lod=118 over a 1.6 s pause against raw=1 lod=170 for the same run live, with the draw mean 12.34 → 18.18 ms — and the trace changed appearance, which is what gets reported. stage_trace_density() grades a column against its own peak from a 1/height deposit per segment, so a column fed ~660 raw inter-sample segments accumulates a far spikier distribution, and a far larger peak to normalise against, than one fed the one or two bucket envelopes the pyramid gives: the paused trace read as a hard bright core with its dwell tail dimmed out. A pause is not a cheaper case than a capture — it freezes the same rings, so anything publish_event() does to a snapshot, publish_pause_view() owes too. Building it is not free (see publish cost: below): it took the pause publish from 6.15 ms to 22.31 ms, a one-off producer stall against ~5 ms a frame of draw for the pause's whole length, which at ~60 fps repays about thirty times over on a 1.6 s pause — and the producer absorbs it without losing packets (gaps=0 with and without a pause, the paused run at an 86 ms publish). The check that the look is restored is frame path: across the pause, not the cost line: a paused run must read the same path as a live one — measured raw=1 lod=555 columns=555 graded=555 against raw=0 lod=373 columns=373 graded=373, same 24711-segment worst frame, segment mean within 5 % — where the defect read raw=21 lod=118. A raw in the tens over a pause is this returning, and it is the appearance as much as the cost.
  • EventSeries::gather() emits a bucket's envelope, not every 2^level-th sample. Point decimation hides exactly the spike the capture was triggered on; a scope has to draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's pyramid is built from sample 0 and nothing is ever dropped from it, so window_base() is 0 and a window index is the absolute one.
  • A trace denser than the axis is drawn one pixel column at a time, and that is what keeps the plots free of moiré. The envelope staged as a polyline banded visibly and crawled under a pan on exactly the signals this app is pointed at — a high rate, a high frequency or noise — and three things compound to produce it, none a bug alone: level_for() sizes a bucket at one to two pixel columns, Envelope emits two points per bucket so each contributes a near-vertical min→max hairline and a near-vertical connector (≈3.6 per column per trace), and every one lands at a fractional x because a trace is deliberately not snapped. Under src-alpha compositing a column's brightness is then a non-linear function of the sub-pixel phases overlapping it, the pattern repeats at the beat between the bucket pitch and the pixel pitch, and a pan sweeps the beat. deps/plotter's Renderer::stage_trace_columns() emits one snapped vertical segment per integer column instead, so the trace has one phase and there is nothing left to beat. Four things to know before touching it, all upstream in docs/data-and-performance.md: the gate is the gathered density (≥2 points per column) and Envelope, never the LOD level — the raw path at 1.5–64 spp beats the same way, and the density term is also what keeps the reduction off a sparse window whose trace is the two segments entering and leaving the frame; a hole stays a hole; disjoint neighbours are joined so a fast edge keeps its join; and it is the one carve-out in u_snap's "never anything drawn at a data position" rule, because a column is a pixel column. It needs a thin ribbon to be visible at all — a window whose buckets span several periods paints a saturated block and hides the beat — so a screenshot of a slow sine at a whole-window view says nothing either way. The guards are upstream's a_dense_trace_is_drawn_as_whole_pixel_columns, which counts the distinct coverage levels in the ink (4 against the polyline's 126; a count of partially covered pixels separates nothing, 577 against 652, because a 1.5 px stroke's fringe always spills into the next column), and frame path:'s columns= field here. trace ink: deliberately cannot see this path: it recomputes the gathered points, which the reduction is downstream of, so its drawn-vs-quoted reads 0.0000 either way and remains the guard for the gather origin alone.
  • A reduced column is graded by dwell, and the grading may not undo the reduction. The column above is honest about where the signal went and silent about how long it was there — a column's min and max are identical whether its samples fill the span or pile at its two ends, so a ramp and a pulse are one flat band. Style::intensity_grading grades it instead (bright where the signal sits, faint where it only passes through, the digital-phosphor look), and udpscope turns it on; the library's default is off, so other hosts are not restyled by an upgrade. The switch is a one-shot in tick() guarded by App::style_stated, not a line in the window build: PlotArea::renderer() is null until the area is realized, so there is no earlier place to state a style. Three things to know here, the rest upstream in docs/data-and-performance.md (the 1/height dwell weighting, the one-column accumulator, the per-column-peak normalisation, the quantised alpha steps):
    • The finer gather level is floored at 1 whenever the flat path would have used the pyramid. Grading asks for samples_per_pixel / intensity_sub_columns, and MinMaxPyramid::level_for() answers 0 — raw samples — as soon as that falls under one bucket (kBucket is 64). Unfloored, every moderately dense frame became an O(visible) gather: at 1.3 MS/s over a 2 s window, 172 samples a pixel, raw=1154 lod=0 against the flat path's raw=710 lod=453, and 2.30 → 6.36 ms of draw mean. The floor degrades the look (fewer sub-columns than asked for, down to what the coarsest buckets give — still two or three per column at 64 samples a bucket) and never the path. It is also why intensity_sub_columns past about 8 buys nothing: the bucket size is the limit.
    • graded= must equal columns= in frame path:, and UDPSCOPE_NO_PHOSPHOR=1 is the A/B that makes either line readable against the ungraded path (see probe-readings.md).
    • The steps are draw calls, not per-segment alpha. LineInstance is four floats of geometry and the colour is a uniform (only TextInstance carries a_colour), so the gradient is intensity_steps LineBatches flushed at different alphas. That is why the segment mean rises from 131 to 1340 on the same frames — the rise is the tiers, not extra geometry per column, and the bound is still axes.w (measured 14.5 runs a column in the library's own guard, 8303 segments over a 572 px axis). The guard is upstream's a_dense_trace_can_be_graded_by_dwell, which draws one frame both ways and profiles the band's outer fifths against its middle: flat reads 191/191, graded 191/131. That 0.69 is a tier ratio, not the dwell ratio — intensity_gamma (0.45) and intensity_floor (0.3) compress it deliberately, the floor so that a fast riser, one sub-bucket deep everywhere, is never dimmed out of existence.