diff --git a/README.md b/README.md index 1464e84..ddd5c3c 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,26 @@ array of per-node structs was measured and bought **nothing** — at six nodes t L1-resident either way. It was kept because a node's state belonging on one cache line is the right shape for a graph that grows, not because it showed up. +### Statistics + +**Statistics** under the measurements list adds a second line to every measurement: `n`, the +mean, the standard deviation, and the range. The observations are the pane's own **recomputes** +— one per refresh, not one per sample — so `n` is a count of *windows measured*, which is what +makes "the mean frequency over the last four hundred windows" the claim it reads as. The pane +refreshes at most ten times a second while the window scrolls and exactly once when it settles, +so a settled window contributes one observation and then stops. + +The accumulator is Welford's, not a sum of squares: a measurement whose values sit at 1e9 with +a spread of 1e-3 is an ordinary case here (an absolute stream time is one), and the naive form +loses that spread entirely. + +**Reset** clears every accumulator. So does editing, adding or deleting a measurement, a CONFIG +that renames the signals, and a change of the display window — each of those changes *what is +being measured*, and a mean across the change describes nothing. A pause, a resume, a trigger +latch, a pan and a zoom do **not** reset: they change which samples are in view, which is +exactly what a statistic over many windows is meant to average. The toggle itself does not +reset either, so turning the line off to read the plot and back on again keeps the history. + ## The session layout file What you arranged is remembered per data source, in @@ -652,6 +672,47 @@ zooming in past the shift showed nothing at all. The self-check now prints `data `drawn=` side by side for the bound trace so the contract is checked rather than assumed. +### Qualified trigger conditions + +The level-and-edge trigger is one of four shapes the **Shape** dropdown offers, and all four +are the *same* trigger: the arming, the hold-off, the pre/post event window and the single/auto +modes are unchanged, and only the question "did an event happen at this sample" differs. + +- **edge** — a crossing of the level in the chosen direction. The original behaviour, bit for + bit. +- **pulse width** — a crossing *and* its return, qualified by how long the excursion lasted: + `<`, `>`, `within` or `outside` a pair of widths. The event is timed at the **closing** edge, + because that is the first sample at which the width is known. `<` a width shorter than the + signal's own pulses is the glitch trigger: nothing but a runt satisfies it. +- **window** — the signal entering or leaving a band between two levels. A band is two levels, + so the panel refuses a zero-width one rather than quietly degrading to an edge. +- **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage: + the condition re-arms only when a crossing returns. + +A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a +width to a window trigger. The condition is **not** persisted in the session file — the session +has never persisted the trigger — so a restart comes up on an edge trigger. + +### Searching the buffer + +**Search the buffer**, at the foot of the trigger panel, runs the trigger's *own* condition over +the whole retained ring and lists every place it holds. There is no second condition editor: a +second place to spell the same question would be a second place to get it wrong. + +The scan runs on the GTK thread in a few milliseconds of work per frame, taking the model's +mutex for one chunk at a time, so neither the frame rate nor the producer stalls on it. Up to +10 000 hits are kept; past that the scan stops and says so. If the stream overtakes the oldest +samples mid-scan the search skips forward to what is still there and reports `the stream +overtook the oldest samples` rather than returning a hit at a time that no longer exists. + +The arrows step through the hits. A step **pauses** the view on the hit, centred, exactly as the +Pause button does — so the frozen window is pinned and navigable within its own data, and +resuming returns to the live view. Up to 16 of the hits **in the visible window** are marked on +every row at once, nearest the centre first and the selected one always among them in its own +colour; when there are more in view than there are marks the panel says `16 of 38 marks shown — +zoom in to see the rest`, which is a true statement about the picture rather than a silent +truncation. + **The live view had two clipping bugs of its own**, both wearing the same face as that one: the older part of the trace blank, the newest part fine, and the crosshair still reading values in the blank part. Neither is a data bug — the samples were always there. diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md index 68f9404..316a084 100644 --- a/docs/claude/invariants-measurements.md +++ b/docs/claude/invariants-measurements.md @@ -108,3 +108,21 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion that the fast path is a fast path and not an approximation. The app's reading is `[measures-probe]` against `--noise 0.1`. +- **A statistic's observation is one *recompute*, not one sample.** The pane already refreshes on + a cadence (at most one pass per 100 ms while the window scrolls, exactly once when it + settles), and the accumulator is fed from that pass's result — so `n` counts windows measured. + Feeding it per sample would make `n` a sample count and the mean a mean of *samples*, which is + not what any of these measurements measure: a frequency over a window is not a property of a + sample. The reading that names this defect is `[stats-probe]`'s `n` in the thousands rather + than the tens. +- **The accumulator is Welford's, and the reason is the same one the time ring is `f64` for.** A + measurement's values are routinely large with a small spread — an absolute stream time is the + obvious case — and `Σx² - (Σx)²/n` cancels to noise there. The standalone check is that the + spread of `{2,4,4,4,5,5,7,9}` survives a `+1e9` offset unchanged. +- **What resets the statistics is "what is being measured changed", not "what is in view + changed".** An explicit Reset, an add/edit/delete (all three funnel through + `rebuild_measures_pane()`), a CONFIG, and a change of display window each change the + *question*, so the history is cleared. A pause, a resume, a trigger latch, a pan and a zoom do + not: they change which samples answer it, which is precisely what averaging over many windows + is for. The pane-wide toggle does not reset either — it only shows and hides labels that are + always created and always fed, so reading the plot for a moment does not cost the history. diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 86105f8..fafe005 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -122,3 +122,21 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear 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()`. +- **`EventDetector` owns every condition shape and holds no arming state; `TriggerEngine` owns + the arming, the hold-off and the post window and holds no condition.** The split is what lets + the buffer search ask the trigger's exact question without owning a trigger, and it is only + true as long as neither half leaks into the other: a detector that knew about the hold-off + would answer a search differently from the live trigger, which is the one thing the + `agree=1` cross-check in `[search-probe]` exists to catch. +- **The detector is fed on *every* sample, including during the hold-off and during a capture.** + A pulse width, a window excursion and a dropout timeout are all *stateful over several + samples*: skipping the feed while the engine is busy loses the opening edge, and the condition + then measures from whatever sample the engine happened to resume on. The hold-off is applied + to the detector's *answer*, not to its input — `process()` feeds first and tests + `now_s < holdoff_until_s_` afterwards. +- **An event's time is the detector's, not the sample's.** A pulse-width event is detected at + the closing edge but could in principle be timed at the opening one, and a dropout is detected + one sample after the timeout expires. `feed()` therefore returns the event's *own* time and + `process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate — + all three, or the marker, the cut and the gate would disagree with each other by the width of + a pulse. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 88a47a4..a0f5942 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -588,3 +588,114 @@ Two lines of the self-check output are assertions in disguise: - `[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. - `[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. + +## `[condition-probe]` + +`UDPSCOPE_PROBE_CONDITION_MS`, with the mock's `--glitch-at`/`--glitch-width` (legs 0 and 1) or +`--quiet-at` (leg 3). + +Four legs, selected by `UDPSCOPE_PROBE_CONDITION_SHAPE`. Every leg prints one line of the form: + +``` +[condition-probe] type=T cmp=C width=W t0=T0 measured=M at0=A earlier=E level2=L2 mode=D +``` + +Measured readings (`--rate 10000 --freq 5`, each on a fresh port): + +``` +shape=0 type=1 cmp=0 width=0.010000 t0=1.842100 measured=0.002000 at0=nan earlier=nan +shape=1 type=1 cmp=1 width=0.010000 t0=2.160100 measured=0.200000 at0=nan earlier=nan +shape=2 type=2 cmp=0 width=0.010000 t0=1.706700 measured=nan at0=-0.500907 earlier=-0.498185 +shape=3 type=3 cmp=0 width=0.010000 t0=4.120200 measured=0.300100 at0=nan earlier=nan +``` + +`measured` is the reading, not a phase. It is the pulse width read back from the held snapshot's +own stamps, which are edge-relative: the snapshot is the ground truth, not the mock's absolute +clock. Three things are worth knowing before calling a reading wrong: + +- **Leg 0**: `measured=0.002` is the injected glitch width. A `measured` near the signal's own + half-period means the width qualifier did not qualify — the trigger caught a normal edge instead + of the runt. +- **Leg 1**: `measured=0.200` is an ordinary square half-period; every square pulse satisfies + `> 0.010`. +- **Leg 2**: `at0` must lie **outside** `[-0.5, +0.5]` and `earlier` **inside** it — those are the + two sample values around the band crossing. The probe prints `nan` for any field a leg cannot + measure. **This leg uses `sine`**, not `square`: the square is exactly ±1 and has no sample + inside the `[-0.5, +0.5]` band, so a window condition on the square can never fire. **Use a + 1.0 s window with 50 % pre** or the probe cannot reach the opening edge of a 0.2 s pulse. +- **Leg 3**: `measured=0.300` is the dropout timeout. **This leg also uses `sine`**. A timeout of + 0.050 s against a sine crossing zero every 0.1 s latches on a healthy signal, so use 0.300 s + or longer; **use a 1.0 s window with 50 % pre** as for leg 2. + +The channel-selection line `[condition-probe] = of channels` is printed before +the trigger fires; if the channel is not found this reads `-1` and the probe fails immediately. +A failure line `[condition-probe] no latch after N polls` means the condition never fired — a +wrong shape, a missing mock flag, or the wrong signal selected. + +## `[stats-probe]` + +`UDPSCOPE_PROBE_STATS_MS`. Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and +`UDPSCOPE_PROBE_MEASURE_MS` to add measurements. Run against the fast mock +(`--rate 1300000 --batch 2000 --freq 1`) for readable figures. + +Three phases, one line each. Every line carries `phase=`, `on=` (the toggle), `passes=` (the +pane's recompute count since start) and one `| n=… mean=… sd=… lo=… hi=…` group per +measurement. Phase 1 adds `dn=` per group. + +Measured readings (fast mock, 1 Hz sine, with +`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`): + +``` +phase=0 on=1 passes=36 | n=30 mean=-0.0370313 sd=0.0953509 lo=-0.246367 hi=0.105167 | n=30 mean=0.633511 … | n=30 mean=1.99567 … hi=2 | n=30 mean=0.997834 … hi=1 +phase=1 … dn=0 (on all four measurements) +phase=2 … n=1 mean=-4.84467e-14 | n=1 mean=0.63662 | n=1 mean=2 | n=1 mean=1 +``` + +- `phase=0` — the running count. **`n` must be in the low tens**, not thousands: a count in the + thousands means the accumulator is fed per sample, not per recompute. `mean=0.633511` (≈ 2/π) + for the `top` measurement on a unit sine is the check that the threshold types are correct, not + merely plausible. `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`. +- `phase=1` — after a two-second pause. **`dn` must be 0 or 1**: a pinned window recomputes once + when it settles and then never again. A `dn` near 120 is the second defect — accumulating per + frame, which adds a hundred-odd observations of the identical window and collapses `sd` towards + zero. Note that `n` is **not** reset by the pause or the resume; `passes` continues growing. +- `phase=2` — after Reset. **`n` must be 0 or 1** (0 if the report ran before the next recompute, + 1 if after); anything larger means Reset cleared nothing. + +## `[search-probe]` + +`UDPSCOPE_PROBE_SEARCH_MS`, scheduled after `UDPSCOPE_PROBE_CONDITION_MS`. Run with +`--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004` and +`UDPSCOPE_EXIT_AFTER_MS=12000`. + +Measured output (port 8419): + +``` +[condition-probe] type=1 cmp=0 width=0.010000 t0=1.710000 measured=0.004000 at0=nan earlier=nan level2=0.500000 mode=2 +[search-probe] started on channel 0, armed t0=5.710000 +[search-probe] state=2 hits=12 scanned=4.5115 total=4.5115 chunks=2 worst=0.106ms total=0.116ms armed t0=5.710000 vs scanned first=5.710000 err=0 agree=1 +[search-probe] jumped to hit 0 t=1.710000 mode=4 axis=[-0.1000..0.1000] pos=1 +[search-probe] marks placed=1 of 1 in view (12 found) locked=1 mode=4 axis=[-0.1000..0.1000] +[udpscope] search marks: placed=1 hits=12 locked=1 +``` + +- **`agree=1` is the load-bearing line of the whole feature.** The search and the live trigger run + the same `EventDetector` over the same stamps, so `agree=0` means one of the two is reading a + different clock — which is exactly the class of defect a search that merely "returns plausible + times" hides. Here `err=0`: exact agreement, not within tolerance. +- `state=2` is `BufferSearch::State::Done` (the enum's ordinal). `state=1` would be `Running`. +- `mode=4` after the jump is `DisplayMode::Paused` (the enum's ordinal). `mode=2` would be + `Capturing`. +- **`placed=1 of 1 in view` is the correct answer, not an under-count.** The jump frames a 0.2 s + window and the glitches are 0.4 s apart (one per square period at 5 Hz / square half-frequency), + so only one hit can be in view at a time. `1 of 1` against `(12 found)` is the marks agreeing + with the window, not with the hit list. +- **`total=4.5115` far short of the run is not a defect.** `condition_probe` enables the channel + (which starts acquisition) and then calls `apply_time_window()`, and each of those rebuilds the + buffer and drops history. 4.51 s was the genuinely retained span. +- `locked=1` says the marks are out of the pointer's reach, like every other readout. The exit + dump's `search marks: placed=1 hits=12 locked=1` is the same assertion from the dump path; its + `placed=` counts marks in the **visible window at exit**, which is the live view after the probe + disarmed the trigger — so `placed=0` there is legitimate and asserts nothing about the search. + The load-bearing `placed=` is the `[search-probe] marks` line, emitted a frame after the jump, + which quotes the window the scope has actually moved to. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 31d9fc4..8c6aa1f 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -177,6 +177,29 @@ precedent, not an exception. `/tmp/udpscope-export`) — collect the export table and write it both ways, skipping only the file chooser. +- `UDPSCOPE_PROBE_CONDITION_MS` — arms a qualified trigger against the mock and reports the latched + `t0`, the measured pulse or band width, and sample values for the window condition. Schedules its + own arm and report stages. `UDPSCOPE_PROBE_CONDITION_SHAPE` picks the leg: `0` pulse width `<` + (the glitch, the default, needs `--glitch-at`/`--glitch-width`), `1` pulse width `>`, `2` window + (uses `sine` with a 1.0 s window and 50 % pre), `3` dropout (needs `--quiet-at`; also uses + `sine` with a 1.0 s window and 50 % pre). +- `UDPSCOPE_PROBE_STATS_MS` — reports every measurement's accumulator (`n`, mean, sd, lo, hi), + pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three + phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs + `UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first. +- `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live + trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to + hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`. + +The following mock waveform features are what the qualified-condition probes are checked against, +as distinct from the fault injection below, which exercises ingest: + +- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the start of + every square period from stream second `S` onwards — a runt pulse of a known width, which is + what a `<` pulse-width trigger and a glitch search are checked against. +- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it stops + crossing zero: the dropout trigger's input. + The mock also carries one **fault injection** that no probe schedules, because what it exercises is the ingest rather than the UI: `--time-glitch-at --time-glitch-by ` makes **one** packet's time signal jump ahead and then carries on from where the true timeline was, which