From a989935bbdf25608da71c3c0541c64885990faa2 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 13 Oct 2026 14:42:13 +0200 Subject: [PATCH] docs: plan the noise-rejection work as twelve testable tasks The design spec is approved; this turns it into the task-by-task plan an implementer with no context can follow, with the four places the plan deliberately departs from the spec collected and argued at the end. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-10-13-noise-rejection.md | 2311 +++++++++++++++++ 1 file changed, 2311 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-13-noise-rejection.md diff --git a/docs/superpowers/plans/2026-10-13-noise-rejection.md b/docs/superpowers/plans/2026-10-13-noise-rejection.md new file mode 100644 index 0000000..0586bf7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-noise-rejection.md @@ -0,0 +1,2311 @@ +# Noise Rejection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the trigger and every statistical measurement an optional amplitude-hysteresis band and an optional one-pole HF reject, and add a `de-noise` math node (running median followed by a one-pole) that rejects impulsive EM peaks. + +**Architecture:** Three independent consumers of the same two ideas. `EventDetector` (shared by the live trigger and by `BufferSearch`) gains a latched comparator driven by a band and a one-pole prefilter; it needs the sample interval, so `dt` is plumbed into `configure()` from its three call sites. The measurement engine gains a per-measurement `MeasureFilter` that the two walks step, which forces the measurement pane's shared per-channel window cache to be keyed by `(channel, fc)` instead of by channel alone, and forces `measure_accumulate` to loop samples-outer/levels-inner. The math engine gains one appended `MathOp::DeNoise` whose runtime state is a pooled ring in `MathRuntime` plus a reused `b0`/`z1` pair for its pole. + +**Tech Stack:** C++20, GTK4 + libadwaita, GtkBuilder `.ui` files compiled by `blueprint-compiler` into a GResource, CMake + Ninja, `xvfb-run` headless self-check against `tools/mock_udpstreamer.py` (Python 3). + +## Global Constraints + +- Every new source file carries the SPDX MIT header used by its neighbours. Copy the header from the file you are next to. +- **There is no unit test suite and you must not add one.** Every check in this plan is either a compile, or the application's own headless self-check. Extend the probes in `src/ui_probe.cpp` and the dumps in `src/ui.cpp`. +- **Build-warning baseline:** a clean build emits exactly **four** warnings (two `-Wmissing-field-initializers` on `MathNode::source` at `src/ui_probe.cpp:304` and `:313`, two `-Woverlength-strings` on the generated GResource `.c` files); an incremental build emits **two**. Any new warning is a defect. If you add a brace-initialised `MathNode` to a probe, you will add a warning — name the fields instead. +- **`Gtk-CRITICAL` baseline: zero.** Any `Gtk-CRITICAL` in a probe run is a defect. +- **No `gtk_widget_*` call may appear in a dump function.** Dumps read model and cached state only. +- Verification runs are **short and serial**: 8–14 s, one `xvfb-run` at a time, a **fresh UDP port per run**. +- Before and after every probe run, in **their own tool calls**, `pkill -x udpscope; pkill -x Xvfb` and `pkill -f 'mock_udpstream[e]r'`. An unbracketed pattern, or a `pkill` in the same command as the run, matches the invoking shell and kills it (exit 144, no output, a stale `/tmp/*.log` that reads like a real run). +- The mock's documented `--batch 2000` **does not work on this machine** (`OSError: [Errno 90] Message too long`, 80 kB > UDP's 65507). Use `--rate 1300000 --batch 1000`. +- `MathOp` is **append-only** and `math_op_at()` is its serialisation: a session file stores the op as its integer index. Never reorder. +- Measurement TOML keys are **named**, so an old session file loads new keys at their defaults. Keep it that way. +- A control that cannot affect the selected measurement or trigger type **is hidden**, not disabled. +- Decimation at ingest is a boxcar **average**, never a stride. `slot_dt()` is the **stored** interval and is the dt every filter in this plan uses. +- Exactly two threads. The producer owns `TriggerEngine` and `MathRuntime`; the GTK main thread owns every widget. A regression in `math_step` costs packets, not frames. + +## File Structure + +**Engine, producer side** + +| File | Responsibility after this plan | +| --- | --- | +| `src/condition.hpp` / `.cpp` | `EventCondition` gains `noise_band` and `hf_fc`. `EventDetector` gains the one-pole prefilter, the latched side comparator, and a `dt_` it is configured with. | +| `src/trigger.hpp` / `.cpp` | Passes `dt` through `configure()` into the detector. No behaviour of its own. | +| `src/search.hpp` / `.cpp` | Passes `dt` through `start()` into the detector. No behaviour of its own. | +| `src/acquisition.cpp` | One call site: supplies `model_.partition().slot_dt()` to `engine.configure()`. | +| `src/math_channel.hpp` / `.cpp` | `MathOp::DeNoise` at index 12, `MathNode::med_n`, `math_op_uses_fc()`, `math_op_uses_median()`, the pooled median ring in `MathRuntime`, the `pole_a()` helper, the `DeNoise` case in `math_step`. | +| `src/measure.hpp` / `.cpp` | `MeasureOptions` gains `noise_pct` and `hf_fc`. New `MeasureFilter`. `measure_set_level()` takes the band fraction as an argument. Both walks take a filter by reference. `measure_accumulate` loops samples-outer. | + +**UI, main thread** + +| File | Responsibility after this plan | +| --- | --- | +| `src/ui/trigger-panel.ui` | Two new label-only rows, `band_row` and `hf_row`. | +| `src/ui_trigger.cpp` | Builds and reads the two new spins; shows/hides them per trigger type. | +| `src/ui_app.hpp` | Two new trigger widget pointers; `MeasureWindow` keyed by `(channel, fc)` in a vector; two new `MeasureDialog` fields; two new `MathDialog` rows; the new probe declarations. | +| `src/ui_measure.cpp` | The pane's window cache becomes a vector keyed by `(channel, fc)`; the level dedupe key becomes `(pct, noise)`; the two walks build and pass a filter. | +| `src/ui_math.cpp` | The de-noise rows in the math dialog form. | +| `src/ui.cpp` | The measurement dialog's two new spins; the math dialog's two new rows; the trigger dump's two new terms; the noise probe's registration. | +| `src/layout_store.cpp` | Two new measurement TOML keys; the math node text grows from 12 to 13 fields and reads either width. | + +**Verification** + +| File | Responsibility after this plan | +| --- | --- | +| `tools/mock_udpstreamer.py` | `--spike-every`, `--spike-amp`, `--spike-width`: an alternating-sign impulse train on `sine`. | +| `src/ui_probe.cpp` | A new `noise_probe` carrying every new reading. `math_probe` gains a de-noise node. | +| `docs/claude/verification.md` | The noise probe's invocation and its readings. | +| `docs/claude/probe-readings.md` | The per-line reference for `[noise-probe]`; the two moved readings. | +| `docs/claude/build-and-run.md` | The three new mock options. | + +**Documentation** + +`README.md` and the six invariant docs that own the touched areas: `invariants-trigger-and-pinning.md`, `invariants-readouts-and-cursors.md`, `invariants-measurements.md`, `invariants-math-channels.md`, `invariants-session-and-export.md`, `invariants-gtk-and-chrome.md`. + +--- + +## Task 1: Mock spike train + +The verification for all three features needs a stimulus whose impulses a band or a median rejects but a bare comparator does not. The mock gains an alternating-sign impulse train on `sine`: an impulse of `--spike-width` samples at `±--spike-amp` starting at every exact multiple of `--spike-every` seconds, positive on even periods and negative on odd. Alternating sign matters — a one-signed train is a DC offset that a median would partly track. + +**Files:** +- Modify: `tools/mock_udpstreamer.py` +- Modify: `docs/claude/build-and-run.md` + +**Interfaces:** +- Produces: `build_data(..., spike_every=0.0, spike_amp=0.0, spike_width=0)` and the three command-line options `--spike-every S --spike-amp A --spike-width N`. Later tasks invoke the mock with `--spike-every 0.005 --spike-amp 0.5 --spike-width 2`. + +- [ ] **Step 1: Read the signature and the body you are extending** + +Read `tools/mock_udpstreamer.py` lines 60–150 and 400–440. Note three things: the five signals are built in the fixed order `time, sine, square, ramp, noise`; `build_data` is called **positionally** at the bottom of the file; the glitch at lines 118–126 is the pattern to copy (a time mask applied to one array). + +- [ ] **Step 2: Add the three parameters to `build_data`** + +Append them to the signature, after `quiet_at`: + +```python +def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY, + noise=0.0, time_spike=0.0, glitch_at=-1.0, glitch_width=0.0, + quiet_at=-1.0, spike_every=0.0, spike_amp=0.0, spike_width=0): +``` + +- [ ] **Step 3: Inject the train into `sine`** + +Immediately after `sine` is built and before `square`, add: + +```python + if spike_every > 0.0 and spike_amp != 0.0 and spike_width > 0: + # An impulse of `spike_width` samples at the start of every `spike_every` + # window, the sign alternating window to window so the train carries no + # DC a median could track. `stamps` are absolute stream seconds. + period = np.floor(stamps / spike_every) + into = stamps - period * spike_every + hit = into < (spike_width * dt) + sign = np.where((period.astype(np.int64) & 1) == 0, 1.0, -1.0) + sine = sine + np.where(hit, sign * spike_amp, 0.0) +``` + +- [ ] **Step 4: Add the three command-line options** + +Beside `--quiet-at` in the argument parser: + +```python + ap.add_argument("--spike-every", type=float, default=0.0, + help="seconds between impulses on sine (0 disables)") + ap.add_argument("--spike-amp", type=float, default=0.0, + help="impulse amplitude, alternating sign window to window") + ap.add_argument("--spike-width", type=int, default=0, + help="impulse length in samples") +``` + +- [ ] **Step 5: Pass them at the call site** + +The `build_data(...)` call is positional. Append the three new arguments **in signature order** after the `quiet_at` argument: + +```python + args.quiet_at, + args.spike_every, + args.spike_amp, + args.spike_width) +``` + +- [ ] **Step 6: Verify the train is there, without the scope** + +Run a 2 s capture and count sign changes in the `sine` column with a one-off reader. There is no existing dump tool, so check it the cheap way — the mock's own log must show `gaps=0` and a non-zero rate: + +```bash +cd /home/martino/Projects/tools/udpscope +timeout 4 python3 tools/mock_udpstreamer.py --port 8101 --rate 1300000 --batch 1000 \ + --duration 2 --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/spike-mock.log 2>&1; \ + cat /tmp/spike-mock.log +``` + +Expected: a final line reporting a rate near `580000` S/s and `gaps=0`. **A traceback mentioning `Message too long` means you changed the batch size** — it must stay at 1000. A traceback mentioning `np.where` or broadcasting means `stamps` is not the array you assumed; re-read how `sine` is built. + +- [ ] **Step 7: Clean up the mock** + +In its own tool call: + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 8: Document the three options** + +`docs/claude/build-and-run.md` carries the mock's option reference. Add the three beside `--glitch-at`/`--glitch-width`: + +- `--spike-every S` — seconds between impulses; `0` (the default) injects none, so every existing invocation is unchanged. +- `--spike-amp A` — the impulse amplitude, added to `sine` on top of the waveform. Alternating sign per period. +- `--spike-width N` — the impulse length in **samples**, not seconds, so its duration follows `--rate`. + +State the two things a reader needs and cannot infer: the train lands on **`sine` only** (the other four signals are untouched, which is what lets a probe compare a spiked channel against a clean one in the same run), and the sign **alternates** per period because a one-signed train is a DC offset a median would partly track rather than reject. Also record that `--batch` must stay at or below 1000 with these options as without them: 2000 samples x 5 signals x `f64` is 80 kB and exceeds UDP's ~65507-byte payload, and the mock dies on its first `sendto` with `OSError: [Errno 90] Message too long` while the run still exits 0. + +- [ ] **Step 9: Commit** + +```bash +git add tools/mock_udpstreamer.py docs/claude/build-and-run.md +git commit -m "test: the mock can inject an alternating-sign impulse train on sine" +``` + +--- + +## Task 2: The trigger's two controls + +`EventCondition` gains `noise_band` and `hf_fc`. `EventDetector` gains a one-pole prefilter and a latched side comparator. Because the pole's coefficient depends on the sample interval, `configure()` takes `dt`, and the three call sites supply it. + +**Files:** +- Modify: `src/condition.hpp`, `src/condition.cpp` +- Modify: `src/trigger.hpp:71-72`, `src/trigger.cpp:16-31` +- Modify: `src/search.hpp:51`, `src/search.cpp:19-30` +- Modify: `src/acquisition.cpp:731` +- Modify: `src/ui_trigger.cpp:349` (call site only; the panel is Task 3) +- Modify: `src/ui_probe.cpp:1429` (call site only; the probe is Task 4) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `double EventCondition::noise_band` and `double EventCondition::hf_fc`, both in `operator==`. + - `void EventDetector::configure(const EventCondition& c, double dt) noexcept` + - `void TriggerEngine::configure(const TriggerConfig&, const std::string&, double post_seconds, double pre_seconds, double oldest_s, double dt)` + - `void BufferSearch::start(const EventCondition&, int channel, double dt)` + +- [ ] **Step 1: Add the two fields** + +In `src/condition.hpp`, inside `EventCondition`, after the existing level fields: + +```cpp + /// Half-width of the amplitude band around `level` a sample must leave before + /// a crossing counts. 0 is the bare comparator. In signal units. + double noise_band = 0.0; + /// Corner of a one-pole low pass applied to the sample before any comparison. + /// 0 is off. In Hz. + double hf_fc = 0.0; +``` + +Add both to `operator==`, which must now compare ten members: + +```cpp + bool operator==(const EventCondition& o) const noexcept { + return type == o.type && level == o.level && level2 == o.level2 && edge == o.edge && + cmp == o.cmp && width_s == o.width_s && width2_s == o.width2_s && + timeout_s == o.timeout_s && noise_band == o.noise_band && hf_fc == o.hf_fc; + } +``` + +- [ ] **Step 2: Declare the new detector members** + +In `src/condition.hpp`, change the `configure` declaration and add the state. Replace + +```cpp + void configure(const EventCondition& c) noexcept; +``` + +with + +```cpp + /// `dt` is the stored sample interval (`Partition::slot_dt()`), needed for the + /// HF reject's pole. A no-op unless the condition or the interval changed. + void configure(const EventCondition& c, double dt) noexcept; +``` + +and in the private section, after `dropped_`: + +```cpp + /// One-pole prefilter. `pole_a_ == 1` passes the input through, so "off" + /// costs no branch. `pole_y_` is the filtered value. + double dt_ = 0.0; + double pole_a_ = 1.0; + double pole_y_ = 0.0; + bool pole_have_ = false; + /// Which side of the band the signal was last *decided* to be on: +1 above, + /// -1 below, 0 undecided. A sample inside the band decides nothing. + int side_ = 0; +``` + +Also declare the two helpers beside `crossed_up`/`crossed_down`: + +```cpp + /// The HF reject, applied to every sample before any comparison. + double filter(double v) noexcept; + /// `up`/`down` for the edge condition, through the band if there is one. + void decide(double v, bool& up, bool& down) noexcept; +``` + +- [ ] **Step 3: Write `configure` and extend `reset`** + +In `src/condition.cpp`, replace `configure`: + +```cpp +void EventDetector::configure(const EventCondition& c, double dt) noexcept { + if (c == cond_ && dt == dt_) return; + cond_ = c; + dt_ = dt; + // a = 1 - exp(-2*pi*fc*dt), clamped just under Nyquist so a never exceeds 1. + // fc == 0 leaves a == 1, which is a pass-through. + pole_a_ = 1.0; + if (cond_.hf_fc > 0.0 && dt > 0.0) { + const double nyquist = 0.5 / dt; + const double fc = cond_.hf_fc < nyquist * 0.99 ? cond_.hf_fc : nyquist * 0.99; + pole_a_ = 1.0 - std::exp(-2.0 * 3.14159265358979323846 * fc * dt); + } + reset(); +} +``` + +Add `#include ` if it is not already there. In `reset()`, add the three new state members (not `dt_`, not `pole_a_` — those are configuration): + +```cpp + pole_y_ = 0.0; + pole_have_ = false; + side_ = 0; +``` + +- [ ] **Step 4: Write `filter` and `decide`** + +In `src/condition.cpp`, above `feed`: + +```cpp +double EventDetector::filter(double v) noexcept { + // A NaN must stay a NaN and must not poison the state: a dropout is a real + // reading here, not an error. + if (!std::isfinite(v)) return v; + if (!pole_have_) { + pole_y_ = v; + pole_have_ = true; + return pole_y_; + } + pole_y_ += pole_a_ * (v - pole_y_); + return pole_y_; +} + +void EventDetector::decide(double v, bool& up, bool& down) noexcept { + up = false; + down = false; + if (cond_.noise_band <= 0.0) { + up = crossed_up(v); + down = crossed_down(v); + return; + } + // A Schmitt trigger: a sample inside the band decides nothing, and a crossing + // is a change between two *decided* sides. Seeding from the undecided side + // would fire on a signal that starts inside the band and leaves it without + // ever reaching `level`. + const int was = side_; + if (v >= cond_.level + cond_.noise_band) side_ = 1; + else if (v <= cond_.level - cond_.noise_band) side_ = -1; + up = was == -1 && side_ == 1; + down = was == 1 && side_ == -1; +} +``` + +- [ ] **Step 5: Run both through `feed`** + +In `src/condition.cpp`, `feed()` takes `(value, t)` in that order and seeds `prev_` on the first sample before dispatching. Filter on the first line so every branch — edge, pulse, window, dropout — and the first-sample seeding all see the same filtered sample: + +```cpp +double EventDetector::feed(double value, double t) noexcept { + value = filter(value); + if (!have_prev_) { + ...unchanged... +``` + +Then replace the first two lines of `feed_edge` only; the `fired` dispatch below them is unchanged: + +```cpp +double EventDetector::feed_edge(double value, double t) noexcept { + bool up = false; + bool down = false; + decide(value, up, down); + const bool fired = (cond_.edge == TriggerEdge::Rising && up) || + (cond_.edge == TriggerEdge::Falling && down) || + (cond_.edge == TriggerEdge::Both && (up || down)); + return fired ? t : kNoEvent; +} +``` + +Filtering into the parameter rather than into a new local is deliberate on a second count: `feed` ends with `prev_ = value`, and `last_value()` returns `prev_`, so `last_value()` comes to report **the comparator's input** with no further change. That is what its one consumer wants — the `v=%.4g` field of the trig-engine diagnostic (`acquisition.cpp:918`), whose whole purpose is to say what the comparator saw. Do not introduce a separate raw `prev_` to "preserve" the old reading; a diagnostic quoting a sample the comparator never looked at is the bug, not the fix. + +**`feed_pulse` and `feed_dropout` keep `crossed_up`/`crossed_down`.** A pulse is measured between a level crossing and its return, and widening that with a band would change the measured width, not just reject noise; a dropout is an absence, which a band cannot describe. Only the edge gets the latched comparator. + +- [ ] **Step 6: Give the window type a latched band** + +In `src/condition.cpp`, `feed_window` computes `lo`/`hi` and an `inside` flag. Widen the band it must leave, clamped so a band can never swallow the window. Replace the first three lines of the function with: + +```cpp + const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2; + const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level; + // The band is applied on the edge the signal must *leave*, and clamped to just + // under half the window so `lo + band` can never pass `hi - band`. + double band = cond_.noise_band > 0.0 ? cond_.noise_band : 0.0; + const double room = 0.49 * (hi - lo); + if (band > room) band = room; + const bool inside = inside_ ? (value >= lo - band && value <= hi + band) + : (value >= lo + band && value <= hi - band); + if (inside == inside_) return kNoEvent; + inside_ = inside; +``` + +At `band == 0` both arms reduce to today's `value >= lo && value <= hi`, so the documented reading that a ±1 square against a `[1,1]` band fires 19 times is unchanged. The `fired` dispatch below is untouched, and so is `feed()`'s first-sample seeding of `inside_` — a latch needs a side to start from, and the un-banded test is the right one for that. + +- [ ] **Step 7: Plumb `dt` through the trigger engine** + +`src/trigger.hpp:71-72` — append the parameter: + +```cpp + void configure(const TriggerConfig& cfg, const std::string& channel_name, + double post_seconds, double pre_seconds, double oldest_s, + double dt); +``` + +`src/trigger.cpp` — take it and pass it on. The existing call becomes: + +```cpp + detector_.configure(cfg_.condition, dt); +``` + +- [ ] **Step 8: Plumb `dt` through the search** + +`src/search.hpp:51`: + +```cpp + void start(const EventCondition& c, int channel, double dt); +``` + +`src/search.cpp` — the body already resets twice around the configure; keep that and pass `dt`: + +```cpp + detector_.reset(); + detector_.configure(c, dt); + detector_.reset(); +``` + +- [ ] **Step 9: Supply `dt` at the three call sites** + +`src/acquisition.cpp:731` — `model_.partition().slot_dt()` is already read into a local earlier in this function (around line 320). Use the same expression: + +```cpp + engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s, + model_.partition().slot_dt()); +``` + +`src/ui_trigger.cpp` — the Run handler already holds the model lock to read the condition and channel. Read the interval in the same critical section: + +```cpp + EventCondition c; + int channel = -1; + double dt = 0.0; + { + std::lock_guard lock(a->model->mutex()); + c = a->model->trigger().condition; + channel = a->model->trigger().channel; + dt = a->model->partition().slot_dt(); + } + a->search.start(c, channel, dt); +``` + +`src/ui_probe.cpp:1429` — `search_probe_run` calls `a->search.start(c, channel)`. Read the interval under the lock the same way and pass it. + +- [ ] **Step 10: Build** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -30 +``` + +Expected: links cleanly, **two** warnings (the incremental baseline: the two `MathNode::source` `-Wmissing-field-initializers` in `ui_probe.cpp`). A third warning, or any error about `configure` taking 5 arguments, means a call site was missed — grep for `\.configure(` and `search.start(`. + +- [ ] **Step 11: One short probe run to confirm nothing regressed** + +Clean up first, in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +Then: + +```bash +cd /home/martino/Projects/tools/udpscope +(timeout 14 python3 tools/mock_udpstreamer.py --port 8102 --rate 1300000 --batch 1000 --duration 12 > /tmp/t2-mock.log 2>&1 &) ; sleep 1 ; \ +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_CONDITION_MS=4000 \ + ./build/udpscope --server 127.0.0.1 --port 8102 2>&1 | tail -40 +``` + +Expected: the four `[condition-probe]` legs report the same `hits`/`type` lines as `docs/claude/probe-readings.md` records, and no `Gtk-CRITICAL`. **`t0` will differ from the documented value and that is not a failure** — `probe-readings.md` states `t0` is a stream stamp rebased by whichever rebuild last dropped history, and reads a different value on every run. + +Clean up, each in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 12: Commit** + +```bash +git add src/condition.hpp src/condition.cpp src/trigger.hpp src/trigger.cpp \ + src/search.hpp src/search.cpp src/acquisition.cpp src/ui_trigger.cpp src/ui_probe.cpp +git commit -m "feat: the trigger can ignore a noise band and reject HF before comparing" +``` + +--- + +## Task 3: The trigger panel's two rows + +The two new fields need widgets. The panel's rows are label-only in the `.ui` file and the value widget is appended by `row_for()`; the rows are shown or hidden per trigger type by `sync_trigger_rows()`, and read back by `publish_trigger_fields()`. + +**Files:** +- Modify: `src/ui/trigger-panel.ui` (two new rows after `holdoff_row`) +- Modify: `src/ui_app.hpp:155-173` (two widget pointers) +- Modify: `src/ui_trigger.cpp` (build, read, show/hide, connect) +- Modify: `src/ui.cpp:5814-5833` (the dump's control count) + +**Interfaces:** +- Consumes: `EventCondition::noise_band`, `EventCondition::hf_fc` from Task 2. +- Produces: `App::trigger_band_spin`, `App::trigger_hf_spin`; the trigger dump line now reads `controls_inside=15/15`. + +- [ ] **Step 1: Add the two label-only rows** + +In `src/ui/trigger-panel.ui`, after the `holdoff_row` child, copying the `level_row` shape exactly: + +```xml + + + horizontal + 6 + + + noise band + start + + + + + + + horizontal + 6 + + + HF reject Hz + start + + + + +``` + +- [ ] **Step 2: Round-trip the `.ui` file** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build --target test-ui 2>&1 | tail -15 +``` + +Expected: every `.ui` file round-trips through `blueprint-compiler` against the GIR with no diff. A failure here is a malformed property or a duplicate id — the ids `band_row` and `hf_row` must be unique in the file. + +- [ ] **Step 3: Add the two widget pointers** + +In `src/ui_app.hpp`, beside `trigger_holdoff_spin`: + +```cpp + GtkWidget* trigger_band_spin = nullptr; ///< EventCondition::noise_band + GtkWidget* trigger_hf_spin = nullptr; ///< EventCondition::hf_fc, 0 is off +``` + +- [ ] **Step 4: Build the two spins** + +In `src/ui_trigger.cpp`'s `build_trigger_panel()`, beside the `holdoff` spin and its `row_for`: + +```cpp + // A band in signal units, on the same scale as the level spins. + app.trigger_band_spin = spin(0.0, 1.0e12, 0.01, 4, 0.0); + row_for(ui, "band_row", app.trigger_band_spin); + // 0 is off. The upper bound is generous; configure() clamps to just under + // the stream's Nyquist, which the panel cannot know. + app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0); + row_for(ui, "hf_row", app.trigger_hf_spin); +``` + +- [ ] **Step 5: Publish them** + +In `publish_trigger_fields()`, read both widgets with the others **before** taking the lock, then assign inside it: + +```cpp + const double band = gtk_spin_button_get_value(GTK_SPIN_BUTTON(a->trigger_band_spin)); + const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(a->trigger_hf_spin)); +``` + +and in the critical section, beside the other condition assignments: + +```cpp + c.noise_band = band; + c.hf_fc = hf; +``` + +- [ ] **Step 6: Show them per type** + +In `sync_trigger_rows()`, beside the existing five `show(...)` calls: + +```cpp + // A band widens the comparator, which every type but Dropout compares with; + // a dropout is an absence and a band cannot describe one. + show("band_row", type != TriggerType::Dropout); + // The HF reject sits before every comparison, so it applies to all types. + show("hf_row", true); +``` + +`type` is whatever local `sync_trigger_rows()` already derives from the type dropdown — read the function and reuse it rather than re-reading the widget. + +- [ ] **Step 7: Connect them** + +In `build_trigger_panel()`'s `value-changed` connect loop (seven spins today), add the two new spins to the list so they call `on_trigger_field_changed`. + +- [ ] **Step 8: Extend the dump** + +`src/ui.cpp:5814-5833` is in the **exit dump**, not a probe, so it prints on every run. It sums 13 `inside(w, root)` terms. Change the format string's `controls_inside=%d/13` to `/15` and add two terms after `inside(app.trigger_holdoff_spin, app.trigger_panel)`: + +```cpp + inside(app.trigger_band_spin, app.trigger_panel) + + inside(app.trigger_hf_spin, app.trigger_panel) + +``` + +`inside()` is the block's own local lambda and takes the widget *and* the root. Add nothing else to this block. + +- [ ] **Step 9: Build and probe** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20 +``` + +Expected: two warnings, no errors. + +Clean up, in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +Then: + +```bash +cd /home/martino/Projects/tools/udpscope +(timeout 14 python3 tools/mock_udpstreamer.py --port 8103 --rate 1300000 --batch 1000 --duration 12 > /tmp/t3-mock.log 2>&1 &) ; sleep 1 ; \ +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_FIELDS_MS=4000 \ + ./build/udpscope --server 127.0.0.1 --port 8103 2>&1 | grep -E 'trigger panel|fields-probe|CRITICAL' +``` + +Expected: `controls_inside=15/15 search_widgets=4/4 search_inside=4/4`, one `[fields-probe]` line, and no `Gtk-CRITICAL`. `14/15` means one new spin is built but outside the panel — `row_for` silently appends nothing when the id does not resolve, so check the id spelling in the `.ui` against the string passed to `row_for`. + +Clean up, each in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 10: Move the documented reading** + +In `docs/claude/probe-readings.md`, the `[trigger-probe]` entry records `controls_inside=13/13`. Change it to `15/15` and add the two new spins to the list of what it counts. + +- [ ] **Step 11: Commit** + +```bash +git add src/ui/trigger-panel.ui src/ui_app.hpp src/ui_trigger.cpp src/ui.cpp docs/claude/probe-readings.md +git commit -m "ui: the trigger panel offers a noise band and an HF reject" +``` + +--- + +## Task 4: The noise probe's trigger legs + +A reading for each of the trigger's two controls. **This deviates from the spec** in two ways, both forced: + +1. The spec asserts four exact `[condition-probe]` `t0` values. `docs/claude/probe-readings.md` states that **`t0` is not an assertion** — it is a stream stamp rebased by whichever rebuild last dropped history, and the same leg read `1.7103`, `1.6975` and `1.8719` on three consecutive runs. The controls are therefore read through `BufferSearch`, whose **hit counts** over one retained buffer are run-independent. +2. The spec measures the HF reject's group delay between a banded leg and a banded-plus-filtered leg on `sine`. That does not work: a `+0.5` impulse arriving while the sine sits at `0.3` already reaches the `0.8` threshold, so the band leg fires up to 20 ms **early**, quantised to the 5 ms spike grid. The delay is instead measured on **`square`**, which carries no spikes, where a one-pole turns a `-1 → +1` step into an exponential crossing zero at exactly `tau * ln2` with `tau = 1/(2*pi*fc)`. + +**Files:** +- Modify: `src/ui_probe.cpp` (the new `noise_probe`) +- Modify: `src/ui_app.hpp` (declare it) +- Modify: `src/ui.cpp` (register `UDPSCOPE_PROBE_NOISE_MS`) +- Modify: `docs/claude/verification.md`, `docs/claude/probe-readings.md` + +**Interfaces:** +- Consumes: `EventCondition::noise_band`, `EventCondition::hf_fc`, `BufferSearch::start(c, channel, dt)` from Task 2. +- Produces: `gboolean noise_probe(gpointer user);` and the `[noise-probe]` dump lines. Tasks 8 and 11 append to the same function. + +- [ ] **Step 1: Write the leg helper** + +In `src/ui_probe.cpp`, above where you will put `noise_probe`: + +```cpp +namespace { + +struct NoiseLeg { + std::size_t hits = 0; + double first = std::numeric_limits::quiet_NaN(); + int state = 0; +}; + +/// One rising-edge search of the whole retained buffer at level 0. +/// +/// Synchronous on purpose. Every leg must see the *same* buffer, and yielding to +/// the main loop between legs would let the producer append between them, so the +/// counts would no longer be comparable. The bound is a safety net: a chunk is +/// `BufferSearch::kChunk` samples, so 20000 pumps cover 160 M of them, far more +/// than the ring holds. +NoiseLeg noise_leg(App* a, int channel, double dt, double band, double hf) { + EventCondition c; + c.type = TriggerType::Edge; + c.edge = TriggerEdge::Rising; + c.level = 0.0; + c.noise_band = band; + c.hf_fc = hf; + a->search.start(c, channel, dt); + for (int i = 0; i < 20000 && a->search.pump(*a->model); ++i) { + } + NoiseLeg r; + r.hits = a->search.hits().size(); + if (r.hits > 0) r.first = a->search.hits().front(); + r.state = static_cast(a->search.state()); + return r; +} + +/// The difference of two first-hit stamps, folded into +/- half a period, so a +/// pair whose first hits landed on different cycles still reads as a delay. +double noise_fold(double d, double period) { + const double half = 0.5 * period; + d = std::fmod(d, period); + if (d > half) d -= period; + if (d < -half) d += period; + return d; +} + +int noise_channel(App* a, const char* name) { + const std::vector& channels = a->model->channels(); + for (std::size_t i = 0; i < channels.size(); ++i) { + if (channels[i].name == name) return static_cast(i); + } + return -1; +} + +} // namespace +``` + +``, `` and `` are already included by this file; check and add only what is missing. + +- [ ] **Step 2: Write the probe** + +```cpp +gboolean noise_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + const int sine = noise_channel(a, "sine"); + const int square = noise_channel(a, "square"); + double dt = 0.0; + { + std::lock_guard lock(a->model->mutex()); + dt = a->model->partition().slot_dt(); + } + if (sine < 0 || square < 0 || !(dt > 0.0)) { + std::fprintf(stderr, "[noise-probe] no stream: sine=%d square=%d dt=%g\n", sine, square, + dt); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + + // A: the bare comparator on a spiked sine. Every impulse landing where the + // sine is within the impulse's own amplitude of zero fires a crossing. + const NoiseLeg a_leg = noise_leg(a, sine, dt, 0.0, 0.0); + // B: a 0.8 band. A +/-0.5 impulse cannot carry the signal across a 1.6-wide + // band, so only the sine's own cycles are left. + const NoiseLeg b_leg = noise_leg(a, sine, dt, 0.8, 0.0); + // C: no band, a 50 Hz reject. A 2-sample 0.5 impulse moves a pole with + // a = 1 - exp(-2*pi*50*dt) by about 5e-4, which cannot reach the level. + const NoiseLeg c_leg = noise_leg(a, sine, dt, 0.0, 50.0); + // D: both. The reject attenuates a 5 Hz sine by only 1/sqrt(1 + (5/50)^2), + // so its peaks still clear the band and the count is unchanged. + const NoiseLeg d_leg = noise_leg(a, sine, dt, 0.8, 50.0); + // E/F: the reject's group delay, on `square`, which carries no impulses. A + // one-pole turns a -1 -> +1 step into -1 + 2*(1 - exp(-t/tau)), which crosses + // zero at tau*ln2 -- exact, and the same on every edge. + const NoiseLeg e_leg = noise_leg(a, square, dt, 0.0, 0.0); + const NoiseLeg f_leg = noise_leg(a, square, dt, 0.0, 50.0); + + const double period = 0.2; // the mock's default --freq is 5 Hz. + const double delay = noise_fold(f_leg.first - e_leg.first, period); + const double want = std::log(2.0) / (2.0 * 3.14159265358979323846 * 50.0); + + std::fprintf(stderr, + "[noise-probe] trigger band: bare=%zu/%d band=%zu/%d ratio=%.2f\n", + a_leg.hits, a_leg.state, b_leg.hits, b_leg.state, + b_leg.hits > 0 ? static_cast(a_leg.hits) / + static_cast(b_leg.hits) + : 0.0); + std::fprintf(stderr, + "[noise-probe] trigger hf: hf=%zu/%d band+hf=%zu/%d band=%zu\n", c_leg.hits, + c_leg.state, d_leg.hits, d_leg.state, b_leg.hits); + std::fprintf(stderr, + "[noise-probe] trigger delay: clean=%zu filtered=%zu delay=%.6f want=%.6f " + "ratio=%.3f\n", + e_leg.hits, f_leg.hits, delay, want, want > 0.0 ? delay / want : 0.0); + std::fflush(stderr); + a->search.clear(); + update_search_panel(*a); + return G_SOURCE_REMOVE; +} +``` + +- [ ] **Step 3: Declare and register it** + +In `src/ui_app.hpp`, beside the other probe declarations: + +```cpp +gboolean noise_probe(gpointer user); +``` + +In `src/ui.cpp`, in the registration block, matching its neighbours exactly: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) { + g_timeout_add(static_cast(std::atoi(env)), noise_probe, &app); + } +``` + +- [ ] **Step 4: Build** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20 +``` + +Expected: two warnings, no errors. + +- [ ] **Step 5: Run the probe** + +Clean up first, in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +Then: + +```bash +cd /home/martino/Projects/tools/udpscope +(timeout 16 python3 tools/mock_udpstreamer.py --port 8104 --rate 1300000 --batch 1000 \ + --duration 14 --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/t4-mock.log 2>&1 &) ; sleep 1 ; \ +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=12000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_NOISE_MS=8000 \ + ./build/udpscope --server 127.0.0.1 --port 8104 2>&1 | grep -E 'noise-probe|CRITICAL' +``` + +Expected, with `N` the number of 5 Hz cycles in the retained buffer (about 30 for a 6 s buffer): + +- `trigger band:` — `bare` is many times `band`; **`ratio` at or above 5**. `band` is about `N`. A `ratio` near 1 means the band is not being applied — check that `decide()` is reached, i.e. that `feed_edge` calls it. +- `trigger hf:` — `hf` and `band+hf` are both within 2 of `band`. `hf` near `bare` means `pole_a_` stayed at 1; check `configure()` actually recomputes it and that `hf_fc` survived `publish_trigger_fields` (it is set directly on the condition here, so a failure is in `configure`). `band+hf` near 0 means the reject is attenuating the sine away — check the Nyquist clamp is not clamping 50 Hz. +- `trigger delay:` — `clean` and `filtered` within 2 of each other, `want=0.002206`, and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 means the filter is a pass-through; a large one means `a` is far too small, i.e. `dt` did not arrive. +- `state` is `2` (`Done`) or `4` (`Capped`) on every leg. `3` (`Truncated`) on more than one leg means the pump loop is being overtaken; nothing to fix in the feature, but say so in the reading. + +No `Gtk-CRITICAL`. + +Clean up, each in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 6: Document the probe** + +In `docs/claude/verification.md`, add the noise probe beside the others: the mock invocation above (including the three spike options and why they are needed), the `UDPSCOPE_PROBE_NOISE_MS=8000` schedule, and the note that this probe needs a **spiked** mock while every other probe uses the plain one. + +In `docs/claude/probe-readings.md`, add a `[noise-probe]` section with one entry per line above, giving for each field the value it must read and the defect a wrong reading names — the text from Step 5, written as the per-line reference the rest of that file is. Record explicitly that `bare` and `band` are proportional to the retained buffer's length and so are **not** fixed numbers; only the ratio and the agreement between legs are assertions. + +- [ ] **Step 7: Commit** + +```bash +git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp docs/claude/verification.md docs/claude/probe-readings.md +git commit -m "test: the noise probe reads the trigger's band and HF reject" +``` + +--- + +## Task 5: The measurement engine's two controls + +The hysteresis band stops being a constant and becomes `MeasureOptions::noise_pct`, and a one-pole prefilter becomes something both walks step. Stepping a filter forces `measure_accumulate` to loop samples-outer, because the filter has one state for the whole walk and the present loop would advance it once per level per sample. + +**Files:** +- Modify: `src/measure.hpp`, `src/measure.cpp` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `double MeasureOptions::noise_pct` (default `10.0`, a percentage) and `double MeasureOptions::hf_fc` (default `0.0`, Hz). + - `struct MeasureFilter { double a; double y; bool have; double step(double) noexcept; void reset() noexcept; };` + - `void measure_filter_set(MeasureFilter& f, double fc, double dt);` + - `void measure_set_level(MeasureLevel&, const MeasureStats&, double pct, double noise_frac);` + - `void measure_scan(const MeasureChunk&, MeasureStats&, MeasureFilter&);` + - `void measure_accumulate(const MeasureChunk&, const std::function&, MeasureLevel*, std::size_t, MeasureFilter&);` + - `bool measure_uses_noise(MeasureType);` + +- [ ] **Step 1: Add the two options** + +In `src/measure.hpp`, in `MeasureOptions` after `show_on_plot`. The band's rationale is `kHysteresisFrac`'s comment, moved here verbatim because the constant is going away: + +```cpp + /// `PulseDuration`/`Period`/`Frequency`: the half-width of the hysteresis band about the + /// threshold, as a percentage of the window's own peak-to-peak. A crossing counts only once + /// the signal has passed the band, so an edge count is a count of the *waveform's* cycles + /// rather than of its noise. + /// + /// The default is measured rather than chosen: 5 % reads the mock's 0.1 interferer correctly + /// and fails at 0.2 (`period=0.075` for a true 0.2), while 10 % holds to an interferer + /// amplitude of 0.3 -- a third of the waveform -- and to white noise at sigma=0.05. Above + /// that the band starts eating a real waveform's own extremes on anything less square than a + /// sine. 0 reduces the walk exactly to a single level. + double noise_pct = 10.0; + /// Every type: the corner of a one-pole low pass applied to each sample before *either* + /// walk reads it, so the extremes, the mean and the crossings all describe the filtered + /// signal. 0 is off. In Hz. + /// + /// It rejects what a band cannot: an impulse large enough to cross the band on its own. + /// It is not free -- a filtered window cannot use the LOD pyramid, because a bucket + /// aggregates unfiltered samples -- so a non-zero corner costs a full raw walk. + double hf_fc = 0.0; +``` + +- [ ] **Step 2: Add `MeasureFilter`** + +In `src/measure.hpp`, above `MeasureChunk`: + +```cpp +/// The HF reject, as a walk carries it: one pole, one state word. +/// +/// `a == 1` is a pass-through and is the default, so an unfiltered walk pays one multiply and +/// no branch, and every caller can step it unconditionally. A non-finite sample passes through +/// unchanged and leaves the state alone -- a dropout is a real reading, and letting it poison +/// `y` would make every sample after it non-finite too. +struct MeasureFilter { + double a = 1.0; + double y = 0.0; + bool have = false; + + double step(double v) noexcept { + if (!std::isfinite(v)) return v; + if (!have) { + y = v; + have = true; + return y; + } + y += a * (v - y); + return y; + } + void reset() noexcept { + y = 0.0; + have = false; + } +}; + +/// `a = 1 - exp(-2*pi*fc*dt)`, clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` +/// or `dt == 0` leaves it at 1, which is the pass-through. Resets the state. +void measure_filter_set(MeasureFilter& f, double fc, double dt); +``` + +`measure.hpp` already includes `` for `MeasureStatsAccum::sd()`; confirm it and add the include if not. + +- [ ] **Step 3: Change the three engine signatures** + +In `src/measure.hpp`: + +```cpp +void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter& filter); +``` + +```cpp +/// `noise_frac` is `MeasureOptions::noise_pct / 100`. It is the caller's because the band is a +/// per-measurement setting and this is called once per *level*, which several measurements may +/// share; the clamp to half the room to the nearer extreme stays here, so every caller gets it. +void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct, + double noise_frac); +``` + +```cpp +void measure_accumulate(const MeasureChunk& chunk, + const std::function& time_of, MeasureLevel* levels, + std::size_t level_count, MeasureFilter& filter); +``` + +Leave `measure_scan_buckets` and `measure_accumulate_bucket` alone: a bucket aggregates unfiltered samples, so a filtered walk must not use them at all. Add that sentence to both of their comments. + +- [ ] **Step 4: Add `measure_uses_noise`** + +In `src/measure.hpp`, beside `measure_uses_side`: + +```cpp +/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The +/// band moves the *edge* decision and nothing else, so it is exactly the crossing types: +/// `Top`/`Bottom` split their sums at the bare level and read the same answer at any band. +bool measure_uses_noise(MeasureType type); +``` + +In `src/measure.cpp`, beside `measure_uses_side`: + +```cpp +bool measure_uses_noise(MeasureType type) { + return type == MeasureType::PulseDuration || type == MeasureType::Period || + type == MeasureType::Frequency; +} +``` + +- [ ] **Step 5: Replace the constant with the argument** + +In `src/measure.cpp`, delete `kHysteresisFrac` and its comment block from the anonymous namespace (the comment has moved to `MeasureOptions::noise_pct`), and add a pi for the pole: + +```cpp +constexpr double kPi = 3.14159265358979323846; +``` + +Then: + +```cpp +void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, + double noise_frac) { + const double span = s.hi - s.lo; + acc.level = s.lo + pct * span; + // Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span + // above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never + // decides the upper side and the measurement reads nothing at all, which is strictly worse than + // a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a + // fixed band, `enters=4 period=0.200000` with the clamp. + const double room = std::min(s.hi - acc.level, acc.level - s.lo); + const double want = noise_frac > 0.0 ? noise_frac * span : 0.0; + acc.hysteresis = std::max(0.0, std::min(want, 0.5 * room)); +} +``` + +- [ ] **Step 6: Write `measure_filter_set`** + +In `src/measure.cpp`, beside `measure_set_level`: + +```cpp +void measure_filter_set(MeasureFilter& f, double fc, double dt) { + f.reset(); + f.a = 1.0; + if (fc > 0.0 && dt > 0.0) { + const double nyquist = 0.5 / dt; + const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99; + f.a = 1.0 - std::exp(-2.0 * kPi * use * dt); + } +} +``` + +- [ ] **Step 7: Filter in pass one** + +In `src/measure.cpp`, `measure_scan` takes the filter and steps it before the finite test. Only the signature and the two lines at the top of the loop change: + +```cpp +void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& filter) { + ...unchanged locals... + for (std::size_t i = 0; i < chunk.count; ++i) { + const double v = filter.step(static_cast(chunk.value[i])); + if (!std::isfinite(v)) continue; + ...unchanged body... +``` + +- [ ] **Step 8: Filter in pass two, and swap the loops** + +In `src/measure.cpp`, replace `measure_accumulate` entirely: + +```cpp +void measure_accumulate(const MeasureChunk& chunk, + const std::function& time_of, MeasureLevel* levels, + std::size_t level_count, MeasureFilter& filter) { + // Samples outer, levels inner -- the reverse of what reads more naturally, and deliberately. + // The filter holds one state for the whole walk, so a levels-outer loop would step it once per + // level per sample and every level after the first would read a signal filtered `k` times over. + // Each level's own state is independent of the others, so the swap costs nothing else. + for (std::size_t i = 0; i < chunk.count; ++i) { + const double v = filter.step(static_cast(chunk.value[i])); + if (!std::isfinite(v)) continue; + for (std::size_t k = 0; k < level_count; ++k) { + MeasureLevel& acc = levels[k]; + // The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and + // below the threshold, and a band here would move a documented exact reading. + const int s = v > acc.level ? 1 : 0; + acc.sum[static_cast(s)] += v; + ++acc.count[static_cast(s)]; + // The *edge* decision reads the band instead. A sample inside it is on neither side, so it + // decides no crossing and the walk keeps the side it last decided -- which is what stops a + // signal that merely wobbles about the level from counting every wobble as an edge. With + // `hysteresis == 0` this is the single-level test, `v == level` skip included. + int d; + if (v > acc.level + acc.hysteresis) { + d = 1; + } else if (v < acc.level - acc.hysteresis) { + d = 0; + } else { + continue; + } + if (acc.have_side && d != (acc.side ? 1 : 0)) { + enter_side(acc, static_cast(d), time_of(chunk.offset + i)); + } + acc.side = d != 0; + acc.have_side = true; + } + } +} +``` + +The inner `continue` now skips to the next *level*, which is what it must do: a sample inside one level's band may be outside another's. + +- [ ] **Step 9: Build** + +The callers in `src/ui_measure.cpp` are Task 6, so this build **will** fail there. Compile just this translation unit: + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build --target udpscope 2>&1 | grep -E 'measure\.cpp|error' | head -20 +``` + +Expected: no line mentioning `measure.cpp`. Errors in `ui_measure.cpp` are expected and are Task 6's. If `measure.cpp` itself errors, fix it before going on. + +- [ ] **Step 10: Commit** + +```bash +git add src/measure.hpp src/measure.cpp +git commit -m "feat: a measurement's band is a setting and its walks can reject HF" +``` + +--- + +## Task 6: The measurement pane's per-filter windows + +The pane shares its two walks **per channel**, but `hf_fc` is **per measurement**: two measurements on one signal at different corners are two different windows. The cache therefore stops being keyed by channel and becomes a vector keyed by `(channel, fc)`. A filtered window must also stop using the LOD pyramid, which one line does — leaving `buckets` empty makes `bulk_from == bulk_to == n`, so the raw walk covers the whole window *and* `lo_search`/`hi_search` stay 0, which makes `pin()` a no-op. + +**Files:** +- Modify: `src/ui_app.hpp:281` (`MeasureWindow` and `measure_samples`) +- Modify: `src/ui_measure.cpp` (five sites in `update_measures`) + +**Interfaces:** +- Consumes: `MeasureFilter`, `measure_filter_set`, `measure_uses_noise`, the new `measure_scan`/`measure_accumulate`/`measure_set_level` signatures from Task 5. +- Produces: `App::MeasureWindow` with `channel`, `fc`, `noise`, `scan_filter`, `acc_filter`; `std::vector App::measure_samples`. + +- [ ] **Step 1: Re-key the window** + +In `src/ui_app.hpp`, replace `MeasureWindow` and its container. `measure_samples` is referenced **only** in `ui_measure.cpp` — verify with a grep before changing it, and if another file appears, fix it too. + +```cpp + /// One channel's window, at one HF corner, with every level accumulator the pane asked for. + /// + /// Keyed by `(channel, fc)` and not by channel alone: the corner is a per-measurement + /// setting, so two measurements on one signal at different corners are reading two + /// different signals and cannot share a walk. + struct MeasureWindow { + int channel = -1; + double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered + MeasureStats stats; + std::vector pct; ///< the level shares asked for, deduplicated + std::vector noise; ///< each entry's band, as a percentage; parallel to `pct` + std::vector level; ///< one accumulator per entry of `pct` + MeasureFilter scan_filter; ///< pass one's state + MeasureFilter acc_filter; ///< pass two's; a separate walk, so a separate state + bool ok = false; + }; +``` + +and + +```cpp + std::vector measure_samples; +``` + +- [ ] **Step 2: Read the sample interval once** + +In `src/ui_measure.cpp`'s `update_measures`, just after `app.measure_samples.clear();`: + +```cpp + // Before any `MeasureWindowReader` exists: the reader holds `model->mutex()` for a live + // window, and asking the model for anything while one is alive would deadlock. + double slot_dt = 0.0; + { + std::lock_guard lock(app.model->mutex()); + slot_dt = app.model->partition().slot_dt(); + } +``` + +- [ ] **Step 3: Rework the grouping loop** + +Replace the loop at `src/ui_measure.cpp:286-300` with: + +```cpp + std::vector chan(app.measures.size(), -1); + std::vector want_window(app.measures.size(), -1); + std::vector want_level(app.measures.size(), -1); + for (std::size_t i = 0; i < app.measures.size(); ++i) { + const Measure& m = app.measures[i]; + const int channel = measure_channel(app, m.signal); + chan[i] = channel; + if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list + const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0; + std::size_t wi = 0; + while (wi < app.measure_samples.size() && + (app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc)) { + ++wi; + } + if (wi == app.measure_samples.size()) { + App::MeasureWindow fresh; + fresh.channel = channel; + fresh.fc = fc; + measure_filter_set(fresh.scan_filter, fc, slot_dt); + measure_filter_set(fresh.acc_filter, fc, slot_dt); + app.measure_samples.push_back(fresh); + } + want_window[i] = static_cast(wi); + App::MeasureWindow& w = app.measure_samples[wi]; + double pct = 0.0; + if (!measure_level_pct(m.type, m.options, pct)) continue; + // A type that does not read the band must not split the accumulator over it: its sums + // split at the bare level, so a `Top` is the same walk whatever the band is. Grouping + // those at the default is what keeps the common pane at exactly one level. + const double noise = + measure_uses_noise(m.type) ? m.options.noise_pct : MeasureOptions{}.noise_pct; + std::size_t k = 0; + while (k < w.pct.size() && (w.pct[k] != pct || w.noise[k] != noise)) ++k; + if (k == w.pct.size()) { + w.pct.push_back(pct); + w.noise.push_back(noise); + } + want_level[i] = static_cast(k); + } +``` + +- [ ] **Step 4: Walk each window** + +In the data loop, replace the `for (auto& entry : app.measure_samples)` header and the reader construction: + +```cpp + for (App::MeasureWindow& w : app.measure_samples) { + const gint64 fetch_start_us = g_get_monotonic_time(); + MeasureWindowReader reader(app, static_cast(w.channel)); +``` + +Delete the `App::MeasureWindow& w = entry.second;` line. + +Gate the bucket fetch on the filter: + +```cpp + // A filtered window cannot use the pyramid: a bucket aggregates *unfiltered* samples, so its + // extremes and its sum describe a different signal. Leaving `buckets` empty is the whole + // disable -- `bulk_from == bulk_to == n` makes the raw walk cover the window, and + // `lo_search`/`hi_search` stay 0, which makes `pin()` below a no-op. One `if` turns off both + // fast paths, and nothing downstream needs to know. + const bool filtered = w.fc > 0.0; + if (!filtered && reader.bucket_range(kb, ke)) { +``` + +Thread the filters through the three calls: + +```cpp + const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); }; +``` + +```cpp + const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) { + if (search == 0) return; + // Only the bucket path leaves `search` non-zero, and a filtered window has no buckets, + // so this is reached pass-through only: a fresh filter here is the *same* filter. + MeasureFilter unfiltered; + MeasureStats one; + measure_scan(MeasureChunk{reader.values(index, search), search, index}, one, unfiltered); +``` + +```cpp + for (std::size_t k = 0; k < w.pct.size(); ++k) { + measure_set_level(w.level[k], w.stats, w.pct[k], 0.01 * w.noise[k]); + } + const auto accumulate = [&](const MeasureChunk& c) { + measure_accumulate(c, time_of, w.level.data(), w.level.size(), w.acc_filter); + }; +``` + +and in the straddling-bucket branch, which a filtered window never reaches: + +```cpp + measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1, + w.acc_filter); +``` + +- [ ] **Step 5: Look the window up by index in the readout loop** + +Replace the `find` at `src/ui_measure.cpp:405-414`: + +```cpp + const App::MeasureWindow* wp = + want_window[i] >= 0 && static_cast(want_window[i]) < app.measure_samples.size() + ? &app.measure_samples[static_cast(want_window[i])] + : nullptr; + if (wp == nullptr || !wp->ok) { + // Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing + // to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks + // was the other half of the rulers blinking off. + app.measure_text[i] = "--"; + if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--"); + continue; + } + const App::MeasureWindow& w = *wp; +``` + +`chan[i]` is still used elsewhere in this loop — leave it in place. + +- [ ] **Step 6: Build** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20 +``` + +Expected: links cleanly, two warnings. An error about `entry` means a site in Step 4 was missed; an error about `find` means Step 5 was. + +- [ ] **Step 7: Confirm the baseline has not moved** + +Clean up, in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +Then the documented measurement probe, unchanged, on the plain mock: + +```bash +cd /home/martino/Projects/tools/udpscope +(timeout 14 python3 tools/mock_udpstreamer.py --port 8106 --rate 1300000 --batch 1000 --duration 12 > /tmp/t6-mock.log 2>&1 &) ; sleep 1 ; \ +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_MEASURE_MS=5000 \ + ./build/udpscope --server 127.0.0.1 --port 8106 2>&1 | grep -E 'measure-probe|measure-trace|measure cost|CRITICAL' +``` + +Expected, all unchanged from `docs/claude/verification.md`: `[measure-trace]` reads `peak to peak=2`, `amplitude=1`, `average=0`, **`top=0.636620`** (2/pi — the one exact reading that a band leaking into the sums would move), and `measure cost:` still shows `raw` at a small fraction of `covered`. A `top` that is no longer 2/pi means the band reached the sums; `raw` near `covered` means `filtered` is reading true on an unfiltered window. + +Clean up, each in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/ui_app.hpp src/ui_measure.cpp +git commit -m "perf: the measurements pane keys its shared walks by channel and HF corner" +``` + +--- + +## Task 7: The measurement dialog and the session file + +Two spins in the dialog, shown only where they do something, and two named TOML keys. + +**Files:** +- Modify: `src/ui_app.hpp:885-907` (`MeasureDialog`) +- Modify: `src/ui.cpp:1609-1723` (`open_measure_dialog`) +- Modify: `src/ui_measure.cpp:90-176` (`measure_dialog_sync`, `on_measure_dialog_apply`) +- Modify: `src/layout_store.cpp:544-563`, `:659-672` + +**Interfaces:** +- Consumes: `MeasureOptions::noise_pct`, `MeasureOptions::hf_fc`, `measure_uses_noise()` from Task 5. +- Produces: `MeasureDialog::noise`, `MeasureDialog::noise_row`, `MeasureDialog::hf`, `MeasureDialog::hf_row`; the TOML keys `noise` and `hf`. + +- [ ] **Step 1: Add the four dialog fields** + +`MeasureDialog` is brace-initialised **positionally** in `src/ui.cpp`, so where you add the fields decides where the arguments go. Add them after `side_label` and before `signals`: + +```cpp + GtkWidget* noise = nullptr; ///< MeasureOptions::noise_pct + GtkWidget* noise_row = nullptr; + GtkWidget* hf = nullptr; ///< MeasureOptions::hf_fc + GtkWidget* hf_row = nullptr; +``` + +- [ ] **Step 2: Build the two rows** + +In `src/ui.cpp`'s `open_measure_dialog`, beside where `threshold` and `side_row` are built, using the same `spin()` and `labelled_row()` helpers: + +```cpp + // A percentage of the window's own peak-to-peak, which is the unit the engine takes. + GtkWidget* noise = spin(0.0, 50.0, 1.0, 1, 10.0); + GtkWidget* noise_label = nullptr; + GtkWidget* noise_row = labelled_row("noise band %", noise, &noise_label); +``` + +```cpp + // 0 is off. The engine clamps to just under the stream's Nyquist, which the dialog + // cannot know. + GtkWidget* hf = spin(0.0, 1.0e7, 1.0, 3, 0.0); + GtkWidget* hf_label = nullptr; + GtkWidget* hf_row = labelled_row("HF reject Hz", hf, &hf_label); +``` + +Append both rows to the dialog's content box where `side_row` is appended. `labelled_row` wants a label out-parameter; these two labels never retitle, so the locals are unused — pass `nullptr` if `labelled_row` accepts it, and if it does not, keep the locals and mark them `(void)noise_label; (void)hf_label;`. Read `src/ui.cpp:1589` before choosing. + +Then add the four arguments to the brace-initialiser in the position Step 1 put them: + +```cpp + auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop, + threshold, above, show, window, + threshold_row, threshold_label, side_row, side_label, + noise, noise_row, hf, hf_row, + sig_names}; +``` + +- [ ] **Step 3: Seed them when editing** + +In `open_measure_dialog`'s editing branch (`src/ui.cpp:1711-1713`), beside the existing seeds: + +```cpp + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->noise), m.options.noise_pct); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hf), m.options.hf_fc); +``` + +- [ ] **Step 4: Show them per type** + +In `src/ui_measure.cpp`'s `measure_dialog_sync`, beside the two existing `gtk_widget_set_visible` calls: + +```cpp + // The band moves the edge decision, so it is shown for exactly the crossing types. The HF + // reject sits before both walks, so it is shown for all of them. + gtk_widget_set_visible(d->noise_row, measure_uses_noise(type)); + gtk_widget_set_visible(d->hf_row, TRUE); +``` + +`type` is the local `measure_dialog_sync` already derives from the type dropdown — reuse it. + +- [ ] **Step 5: Read them on apply** + +In `src/ui_measure.cpp`'s `on_measure_dialog_apply`, beside the existing reads: + +```cpp + options.noise_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->noise)); + options.hf_fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hf)); +``` + +- [ ] **Step 6: Persist them** + +In `src/layout_store.cpp`'s measurement write (`:659-672`), append two keys to the `[[measure]]` table: + +```cpp + o << "\nnoise = " << m.options.noise_pct << "\nhf = " << m.options.hf_fc; +``` + +Match the surrounding statement's exact style — read it first; if each key is its own `o <<` line, write two of those instead. + +In the keyed read (`:544-563`), add the two keys beside `threshold`, following whatever helper the existing keys use. The keys are **named**, so a session file written before this change loads with `noise_pct = 10.0` and `hf_fc = 0.0` — the defaults, which reproduce today's behaviour exactly. Do not add a version check. + +- [ ] **Step 7: Build and round-trip a session** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20 +``` + +Expected: two warnings, no errors. An error naming a `GtkWidget*` conversion in the brace-initialiser means Step 1 and Step 2 disagree about the field order. + +Clean up, in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +Then the existing session probe, which saves and reloads: + +```bash +cd /home/martino/Projects/tools/udpscope +(timeout 14 python3 tools/mock_udpstreamer.py --port 8107 --rate 1300000 --batch 1000 --duration 12 > /tmp/t7-mock.log 2>&1 &) ; sleep 1 ; \ +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_CONFIGS_MS=5000 \ + ./build/udpscope --server 127.0.0.1 --port 8107 2>&1 | grep -E 'configs|CRITICAL' +``` + +Expected: the `[configs]` readings unchanged from `docs/claude/probe-readings.md`, and no `Gtk-CRITICAL`. + +Clean up, each in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/ui_app.hpp src/ui.cpp src/ui_measure.cpp src/layout_store.cpp +git commit -m "ui: a measurement's noise band and HF reject are settable and persisted" +``` + +--- + +## Task 8: The noise probe's measurement legs + +Five measurements in one pane on the spiked `sine`, chosen so that three of them share a channel at **three different corners** — which is what exercises the `(channel, fc)` keying — and so that each control has one legible reading. + +**Files:** +- Modify: `src/ui_probe.cpp` (extend `noise_probe`) +- Modify: `docs/claude/verification.md`, `docs/claude/probe-readings.md` + +**Interfaces:** +- Consumes: everything from Tasks 5–7, and `noise_probe`/`noise_channel` from Task 4. +- Produces: the `[noise-probe] measure ...` dump lines. + +- [ ] **Step 1: Add the five measurements and read them back** + +In `src/ui_probe.cpp`, in `noise_probe`, after the trigger legs and before `a->search.clear()`: + +```cpp + // Five measurements on the spiked sine, as *two* windows: three legs at `hf=0` and two at + // `hf=50`. The pane keys its walks by (channel, corner), so this is also what checks that a + // second corner does not reuse the first one's walk. + std::string name; + { + std::lock_guard lock(a->model->mutex()); + if (a->model->channels().empty()) return G_SOURCE_REMOVE; + name = a->model->channels().front().name; // `sine`; signal 0 is the timestamp + } + struct Leg { + const char* label; + MeasureType type; + double noise; + double hf; + }; + // The impulses dominate the window's extremes, so `peak to peak` reads the *stimulus* + // (about 3) unfiltered and the *waveform* (about 2) filtered -- the one reading that says + // the filter reached pass one and not only pass two. + const Leg legs[] = { + {"pkpk bare", MeasureType::PeakToPeak, 10.0, 0.0}, + {"pkpk hf", MeasureType::PeakToPeak, 10.0, 50.0}, + {"period noband", MeasureType::Period, 0.0, 0.0}, + {"period band", MeasureType::Period, 10.0, 0.0}, + {"period hf", MeasureType::Period, 0.0, 50.0}, + }; + const std::size_t base = a->measures.size(); + for (const Leg& leg : legs) { + MeasureOptions options; + options.noise_pct = leg.noise; + options.hf_fc = leg.hf; + a->measures.push_back({name, leg.type, options}); + } + rebuild_measures_pane(*a); + update_measures(*a, /*force=*/true, /*accumulate=*/true); + for (std::size_t i = 0; i < std::size(legs); ++i) { + const std::size_t at = base + i; + // A *value*, not a reference: the conditional's other arm is a temporary. + const MeasureResult r = + at < a->measure_results.size() ? a->measure_results[at] : MeasureResult{}; + std::fprintf(stderr, "[noise-probe] measure %-14s noise=%.1f hf=%.1f valid=%d value=%.6f\n", + legs[i].label, legs[i].noise, legs[i].hf, int(r.valid), r.primary); + } + std::fprintf(stderr, "[noise-probe] measure windows=%zu\n", a->measure_samples.size()); + std::fflush(stderr); +``` + +`` provides `std::size`; it is already pulled in by this file's includes, but check and add it if not. + +- [ ] **Step 2: Build** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20 +``` + +Expected: two warnings, no errors. A third `-Wmissing-field-initializers` means a brace-initialiser here left a field out — name every field of `Leg`. + +- [ ] **Step 3: Run the probe** + +Clean up, in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +Then: + +```bash +cd /home/martino/Projects/tools/udpscope +(timeout 16 python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 \ + --duration 14 --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/t8-mock.log 2>&1 &) ; sleep 1 ; \ +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=12000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_NOISE_MS=8000 \ + ./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL' +``` + +Expected: + +- `pkpk bare` — about **3.0** (the sine's 2 plus an impulse either way). `valid=1`. +- `pkpk hf` — about **1.99** (`2 / sqrt(1 + (5/50)^2)`). `valid=1`. A reading near 3 means the filter never reached pass one — check `measure_scan` is being given `w.scan_filter` and that `filtered` disabled the buckets, since a bucket's aggregate is unfiltered. +- `period noband` — far **below** 0.2, around 0.014: a 5 ms impulse train recrossing the mid level is counted as the waveform. This is the defect the band exists to fix, and it must read wrong here. +- `period band` — about **0.2**. A reading near `period noband` means `noise_pct` is not reaching `measure_set_level`. +- `period hf` — about **0.2**, with the band off. A reading near `period noband` means the pole coefficient is 1; check `measure_filter_set` got a non-zero `dt`. +- `measure windows=2`. The five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two distinct windows. **`windows=1` means the corner is not part of the key** — the five measurements collapsed onto one walk and two of them are reading a signal they did not ask for. + +No `Gtk-CRITICAL`. + +Clean up, each in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 4: Document the readings** + +Add the six lines above to the `[noise-probe]` section of `docs/claude/probe-readings.md`, each with its value and the defect a wrong reading names. State plainly that `period noband` is **supposed** to read wrong — it is the control reading the other two are compared against, and a future reader who "fixes" it has removed the check. + +Note in `docs/claude/verification.md` that `UDPSCOPE_PROBE_NOISE_MS` and `UDPSCOPE_PROBE_MEASURE_MS` must not be scheduled in the same run: both push onto `app.measures`, and the second would read the first's rows. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui_probe.cpp docs/claude/verification.md docs/claude/probe-readings.md +git commit -m "test: the noise probe reads a measurement's band and HF reject" +``` + +--- + +## Task 9: The de-noise math node + +A running median followed by a one-pole, as one op. The median rejects an impulse outright — a peak shorter than half the window cannot move it at all — and the pole then takes the broadband hash the median leaves behind. + +`MathOp` is **append-only**: a session file stores an op as its `math_op_at()` index, so `DeNoise` goes at index 12 and `Div` gets an explicit `case 11`. + +**Files:** +- Modify: `src/math_channel.hpp`, `src/math_channel.cpp` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `MathOp::DeNoise` at `math_op_at(12)`, `math_op_count() == 13`, `math_op_name() == "de-noise"`. + - `int MathNode::med_n = 5;` in `operator==`. + - `bool math_op_uses_fc(MathOp);` and `bool math_op_uses_median(MathOp);` + - `MathNodeRt::med_at`, `::med_n`, `::med_pos`, `::med_have`; `MathRuntime::median`. + +- [ ] **Step 1: Append the op** + +In `src/math_channel.hpp`, after `Div`: + +```cpp + /// A running median of `med_n` samples, then a one-pole at `fc` Hz. For impulsive + /// interference -- an EM peak a few samples wide -- which a linear filter can only smear + /// over its own settling time. `fc = 0` is the median alone. + DeNoise, +``` + +Also widen the enum's own leading comment: it currently says "the rest take one", which stays true. + +- [ ] **Step 2: Add the window length to the node** + +In `src/math_channel.hpp`, in `MathNode` after `hi`: + +```cpp + /// `DeNoise`: the median window, in samples. Odd and between 3 and 9 -- `median_n()` in the + /// implementation is what enforces that, so a session file holding anything else still loads. + /// An impulse up to `med_n / 2` samples wide is rejected outright. + int med_n = 5; +``` + +and add it to `operator==`: + +```cpp + lo == other.lo && hi == other.hi && med_n == other.med_n; +``` + +- [ ] **Step 3: Declare the two new predicates** + +In `src/math_channel.hpp`, after `math_op_uses_clamp`: + +```cpp +/// Whether an op reads `fc`. Wider than `math_op_uses_filter()`, which keeps its present meaning +/// of *a biquad*: `DeNoise` reads `fc` for a one-pole and has no `q`. +bool math_op_uses_fc(MathOp op); +/// Whether an op reads `med_n`. +bool math_op_uses_median(MathOp op); +``` + +- [ ] **Step 4: Add the runtime state** + +In `src/math_channel.hpp`, in `MathNodeRt` after `src`: + +```cpp + /// `DeNoise`: where this node's median window starts in `MathRuntime::median`, how long it is, + /// the ring's write position, and how many samples it holds. The window is *pooled* rather than + /// held here because a node's state belongs on one cache line, and nine doubles would push this + /// struct across two for every node in the graph, filter or not. + /// + /// The one-pole reuses `b0` for its coefficient and `z1` for its value, which is not a + /// shortcut: `math_prepare()` deliberately carries `z1` across a re-prepare, so the pole keeps + /// its history through a decimation change exactly as the biquads keep theirs. + std::size_t med_at = 0; + int med_n = 0; + std::size_t med_pos = 0; + std::size_t med_have = 0; +``` + +In `MathRuntime`, after `node`: + +```cpp + /// Every `DeNoise` node's median window, end to end: one allocation for the channel, sized by + /// `math_prepare()`, a node's slice starting at its own `med_at`. + std::vector median; +``` + +- [ ] **Step 5: Register the op in the four tables** + +In `src/math_channel.cpp`: + +```cpp + case MathOp::Div: return "ratio"; + case MathOp::DeNoise: return "de-noise"; +``` + +```cpp +int math_op_count() { return 13; } +``` + +```cpp + case 10: return MathOp::Mul; + case 11: return MathOp::Div; + default: return MathOp::DeNoise; +``` + +`math_op_arity` needs no change: `DeNoise` is unary and falls into its `default: return 1`. + +```cpp +bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; } +bool math_op_uses_fc(MathOp op) { return math_op_uses_filter(op) || op == MathOp::DeNoise; } +bool math_op_uses_median(MathOp op) { return op == MathOp::DeNoise; } +``` + +- [ ] **Step 6: Add the two helpers** + +In `src/math_channel.cpp`'s anonymous namespace, beside `biquad`: + +```cpp +/// One pole's coefficient for a corner of `fc` Hz at `dt` seconds a sample: +/// `a = 1 - exp(-2*pi*fc*dt)`, clamped just under Nyquist so `a` cannot exceed 1. `fc == 0` +/// returns 1, which is a pass-through -- so "no pole" costs the step no branch. +double pole_a(double fc, double dt) { + if (!(fc > 0.0) || !(dt > 0.0)) return 1.0; + const double nyquist = 0.5 / dt; + const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99; + return 1.0 - std::exp(-2.0 * kPi * use * dt); +} + +/// The median window's length: odd, and between 3 and 9. +/// +/// Odd because an even window's median is the average of two samples, which is a value the +/// signal never took -- and the whole point of a median here is that its output is a sample that +/// was really there. Rounded *up*, so a request for 4 gets 5 rather than 3. The ceiling of 9 is +/// what `math_step`'s fixed `double sorted[9]` is sized for. +int median_n(int want) { + int n = want < 3 ? 3 : (want > 9 ? 9 : want); + if ((n & 1) == 0) ++n; + return n; +} +``` + +- [ ] **Step 7: Size the pool and the pole in `math_prepare`** + +In `src/math_channel.cpp`, between `rt.ready = true;` and the per-node loop: + +```cpp + // The median windows, pooled: one pass to size the pool, then the node loop hands out the + // slices. A window starts from rest after a re-prepare -- the same choice the biquads' `z2` + // makes -- while the pole's `z1` is carried, so a decimation change does not make the trace + // jump. + std::size_t med_total = 0; + for (std::size_t i = 0; i < n; ++i) { + if (channel.nodes[i].op == MathOp::DeNoise) { + med_total += static_cast(median_n(channel.nodes[i].med_n)); + } + } + rt.median.assign(med_total, 0.0); + std::size_t med_next = 0; +``` + +and in the per-node loop, between the `Source` block and the `math_op_uses_filter` guard: + +```cpp + if (node.op == MathOp::DeNoise) { + out.med_n = median_n(node.med_n); + out.med_at = med_next; + out.med_pos = 0; + out.med_have = 0; + med_next += static_cast(out.med_n); + out.b0 = pole_a(node.fc, dt); + continue; + } +``` + +- [ ] **Step 8: Add the step** + +In `src/math_channel.cpp`, in `math_step`'s switch, after the `Div` case: + +```cpp + case MathOp::DeNoise: { + const double in = at(node.in_a); + // A hole stays a hole, and the window is left alone: folding a NaN into the median + // would smear one gap across `med_n` samples, and the whole trace after it in the + // pole. `med_n <= 0` is a node the runtime has not prepared -- the window pointer + // below would be a read past the pool, so it is checked rather than assumed. + if (!std::isfinite(in) || self.med_n <= 0) { + x = kNaN; + break; + } + const std::size_t len = static_cast(self.med_n); + double* win = rt.median.data() + self.med_at; + win[self.med_pos] = in; + self.med_pos = self.med_pos + 1 == len ? 0 : self.med_pos + 1; + if (self.med_have < len) ++self.med_have; + // An insertion sort of at most nine doubles into a local. The window is tiny and + // fixed, so this beats anything that allocates, and it leaves the ring itself in + // arrival order -- which is what lets the oldest sample be overwritten in place. + // Before the window has filled, the median is taken over what there is: a shorter + // window, deliberately, rather than NaN. A derived trace that reads "no data" for + // its first few samples after every rebuild looks like a broken graph. + double sorted[9]; + const std::size_t m = self.med_have; + for (std::size_t j = 0; j < m; ++j) { + const double v = win[j]; + std::size_t p = j; + while (p > 0 && sorted[p - 1] > v) { + sorted[p] = sorted[p - 1]; + --p; + } + sorted[p] = v; + } + const double med = sorted[m / 2]; + // Median *then* pole, and the order is the design. The median kills an impulse + // outright; the pole then takes the broadband hash it leaves behind. The other way + // round feeds the pole the impulse, which smears it over the pole's whole settling + // time and leaves the median a wide bump it cannot reject. + self.z1 += self.b0 * (med - self.z1); + x = self.z1; + break; + } +``` + +The pole starts from rest (`z1 == 0`), exactly as the biquads do, so the first few samples of a new node ramp up to the signal. That is the same honest-starting-point choice `math_prepare`'s comment already records; do not prime it. + +- [ ] **Step 9: Build** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -25 +``` + +Expected: links cleanly, two warnings. A `-Wswitch` error names a `switch (op)` that has not been given a `DeNoise` case — `math_op_name` is the one with no default. Any *other* switch over `MathOp` the compiler names must also be handled; add the case rather than a default, which is what makes the next appended op fail loudly here too. + +- [ ] **Step 10: Commit** + +```bash +git add src/math_channel.hpp src/math_channel.cpp +git commit -m "feat: a de-noise math node rejects impulses with a median and a one-pole" +``` + +--- + +## Task 10: The de-noise node in the editor and the session file + +The node editor's form gains a median-length row and starts showing the corner row for `DeNoise` as well as for the biquads. The node's pipe string grows a thirteenth field and must still read a twelve-field one. + +**Files:** +- Modify: `src/ui_app.hpp:914-986` (`MathDialog`) +- Modify: `src/ui.cpp:2042-2166` (the math form), `src/ui.cpp:2143` (the comment) +- Modify: `src/ui_math.cpp:823-847`, `:888-981` +- Modify: `src/layout_store.cpp:357-371`, `:373-407` + +**Interfaces:** +- Consumes: `MathOp::DeNoise`, `MathNode::med_n`, `math_op_uses_fc()`, `math_op_uses_median()` from Task 9. +- Produces: `MathDialog::med` and `MathDialog::rows[6]`; a 13-field math node string. + +- [ ] **Step 1: Widen the dialog's rows** + +In `src/ui_app.hpp`, in `MathDialog`, change `GtkWidget* rows[6]` to `GtkWidget* rows[7]` and add the spin beside `d->q`: + +```cpp + GtkWidget* med = nullptr; ///< MathNode::med_n +``` + +Read how `rows[6]` is initialised in the existing `= {...}` and extend it with one more `nullptr`. + +- [ ] **Step 2: Build the median row** + +In `src/ui.cpp`'s math form, after the clamp row (`d->rows[4]`) and before the wiring row, build `rows[6]` the same way its neighbours are built. The spin is an integer count: + +```cpp + // Odd, 3..9. The engine rounds up to odd anyway, but a step of 2 means the widget never + // offers an even one. + d->med = gtk_spin_button_new_with_range(3.0, 9.0, 2.0); + gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->med), 0); +``` + +Wrap it in a row with the label `median samples` exactly as the `corner Hz` row is wrapped, assign the row to `d->rows[6]`, and append it to the form's box in the same place the other rows are appended. Read the clamp row's construction and copy its shape; do not invent a different one. + +Keep `rows[5]` as the wiring row — it is the last row the form shows for every op, and moving it would change the form's order for every existing op. + +- [ ] **Step 3: Fix the op-menu comment** + +`src/ui.cpp:2143` says "A menu of the twelve ops". It is thirteen. The loop itself runs to `math_op_count()`, so only the comment changes. + +- [ ] **Step 4: Show the two rows per op** + +In `src/ui_math.cpp`'s `math_dialog_sync_form`, replace the visibility block's use of `filter` for the corner row: + +```cpp + const bool gain = math_op_uses_gain(op); + const bool filter = math_op_uses_filter(op); + const bool clamp = math_op_uses_clamp(op); + const bool source = op == MathOp::Source; + gtk_widget_set_visible(d->rows[0], source); + gtk_widget_set_visible(d->rows[1], gain); + // The corner row is shown for anything that reads `fc`, which is now the three biquads *and* + // the de-noise node; `q` stays a band-pass-only row. + gtk_widget_set_visible(d->rows[2], math_op_uses_fc(op)); + gtk_widget_set_visible(d->rows[3], filter && op == MathOp::BandPass); + gtk_widget_set_visible(d->rows[4], clamp); + gtk_widget_set_visible(d->rows[5], true); + gtk_widget_set_visible(d->rows[6], math_op_uses_median(op)); +``` + +and seed the new spin with the others: + +```cpp + gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->med), static_cast(n.med_n)); +``` + +- [ ] **Step 5: Read it back** + +In `src/ui_math.cpp`'s `on_math_param_changed`, beside the other reads: + +```cpp + n.med_n = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->med))); +``` + +- [ ] **Step 6: Persist it** + +In `src/layout_store.cpp`'s `math_node_text`, append `med_n` as the **thirteenth** field, after `y`. The op is written as its `math_op_at()` index, which is already how field 0 works and needs no change. + +In `math_node_from_text`, accept either width and read the new field only when it is there: + +```cpp + // Twelve fields is a session file written before the de-noise node existed; its `med_n` + // stays at the struct's default, which is what that file meant. + if (f.size() != 12u && f.size() != 13u) return false; +``` + +and after the existing `number(...)` chain: + +```cpp + if (f.size() == 13u) { + double med = 0.0; + if (!number(12, med)) return false; + out.med_n = static_cast(med); + } + return true; +``` + +The existing chain `return number(4, out.gain) && ... && number(11, out.y);` must therefore become a guarded sequence rather than one `return`. Rewrite it as: + +```cpp + if (!(number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && + number(7, out.q) && number(8, out.lo) && number(9, out.hi) && number(10, out.x) && + number(11, out.y))) { + return false; + } +``` + +- [ ] **Step 7: Build and round-trip the editor** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20 +``` + +Expected: two warnings, no errors. An out-of-bounds warning on `rows` means Step 1 was skipped. + +Clean up, in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +Then the existing math probe, which opens the editor and clicks through it: + +```bash +cd /home/martino/Projects/tools/udpscope +(timeout 14 python3 tools/mock_udpstreamer.py --port 8110 --rate 1300000 --batch 1000 --duration 12 > /tmp/t10-mock.log 2>&1 &) ; sleep 1 ; \ +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_MATH_MS=4000 \ + ./build/udpscope --server 127.0.0.1 --port 8110 2>&1 | grep -E 'math-probe|CRITICAL' +``` + +Expected: every `[math-probe]` line as `docs/claude/probe-readings.md` records it, including `graph edit:`, and no `Gtk-CRITICAL`. A `Gtk-CRITICAL` naming a NULL widget means `d->med` is read before it is built — `math_dialog_sync_form` runs during construction. + +Clean up, each in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/ui_app.hpp src/ui.cpp src/ui_math.cpp src/layout_store.cpp +git commit -m "ui: the node editor offers the de-noise node and persists its window" +``` + +--- + +## Task 11: The noise probe's de-noise leg + +One reading that says the node rejects what it is for: the peak-to-peak of a de-noised `sine` against the peak-to-peak of the raw one. The impulses are 2 samples wide and the median is 5, so the median rejects them outright — about **2** against about **3**. + +The node must have been producing for a while before it can be measured: a math channel is a channel, and it starts at the moment it is applied. + +**The graph therefore cannot be applied inside `noise_probe` itself.** `apply_math_channels()` calls `set_math_channels()`, whose comment records that it is a *structural rebuild* that **replaces the buffer and drops its history** — and every reading in Tasks 4 and 8 is taken over retained history. Applying the graph at the top of `noise_probe` would leave the six search legs scanning a buffer a few milliseconds old (`bare=0`, `band=0`, reading exactly like a broken comparator) and the five measurement legs reading a window that is mostly unfilled. So the probe gains a **setup stage** that runs five seconds earlier, does nothing but apply the graph, and then schedules `noise_probe` — which lands at the same 8000 ms it does today, over a buffer that has refilled since the rebuild, with five seconds of de-noised history behind it. + +**Files:** +- Modify: `src/ui_probe.cpp` (a new `noise_probe_setup`, a new `noise_probe_math`, and a de-noise node in `math_probe`) +- Modify: `src/ui_app.hpp` (declare both new functions) +- Modify: `src/ui.cpp` (register `noise_probe_setup` instead of `noise_probe`) +- Modify: `docs/claude/verification.md`, `docs/claude/probe-readings.md` + +**Interfaces:** +- Consumes: Tasks 4, 8 and 9. +- Produces: `gboolean noise_probe_setup(gpointer user);`, `gboolean noise_probe_math(gpointer user);` and the `[noise-probe] denoise ...` line. + +- [ ] **Step 1: Apply the graph in a setup stage** + +In `src/ui_probe.cpp`, immediately **above** `noise_probe`: + +```cpp +// Stage one of two. `apply_math_channels()` is a structural rebuild that drops the buffer's +// history, and every reading in `noise_probe` is taken over retained history -- so the rebuild +// happens here, five seconds before the first leg runs, and the buffer refills in between. The +// de-noise channel also needs that time: a math channel starts producing when it is applied. +// +// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode` +// is what the two `-Wmissing-field-initializers` in this file already are, and the build +// baseline is exactly four warnings. +gboolean noise_probe_setup(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + MathChannel dn; + dn.name = "math denoise"; + dn.enabled = true; + MathNode src_node; + src_node.op = MathOp::Source; + src_node.source = "sine"; + MathNode dn_node; + dn_node.op = MathOp::DeNoise; + dn_node.in_a = 0; + dn_node.med_n = 5; + // The median alone: a 2-sample impulse is shorter than half a 5-sample window, so it is + // rejected outright and the pole has nothing left to do. A corner here would attenuate the + // 5 Hz sine as well and make the peak-to-peak reading a function of two things. + dn_node.fc = 0.0; + dn.nodes = {src_node, dn_node}; + dn.output = 1; + a->math.push_back(dn); + apply_math_channels(*a); + std::fprintf(stderr, "[noise-probe] setup channels=%zu\n", a->math.size()); + std::fflush(stderr); + g_timeout_add(5000, noise_probe, a); + return G_SOURCE_REMOVE; +} +``` + +`noise_probe` itself is unchanged by this step — it keeps the `sine < 0 || square < 0` guard at its top, and the channel lookup there now also has to resolve over a channel list one entry longer, which it does by name and so does not care. + +- [ ] **Step 2: Register the setup stage instead, and schedule the follow-up** + +In `src/ui.cpp`, change the registration added in Task 4 to call the setup stage. `UDPSCOPE_PROBE_NOISE_MS` keeps its name and now times the *setup*, so every run command drops from 8000 to **3000** and the legs still land at 8000 ms: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) { + const int ms = std::atoi(env); + // Times the *setup* stage; `noise_probe` runs 5000 ms after it, so 3000 puts the legs at + // the same 8000 ms they were measured at. + if (ms > 0) g_timeout_add(static_cast(ms), noise_probe_setup, &app); + } +``` + +At the end of `noise_probe`, replace `return G_SOURCE_REMOVE;` with: + +```cpp + g_timeout_add(2500, noise_probe_math, a); + return G_SOURCE_REMOVE; +``` + +Keep the `a->search.clear(); update_search_panel(*a);` lines before it. + +- [ ] **Step 3: Write the follow-up** + +In `src/ui_probe.cpp`, after `noise_probe`: + +```cpp +gboolean noise_probe_math(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + const std::size_t base = a->measures.size(); + // Both unfiltered and at no band: the only difference between the two readings is the node. + MeasureOptions options; + a->measures.push_back({std::string("sine"), MeasureType::PeakToPeak, options}); + a->measures.push_back({std::string("math denoise"), MeasureType::PeakToPeak, options}); + rebuild_measures_pane(*a); + update_measures(*a, /*force=*/true, /*accumulate=*/true); + const MeasureResult raw = + base < a->measure_results.size() ? a->measure_results[base] : MeasureResult{}; + const MeasureResult den = + base + 1 < a->measure_results.size() ? a->measure_results[base + 1] : MeasureResult{}; + std::fprintf(stderr, + "[noise-probe] denoise raw=%.6f/%d denoised=%.6f/%d ratio=%.3f channels=%zu\n", + raw.primary, int(raw.valid), den.primary, int(den.valid), + raw.primary > 0.0 ? den.primary / raw.primary : 0.0, a->math.size()); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} +``` + +Declare both in `src/ui_app.hpp` beside `noise_probe`: + +```cpp +gboolean noise_probe_setup(gpointer user); +gboolean noise_probe_math(gpointer user); +``` + +- [ ] **Step 4: Give `math_probe` a de-noise node too** + +`math_probe` is the construction crash test for the editor: it opens the dialog on a graph and clicks the nodes. A de-noise node there is what exercises the new form row on every baseline run. In `src/ui_probe.cpp`'s `math_probe`, after the `lp` channel is pushed: + +```cpp + // A third channel so the editor's new form row is built and synced on a baseline run. + // Assigned field by field: a partial brace-initialiser would add a fifth build warning. + MathChannel dn; + dn.name = "math denoise"; + dn.enabled = true; + MathNode dn_src; + dn_src.op = MathOp::Source; + dn_src.source = name; + MathNode dn_op; + dn_op.op = MathOp::DeNoise; + dn_op.in_a = 0; + dn_op.med_n = 5; + dn_op.fc = 500.0; + dn.nodes = {dn_src, dn_op}; + dn.output = 1; + channels.push_back(dn); +``` + +and change its own report line from `2 math channels` to `3 math channels`, keeping the two names it prints. + +- [ ] **Step 5: Build** + +```bash +cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20 +``` + +Expected: two warnings, no errors. **A clean build must still be four** — check that with `rm -rf build` only if a warning count looks wrong, since a clean build is slow. + +- [ ] **Step 6: Run the noise probe** + +Clean up, in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +Then: + +```bash +cd /home/martino/Projects/tools/udpscope +(timeout 18 python3 tools/mock_udpstreamer.py --port 8111 --rate 1300000 --batch 1000 \ + --duration 16 --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/t11-mock.log 2>&1 &) ; sleep 1 ; \ +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_NOISE_MS=3000 \ + ./build/udpscope --server 127.0.0.1 --port 8111 2>&1 | grep -E 'noise-probe|CRITICAL' +``` + +`3000` and not `8000`: the variable now times the setup stage, and `noise_probe` runs 5000 ms after it, so the legs land at the same 8000 ms Tasks 4 and 8 measured them at. `noise_probe_math` then reports at 10500 ms, inside the 14000 ms exit. + +Expected, in addition to Tasks 4 and 8's lines: + +- `setup channels=1`, printed first. +- `denoise raw=` about **3.0**, `denoised=` about **2.0**, **`ratio` between 0.6 and 0.72**, both `valid=1`, `channels=1`. +- **Tasks 4 and 8's own readings must be unchanged from their runs.** If `bare` and `band` have collapsed to 0, or the measurement legs read `valid=0`, the setup stage is not actually running five seconds ahead of the legs — the buffer was rebuilt under them. Check `UDPSCOPE_PROBE_NOISE_MS` is `3000` and that the registration calls `noise_probe_setup`. +- `denoised` near 3 means the median is not rejecting: check `med_n` arrived as 5 and not 0, since a `med_n <= 0` node returns NaN and `peak to peak` would then read `--` rather than 3. +- `denoised=0` with `valid=0` means the channel never produced — check the source name `sine` resolved, and that `apply_math_channels` was called. + +No `Gtk-CRITICAL`. + +Clean up, each in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 7: Run the math probe, whose baseline moved** + +Clean up, in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +Then: + +```bash +cd /home/martino/Projects/tools/udpscope +(timeout 14 python3 tools/mock_udpstreamer.py --port 8112 --rate 1300000 --batch 1000 --duration 12 > /tmp/t11b-mock.log 2>&1 &) ; sleep 1 ; \ +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + UDPSCOPE_PROBE_MATH_MS=4000 \ + ./build/udpscope --server 127.0.0.1 --port 8112 2>&1 | grep -E 'math-probe|math cost|CRITICAL' +``` + +Expected: `[math-probe] 3 math channels`, every other `[math-probe]` line as before, and no `Gtk-CRITICAL`. `math cost:` will be higher than the recorded figure — a third channel with a median per sample — so note the new figure. + +Clean up, each in its own tool call: + +```bash +pkill -x udpscope; pkill -x Xvfb +``` + +```bash +pkill -f 'mock_udpstream[e]r' +``` + +- [ ] **Step 8: Document the readings** + +In `docs/claude/probe-readings.md`: the `setup` and `denoise` lines in the `[noise-probe]` section, with the figures and the defects above; and update the `[math-probe]` entry from two math channels to three, including the new `math cost:` figure measured in Step 7. + +In `docs/claude/verification.md`: correct the noise probe's schedule written in Task 4 from `UDPSCOPE_PROBE_NOISE_MS=8000` to **`=3000`**, and state why it is not simply the time the readings are taken — the variable times a setup stage that applies a math graph, `apply_math_channels()` drops the buffer's history, and the five seconds between the two stages are what let it refill before any leg scans it. A reader who "simplifies" the two stages into one gets `bare=0` on every search leg. + +Also note that `UDPSCOPE_PROBE_NOISE_MS` and `UDPSCOPE_PROBE_MATH_MS` must not be scheduled together either, since both write `a->math`. + +- [ ] **Step 9: Commit** + +```bash +git add src/ui.cpp src/ui_probe.cpp src/ui_app.hpp docs/claude/verification.md docs/claude/probe-readings.md +git commit -m "test: the noise probe reads the de-noise node, and the math probe builds one" +``` + +--- + +## Task 12: Documentation + +`README.md` documents what was *built*; the invariant docs carry the rules a future change must not break. Both are part of the feature, not an afterthought. + +**Files:** +- Modify: `README.md` +- Modify: `docs/claude/invariants-trigger-and-pinning.md`, `invariants-readouts-and-cursors.md`, `invariants-measurements.md`, `invariants-math-channels.md`, `invariants-session-and-export.md`, `invariants-gtk-and-chrome.md` + +**Interfaces:** +- Consumes: every earlier task. +- Produces: nothing code depends on. + +- [ ] **Step 1: README** + +Add the three controls where the README describes the trigger, the measurements and the math channels. For each: what it does, its unit, what "off" is, and the cost. Name the two deliberate deviations from `SPECS.md`/the design spec in whatever section the README already keeps deviations in: + +- The trigger's band applies to `Edge` and `Window` and **not** to `PulseWidth` or `Dropout`. A pulse's width is measured between a crossing and its return, so widening it with a band would change the measured width rather than reject noise; a dropout is an absence, which a band cannot describe. +- The noise probe verifies the trigger through `BufferSearch` hit counts rather than through `[condition-probe]` latch times, because a latch time is a stream stamp and is explicitly not an assertion. + +- [ ] **Step 2: `invariants-trigger-and-pinning.md`** + +Add: `EventCondition` now carries `noise_band` and `hf_fc`, both in `operator==`, and `EventDetector::configure()` takes `dt` and early-returns only when **both** the condition and the interval are unchanged — it runs on every packet, so a configure that resets would restart the machine before any pulse could close. `dt` is `Partition::slot_dt()`, the *stored* interval, and comes from exactly three call sites (`acquisition.cpp`, `ui_trigger.cpp`, `ui_probe.cpp`). A crossing is a change between two *decided* sides, never a change from the undecided one: seeding from `side_ == 0` fires on a signal that starts inside the band and leaves it without ever reaching the level. + +- [ ] **Step 3: `invariants-readouts-and-cursors.md`** + +That file's first rule is that **a readout quotes a stored sample and never a value between two**. A measurement with `hf_fc > 0` breaks it, deliberately and inherently. Add the exception next to the rule rather than leaving the rule reading as though it were still unconditional: + +> A measurement with a non-zero `hf_fc` is the one exception. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered copy of the window, so the quoted value need not equal any stored sample — and with `show_on_plot` its ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering *is*; it is not a rounding error and must not be "fixed" by snapping the readout to the nearest sample. Every measurement with `hf_fc == 0`, which is the default, still quotes a stored sample exactly as before. + +Say in the same place that the filter is seeded with the window's **first sample** and never from history before the window, so a measurement never depends on data outside the window it reports on — the left-edge step transient is removed by the seeding, not by priming from earlier samples. + +- [ ] **Step 4: `invariants-measurements.md`** + +Add: the hysteresis band is `MeasureOptions::noise_pct`, a percentage, clamped in `measure_set_level()` to half the room to the nearer extreme. The band moves the **edge decision only** — the sums still split at the bare level, because `Top` on a unit sine must read 2/pi. `measure_accumulate` loops **samples outer, levels inner**, which looks backwards and is not: the filter holds one state for the whole walk, so levels-outer would step it once per level per sample. A non-zero `hf_fc` disables **both** bucket fast paths with the one `if (!filtered && reader.bucket_range(...))` — an empty `buckets` makes `bulk_from == bulk_to == n` *and* leaves `lo_search`/`hi_search` at 0, so `pin()` becomes a no-op; a bucket aggregates unfiltered samples and cannot be mixed with a filtered walk. The pane's shared windows are keyed by `(channel, fc)`, not by channel. + +- [ ] **Step 5: `invariants-math-channels.md`** + +Add: `MathOp` is append-only because `math_op_at()` is the serialisation; `DeNoise` is index 12. `math_op_uses_filter()` keeps its meaning of *a biquad* — `math_op_uses_fc()` is the wider question and is what the dialog asks. The median window is **pooled in `MathRuntime`**, not held in `MathNodeRt`, so a node's state stays on one cache line. The de-noise pole reuses `b0` and `z1`, which is deliberate: `math_prepare()` carries `z1` across a re-prepare, so the pole keeps its history through a decimation change exactly as the biquads do, while the median window restarts from rest. `median_n()` rounds up to odd and caps at 9, which is what `double sorted[9]` is sized for. Median **then** pole, and the reason. A node runs on the producer thread, so a regression here costs packets and not frames. + +- [ ] **Step 6: `invariants-session-and-export.md`** + +Add: measurement options gain the **named** keys `noise` and `hf`, so a file written before them loads at the defaults — which are today's behaviour exactly. The math node string grows from twelve pipe fields to thirteen and `math_node_from_text` accepts **either** width; the op stays field 0 as a `math_op_at()` index, which is why `MathOp` may only be appended to. + +- [ ] **Step 7: `invariants-gtk-and-chrome.md`** + +Add: `MeasureDialog` and `MathDialog` are brace-initialised **positionally** in `src/ui.cpp`, so a field added in the middle of either silently shifts every argument after it — add at the end of the widget group, and check the initialiser in the same commit. A control that cannot affect the current type is **hidden**, not disabled: the trigger's band row is hidden for `Dropout`, the measurement's for every non-crossing type, and the node editor's median row for every op but `DeNoise`. + +- [ ] **Step 8: Commit** + +```bash +git add README.md docs/claude/invariants-trigger-and-pinning.md \ + docs/claude/invariants-readouts-and-cursors.md \ + docs/claude/invariants-measurements.md docs/claude/invariants-math-channels.md \ + docs/claude/invariants-session-and-export.md docs/claude/invariants-gtk-and-chrome.md +git commit -m "docs: record the three noise-rejection controls and their invariants" +``` + +--- + +## Deviations from the spec, collected + +A reviewer comparing this plan with `docs/superpowers/specs/2026-10-13-noise-rejection-design.md` will find four differences. All four are deliberate and are recorded in the tasks that make them. + +| Spec says | Plan does | Why | +| --- | --- | --- | +| Verify the trigger through four exact `[condition-probe]` `t0` values. | Six `BufferSearch` legs, asserting hit counts and a group delay. | `probe-readings.md` states `t0` is **not** an assertion: it is a stream stamp rebased by whichever rebuild last dropped history, and read `1.7103`, `1.6975` and `1.8719` on three consecutive runs of the same leg. | +| Measure the group delay between a banded leg and a banded-plus-filtered leg on `sine`. | Measure it between two unbanded legs on `square`. | A `+0.5` impulse arriving while the sine sits at `0.3` already reaches a `0.8` threshold, so the banded leg fires up to 20 ms early, quantised to the 5 ms spike grid. A band rejects spurious crossings; it does not stop a real one from happening early. `square` carries no impulses, and a one-pole crosses a step's midpoint at exactly `tau*ln2`. | +| `feed_window` takes the band on each edge of its own band. | The same, **latched**: the band is applied to whichever edge the signal must *leave*. | An unlatched band on both edges has a dead zone the signal can sit in, so a slow traversal of the window reports no change of side at all. | +| The band applies to every condition type. | `Edge` and `Window` only. | A pulse's width is measured from a crossing to its return, so a band there changes the measured width rather than rejecting noise — the pulse-width legs' documented readings would move. A dropout is a measured absence, which a band cannot describe. |