# 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. |