Files
udpscope/docs/superpowers/plans/2026-10-13-noise-rejection.md
T
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

103 KiB
Raw Blame History

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:

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:

    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:

    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:

                           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:

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:

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
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:

  /// 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:

  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

  void configure(const EventCondition& c) noexcept;

with

  /// `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_:

  /// 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:

  /// 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:

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):

  pole_y_ = 0.0;
  pole_have_ = false;
  side_ = 0;
  • Step 4: Write filter and decide

In src/condition.cpp, above feed:

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:

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:

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:

  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:

  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:

  detector_.configure(cfg_.condition, dt);
  • Step 8: Plumb dt through the search

src/search.hpp:51:

  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:

  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:

  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:

  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
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:

pkill -x udpscope; pkill -x Xvfb

Then:

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:

pkill -x udpscope; pkill -x Xvfb
pkill -f 'mock_udpstream[e]r'
  • Step 12: Commit
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:

            <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
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:

  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:

  // 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:

  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:

  c.noise_band = band;
  c.hf_fc = hf;
  • Step 6: Show them per type

In sync_trigger_rows(), beside the existing five show(...) calls:

  // 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):

                      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
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:

pkill -x udpscope; pkill -x Xvfb

Then:

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:

pkill -x udpscope; pkill -x Xvfb
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
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:

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
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:

gboolean noise_probe(gpointer user);

In src/ui.cpp, in the registration block, matching its neighbours exactly:

  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
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:

pkill -x udpscope; pkill -x Xvfb

Then:

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:

pkill -x udpscope; pkill -x Xvfb
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
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:

  /// `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:

/// 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:

void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter& filter);
/// `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);
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:

/// 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:

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:

constexpr double kPi = 3.14159265358979323846;

Then:

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:

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:

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:

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:

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

  /// 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

  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();:

  // 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:

  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:

  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:

    // 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:

    const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); };
      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);
      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:

          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:

    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
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:

pkill -x udpscope; pkill -x Xvfb

Then the documented measurement probe, unchanged, on the plain mock:

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:

pkill -x udpscope; pkill -x Xvfb
pkill -f 'mock_udpstream[e]r'
  • Step 8: Commit
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:

  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:

  // 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);
  // 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:

  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:

    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:

  // 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:

  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:

    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
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:

pkill -x udpscope; pkill -x Xvfb

Then the existing session probe, which saves and reloads:

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:

pkill -x udpscope; pkill -x Xvfb
pkill -f 'mock_udpstream[e]r'
  • Step 8: Commit
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():

  // 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
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:

pkill -x udpscope; pkill -x Xvfb

Then:

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:

pkill -x udpscope; pkill -x Xvfb
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
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:

  /// 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:

  /// `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==:

           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:

/// 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:

  /// `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:

  /// 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:

    case MathOp::Div: return "ratio";
    case MathOp::DeNoise: return "de-noise";
int math_op_count() { return 13; }
    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.

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:

/// 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:

  // 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:

    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:

      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
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
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:

  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:

  // 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:

  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:

  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:

  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:

  // 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:

  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:

  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
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:

pkill -x udpscope; pkill -x Xvfb

Then the existing math probe, which opens the editor and clicks through it:

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:

pkill -x udpscope; pkill -x Xvfb
pkill -f 'mock_udpstream[e]r'
  • Step 8: Commit
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:

// 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:

  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:

  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:

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:

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:

  // 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
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:

pkill -x udpscope; pkill -x Xvfb

Then:

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:

pkill -x udpscope; pkill -x Xvfb
pkill -f 'mock_udpstream[e]r'
  • Step 7: Run the math probe, whose baseline moved

Clean up, in its own tool call:

pkill -x udpscope; pkill -x Xvfb

Then:

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:

pkill -x udpscope; pkill -x Xvfb
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
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
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.