fix: persistence is the trace's own phosphor, not a density behind a stroke

The plotter now deposits every acquisition -- the newest included -- into one grid per trace and
draws no solid trace over it, each grid in that trace's colour and with a one-sweep ceiling (its own
commit, **1b8b55d**). This side needed almost no change of its own, because the colour was already
the trace's and the trace's colour is `RowSet::signal_color()` -- the invariants' one route -- but the
figures it reads back are now per trace:

- **The ceiling is left at the library's default of one sweep**, no longer derived from the fade.
  Deriving it from the steady state is the gain offset the user reported between the latest
  acquisition and the persistent image; a signal crossing the same place every acquisition now settles
  at the trace's colour, at full strength, and the newest sweep reads as the trace it is a picture of.
- **`cells`/`grid_bytes` are the sum over the row's traces** -- one grid each, a trace with no series
  holding none -- and both the probe and the exit dump carry `traces=` beside `rows=`. That is what
  makes the per-signal memory figure read: **556 537 cells and 2.2 MB per signal**, at 8904600 bytes
  over the four one-signal rows of the probe's run.

The probes, re-read on this build (the `verification.md` command, `--rate 500000 --batch 1000`):

    [density-probe] off enabled=0 ... rows=0 traces=0 cells=0 ink=0.0 peak=0.0000
    [density-probe] on enabled=1 ... rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000
    [density-probe] step-acquisitions ... peak=1.5000
    [density-probe] time-before ... peak=2.1359
    [density-probe] step-time ... ratio=0.367879
    [density-probe] frame cost: tick 0.62 -> 6.96 ms (184/23 frames), draw 2.02 -> 6.16 ms (177/18)
    [udpscope]   density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150

`on` `peak=1.0000`, `step-acquisitions` `peak=1.5000` and `step-time` `ratio=0.367879` are the legs
that were expected, unchanged; the *draw* mean is now **below** the run before the split (2.02 -> 6.16
against 2.09 -> 20.91), because the solid traces are no longer drawn over the image.

Docs updated with it: the trigger manual page (`persistence` is the trace, one image per signal, the
newest included), the README's persistence section, `invariants-trigger-and-pinning.md` (five rules,
the ceiling and the no-stroke rule among them), `verification.md` and `probe-readings.md` (the lines
above), the trigger panel's comment and tooltips, and the plotter submodule.

`cmake --build build --clean-first -j4`: **0 errors, the documented three warnings** (two
`MathNode::source`, one `-Woverlength-strings` at 12352); `make -C build test-ui` **13 ok**;
`make -C build test-help` **green** (15 pages); the self-check **0 `Gtk-WARNING`/`Gtk-CRITICAL`** and
`gtk warnings: 0`, two `buffer allocated:` lines.
This commit is contained in:
Martino Ferrari committed 2026-10-16 20:21:57 +02:00
1 parent 5e32199597
commit da826f2cb5
11 files changed
+181 -130

No files matched your search

+33 -23
View File
@@ -376,21 +376,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
trigger that has stopped firing never reaches another publish. A depth change is therefore also
how the memory comes back.
- **The phosphor density is one *image*, and that is a different display from the layers above, not a
- **The phosphor density is the *trace*, and that is a different display from the layers above, not a
mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple
events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence*
is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, a screen-sized intensity grid
every acquisition is deposited into and which fades either by a time constant or by a
per-acquisition factor. There is **no depth** on the density and no per-acquisition memory: the
image is `columns * rows * 4` bytes, one cell per device pixel of the row's axes (2.2 MB over the
four rows of the probe's run), so a thousand acquisitions cost what one costs. A cell's value is a
number of *sweeps*: the library deposits a cell once per trace per sweep however many of that
trace's own samples crossed it (`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit
deposit read 18) -- dwell is `Style::intensity_grading`'s question, not this one's.
Four things hold it together, each of which is a rule:
**per trace, drawn in that trace's own colour**, every acquisition deposited into it, which fades
either by a time constant or by a per-acquisition factor. There is **no depth** on the density and
no per-acquisition memory: the image is `traces * columns * rows * 4` bytes, one cell per device
pixel of the row's axes for every trace the row draws (the app's default four signals in one row
are four grids -- 6 MB at 800x480; the probe's one-signal-per-row run is 2.2 MB over four rows), so
a thousand acquisitions cost what one costs. A cell's value is a number of *sweeps*: the library
deposits a cell once per trace per sweep however many of that trace's own samples crossed it
(`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit deposit read 18) -- dwell is
`Style::intensity_grading`'s question, not this one's. The colour is the trace's own
(`RowSet::signal_color()`, the same source the trace was built with and never a second one), so a
saturated cell is that signal's colour at full strength and two signals agreeing in a cell are two
quads of two colours rather than a blend.
Five things hold it together, each of which is a rule:
* **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as
the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops
the grid **at once** rather than at an acquisition a quiet trigger never sends (the rule
the grids **at once** rather than at an acquisition a quiet trigger never sends (the rule
`set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an
empty image rather than a stale one at another scale.
* **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()`
@@ -403,21 +408,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending
flag, so a user who turns persistence on while an event is held sees it instead of waiting for a
latch that `single` mode may never send.
* **The brightness ceiling is the fade's steady state, and the ramp is linear in sweeps.** The app
states `DensityConfig::full_scale` from the fade it configured -- `1/(1 - factor)` for the
acquisition fade, the library's default two sweeps for the time one, which has no fixed steady
state (it settles where the rate and the constant meet) -- and leaves `gamma` at 1.0. A ceiling
at *one* sweep is the defect a user reported as "the colour fading is not applied, it looks
white": a single sweep was already full ink, everything above it clamped to the same pixel, and
a display of one sweep and a display of five were the same white. It is also the one half that
the *grid's* own numbers cannot see -- `ink`/`peak` were right while the picture was flat -- so
the assertion is a **framebuffer** one: `a_phosphor_density_is_drawn_under_the_traces` measures a
cell at a quarter of the scale against one at the ceiling and against the background
(`empty=21 full=83 quarter=54`), which is the reading the eye asked for.
* **It is the plot's, so a figure rebuild resets it** -- the grid lives on each `Plot`, and
* **No trace is drawn over it, and the newest acquisition is inside it.** `Renderer::draw_traces()`
returns at once while a density is on: the trace ink *is* the image, so the acquisition just
deposited is a sweep in it and fades with the rest. Drawing the polyline too put a solid,
unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition",
where the last acquisition never faded in time mode -- and the assertion is a **framebuffer** one:
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the same pixel before and after one
time constant and requires it dimmer (`full=224 -> faded=115`, the trace's own `0xE0` green),
which a stroke over the density could not do.
* **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.** The
app leaves `DensityConfig::full_scale` at the library's default of **one sweep** and `gamma` at
1.0, so the acquisition just deposited reads as the trace it is a picture of and a signal crossing
the same place every acquisition settles *at* the trace's colour (the ceiling clamps), rather than
a fraction of a brighter steady state. The old ceiling -- the fade's steady state, `1/(1-factor)`
for the acquisition fade -- is the gain offset a user reported between the latest acquisition and
the persistent image, and the "dimmer by design" headroom that made the ramp visible is reversed
here.
* **It is the plot's, so a figure rebuild resets it** -- the grids live on each `Plot`, and
`rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal
switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else,
and the alternative -- keeping the grid in the `RowSet` -- is what would make it not a plot
and the alternative -- keeping the grids in the `RowSet` -- is what would make it not a plot
feature. The layout is unaffected either way: the density reserves no space and nothing on it can
be picked, exactly as `set_invalid_spans()` does.
* **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and
+35 -27
View File
@@ -1980,53 +1980,61 @@ ring's bindings, and a reading taken there says nothing about either.
```
[density-probe] arming on 'square', window 0.5 s, display off
[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000
[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=226596.0 peak=1.0000 ratio=1.000000
[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=262722.0 peak=1.5000 ratio=1.159429
[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=213362.2 peak=1.2182 ratio=1.000000
[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=78491.6 peak=0.4481 ratio=0.367879
[density-probe] frame cost: tick before=0.74 ms (163 frames) after=9.99 ms (12 frames) draw before=2.09 ms (157) after=20.91 ms (7)
[udpscope] density: frames=504 rows=4 cells=2226150 grid_bytes=8904600 ink=632845.4 peak=2.8 upload_bytes=1081350
[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 traces=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000
[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000 ratio=1.000000
[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=313971.0 peak=1.5000 ratio=1.384437
[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=458610.1 peak=2.1359 ratio=1.000000
[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=2226150 ink=168713.2 peak=0.7858 ratio=0.367879
[density-probe] frame cost: tick before=0.62 ms (184 frames) after=6.96 ms (23 frames) draw before=2.02 ms (177) after=6.16 ms (18)
[udpscope] density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150
```
The **phosphor density** (`Plot::set_density()`, the trigger pane's `persistence` switch): one
screen-sized intensity image per row, every acquisition accumulated into it, drawn behind the traces and
fading by a count or by the clock. The numbers are the plotter's own -- `DensityGrid::ink()`/`peak()` summed
and maxed over the rows the display is on, which is the same source the library's tests read -- and the
screen-sized intensity image **per trace**, in that trace's own signal colour, every acquisition
accumulated into it (the newest included), drawn *in place of* the traces and fading by a count or by
the clock. The numbers are the plotter's own -- `DensityGrid::ink()`/`peak()` summed and maxed over the
traces the display is on, which is the same source the library's tests read -- and the
probe drives the panel's **own widgets** for the switch and the fade, so what it measures is this app's
wiring.
- **`off` is the display without it**: `rows=0`, so there is no grid at all and both numbers are zero.
That is also the run the frame cost is read against, and it is a plain run -- the density is off by
default and nothing draws it.
- **`on` is one sweep**: `peak=1.0000` exactly, and `ink=226596` is the cells that sweep covered over
four rows. A cell takes **1.0 once per trace per sweep** however many of the trace's own samples crossed
it, which is what makes a sweep's peak *one* rather than the eighteen a per-hit deposit read on the same
run -- and what makes the sweeps behind it visible at all.
- **`off` is the display without it**: `rows=0 traces=0`, so there is no grid at all and both numbers
are zero. That is also the run the frame cost is read against, and it is a plain run -- the density
is off by default and nothing draws it.
- **`on` is one sweep**: `traces=4` -- one grid per signal, four rows of one signal each -- and
`peak=1.0000` exactly, with `ink=226786` the cells that sweep covered. A cell takes **1.0 once per
trace per sweep** however many of the trace's own samples crossed it, which is what makes a sweep's
peak *one* rather than the eighteen a per-hit deposit read on the same run -- and what makes the
sweeps behind it visible at all.
- **`step-acquisitions`** deposits one more sweep: `peak=1.0000 -> 1.5000`, which is `1 + 0.5 x 1` -- the
panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. `ink` rises from
`226596` to `262722` for the same reason: the old image halved and one sweep landed on it. The ratio is
panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. The ratio is
**not** a constant to expect -- the acquisition on screen can move between the two reads, so what it says
is "the old image halved and a pass landed on it"; the exact relation is the *library's* case
(`a_density_halves_per_acquisition_and_keeps_the_newest_brightest`), where the same frame is deposited
twice.
- **`step-time` is `1/e` by construction**: one step of `dt = tau` through the plot's own `decay_density()`,
so `ratio=0.367879` is `exp(-1)`. The leg before it (`time-before`) reads the same image under the time
fade, and the pair is the reading that says the *panel's* constant reaches the grid.
fade (`peak=2.1359`), and the pair is the reading that says the *panel's* constant reaches the grid.
- **`frame cost:` is the A/B inside one run**: the accumulators as of the tick the display was switched on
against the frames after it, so both means are the same stream, the same layout and the same mock. At four
rows of 573x412 cells it reads **tick 0.74 -> 9.99 ms** and **draw 2.09 -> 20.91 ms**: the per-frame
rows of 573x412 cells it reads **tick 0.62 -> 6.96 ms** and **draw 2.02 -> 6.16 ms**: the per-frame
conversion, upload and deposit. It is llvmpipe, and the arithmetic is the grid's -- 2.2 M cells, one byte
each, every frame the time fade moves it.
each, every frame the time fade moves it. The *draw* mean is now below the run before the per-trace
split (2.02 -> 6.16 against 2.09 -> 20.91): the solid traces are no longer drawn over the image, so the
frame draws four quads where it used to draw four quads *and* every trace.
- **`cells`/`grid_bytes` are the per-signal memory figure**: 556 537 cells and 2.2 MB *per signal*, times
the four signals here (2 226 150 cells, 8 904 600 bytes). It is the axes rect's device pixels, so it is
the screen and the trace count and nothing the trigger has done -- and the app's default layout, four
signals in one row, is four grids for that one row.
- **The dump's `density:` line** is captured on the last tick the display was **on**, not read at exit: the
exit probe switches the trigger off before it dumps, which takes the display with it, so a dump that asked
the plots would say `rows=0` about a run whose tail had the image up. `upload_bytes` is the **most** any
one frame uploaded -- one byte a cell, over the rows that moved -- and not the last frame's, for the same
reason.
one frame uploaded -- one byte a cell **per trace**, over the grids that moved -- and not the last frame's,
for the same reason.
**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink, so
a steady display under the acquisition fade sits between 1 and 2, and a `peak` well above that (the
per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now catches.
**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink
(the trace's own colour), so a steady display under the acquisition fade sits between 1 and 2, and a `peak`
well above that (the per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now
catches.
**And `N = 1` is the display without the feature**, which is the same command at
`UDPSCOPE_PROBE_PERSIST_DEPTH=1`:
+6 -4
View File
@@ -115,8 +115,9 @@ precedent, not an exception.
schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump
lines are in [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_DENSITY_MS` (+ `UDPSCOPE_PROBE_DENSITY_TAU`, default 1.0 s) — the **phosphor
density**: one screen-sized intensity image every triggered acquisition is accumulated into, drawn
behind the traces, fading by a per-acquisition factor or by a time constant. It states the display
density**: one screen-sized intensity image **per trace**, in that trace's own colour, every
triggered acquisition accumulated into it (the newest included), drawn *in place of* the traces,
fading by a per-acquisition factor or by a time constant. It states the display
**off** through the panel's own switch first, so the `off` leg is the run without it, then arms the
trigger on the mock's `square` with one signal per row — the arrangement the persistence probe uses
— and only then switches it on, so the acquisition already on screen is the first image. Four legs,
@@ -131,8 +132,9 @@ precedent, not an exception.
kill $PY
```
**The assertions, in order:** `off` reads `rows=0 cells=0 ink=0.0 peak=0.0000` (there is no grid at
all); `on` reads `rows=4` — every row has its own image — and **`peak=1.0000`**, one sweep's ink
**The assertions, in order:** `off` reads `rows=0 traces=0 cells=0 ink=0.0 peak=0.0000` (there is no
grid at all); `on` reads `rows=4` — every row has its own image, one per trace in its own colour,
so `traces=4` with one signal a row — and **`peak=1.0000`**, one sweep's ink
being one per cell it covers; `step-acquisitions` reads **`peak=1.5000`**, which is the panel's
0.5 applied to the old image plus the new sweep's own pass; and `step-time` reads
**`ratio=0.367879`**, one step of `dt = tau` through the plot's own `decay_density()`. A `peak` in