diff --git a/docs/claude/invariants-data-path.md b/docs/claude/invariants-data-path.md index 40c7d89..e76436c 100644 --- a/docs/claude/invariants-data-path.md +++ b/docs/claude/invariants-data-path.md @@ -115,7 +115,21 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear never amended. `neighbours()' binary search requires a monotonic array, and `window_x0_` must remain valid; pre-stamping the hole at the declared cadence (exact in relative mode, the bridge mechanism in stream mode) is what keeps both invariants, and it is why a late - packet's counter, not its arrival stamp, is what addresses the hole. + packet's counter, not its arrival stamp, is what addresses the hole. The reservation cap + (`kGlitchPackets`, 256) is counted in **packets, not samples**, so one hole's sample cost + is `256 × batch_` and scales with the stream's batch — 25 600 samples at a batch of 100, + 2.56 M at a batch of 10 000 — which is the number to weigh against the ring, not the 256. + - **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces + the store, so `produced()` restarts at 0 and a hole's `slot_begin`/`tick_begin`/`x_begin` + describe a buffer that is gone; it therefore clears `holes_` and bumps + `ScopeModel::plan_generation()`, which the producer compares against the value it last saw + and resets `tick_` on. That is what keeps `tick_ / period == produced()`, the relation + `backfill_from()` derives a slot index from — without it the amend falls outside the ring + and repairs nothing while the hole is still marked repaired, a band saying the opposite of + the truth. The generation is the signal and the stride/channel-count comparison is not: a + reconnect with an identical CONFIG rebuilds the store leaving both unchanged. `slot_base_` + is **not** reset — it is relative mode's clock origin, samples since the stream began, and + relative time must not jump backwards when the user retypes the window. - **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins the published window to the value ring's base and both rings keep sliding afterwards — their capacities are equal, so there is no slack — which means the oldest few samples diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 753cda0..c5cd77e 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -586,32 +586,37 @@ Two lines of the self-check output are assertions in disguise: it afterwards. - `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `: : ->` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). - `[color] '' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. -- `[reseq-probe] reseq=N late=N stale=N holes=N hole_slots=N backfilled=N open_holes=N bands=N - bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` (stderr, `UDPSCOPE_PROBE_RESEQ_MS`) — +- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N + open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` (stderr, + `UDPSCOPE_PROBE_RESEQ_MS`) — the resequencing path's mid-run state; the measured readings and the per-case command are in - [verification.md](verification.md). Six things to read carefully: + [verification.md](verification.md). Eight things to read carefully: 1. **`backfilled` and `hole_slots` are not comparable directly.** `hole_slots` counts *time-ring* slots once per hole; `backfilled` counts *value-ring* slots repaired and therefore sums over every non-math channel. On a stream with 4 non-math channels they - stand a factor of 4 apart. `hole_slots=4200 backfilled=16800` in the `late` case is - healthy, not an error; `backfilled` below `4 × hole_slots` would mean some channels - went unrepaired. + stand a factor of 4 apart. `hole_slots=4000 backfilled=15600` in the `late` case is + healthy, not an error (the shortfall is the one hole whose late packet is still in + flight); `backfilled` well below `4 × hole_slots` would mean some channels went + unrepaired. 2. **`nan_slots=0` beside a large `holes` is healthy, not a contradiction.** A hole is retired from the table only when its slots age out of the ring - (`ScopeModel::forget_holes_before()`), *never* on repair. So the `late` case reads - `holes=42 nan_slots=0`: every hole was fully repaired, and all 42 entries stay in the - table with no placeholder left in the data. What *would* be a defect is `nan_slots > 0` - with `bands_repaired == bands`, which would mean a band claimed data was recovered while - placeholders remained. The discriminator is `bands_repaired`, not `holes`. + (`ScopeModel::forget_holes_before()`) or a re-plan drops it, *never* on repair. So the + `late` case reads `holes=40 nan_slots=100`: 39 of the 40 holes were fully repaired and all + 40 entries stay in the table, the 100 remaining placeholders being the one packet still in + flight. What *would* be a defect is `nan_slots > 0` with `bands_repaired == bands`, which + would mean a band claimed data was recovered while placeholders remained. The + discriminator is `bands_repaired`, not `holes`. 3. **`bands` is the visible spans, `open_holes` the whole table.** `bands` counts what the plot is actually shading in the visible window; `open_holes` counts every live hole entry - whether or not it is in view. `bands` below `open_holes` is normal — in the `mixed` case - `open_holes=43` against `bands=42` because the 43rd hole has scrolled out of the visible - window and `build_invalid_spans()` drops it. `bands` *above* `open_holes` would name a - stale span left on a plot after its hole was retired. + whether or not it is in view. `bands` below `open_holes` is normal — a hole that has + scrolled out of the visible window is dropped by `build_invalid_spans()` rather than + pinned to the edge. `bands` *above* `open_holes` would name a stale span left on a plot + after its hole was retired. Neither is comparable with `holes`, which is cumulative: a + re-plan clears the table and `kMaxHoles` caps it at 64, so the re-plan case reads + `holes=315 open_holes=64`. 4. **`bands_repaired` is the faint tier.** A hole whose late packets have all arrived is still drawn, in `Style::invalid_repaired_fill` rather than `Style::invalid_fill`, so the @@ -633,6 +638,24 @@ Two lines of the self-check output are assertions in disguise: actually holds, so a reservation that mismeasured the hole shows up there and nowhere else. + 7. **`stale` is 0 by construction whenever resequencing is enabled**, which is every default + run. The client increments it only on the `reorder_window_s <= 0` branch, where it names + the pre-resequencing behaviour: a packet that did not advance the counter was dropped. + With the window open such a packet is delivered flagged instead and counted in `late`, so + a reading of 0 here is the feature working and not a dead counter. The only way to see it + move is to turn resequencing off. + + 8. **`overflows` is how an undersized reorder ring reads.** It counts the times a packet + needed a slot in the client's 64-slot / 4 MB reorder ring and found none, so the gap was + committed early to make room — the window those packets got was shorter than + `reorder_window_s` asked for. Nothing is dropped on that path (the arriving packet is held + after the commit, or delivered in order with the counters it skipped reported as `lost`), + which is the point: a dropped packet would leave a hole nothing could ever backfill. It + reads 0 at the four cases' 10 kS/s and non-zero at the re-plan case's 100 kS/s, where + `--reorder-every 10 --reorder-delay-ms 50` keeps about 50 packets in flight against 64 + slots. A non-zero reading beside a healthy `backfilled` is a note about the ring's size, + not a defect. + - `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires). - `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for ` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index dcfe97e..44995bf 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -201,58 +201,84 @@ as distinct from the fault injection below, which exercises ingest: - `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it stops crossing zero: the dropout trigger's input. -- `UDPSCOPE_PROBE_RESEQ_MS` — one mid-run reading of the resequencing path. Four cases, - **run each as its own process** — four back to back starve the mock past its 2 s silence +- `UDPSCOPE_PROBE_RESEQ_MS` — one mid-run reading of the resequencing path. Five cases, + **run each as its own process** — five back to back starve the mock past its 2 s silence timeout and every case then reads a reconnect instead of what it was testing. Common - conditions for all four: the mock at `--rate 10000 --batch 100 --freq 5`, udpscope with + conditions for the first four: the mock at `--rate 10000 --batch 100 --freq 5`, udpscope with no extra options, under `xvfb-run` with `UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=600 UDPSCOPE_EXIT_DUMP=1 - UDPSCOPE_PROBE_RESEQ_MS=5000`. + UDPSCOPE_PROBE_RESEQ_MS=5000`. Delete `~/.config/udpscope/127.0.0.1_8000.toml` first: a + session left by an earlier run restores its math channel, and five channels instead of four + change `channels=`, `span=` and every count derived from them. | case | mock extra flags | key reading | | --- | --- | --- | | **base** | *(none)* | every counter 0; proves the machinery costs nothing | | **reorder** | `--reorder-every 10` | `reseq=51` (mid-run) / `102` (dump); `holes=0`; resequencing inside the window leaves no reservation | - | **late** | `--reorder-every 10 --reorder-delay-ms 50` | `holes=42 bands=42 bands_repaired=42 nan_slots=0`; every band is the faint, repaired tier | - | **mixed** | `--drop-every 20 --reorder-every 10 --reorder-delay-ms 50` | `bands=42 bands_repaired=20 nan_slots=2300`; all three band states at once | + | **late** | `--reorder-every 10 --reorder-delay-ms 50` | `bands_repaired` one short of `bands` with `nan_slots=100`; the band still solid is the one hole whose late packet is in flight, and it is the *only* unrepaired one | + | **mixed** | `--drop-every 20 --reorder-every 10 --reorder-delay-ms 50` | `bands=43 bands_repaired=20 nan_slots=2200`; all three band states at once | + | **re-plan** | `--rate 100000` with the `mixed` flags, and `UDPSCOPE_PROBE_WINDOW_MS=4000 UDPSCOPE_PROBE_RESEQ_MS=6000 UDPSCOPE_EXIT_AFTER_MS=9000` | four `buffer rebuilt` lines before the reading, then `bands=64 bands_repaired=16 nan_slots=7800`: **a band is still only marked repaired when its own slots hold values**. A `bands_repaired == bands` beside a non-zero `nan_slots` here is the hole table and the producer's `tick_` surviving a re-plan that reset `produced()` — a band marked settled over samples that are all still NaN. The non-zero `overflows` is this case too: 100 kS/s with a 50 ms reorder delay keeps about 50 packets in flight against 64 slots | - Full measured readings (probe line first, then exit-dump; `bands_repaired` was added after the - original probe and any earlier reading without it is obsolete): + The re-plan case needs `--rate 100000`, not the other four's 10 000: at 10 kS/s the ring + already covers 838 s, so no window the control can reach moves `time_stride`, + `set_target_span()` returns without rebuilding, and the window probe reports `decim=1` + throughout — it re-plans nothing. At 100 kS/s the ring covers 83.9 s and the probe's + 500 ms → 500 s → 120 s → 600 s → 60 s walk crosses the boundary four times. + + Full measured readings (probe line first, then exit-dump): ``` === base mock: none - [reseq-probe] reseq=0 late=0 stale=0 holes=0 hole_slots=0 backfilled=0 open_holes=0 bands=0 - bands_repaired=0 nan_slots=0 scanned=44000 trigger_in_hole=0 - [udpscope] frames=1024 packets=1025 gaps=0 channels=4 span=838.861s stride=1 reseq=0 late=0 - stale=0 holes=0 hole_slots=0 backfilled=0 - [udpscope] channel 0 'sine' stored=95200 published=95000 + [reseq-probe] reseq=0 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 open_holes=0 + bands=0 bands_repaired=0 nan_slots=0 scanned=44200 trigger_in_hole=0 + [udpscope] frames=1032 packets=1033 gaps=0 channels=4 span=838.861s stride=1 reseq=0 late=0 + stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 + [udpscope] channel 0 'sine' stored=95700 published=95400 === reorder mock: --reorder-every 10 - [reseq-probe] reseq=51 late=0 stale=0 holes=0 hole_slots=0 backfilled=0 open_holes=0 bands=0 - bands_repaired=0 nan_slots=0 scanned=44000 trigger_in_hole=0 - [udpscope] frames=1028 packets=1029 gaps=0 channels=4 span=838.861s stride=1 reseq=102 late=0 - stale=0 holes=0 hole_slots=0 backfilled=0 - [udpscope] channel 0 'sine' stored=95400 published=95100 + [reseq-probe] reseq=51 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 + open_holes=0 bands=0 bands_repaired=0 nan_slots=0 scanned=44200 + trigger_in_hole=0 + [udpscope] frames=1027 packets=1028 gaps=0 channels=4 span=838.861s stride=1 reseq=102 late=0 + stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 + [udpscope] channel 0 'sine' stored=95300 published=95100 === late mock: --reorder-every 10 --reorder-delay-ms 50 - [reseq-probe] reseq=142 late=48 stale=0 holes=42 hole_slots=4200 backfilled=16800 open_holes=42 - bands=42 bands_repaired=42 nan_slots=0 scanned=44000 trigger_in_hole=0 - [udpscope] frames=1026 packets=1027 gaps=100 channels=4 span=838.861s stride=1 reseq=288 - late=100 stale=0 holes=94 hole_slots=9400 backfilled=37600 - [udpscope] channel 0 'sine' stored=95700 published=95500 + [reseq-probe] reseq=157 late=46 stale=0 overflows=0 holes=40 hole_slots=4000 backfilled=15600 + open_holes=40 bands=40 bands_repaired=39 nan_slots=100 scanned=44000 + trigger_in_hole=0 + [udpscope] frames=1027 packets=1028 gaps=98 channels=4 span=838.861s stride=1 reseq=296 + late=98 stale=0 overflows=0 holes=91 hole_slots=9100 backfilled=36400 + [udpscope] channel 0 'sine' stored=95300 published=95000 === mixed mock: --drop-every 20 --reorder-every 10 --reorder-delay-ms 50 - [reseq-probe] reseq=163 late=24 stale=0 holes=43 hole_slots=4300 backfilled=8000 open_holes=43 - bands=42 bands_repaired=20 nan_slots=2300 scanned=44100 trigger_in_hole=0 - [udpscope] frames=974 packets=975 gaps=101 channels=4 span=838.861s stride=1 reseq=314 late=50 - stale=0 holes=94 hole_slots=9400 backfilled=18400 - [udpscope] channel 0 'sine' stored=95100 published=94900 + [reseq-probe] reseq=159 late=25 stale=0 overflows=0 holes=43 hole_slots=4300 backfilled=8400 + open_holes=43 bands=43 bands_repaired=20 nan_slots=2200 scanned=44100 + trigger_in_hole=0 + [udpscope] frames=978 packets=981 gaps=101 channels=4 span=838.861s stride=1 reseq=309 late=50 + stale=0 overflows=0 holes=94 hole_slots=9400 backfilled=18400 + [udpscope] channel 0 'sine' stored=95400 published=95400 + + === re-plan mock: --rate 100000 --drop-every 20 --reorder-every 10 --reorder-delay-ms 50 + [reseq-probe] reseq=4374 late=72 stale=0 overflows=36 holes=315 hole_slots=31136 + backfilled=26800 open_holes=64 bands=64 bands_repaired=16 nan_slots=7800 + scanned=156600 trigger_in_hole=0 + [udpscope] frames=8949 packets=8953 gaps=614 channels=4 span=83.886s stride=1 reseq=8028 + late=144 stale=0 overflows=36 holes=603 hole_slots=59936 backfilled=55600 + [udpscope] channel 0 'sine' stored=588600 published=584600 ``` - The `mixed` case's `open_holes=43` against `bands=42` is correct: the 43rd hole has scrolled - out of the visible window, and `build_invalid_spans()` drops a span outside the window rather - than pinning it to the edge. The per-field reference is in - [probe-readings.md](probe-readings.md). + The `late` case's `bands_repaired` is one short of `bands` with `nan_slots=100` — exactly one + packet's worth of slots at stride 1 — because the probe fires 50 ms-delayed releases into a + window that always has one in flight. An earlier recording of this case read + `bands_repaired=42 nan_slots=0`; both are healthy, and which one a run gets is where the + probe's instant falls relative to the newest release. A `bands_repaired` *above* the count of + holes whose slots hold values is the defect, in either direction. + + `holes` far above `open_holes` in the re-plan case is the two bounds working: a re-plan clears + the table (the old entries address a buffer that is gone) and `kMaxHoles` caps what survives + at 64. The `mixed` case's `open_holes=43` against `bands=43` has no such spread because + nothing re-planned. The per-field reference is in [probe-readings.md](probe-readings.md). The mock also carries three **fault injections** that no probe schedules, because what they exercise is the ingest rather than the UI. The first: `--time-glitch-at --time-glitch-by ` makes