Files
udpscope/docs/superpowers/plans/2026-10-13-noise-rejection.md
Martino FerrariandClaude Opus 4.6 a989935bbd docs: plan the noise-rejection work as twelve testable tasks
The design spec is approved; this turns it into the task-by-task plan an
implementer with no context can follow, with the four places the plan
deliberately departs from the spec collected and argued at the end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-13 14:42:13 +02:00

2312 lines
103 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <cmath>` 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<std::mutex> 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
<child>
<object class="GtkBox" id="band_row">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">noise band</property>
<property name="halign">start</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="hf_row">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">HF reject Hz</property>
<property name="halign">start</property>
</object>
</child>
</object>
</child>
```
- [ ] **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<double>::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<int>(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<ChannelInfo>& channels = a->model->channels();
for (std::size_t i = 0; i < channels.size(); ++i) {
if (channels[i].name == name) return static_cast<int>(i);
}
return -1;
}
} // namespace
```
`<cmath>`, `<limits>` and `<vector>` 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<App*>(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<std::mutex> 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<double>(a_leg.hits) /
static_cast<double>(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<guint>(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<double(std::size_t)>&, 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 `<cmath>` 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<double(std::size_t)>& 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<double>(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<double(std::size_t)>& 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<double>(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<std::size_t>(s)] += v;
++acc.count[static_cast<std::size_t>(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<std::size_t>(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::MeasureWindow> 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<double> pct; ///< the level shares asked for, deduplicated
std::vector<double> noise; ///< each entry's band, as a percentage; parallel to `pct`
std::vector<MeasureLevel> 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<MeasureWindow> 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<std::mutex> 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<int> chan(app.measures.size(), -1);
std::vector<int> want_window(app.measures.size(), -1);
std::vector<int> 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<int>(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<int>(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<std::size_t>(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<std::size_t>(want_window[i]) < app.measure_samples.size()
? &app.measure_samples[static_cast<std::size_t>(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<std::mutex> 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);
```
`<iterator>` 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<double> 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<std::size_t>(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<std::size_t>(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<std::size_t>(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<double>(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<int>(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<int>(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<App*>(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<guint>(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<App*>(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. |