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>
161 lines
14 KiB
Markdown
161 lines
14 KiB
Markdown
# 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](../../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](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` `LineBatch`es 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.
|