Files
udpscope/docs/superpowers/plans/2026-10-07-scope-features.md
T
Martino FerrariandClaude Opus 4.6 9aa1d80158 docs: an implementation plan for the three new scope features
Nine tasks with literal code: the mock's injected faults, the shared
EventDetector the trigger and the search both run, the four condition
shapes and their panel rows, the Welford statistics and what resets
them, the chunked buffer search, its marks, and the documentation.

Every probe's expected reading is written as a shape with the measured
figure left to be recorded from a run, never assumed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 12:27:30 +02:00

154 KiB

Qualified triggers, measurement statistics and a buffer search: 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 udpscope the three things the spec 2026-10-07-scope-features-design.md asks for: a trigger that can qualify on pulse width, a level band or a dropout; running statistics beside every measurement; and a search that scans the whole retained buffer for the trigger's own condition and lets the user step through the hits.

Architecture: One new evaluator, EventDetector in src/condition.{hpp,cpp}, owns every condition shape. TriggerEngine keeps the arming, hold-off and post-window state and delegates the question "is this sample an event?" to it. BufferSearch in src/search.{hpp,cpp} runs the same evaluator over retained samples in chunks on the GTK thread, so a hit is by construction the same answer the live trigger would have given. Statistics are a MeasureStatsAccum per measurement, fed one observation per measurements-pane recompute. Navigation to a hit reuses the existing Paused display mode and publish_pause_view().

Tech Stack: C++20, GTK4, the vendored deps/plotter and deps/udpsclient, CMake. No test framework: verification is the application's own headless probes under xvfb-run against tools/mock_udpstreamer.py, plus standalone /tmp compilation units for pure logic.

Global Constraints

Copied from the spec. Every task's requirements implicitly include these.

  • Edge behaviour must be bit-identical to today's TriggerEngine::process(). The crossing tests are prev_ < level && v >= level (up) and prev_ > level && v <= level (down); the three-way fired expression is unchanged. No new finite-value check anywhere on the sample path.
  • The event window stays counted in stream seconds end to end. Nothing on this path may convert seconds to a sample count, and no rate estimate may be reintroduced (docs/claude/invariants-trigger-and-pinning.md).
  • The detector holds no arming or capture state. That is TriggerEngine's, so the search can run the detector alone.
  • EventDetector::configure() is called on every packet and must be a no-op when the condition has not changed.
  • Statistics are one observation per recompute. n counts recomputes, never samples. An invalid result contributes nothing, and the measure_last_valid fallback is not accumulated.
  • Statistics reset explicitly (the Reset button) and implicitly on a measurement edit, on a CONFIG, and on a display-window change. Not on pause, resume, a trigger latch, or a pan/zoom.
  • kSearchHitsMax = 10000, kSearchMarks = 16, kMeasureMarks stays 6.
  • The statistics toggle is the only new session key. The trigger — and therefore the condition — is not persisted at all; LayoutState has no trigger field.
  • A zero-width Window band is refused by the panel, by clamping the second level spin at least one increment away from the first. The detector does not special-case it (a band test is a change of side, so equal levels silently never fire).
  • Every new source file carries the SPDX MIT header, matching src/trigger.cpp lines 1-2.
  • Build warning baseline: an incremental build must produce exactly the 2 existing -Wmissing-field-initializers warnings (src/ui.cpp:7467, :7476); a clean build exactly 4. Zero Gtk-CRITICAL.
  • Verification runs are short and serial. Start one mock, run one probe, kill the mock with pkill -f 'mock_udpstrea[m]' (the bracket avoids killing the invoking shell), then move on.
  • ~/.config/udpscope/127.0.0.1_8000.toml holds a 100 s window that blocks every trigger latch through the pre-window gate. Move it aside before any trigger or search probe and restore it after.
  • The mock is not executable: invoke it as python3 ./tools/mock_udpstreamer.py, and connect udpscope with --server, not --host.

File Structure

File Responsibility
src/condition.hpp (new) TriggerEdge, TriggerType, WidthCmp, EventCondition, EventDetector. The whole evaluator's interface; no GTK, no model.
src/condition.cpp (new) The four condition state machines.
src/search.hpp (new) BufferSearch: a chunked scan of the retained buffer with an EventDetector, a hit list and a cursor over it. Includes model.hpp; no GTK.
src/search.cpp (new) The scan, the hit list, the clamp against a ring that keeps sliding.
src/trigger.hpp / src/trigger.cpp Loses TriggerEdge (moved) and TriggerConfig::level/edge (replaced by EventCondition condition); gains an EventDetector member it delegates to. Keeps arming, hold-off, the post window.
src/measure.hpp Gains MeasureStatsAccum (Welford).
src/plot_host.hpp / src/plot_host.cpp Gains kSearchMarks, a search_v cursor pool per row, set_search_marks(), search_marks_placed(); readouts_locked() grows to cover the pool.
src/layout_store.hpp / src/layout_store.cpp Gains the one new session key, measure_stats.
src/ui.cpp The trigger panel's condition rows and their show/hide; the statistics toggle, reset and second line; the Search expander and its navigation; three new probes.
tools/mock_udpstreamer.py --glitch-at, --glitch-width, --quiet-at.
CMakeLists.txt Adds the two new translation units.
README.md, docs/claude/verification.md, docs/claude/probe-readings.md, docs/claude/invariants-*.md Documentation, last task.

Task 1: Mock waveforms for the new conditions

The later probes need a narrow pulse in square (for a pulse-width/glitch condition) and a signal that stops moving (for a dropout). This task comes first because nothing downstream can be verified without it.

Files:

  • Modify: tools/mock_udpstreamer.py:72-110 (build_data), :168-178 (the argument block), :258-269 (the build_data call)
  • Test: /tmp/check_mock_waveforms.py (standalone; the project has no test framework)

Interfaces:

  • Consumes: nothing.

  • Produces: 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); the command-line flags --glitch-at <s>, --glitch-width <s>, --quiet-at <s>.

  • Step 1: Write the failing test

Create /tmp/check_mock_waveforms.py:

#!/usr/bin/env python3
"""Checks tools/mock_udpstreamer.py's --glitch-* and --quiet-at waveform shapes.

Calls build_data() directly and unpacks the payload, so no socket is involved.
Run from the repository root.
"""
import importlib.util
import math
import struct
import sys

spec = importlib.util.spec_from_file_location("mock", "tools/mock_udpstreamer.py")
mock = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mock)

BATCH = 10000
DT = 1.0e-4
FREQ = 5.0
GLITCH_AT = 0.25
GLITCH_WIDTH = 0.002
QUIET_AT = 0.26

pkt = mock.build_data(
    1, 0.0, DT, BATCH, FREQ, mock.TIME_FULL_ARRAY, 0.0, 0.0,
    GLITCH_AT, GLITCH_WIDTH, QUIET_AT,
)
off = 17 + 12  # 17-byte header, then "<QI"
n = BATCH * 8
sine = struct.unpack_from("<%dd" % BATCH, pkt, off + n)
square = struct.unpack_from("<%dd" % BATCH, pkt, off + 2 * n)

fails = []

# The square is inverted for `glitch_width` seconds, once per square period (2/freq).
sq_period = 2.0 / FREQ
for i in range(BATCH):
    t = i * DT
    nominal = 1.0 if math.sin(2.0 * math.pi * FREQ * 0.5 * t) >= 0.0 else -1.0
    inside = False
    if t >= GLITCH_AT:
        k = math.floor((t - GLITCH_AT) / sq_period)
        start = GLITCH_AT + k * sq_period
        inside = t < start + GLITCH_WIDTH
    want = -nominal if inside else nominal
    if square[i] != want:
        fails.append("square[%d] (t=%.5f) = %+.1f want %+.1f" % (i, t, square[i], want))
        break

# Two glitches must land in one second of a 5 Hz square: at 0.25 and at 0.65.
if square[2500] == square[2499]:
    fails.append("no glitch edge at t=0.2500")
if square[6500] == square[6499]:
    fails.append("no glitch edge at t=0.6500")

# `sine` is held on the opposite side of zero from where it was at `quiet_at`.
held = -0.5 if math.sin(2.0 * math.pi * FREQ * QUIET_AT) >= 0.0 else 0.5
if sine[2599] != math.sin(2.0 * math.pi * FREQ * 0.2599):
    fails.append("sine moved before quiet_at")
for i in (2600, 5000, BATCH - 1):
    if sine[i] != held:
        fails.append("sine[%d] = %.4f want %.4f" % (i, sine[i], held))

print("FAIL: " + "; ".join(fails) if fails else "ok")
sys.exit(1 if fails else 0)
  • Step 2: Run it to verify it fails

Run: cd /home/martino/Projects/tools/udpscope && python3 /tmp/check_mock_waveforms.py Expected: TypeError: build_data() takes from 5 to 8 positional arguments but 11 were given

  • Step 3: Widen build_data's signature

In tools/mock_udpstreamer.py, replace:

def build_data(
    counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY, noise=0.0, time_spike=0.0
):

with:

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,
):
  • Step 4: Shape the two waveforms

In the same function, replace:

    ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]

with:

    ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
    # A glitch is an *inversion* of the square for `glitch_width` seconds, repeated once per
    # square period (2/freq -- the period of sin(2*pi*freq*0.5*t)). Inverting rather than
    # forcing a level makes it a narrow pulse whatever the square's polarity is, and repeating
    # it lets a probe assert the *spacing* of the hits a width condition finds instead of an
    # absolute count, which depends on when the probe happened to start looking.
    if glitch_at >= 0.0 and glitch_width > 0.0:
        sq_period = 2.0 / freq
        for i, t in enumerate(stamps):
            if t < glitch_at:
                continue
            k = math.floor((t - glitch_at) / sq_period)
            start = glitch_at + k * sq_period
            if t < start + glitch_width:
                square[i] = -square[i]
    # `--quiet-at` is for the dropout condition: the sine stops at a level on the *opposite*
    # side of zero from where it was, so `quiet_at` itself is its last zero crossing and a
    # dropout at level 0 fires at exactly `quiet_at + timeout`. Holding at the same sign would
    # leave the last crossing at whichever earlier zero happened to precede it, which is not a
    # figure a probe can assert. Choose a `quiet_at` away from the sine's own zeros.
    if quiet_at >= 0.0:
        held = -0.5 if math.sin(2.0 * math.pi * freq * quiet_at) >= 0.0 else 0.5
        for i, t in enumerate(stamps):
            if t >= quiet_at:
                sine[i] = held
  • Step 5: Run the test to verify it passes

Run: cd /home/martino/Projects/tools/udpscope && python3 /tmp/check_mock_waveforms.py Expected: ok

  • Step 6: Add the three flags

In main(), after the --time-glitch-by argument block, insert:

    ap.add_argument(
        "--glitch-at",
        type=float,
        default=-1.0,
        help="stream time (s) of the first narrow pulse injected into `square`: the square "
        "is inverted for --glitch-width seconds, once per square period (2/freq), so a "
        "pulse-width or glitch trigger has a repeating event to find. -1 = never",
    )
    ap.add_argument(
        "--glitch-width",
        type=float,
        default=0.0,
        help="width of each injected pulse, in stream seconds",
    )
    ap.add_argument(
        "--quiet-at",
        type=float,
        default=-1.0,
        help="stream time (s) after which `sine` is held at a constant level on the "
        "opposite side of zero, so --quiet-at is its last zero crossing: the shape a "
        "dropout trigger is read against. -1 = never",
    )
  • Step 7: Pass them through

In the send loop, replace:

        sock.sendto(
            build_data(
                counter,
                t_stream,
                pdt,
                args.batch,
                args.freq,
                time_mode,
                args.noise,
                spike,
            ),
            peer,
        )

with:

        sock.sendto(
            build_data(
                counter,
                t_stream,
                pdt,
                args.batch,
                args.freq,
                time_mode,
                args.noise,
                spike,
                args.glitch_at,
                args.glitch_width,
                args.quiet_at,
            ),
            peer,
        )
  • Step 8: Check the flags reach the wire

Run: cd /home/martino/Projects/tools/udpscope && python3 ./tools/mock_udpstreamer.py --port 8000 --duration 1 --glitch-at 0.25 --glitch-width 0.002 --quiet-at 0.26 & sleep 1; pkill -f 'mock_udpstrea[m]' Expected: the waiting for CONNECT on :8000 banner and no traceback.

  • Step 9: Commit
git add tools/mock_udpstreamer.py
git commit -m "feat: the mock can inject a narrow pulse and stop a signal, so a width and a dropout condition have something to find"

Task 2: EventDetector, and TriggerEngine delegating to it

The whole migration, with Edge as the only implemented condition. Bit-identity is the deliverable: the existing trigger probes must read exactly as they do today.

Files:

  • Create: src/condition.hpp, src/condition.cpp
  • Modify: src/trigger.hpp:6-36 (includes, the moved enum, TriggerConfig), :95-111 (the accessors and the members), src/trigger.cpp:4-106, src/ui.cpp:1604-1618 (publish_trigger_fields), :2053 (the default level), :6347 (the status line), :8270-8274 (the trigrenum probe), :8298-8300 (the fields probe), CMakeLists.txt:100-120
  • Test: /tmp/check_condition.cpp (standalone; compiled against src/condition.cpp)

Interfaces:

  • Consumes: nothing from Task 1.

  • Produces:

    • enum class udpscope::TriggerEdge { Rising, Falling, Both }; — moved out of trigger.hpp into condition.hpp, which trigger.hpp now includes.
    • enum class udpscope::TriggerType { Edge, PulseWidth, Window, Dropout };
    • enum class udpscope::WidthCmp { Less, Greater, Within, Outside };
    • struct udpscope::EventCondition { TriggerType type; double level; double level2; TriggerEdge edge; WidthCmp cmp; double width_s; double width2_s; double timeout_s; bool operator==(const EventCondition&) const noexcept; bool operator!=(const EventCondition&) const noexcept; };
    • class udpscope::EventDetector with static constexpr double kNoEvent, void configure(const EventCondition&) noexcept, void reset() noexcept, double feed(double value, double t) noexcept, double last_value() const noexcept, bool have_last() const noexcept, const EventCondition& condition() const noexcept.
    • TriggerConfig loses level and edge; it gains EventCondition condition;. TriggerEngine::last_value()/have_last() keep their signatures and forward to the detector, so src/acquisition.cpp:655 needs no change.
  • Step 1: Write the failing test

Create /tmp/check_condition.cpp:

// Standalone check of EventDetector's Edge path against the engine's old expression.
#include <cmath>
#include <cstdio>
#include <vector>

#include "condition.hpp"

using udpscope::EventCondition;
using udpscope::EventDetector;
using udpscope::TriggerEdge;
using udpscope::TriggerType;

namespace {
int fails = 0;

void expect(bool ok, const char* what) {
  if (!ok) {
    std::fprintf(stderr, "FAIL: %s\n", what);
    ++fails;
  }
}

/// Feeds a ramp of samples and collects every event time the detector reports.
std::vector<double> run(const EventCondition& c, const std::vector<double>& v, double dt) {
  EventDetector d;
  d.configure(c);
  std::vector<double> out;
  for (std::size_t i = 0; i < v.size(); ++i) {
    const double t = static_cast<double>(i) * dt;
    const double e = d.feed(v[i], t);
    if (std::isfinite(e)) out.push_back(e);
  }
  return out;
}
}  // namespace

int main() {
  // One rising crossing of 0.0, at index 2 (t = 0.2).
  const std::vector<double> up_down = {-1.0, -0.5, 0.5, 1.0, 0.5, -0.5, -1.0};

  EventCondition rising;
  rising.type = TriggerType::Edge;
  rising.level = 0.0;
  rising.edge = TriggerEdge::Rising;
  const std::vector<double> r = run(rising, up_down, 0.1);
  expect(r.size() == 1, "rising: one event");
  expect(!r.empty() && std::fabs(r[0] - 0.2) < 1e-12, "rising: event at t=0.2");

  EventCondition falling = rising;
  falling.edge = TriggerEdge::Falling;
  const std::vector<double> f = run(falling, up_down, 0.1);
  expect(f.size() == 1, "falling: one event");
  expect(!f.empty() && std::fabs(f[0] - 0.5) < 1e-12, "falling: event at t=0.5");

  EventCondition both = rising;
  both.edge = TriggerEdge::Both;
  expect(run(both, up_down, 0.1).size() == 2, "both: two events");

  // The very first sample can never be an event: there is no previous value to cross from.
  EventDetector d;
  d.configure(rising);
  expect(!std::isfinite(d.feed(1.0, 0.0)), "first sample is never an event");
  expect(d.have_last() && d.last_value() == 1.0, "first sample is remembered");

  // Re-applying the same condition must not disturb the state, because the producer calls
  // configure() on every packet; a different one must reset it.
  d.configure(rising);
  expect(d.have_last(), "re-configure with the same condition keeps state");
  d.configure(falling);
  expect(!d.have_last(), "configure with a different condition resets");

  // A level exactly on a sample counts once, on the sample that reaches it.
  const std::vector<double> touch = {-1.0, 0.0, 0.0, 1.0};
  const std::vector<double> tr = run(rising, touch, 1.0);
  expect(tr.size() == 1, "touching the level counts one rising event");
  expect(!tr.empty() && tr[0] == 1.0, "touching the level fires at t=1");

  std::fprintf(stderr, fails == 0 ? "ok\n" : "%d failure(s)\n", fails);
  return fails == 0 ? 0 : 1;
}
  • Step 2: Run it to verify it fails

Run: cd /home/martino/Projects/tools/udpscope && g++ -std=c++20 -O2 -I src /tmp/check_condition.cpp src/condition.cpp -o /tmp/check_condition Expected: fatal error: condition.hpp: No such file or directory

  • Step 3: Write src/condition.hpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari

#pragma once

#include <limits>

namespace udpscope {

/// Which way through a level counts. Shared by every condition type: for `Window` it is
/// the direction through the *band* (into it, out of it, or either), and for `Dropout` it
/// selects which crossings count as the signal still being alive.
///
/// It lives here rather than in `trigger.hpp` because the buffer search needs the
/// evaluator without the engine's arming state.
enum class TriggerEdge { Rising, Falling, Both };

/// What shape in the signal counts as an event.
enum class TriggerType { Edge, PulseWidth, Window, Dropout };

/// How a measured pulse width is compared with the condition's bounds. `Less` is the
/// glitch trigger: "a pulse shorter than it should be".
enum class WidthCmp { Less, Greater, Within, Outside };

/// Everything that decides whether a sample is an event. No arming, no capture window:
/// those belong to `TriggerConfig`.
struct EventCondition {
  TriggerType type = TriggerType::Edge;
  double level = 0.0;
  double level2 = 0.0;               ///< Window: the band's other edge. Ignored otherwise.
  TriggerEdge edge = TriggerEdge::Rising;
  WidthCmp cmp = WidthCmp::Greater;  ///< PulseWidth only.
  double width_s = 1.0e-3;           ///< PulseWidth only.
  double width2_s = 2.0e-3;          ///< PulseWidth, Within/Outside only.
  double timeout_s = 1.0e-3;         ///< Dropout only.

  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;
  }
  bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); }
};

/// The event condition's state machine: one sample in, an event time or nothing out.
///
/// It holds no arming state and no capture state -- that is `TriggerEngine`'s job -- which
/// is what lets the buffer search run the *same* evaluator over retained samples and get
/// the answers the live trigger would have given.
///
/// Every condition but `Edge` is sequential: a pulse's width is only known at its closing
/// edge, and a dropout is a measured absence. Callers must therefore feed it **every**
/// sample in stream order, including samples they intend to ignore; a gap is a pulse it can
/// never close.
class EventDetector {
 public:
  /// What `feed()` returns when the sample is not an event.
  static constexpr double kNoEvent = std::numeric_limits<double>::quiet_NaN();

  /// Re-applying the same condition is a no-op, deliberately: the producer calls this on
  /// every packet, and resetting there would restart the machine before any pulse could
  /// close. A genuinely different condition resets, because its state means nothing.
  void configure(const EventCondition& c) noexcept;
  void reset() noexcept;

  /// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`.
  ///
  /// There is no finite-value check: a NaN sample is benign here (every comparison against
  /// it is false, so it cannot fire), and rejecting one would leave `prev_` at the last good
  /// value and let the sample *after* the NaN fire a crossing that today cannot happen.
  double feed(double value, double t) noexcept;

  double last_value() const noexcept { return prev_; }
  bool have_last() const noexcept { return have_prev_; }
  const EventCondition& condition() const noexcept { return cond_; }

 private:
  bool crossed_up(double v) const noexcept {
    return prev_ < cond_.level && v >= cond_.level;
  }
  bool crossed_down(double v) const noexcept {
    return prev_ > cond_.level && v <= cond_.level;
  }
  double feed_edge(double value, double t) noexcept;

  EventCondition cond_{};
  bool have_prev_ = false;
  double prev_ = 0.0;
};

}  // namespace udpscope
  • Step 4: Write src/condition.cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari

#include "condition.hpp"

namespace udpscope {

void EventDetector::configure(const EventCondition& c) noexcept {
  if (c == cond_) return;
  cond_ = c;
  reset();
}

void EventDetector::reset() noexcept {
  have_prev_ = false;
  prev_ = 0.0;
}

double EventDetector::feed(double value, double t) noexcept {
  if (!have_prev_) {
    prev_ = value;
    have_prev_ = true;
    return kNoEvent;
  }
  double event = kNoEvent;
  switch (cond_.type) {
    case TriggerType::Edge:
      event = feed_edge(value, t);
      break;
    case TriggerType::PulseWidth:
    case TriggerType::Window:
    case TriggerType::Dropout:
      break;  // implemented in the tasks that add their panel rows
  }
  // After the handler: every crossing test above reads `prev_` as the previous sample.
  prev_ = value;
  return event;
}

double EventDetector::feed_edge(double value, double t) noexcept {
  const bool up = crossed_up(value);
  const bool down = crossed_down(value);
  const bool fired = (cond_.edge == TriggerEdge::Rising && up) ||
                     (cond_.edge == TriggerEdge::Falling && down) ||
                     (cond_.edge == TriggerEdge::Both && (up || down));
  return fired ? t : kNoEvent;
}

}  // namespace udpscope
  • Step 5: Run the test to verify it passes

Run: cd /home/martino/Projects/tools/udpscope && g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -I src /tmp/check_condition.cpp src/condition.cpp -o /tmp/check_condition && /tmp/check_condition Expected: ok, no warnings.

  • Step 6: Move the enum and swap the two TriggerConfig fields

In src/trigger.hpp, replace:

#include <cstdint>
#include <string>

namespace udpscope {

/// Which way the trigger signal crosses the level.
enum class TriggerEdge { Rising, Falling, Both };

/// How an armed trigger re-arms after it fired.

with:

#include <cstdint>
#include <string>

#include "condition.hpp"

namespace udpscope {

/// How an armed trigger re-arms after it fired.

and replace:

  int channel = -1;              ///< index into ScopeModel::channels(), -1 = none.
  double level = 0.0;
  TriggerEdge edge = TriggerEdge::Rising;
  double pretrigger_pct = 50.0;  ///< share of the buffer shown before the event.

with:

  int channel = -1;              ///< index into ScopeModel::channels(), -1 = none.
  /// What counts as an event on that channel: the level and edge, and for the qualified
  /// shapes the width, band or timeout as well. The engine does not interpret it -- it
  /// hands it to an `EventDetector`.
  EventCondition condition{};
  double pretrigger_pct = 50.0;  ///< share of the buffer shown before the event.
  • Step 7: Give the engine a detector instead of a previous sample

In src/trigger.hpp, replace:

  double last_value() const noexcept { return prev_; }
  bool have_last() const noexcept { return have_prev_; }

with:

  double last_value() const noexcept { return detector_.last_value(); }
  bool have_last() const noexcept { return detector_.have_last(); }

and replace:

  bool armed_ = false;
  bool have_prev_ = false;
  double prev_ = 0.0;
  bool pending_ = false;      ///< an edge was seen; collecting the post window

with:

  bool armed_ = false;
  /// What an event *is*. The engine owns when one may open a capture; this owns whether
  /// the sample is one at all.
  EventDetector detector_{};
  bool pending_ = false;      ///< an edge was seen; collecting the post window
  • Step 8: Delegate in src/trigger.cpp

Replace #include <algorithm> with:

#include <algorithm>
#include <cmath>

Replace the body of configure():

  const bool channel_changed = channel_name != channel_name_;
  if (channel_changed) channel_name_ = channel_name;
  cfg_ = cfg;
  post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
  pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
  oldest_s_ = oldest_s;
  if (channel_changed) {
    have_prev_ = false;
    pending_ = false;
    post_accum_count_ = 0;
  }

with:

  const bool channel_changed = channel_name != channel_name_;
  if (channel_changed) channel_name_ = channel_name;
  cfg_ = cfg;
  post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
  pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
  oldest_s_ = oldest_s;
  // A no-op unless the condition actually changed: this runs on every packet.
  detector_.configure(cfg_.condition);
  if (channel_changed) {
    detector_.reset();
    pending_ = false;
    post_accum_count_ = 0;
  }

Replace in arm():

  holdoff_until_s_ = now_s;
  have_prev_ = false;

with:

  holdoff_until_s_ = now_s;
  detector_.reset();
  • Step 9: Reorder process() around the detector

Replace the whole body of TriggerEngine::process() (from if (!cfg_.enabled) through the final return false;) with:

  if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;

  // The detector is fed **first and unconditionally**, on every sample the engine sees --
  // including during the hold-off and during an open capture, where its answer is thrown
  // away. It is a sequential machine: a pulse whose opening edge fell in a skipped stretch
  // can never be closed, and the symptom would be a width condition that silently stops
  // firing after the first hold-off.
  const double event_t = detector_.feed(value, now_s);

  // Hold-off: settled, waiting for the next event.
  if (!pending_ && now_s < holdoff_until_s_) {
    last_t_s_ = now_s;
    return false;
  }

  last_t_s_ = now_s;

  if (pending_) {
    // Accumulate the post-trigger window; latch once the stream clock has advanced
    // past it. Time, not a sample count: `now_s` is the same clock the x axis reads,
    // so the capture covers exactly the requested span whatever the cadence is.
    ++post_accum_count_;
    if (now_s - pending_start_s_ >= post_seconds_) {
      pending_ = false;
      post_accum_count_ = 0;
      if (cfg_.mode == TriggerMode::Single) {
        armed_ = false;
      } else {
        holdoff_until_s_ = now_s + std::max(0.0, cfg_.holdoff_s);
      }
      return true;
    }
    return false;
  }

  if (!std::isfinite(event_t)) return false;

  // The pre-trigger window has to already be behind the event, or the capture opens on a
  // half-filled pre window and the event is short on its left side -- the plot then shows a
  // window that is correctly sized but only partly drawn. The buffer fills whether or not the
  // trigger is armed, so this normally passes on the first event; it only bites just after a
  // rebuild (a CONFIG, or enabling the signal), when the history genuinely is not there yet.
  //
  // Measured from the *event's own* time, not from `now_s`: for a qualified condition the two
  // differ (a pulse is reported at its closing edge), and the pre window belongs to the event.
  if (event_t - oldest_s_ < pre_seconds_) return false;

  // The event's own time, which is what a trigger marker marks: the latch itself may happen
  // a post-window later.
  last_trigger_s_ = event_t;

  // The event always opens a capture; there is no "latch on the event itself" case.
  // A zero-length post window latches on the very next sample, which is the least a
  // capture can hold, and every longer window is just more samples -- no conversion
  // from seconds to a sample count, so no conversion to get wrong.
  pending_ = true;
  pending_start_s_ = event_t;
  post_accum_count_ = 0;
  return false;
  • Step 10: Move the seven level/edge readers onto condition

src/ui.cpp, in publish_trigger_fields(), replace:

    tc.level = level;
    tc.pretrigger_pct = pre;
    tc.holdoff_s = holdoff;
    tc.edge = edge == 0 ? TriggerEdge::Rising
              : edge == 1 ? TriggerEdge::Falling
                          : TriggerEdge::Both;

with:

    tc.condition.level = level;
    tc.pretrigger_pct = pre;
    tc.holdoff_s = holdoff;
    tc.condition.edge = edge == 0 ? TriggerEdge::Rising
                        : edge == 1 ? TriggerEdge::Falling
                                    : TriggerEdge::Both;

Replace src/ui.cpp:2053:

      tc.level = mid;

with:

      tc.condition.level = mid;

Replace src/ui.cpp:6347:

    trigger_level = model.trigger().level;

with:

    trigger_level = model.trigger().condition.level;

In the trigrenum probe, replace:

                 tc.post_span_s(a->model->event_span()), tc.holdoff_s, tc.level, int(tc.edge),

with:

                 tc.post_span_s(a->model->event_span()), tc.holdoff_s, tc.condition.level,
                 int(tc.condition.edge),

In the fields probe, replace:

               int(a->model->trigger().mode), int(a->model->trigger().edge),

with:

               int(a->model->trigger().mode), int(a->model->trigger().condition.edge),
  • Step 11: Add the translation unit to the build

In CMakeLists.txt, in the add_executable(udpscope …) source list, insert src/condition.cpp on the line immediately before src/trigger.cpp.

  • Step 12: Build and check the warning baseline

Run: cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | grep -c 'warning:' Expected: 4 (a clean build; src/condition.cpp is new so every dependant recompiles). No error:.

  • Step 13: Verify bit-identity with the existing trigger probes

Run, serially, each followed by pkill -f 'mock_udpstrea[m]':

cd /home/martino/Projects/tools/udpscope
mv ~/.config/udpscope/127.0.0.1_8000.toml /tmp/udpscope-session.toml
python3 ./tools/mock_udpstreamer.py --port 8000 --duration 20 &
sleep 1
UDPSCOPE_PROBE_TRIGRENUM_MS=3000 UDPSCOPE_EXIT_MS=9000 \
  xvfb-run -a ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep trigrenum
pkill -f 'mock_udpstrea[m]'

Expected: [trigrenum-probe] lines with t0_held=1, and level=/edge= printing the same values as before the change. Repeat the same shape for UDPSCOPE_PROBE_FIELDS_MS=3000 (expected: one [fields-probe] line, no SIGSEGV), UDPSCOPE_PROBE_DISARM_MS, and UDPSCOPE_ARM_TRIGGER_MS. Restore the session afterwards: mv /tmp/udpscope-session.toml ~/.config/udpscope/127.0.0.1_8000.toml.

  • Step 14: Commit
git add src/condition.hpp src/condition.cpp src/trigger.hpp src/trigger.cpp src/ui.cpp CMakeLists.txt
git commit -m "refactor: what counts as an event moves into its own evaluator, so a search can ask the same question the trigger asks"

Task 3: The pulse-width condition and its panel rows

Files:

  • Modify: src/condition.hpp (the private members and two helpers), src/condition.cpp (reset(), feed()'s dispatch, the new handlers), src/ui.cpp (two index helpers and publish_trigger_fields() near :1599; sync_trigger_rows() and on_trigger_shape_changed() after it; the App widget fields near :133; build_trigger_panel() near :6544; the new probe beside the trigger probes near :8280; the probe schedule near :9663)
  • Test: /tmp/check_condition.cpp (extended)

Interfaces:

  • Consumes: Task 1's --glitch-at/--glitch-width; Task 2's EventDetector, EventCondition, TriggerConfig::condition.

  • Produces:

    • EventDetector now answers TriggerType::PulseWidth, reporting the event at the pulse's closing edge (the width is not known before it).
    • udpscope::TriggerType trigger_type_at(guint) and udpscope::WidthCmp width_cmp_at(guint) in src/ui.cpp's anonymous namespace — the dropdown-index tables Task 4 extends.
    • void sync_trigger_rows(App&) in src/ui.cpp — shows only the rows the selected condition uses; Task 4 extends it.
    • App::trigger_type_drop, trigger_cmp_drop, trigger_width_spin, trigger_width2_spin, trigger_cmp_row, trigger_width_row, trigger_width2_row.
    • The probe UDPSCOPE_PROBE_CONDITION_MS, printing a [condition-probe] line.
  • Step 1: Write the failing test

Append to /tmp/check_condition.cpp, immediately before the final std::fprintf(stderr, fails == 0 …):

  // A pulse-width condition reports the *closing* edge, because that is where the width
  // becomes known. This signal is high for 2 samples and then high for 5.
  const std::vector<double> pulses = {-1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0,
                                      1.0,  1.0, -1.0};
  EventCondition narrow;
  narrow.type = TriggerType::PulseWidth;
  narrow.level = 0.0;
  narrow.edge = TriggerEdge::Rising;
  narrow.cmp = udpscope::WidthCmp::Less;
  narrow.width_s = 3.5;  // sample period 1.0, so "shorter than 3.5 samples"
  const std::vector<double> nw = run(narrow, pulses, 1.0);
  expect(nw.size() == 1, "width<3.5: only the 2-sample pulse");
  expect(!nw.empty() && nw[0] == 3.0, "width<3.5: reported at the closing edge t=3");

  EventCondition wide = narrow;
  wide.cmp = udpscope::WidthCmp::Greater;
  const std::vector<double> wd = run(wide, pulses, 1.0);
  expect(wd.size() == 1, "width>3.5: only the 5-sample pulse");
  expect(!wd.empty() && wd[0] == 10.0, "width>3.5: reported at the closing edge t=10");

  EventCondition within = narrow;
  within.cmp = udpscope::WidthCmp::Within;
  within.width_s = 4.0;
  within.width2_s = 6.0;
  expect(run(within, pulses, 1.0).size() == 1, "within 4..6: one pulse");
  EventCondition outside = within;
  outside.cmp = udpscope::WidthCmp::Outside;
  expect(run(outside, pulses, 1.0).size() == 1, "outside 4..6: the other pulse");

  // Bounds are taken in either order, so a panel cannot produce an empty band by typing
  // the larger figure first.
  EventCondition swapped = within;
  swapped.width_s = 6.0;
  swapped.width2_s = 4.0;
  expect(run(swapped, pulses, 1.0).size() == 1, "within: bounds order does not matter");

  // `Both` measures every half-period of a square wave, not every other one: the closing
  // edge of one pulse is the opening edge of the next.
  EventCondition halves = narrow;
  halves.edge = TriggerEdge::Both;
  halves.cmp = udpscope::WidthCmp::Greater;
  halves.width_s = 0.5;
  const std::vector<double> hv = run(halves, pulses, 1.0);
  expect(hv.size() == 3, "both: every half-period after the first closes");
  • Step 2: Run it to verify it fails

Run: cd /home/martino/Projects/tools/udpscope && g++ -std=c++20 -O2 -I src /tmp/check_condition.cpp src/condition.cpp -o /tmp/check_condition && /tmp/check_condition Expected: failures reporting width<3.5: only the 2-sample pulse and the five after it.

  • Step 3: Declare the pulse state

In src/condition.hpp, replace:

  double feed_edge(double value, double t) noexcept;

  EventCondition cond_{};
  bool have_prev_ = false;
  double prev_ = 0.0;

with:

  double feed_edge(double value, double t) noexcept;
  double feed_pulse(double value, double t) noexcept;
  /// Whether a measured width satisfies `cond_.cmp`. `Within`/`Outside` take their two
  /// bounds in either order, so a panel cannot make an empty band by typing the larger
  /// figure into the first spin.
  bool qualifies(double width_s) const noexcept;

  EventCondition cond_{};
  bool have_prev_ = false;
  double prev_ = 0.0;
  bool in_pulse_ = false;      ///< PulseWidth: an opening edge has been seen.
  bool pulse_up_ = false;      ///< PulseWidth: its polarity, so we know which edge closes it.
  double pulse_start_s_ = 0.0;
  • Step 4: Implement the handler

In src/condition.cpp, replace the body of reset():

  have_prev_ = false;
  prev_ = 0.0;

with:

  have_prev_ = false;
  prev_ = 0.0;
  in_pulse_ = false;
  pulse_up_ = false;
  pulse_start_s_ = 0.0;

Replace in feed():

    case TriggerType::PulseWidth:
    case TriggerType::Window:

with:

    case TriggerType::PulseWidth:
      event = feed_pulse(value, t);
      break;
    case TriggerType::Window:

And append, before the closing } // namespace udpscope:

bool EventDetector::qualifies(double width_s) const noexcept {
  const double a = cond_.width_s < cond_.width2_s ? cond_.width_s : cond_.width2_s;
  const double b = cond_.width_s < cond_.width2_s ? cond_.width2_s : cond_.width_s;
  switch (cond_.cmp) {
    case WidthCmp::Less:
      return width_s < cond_.width_s;
    case WidthCmp::Greater:
      return width_s > cond_.width_s;
    case WidthCmp::Within:
      return width_s >= a && width_s <= b;
    case WidthCmp::Outside:
      return width_s < a || width_s > b;
  }
  return false;
}

double EventDetector::feed_pulse(double value, double t) noexcept {
  const bool up = crossed_up(value);
  const bool down = crossed_down(value);
  if (!in_pulse_) {
    // Which crossing opens a pulse is what `edge` selects: Rising measures the time spent
    // above the level, Falling the time below, Both every excursion either way.
    const bool opens = (cond_.edge == TriggerEdge::Rising && up) ||
                       (cond_.edge == TriggerEdge::Falling && down) ||
                       (cond_.edge == TriggerEdge::Both && (up || down));
    if (opens) {
      in_pulse_ = true;
      pulse_up_ = up;
      pulse_start_s_ = t;
    }
    return kNoEvent;
  }
  const bool closes = pulse_up_ ? down : up;
  if (!closes) return kNoEvent;
  const double width = t - pulse_start_s_;
  if (cond_.edge == TriggerEdge::Both) {
    // The closing edge of one excursion is the opening edge of the next, so a square wave
    // measures *every* half-period. Dropping the state here instead would measure every
    // other one, and the hits would come out half as dense as the waveform.
    pulse_up_ = !pulse_up_;
    pulse_start_s_ = t;
  } else {
    in_pulse_ = false;
  }
  return qualifies(width) ? t : kNoEvent;
}
  • Step 5: Run the test to verify it passes

Run: cd /home/martino/Projects/tools/udpscope && g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -I src /tmp/check_condition.cpp src/condition.cpp -o /tmp/check_condition && /tmp/check_condition Expected: ok, no warnings.

  • Step 6: Add the widget fields

In src/ui.cpp's App, replace:

  GtkWidget* trigger_edge_drop = nullptr;

with:

  GtkWidget* trigger_edge_drop = nullptr;
  GtkWidget* trigger_type_drop = nullptr;
  GtkWidget* trigger_cmp_drop = nullptr;
  GtkWidget* trigger_width_spin = nullptr;
  GtkWidget* trigger_width2_spin = nullptr;
  /// The `labeled_box` rows a condition may not use: held so `sync_trigger_rows()` can
  /// hide them. A hidden row keeps its widget's value, so switching condition and back
  /// does not lose what was typed.
  GtkWidget* trigger_cmp_row = nullptr;
  GtkWidget* trigger_width_row = nullptr;
  GtkWidget* trigger_width2_row = nullptr;
  • Step 7: Publish the new fields

In src/ui.cpp, immediately above void publish_trigger_fields(App* app) {, insert:

/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
/// only the conditions that are implemented, so its index is not `TriggerType`'s value.
TriggerType trigger_type_at(guint i) {
  switch (i) {
    case 1:
      return TriggerType::PulseWidth;
    default:
      return TriggerType::Edge;
  }
}

/// The width-comparison dropdown's order. `<` first, because that is the glitch trigger
/// and the reason this condition exists.
WidthCmp width_cmp_at(guint i) {
  switch (i) {
    case 0:
      return WidthCmp::Less;
    case 2:
      return WidthCmp::Within;
    case 3:
      return WidthCmp::Outside;
    default:
      return WidthCmp::Greater;
  }
}

In publish_trigger_fields(), replace:

  const guint mode = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_mode_drop));

with:

  const guint mode = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_mode_drop));
  const guint type = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_type_drop));
  const guint cmp = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_cmp_drop));
  const double width = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width_spin));
  const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin));

and replace:

    tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single;

with:

    tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single;
    tc.condition.type = trigger_type_at(type);
    tc.condition.cmp = width_cmp_at(cmp);
    tc.condition.width_s = width;
    tc.condition.width2_s = width2;
  • Step 8: Show only the rows the condition uses

In src/ui.cpp, immediately after publish_trigger_fields()'s closing brace, insert:

/// Shows only the rows the selected condition reads, so the panel never offers a figure the
/// condition ignores. The widgets keep their values while hidden: `EventCondition` carries
/// all eight fields whatever its type, and switching away and back must not lose a setting.
void sync_trigger_rows(App& app) {
  if (app.trigger_type_drop == nullptr) return;
  const TriggerType type =
      trigger_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_type_drop)));
  const WidthCmp cmp =
      width_cmp_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_cmp_drop)));
  const bool pulse = type == TriggerType::PulseWidth;
  gtk_widget_set_visible(app.trigger_cmp_row, pulse);
  gtk_widget_set_visible(app.trigger_width_row, pulse);
  gtk_widget_set_visible(app.trigger_width2_row,
                         pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
}

And immediately after on_trigger_drop_changed()'s closing brace, insert:

/// The two dropdowns that change *which rows exist*, as opposed to a value within them.
/// Same `notify::selected` signature trap as `on_trigger_drop_changed`.
void on_trigger_shape_changed(GObject*, GParamSpec*, gpointer user) {
  App* app = static_cast<App*>(user);
  publish_trigger_fields(app);
  sync_trigger_rows(*app);
}
  • Step 9: Build the rows

In build_trigger_panel(), replace:

  const char* edges[] = {"rising", "falling", "both", nullptr};
  app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges);
  gtk_box_append(GTK_BOX(box), labeled_box("edge", app.trigger_edge_drop));

with:

  const char* types[] = {"edge", "pulse width", nullptr};
  app.trigger_type_drop = gtk_drop_down_new_from_strings(types);
  gtk_widget_set_tooltip_text(app.trigger_type_drop,
                              "what counts as an event: a level crossing, or a pulse whose "
                              "width satisfies the comparison below");
  gtk_box_append(GTK_BOX(box), labeled_box("condition", app.trigger_type_drop));

  const char* edges[] = {"rising", "falling", "both", nullptr};
  app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges);
  gtk_box_append(GTK_BOX(box), labeled_box("edge", app.trigger_edge_drop));

  const char* cmps[] = {"<", ">", "within", "outside", nullptr};
  app.trigger_cmp_drop = gtk_drop_down_new_from_strings(cmps);
  gtk_widget_set_tooltip_text(app.trigger_cmp_drop,
                              "how the measured pulse width is compared: '<' is the glitch "
                              "trigger -- a pulse shorter than it should be");
  app.trigger_cmp_row = labeled_box("width is", app.trigger_cmp_drop);
  gtk_box_append(GTK_BOX(box), app.trigger_cmp_row);

  app.trigger_width_spin = spin(0.0, 3600.0, 0.001, 6);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width_spin), 0.001);
  app.trigger_width_row = labeled_box("width s", app.trigger_width_spin);
  gtk_box_append(GTK_BOX(box), app.trigger_width_row);

  app.trigger_width2_spin = spin(0.0, 3600.0, 0.001, 6);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width2_spin), 0.002);
  app.trigger_width2_row = labeled_box("and s", app.trigger_width2_spin);
  gtk_box_append(GTK_BOX(box), app.trigger_width2_row);

Replace:

  for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin}) {

with:

  for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
                       app.trigger_width_spin, app.trigger_width2_spin}) {

And replace:

  g_signal_connect(app.trigger_mode_drop, "notify::selected",
                   G_CALLBACK(on_trigger_drop_changed), &app);
  return box;

with:

  g_signal_connect(app.trigger_mode_drop, "notify::selected",
                   G_CALLBACK(on_trigger_drop_changed), &app);
  g_signal_connect(app.trigger_type_drop, "notify::selected",
                   G_CALLBACK(on_trigger_shape_changed), &app);
  g_signal_connect(app.trigger_cmp_drop, "notify::selected",
                   G_CALLBACK(on_trigger_shape_changed), &app);
  sync_trigger_rows(app);
  return box;
  • Step 10: Build and check

Run: cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | grep -c 'warning:' Expected: 2. No error:.

  • Step 11: Write the condition probe

In src/ui.cpp, immediately after fields_probe(), insert:

/// Headless self-check for the qualified conditions: arms a pulse-width condition on the
/// mock's `square`, whose narrow pulse the mock injects once per square period, and reports
/// the latched t0 against where the pulse's closing edge must be.
///
/// The assertion is the *phase* rather than an absolute t0: the probe arms at whatever stream
/// time the app happened to reach, so which glitch it catches is not fixed, but every glitch
/// closes at `--glitch-at + --glitch-width + k * (2 / --freq)`. A `phase` of a few sample
/// periods is the pulse being found exactly; a phase near half the square period means the
/// condition fired on an ordinary edge instead.
struct ConditionProbe {
  double glitch_at = 0.25;
  double glitch_width = 0.002;
  double square_period = 0.4;
  int waited = 0;
};
ConditionProbe g_condition;

gboolean condition_probe_report(gpointer user) {
  App* a = static_cast<App*>(user);
  if (a->window == nullptr) return G_SOURCE_REMOVE;
  const double t0 = a->model->trigger_time();
  if (!std::isfinite(t0) || t0 <= 0.0) {
    if (++g_condition.waited < 200) return G_SOURCE_CONTINUE;
    std::fprintf(stderr, "[condition-probe] no latch after %d polls\n", g_condition.waited);
    std::fflush(stderr);
    return G_SOURCE_REMOVE;
  }
  const double want = g_condition.glitch_at + g_condition.glitch_width;
  double phase = std::fmod(t0 - want, g_condition.square_period);
  if (phase < 0.0) phase += g_condition.square_period;
  if (phase > 0.5 * g_condition.square_period) phase -= g_condition.square_period;
  std::fprintf(stderr, "[condition-probe] type=%d cmp=%d width=%.6f t0=%.6f phase=%.6f mode=%d\n",
               int(a->model->trigger().condition.type), int(a->model->trigger().condition.cmp),
               a->model->trigger().condition.width_s, t0, phase, int(a->model->display_mode()));
  std::fflush(stderr);
  return G_SOURCE_REMOVE;
}

gboolean condition_probe_arm(gpointer user) {
  App* a = static_cast<App*>(user);
  if (a->window == nullptr) return G_SOURCE_REMOVE;
  // A window short enough that the pre-trigger share is already behind the glitch.
  apply_time_window(*a, 0.2);
  const std::vector<ChannelInfo>& channels = a->model->channels();
  int sel = -1;
  for (std::size_t i = 0; i < channels.size(); ++i) {
    if (channels[i].name == "square") sel = static_cast<int>(i);
  }
  std::fprintf(stderr, "[condition-probe] square=%d of %zu channels\n", sel, channels.size());
  if (sel >= 0) {
    gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop),
                               static_cast<guint>(sel));
  }
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), 1);  // pulse width
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop), 0);   // <
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), 0);  // rising
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 10.0);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0);
  publish_trigger_fields(a);
  if (a->trigger_toggle != nullptr)
    gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
  g_condition.waited = 0;
  g_timeout_add(25, condition_probe_report, a);
  return G_SOURCE_REMOVE;
}

gboolean condition_probe(gpointer user) {
  App* a = static_cast<App*>(user);
  if (a->window == nullptr) return G_SOURCE_REMOVE;
  // `square` is not acquired until it is enabled, and the trigger can only name a channel
  // that is: enabling is what starts acquisition.
  const std::vector<SignalInfo>& signals = a->model->signals();
  for (std::size_t i = 0; i < signals.size(); ++i) {
    if (signals[i].name == "square") a->model->set_signal_enabled(i, true);
  }
  // One beat for the channel list to be rebuilt and the ring to refill behind the glitch.
  g_timeout_add(1500, condition_probe_arm, a);
  return G_SOURCE_REMOVE;
}
  • Step 12: Schedule it

In run_ui(), immediately after the UDPSCOPE_PROBE_TRIGRENUM_MS block, insert:

  if (const char* env = g_getenv("UDPSCOPE_PROBE_CONDITION_MS")) {
    g_timeout_add(static_cast<guint>(std::atoi(env)), condition_probe, &app);
  }
  • Step 13: Run the probe
cd /home/martino/Projects/tools/udpscope
mv ~/.config/udpscope/127.0.0.1_8000.toml /tmp/udpscope-session.toml
python3 ./tools/mock_udpstreamer.py --port 8000 --duration 25 --freq 5 \
  --glitch-at 0.25 --glitch-width 0.002 &
sleep 1
UDPSCOPE_PROBE_CONDITION_MS=2000 UDPSCOPE_EXIT_MS=14000 \
  xvfb-run -a ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E 'condition-probe|Gtk-CRITICAL'
pkill -f 'mock_udpstrea[m]'
mv /tmp/udpscope-session.toml ~/.config/udpscope/127.0.0.1_8000.toml

Expected shape: [condition-probe] square=N of M channels, then one [condition-probe] type=1 cmp=0 width=0.010000 t0=… phase=… mode=… with phase a small fraction of a millisecond (within a few sample periods of 0) and mode=2 or 3 (Held or Capturing). No Gtk-CRITICAL. Record the measured phase — Task 9 writes it into probe-readings.md.

  • Step 14: Commit
git add src/condition.hpp src/condition.cpp src/ui.cpp
git commit -m "feat: a pulse whose width is wrong can be the trigger's event, so a glitch can be caught"

Task 4: The window and dropout conditions

Files:

  • Modify: src/condition.hpp, src/condition.cpp, src/ui.cpp (trigger_type_at(), publish_trigger_fields(), sync_trigger_rows(), the App fields, build_trigger_panel(), condition_probe_arm())
  • Test: /tmp/check_condition.cpp (extended)

Interfaces:

  • Consumes: Task 1's --quiet-at; Task 3's trigger_type_at(), sync_trigger_rows(), the condition probe.

  • Produces: EventDetector answers all four TriggerType values. App::trigger_level2_spin, trigger_timeout_spin, trigger_level2_row, trigger_timeout_row. trigger_type_at() maps index 2 → Window, 3 → Dropout.

  • Step 1: Write the failing test

Append to /tmp/check_condition.cpp, before the final std::fprintf:

  // A window condition fires on a change of *side* of the band, so a zero-width band can
  // never fire -- which is why the panel refuses one rather than the detector special-casing
  // it. Band [-0.5, 0.5]: the signal enters at t=1 and leaves at t=3.
  const std::vector<double> band_walk = {-1.0, 0.0, 0.25, 1.0, 1.0, -1.0};
  EventCondition enter;
  enter.type = TriggerType::Window;
  enter.level = -0.5;
  enter.level2 = 0.5;
  enter.edge = TriggerEdge::Rising;  // into the band
  const std::vector<double> en = run(enter, band_walk, 1.0);
  expect(en.size() == 1, "window/into: one event");
  expect(!en.empty() && en[0] == 1.0, "window/into: at t=1");

  EventCondition leave = enter;
  leave.edge = TriggerEdge::Falling;  // out of the band
  const std::vector<double> lv = run(leave, band_walk, 1.0);
  expect(lv.size() == 2, "window/out of: leaves at t=0 and t=3");

  EventCondition either = enter;
  either.edge = TriggerEdge::Both;
  expect(run(either, band_walk, 1.0).size() == 3, "window/either: every change of side");

  EventCondition reversed = enter;
  reversed.level = 0.5;
  reversed.level2 = -0.5;
  expect(run(reversed, band_walk, 1.0).size() == 1, "window: level order does not matter");

  // A dropout fires once, `timeout` after the last crossing, and does not fire again until
  // the signal crosses the level afresh.
  const std::vector<double> stops = {-1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0};
  EventCondition quiet;
  quiet.type = TriggerType::Dropout;
  quiet.level = 0.0;
  quiet.edge = TriggerEdge::Both;
  quiet.timeout_s = 3.0;
  const std::vector<double> q = run(quiet, stops, 1.0);
  expect(q.size() == 1, "dropout: fires exactly once");
  expect(!q.empty() && q[0] == 5.0, "dropout: at last crossing (t=1) + timeout (3), i.e. t=5");

  const std::vector<double> alive = {-1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0};
  expect(run(quiet, alive, 1.0).empty(), "dropout: a crossing every sample never fires");
  • Step 2: Run it to verify it fails

Run: cd /home/martino/Projects/tools/udpscope && g++ -std=c++20 -O2 -I src /tmp/check_condition.cpp src/condition.cpp -o /tmp/check_condition && /tmp/check_condition Expected: failures starting at window/into: one event.

  • Step 3: Declare the state

In src/condition.hpp, replace:

  double feed_pulse(double value, double t) noexcept;

with:

  double feed_pulse(double value, double t) noexcept;
  double feed_window(double value, double t) noexcept;
  double feed_dropout(double value, double t) noexcept;

and replace:

  bool pulse_up_ = false;      ///< PulseWidth: its polarity, so we know which edge closes it.
  double pulse_start_s_ = 0.0;

with:

  bool pulse_up_ = false;      ///< PulseWidth: its polarity, so we know which edge closes it.
  double pulse_start_s_ = 0.0;
  bool inside_ = false;        ///< Window: which side of the band the previous sample was.
  double last_cross_s_ = 0.0;  ///< Dropout: when the signal was last seen crossing.
  bool dropped_ = false;       ///< Dropout: the timeout has already been reported.
  • Step 4: Seed the two states on the first sample

Both conditions compare against a previous side, which the first sample has to establish: there is no crossing to derive it from. In src/condition.cpp, replace:

  if (!have_prev_) {
    prev_ = value;
    have_prev_ = true;
    return kNoEvent;
  }

with:

  if (!have_prev_) {
    prev_ = value;
    have_prev_ = true;
    // The window condition needs a side to change *from*, and the dropout a last-seen time
    // to count from; neither can be derived from a crossing that has not happened. Seeding
    // them here is what lets both handlers run without a "have we started" guard.
    const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
    const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
    inside_ = value >= lo && value <= hi;
    last_cross_s_ = t;
    dropped_ = false;
    return kNoEvent;
  }

Replace the body of reset()'s tail:

  in_pulse_ = false;
  pulse_up_ = false;
  pulse_start_s_ = 0.0;

with:

  in_pulse_ = false;
  pulse_up_ = false;
  pulse_start_s_ = 0.0;
  inside_ = false;
  last_cross_s_ = 0.0;
  dropped_ = false;
  • Step 5: Implement the two handlers

Replace in feed():

    case TriggerType::Window:
    case TriggerType::Dropout:
      break;  // implemented in the tasks that add their panel rows

with:

    case TriggerType::Window:
      event = feed_window(value, t);
      break;
    case TriggerType::Dropout:
      event = feed_dropout(value, t);
      break;

Append, before the closing } // namespace udpscope:

double EventDetector::feed_window(double value, double t) noexcept {
  const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
  const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
  const bool inside = value >= lo && value <= hi;
  if (inside == inside_) return kNoEvent;
  inside_ = inside;
  // The event is the *change of side*, so `Rising` reads as "into the band" and `Falling`
  // as "out of it". A band of zero width therefore never fires at all, because no sample
  // can be on a different side of it than its neighbour: the panel refuses one.
  const bool fired = (cond_.edge == TriggerEdge::Rising && inside) ||
                     (cond_.edge == TriggerEdge::Falling && !inside) ||
                     cond_.edge == TriggerEdge::Both;
  return fired ? t : kNoEvent;
}

double EventDetector::feed_dropout(double value, double t) noexcept {
  const bool up = crossed_up(value);
  const bool down = crossed_down(value);
  const bool crossed = (cond_.edge == TriggerEdge::Rising && up) ||
                       (cond_.edge == TriggerEdge::Falling && down) ||
                       (cond_.edge == TriggerEdge::Both && (up || down));
  if (crossed) {
    last_cross_s_ = t;
    dropped_ = false;
    return kNoEvent;
  }
  // One event per silence, not one per sample of it: without the latch a dead signal would
  // report an event on every sample after the timeout and fill the search's hit list with
  // one answer repeated.
  if (dropped_) return kNoEvent;
  if (t - last_cross_s_ <= cond_.timeout_s) return kNoEvent;
  dropped_ = true;
  return t;
}
  • Step 6: Run the test to verify it passes

Run: cd /home/martino/Projects/tools/udpscope && g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -I src /tmp/check_condition.cpp src/condition.cpp -o /tmp/check_condition && /tmp/check_condition Expected: ok, no warnings.

  • Step 7: Extend the index table

In src/ui.cpp, replace:

TriggerType trigger_type_at(guint i) {
  switch (i) {
    case 1:
      return TriggerType::PulseWidth;
    default:
      return TriggerType::Edge;
  }
}

with:

TriggerType trigger_type_at(guint i) {
  switch (i) {
    case 1:
      return TriggerType::PulseWidth;
    case 2:
      return TriggerType::Window;
    case 3:
      return TriggerType::Dropout;
    default:
      return TriggerType::Edge;
  }
}
  • Step 8: Add the two widgets

In App, replace:

  GtkWidget* trigger_width2_spin = nullptr;

with:

  GtkWidget* trigger_width2_spin = nullptr;
  GtkWidget* trigger_level2_spin = nullptr;
  GtkWidget* trigger_timeout_spin = nullptr;

and replace:

  GtkWidget* trigger_width2_row = nullptr;

with:

  GtkWidget* trigger_width2_row = nullptr;
  GtkWidget* trigger_level2_row = nullptr;
  GtkWidget* trigger_timeout_row = nullptr;

In build_trigger_panel(), replace:

  const char* types[] = {"edge", "pulse width", nullptr};

with:

  const char* types[] = {"edge", "pulse width", "window", "dropout", nullptr};

and replace:

  app.trigger_width2_row = labeled_box("and s", app.trigger_width2_spin);
  gtk_box_append(GTK_BOX(box), app.trigger_width2_row);

with:

  app.trigger_width2_row = labeled_box("and s", app.trigger_width2_spin);
  gtk_box_append(GTK_BOX(box), app.trigger_width2_row);

  app.trigger_level2_spin = spin(-1.0e12, 1.0e12, 0.01, 4);
  gtk_widget_set_tooltip_text(app.trigger_level2_spin,
                              "the band's other edge; it is kept at least one increment away "
                              "from the level, because a band of zero width can never be "
                              "crossed");
  app.trigger_level2_row = labeled_box("to level", app.trigger_level2_spin);
  gtk_box_append(GTK_BOX(box), app.trigger_level2_row);

  app.trigger_timeout_spin = spin(0.0, 3600.0, 0.001, 6);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_timeout_spin), 0.001);
  app.trigger_timeout_row = labeled_box("timeout s", app.trigger_timeout_spin);
  gtk_box_append(GTK_BOX(box), app.trigger_timeout_row);

Replace:

                       app.trigger_width_spin, app.trigger_width2_spin}) {

with:

                       app.trigger_width_spin, app.trigger_width2_spin,
                       app.trigger_level2_spin, app.trigger_timeout_spin}) {
  • Step 9: Refuse a zero-width band, and show the new rows

In src/ui.cpp, replace sync_trigger_rows()'s body with:

  if (app.trigger_type_drop == nullptr) return;
  const TriggerType type =
      trigger_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_type_drop)));
  const WidthCmp cmp =
      width_cmp_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_cmp_drop)));
  const bool pulse = type == TriggerType::PulseWidth;
  const bool window = type == TriggerType::Window;
  gtk_widget_set_visible(app.trigger_cmp_row, pulse);
  gtk_widget_set_visible(app.trigger_width_row, pulse);
  gtk_widget_set_visible(app.trigger_width2_row,
                         pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
  gtk_widget_set_visible(app.trigger_level2_row, window);
  gtk_widget_set_visible(app.trigger_timeout_row, type == TriggerType::Dropout);
  // A band of zero width is not a degenerate edge trigger -- a window event is a change of
  // *side*, so equal levels silently never fire, which looks exactly like a broken trigger.
  // The panel is where that is refused: the second level is pushed at least one increment
  // clear of the first, in whichever direction it already lay.
  if (window) {
    GtkSpinButton* l2 = GTK_SPIN_BUTTON(app.trigger_level2_spin);
    const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app.trigger_level_spin));
    double step = 0.0;
    gtk_spin_button_get_increments(l2, &step, nullptr);
    const double other = gtk_spin_button_get_value(l2);
    if (std::fabs(other - level) < step) {
      gtk_spin_button_set_value(l2, other < level ? level - step : level + step);
    }
  }

Then replace, in build_trigger_panel():

  g_signal_connect(app.trigger_cmp_drop, "notify::selected",
                   G_CALLBACK(on_trigger_shape_changed), &app);

with:

  g_signal_connect(app.trigger_cmp_drop, "notify::selected",
                   G_CALLBACK(on_trigger_shape_changed), &app);
  // The clamp lives in `sync_trigger_rows()`, so the two level spins have to run it rather
  // than the plain field publish.
  for (GtkWidget* w : {app.trigger_level_spin, app.trigger_level2_spin}) {
    g_signal_connect(w, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) {
                       sync_trigger_rows(*static_cast<App*>(u));
                     }),
                     &app);
  }
  • Step 10: Publish the two new fields

In publish_trigger_fields(), replace:

  const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin));

with:

  const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin));
  const double level2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level2_spin));
  const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin));

and replace:

    tc.condition.width2_s = width2;

with:

    tc.condition.width2_s = width2;
    tc.condition.level2 = level2;
    tc.condition.timeout_s = timeout;
  • Step 11: Build and check

Run: cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | grep -c 'warning:' Expected: 2. No error:.

  • Step 12: Extend the probe to the dropout

In src/ui.cpp, replace ConditionProbe's declaration:

struct ConditionProbe {
  double glitch_at = 0.25;
  double glitch_width = 0.002;
  double square_period = 0.4;
  int waited = 0;
};

with:

struct ConditionProbe {
  double glitch_at = 0.25;
  double glitch_width = 0.002;
  double square_period = 0.4;
  /// With `--quiet-at`, the sine stops at a level on the other side of zero, so `quiet_at`
  /// is its last crossing and the dropout's event time is exactly `quiet_at + timeout`.
  /// Unlike the glitch this happens *once*, so the assertion is the absolute t0.
  double quiet_at = -1.0;
  double timeout = 0.050;
  int waited = 0;
};

In condition_probe_report(), replace:

  const double want = g_condition.glitch_at + g_condition.glitch_width;
  double phase = std::fmod(t0 - want, g_condition.square_period);
  if (phase < 0.0) phase += g_condition.square_period;
  if (phase > 0.5 * g_condition.square_period) phase -= g_condition.square_period;

with:

  double phase = 0.0;
  if (g_condition.quiet_at >= 0.0) {
    // One event only: the error is absolute.
    phase = t0 - (g_condition.quiet_at + g_condition.timeout);
  } else {
    const double want = g_condition.glitch_at + g_condition.glitch_width;
    phase = std::fmod(t0 - want, g_condition.square_period);
    if (phase < 0.0) phase += g_condition.square_period;
    if (phase > 0.5 * g_condition.square_period) phase -= g_condition.square_period;
  }

In condition_probe_arm(), replace:

  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), 1);  // pulse width
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop), 0);   // <
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), 0);  // rising
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010);

with:

  const bool dropout = g_condition.quiet_at >= 0.0;
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), dropout ? 3u : 1u);
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop), 0);  // <
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), dropout ? 2u : 0u);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_timeout_spin), g_condition.timeout);

and replace:

    if (channels[i].name == "square") sel = static_cast<int>(i);

with:

    if (channels[i].name == (dropout ? "sine" : "square")) sel = static_cast<int>(i);

In condition_probe(), replace:

  const std::vector<SignalInfo>& signals = a->model->signals();
  for (std::size_t i = 0; i < signals.size(); ++i) {
    if (signals[i].name == "square") a->model->set_signal_enabled(i, true);
  }

with:

  // `UDPSCOPE_PROBE_QUIET_AT` mirrors the mock's `--quiet-at`: it selects the dropout leg of
  // the probe and tells it where the sine's last crossing is. Absent, the glitch leg runs.
  if (const char* q = g_getenv("UDPSCOPE_PROBE_QUIET_AT")) g_condition.quiet_at = std::atof(q);
  const char* want = g_condition.quiet_at >= 0.0 ? "sine" : "square";
  const std::vector<SignalInfo>& signals = a->model->signals();
  for (std::size_t i = 0; i < signals.size(); ++i) {
    if (signals[i].name == want) a->model->set_signal_enabled(i, true);
  }
  • Step 13: Cover the remaining three shapes

Two of the four shapes are now checked (pulse width < in Task 3, dropout above). The spec asks for five legs — Edge, PulseWidth/Less, PulseWidth/Greater, Window, Dropout — so the probe takes a shape selector rather than inferring the leg from quiet_at alone. Edge is already covered by the four existing trigger probes the Task 2 regression gate runs, so this step adds the other two. Both land on a square transition, so both reuse the phase assertion: a Window band straddling zero is entered and left at each transition, and a > width shorter than the square's own half-period is satisfied by every one of them.

In src/ui.cpp, replace ConditionProbe's int waited = 0; with:

  /// 0 = pulse width `<` (the glitch), 1 = pulse width `>`, 2 = window, 3 = dropout. Selected
  /// by `UDPSCOPE_PROBE_CONDITION_SHAPE`; the default keeps Task 3's glitch leg.
  int shape = 0;
  int waited = 0;

In condition_probe(), replace:

  if (const char* q = g_getenv("UDPSCOPE_PROBE_QUIET_AT")) g_condition.quiet_at = std::atof(q);
  const char* want = g_condition.quiet_at >= 0.0 ? "sine" : "square";

with:

  if (const char* q = g_getenv("UDPSCOPE_PROBE_QUIET_AT")) g_condition.quiet_at = std::atof(q);
  if (const char* s = g_getenv("UDPSCOPE_PROBE_CONDITION_SHAPE")) {
    g_condition.shape = std::atoi(s);
  } else if (g_condition.quiet_at >= 0.0) {
    g_condition.shape = 3;
  }
  const char* want = g_condition.shape == 3 ? "sine" : "square";

In condition_probe_arm(), replace:

  const bool dropout = g_condition.quiet_at >= 0.0;
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), dropout ? 3u : 1u);
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop), 0);  // <
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), dropout ? 2u : 0u);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_timeout_spin), g_condition.timeout);

with:

  const bool dropout = g_condition.shape == 3;
  // Shape 2 is `window`, which the dropdown numbers 2; the two pulse-width legs share type 1
  // and differ only in the comparison.
  const guint type = g_condition.shape == 2 ? 2u : (dropout ? 3u : 1u);
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), type);
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_cmp_drop),
                             g_condition.shape == 1 ? 1u : 0u);  // `>` for leg 1, `<` otherwise
  // `both` for the window and the dropout: a band is entered *and* left, and a dropout has no
  // direction at all. Rising for the two pulse-width legs, which open on a rising excursion.
  gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop),
                             (dropout || g_condition.shape == 2) ? 2u : 0u);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_width_spin), 0.010);
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_timeout_spin), g_condition.timeout);
  // The band for the window leg: straddling zero, well inside the square's +/-1, so each
  // transition both enters and leaves it. Harmless for the other legs, which ignore `level2`.
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level2_spin), 0.5);

The channel-selection line below it needs no edit: dropout is now derived from shape, and the line already reads it. Next, in the same function, replace:

  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);

with:

  // Zero for every leg but the window, whose band is `[-0.5, +0.5]`.
  gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin),
                            g_condition.shape == 2 ? -0.5 : 0.0);

Finally, in condition_probe_report(), replace:

  double phase = 0.0;
  if (g_condition.quiet_at >= 0.0) {

with:

  double phase = 0.0;
  // Legs 1 and 2 land on a square *transition*, which recurs every half-period, so their phase
  // is taken modulo that rather than modulo the full period.
  const double period = (g_condition.shape == 1 || g_condition.shape == 2)
                            ? 0.5 * g_condition.square_period
                            : g_condition.square_period;
  if (g_condition.shape == 3) {

and, in the same function, replace:

    const double want = g_condition.glitch_at + g_condition.glitch_width;
    phase = std::fmod(t0 - want, g_condition.square_period);
    if (phase < 0.0) phase += g_condition.square_period;
    if (phase > 0.5 * g_condition.square_period) phase -= g_condition.square_period;

with:

    // Legs 1 and 2 are timed at a transition itself, leg 0 at the end of an injected runt.
    const double want = g_condition.shape == 0
                            ? g_condition.glitch_at + g_condition.glitch_width
                            : 0.0;
    phase = std::fmod(t0 - want, period);
    if (phase < 0.0) phase += period;
    if (phase > 0.5 * period) phase -= period;
  • Step 14: Run every leg of the probe

The glitch leg, unchanged from Task 3 (expected: the same phase near zero). Then the pulse-width > leg and the window leg, which share one invocation shape:

cd /home/martino/Projects/tools/udpscope
mv ~/.config/udpscope/127.0.0.1_8000.toml /tmp/udpscope-session.toml
python3 ./tools/mock_udpstreamer.py --port 8000 --duration 20 --freq 5 &
sleep 1
for shape in 1 2; do
  UDPSCOPE_PROBE_CONDITION_MS=2000 UDPSCOPE_PROBE_CONDITION_SHAPE=$shape UDPSCOPE_EXIT_MS=10000 \
    xvfb-run -a ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | \
    grep -E 'condition-probe|Gtk-CRITICAL'
done
pkill -f 'mock_udpstrea[m]'
mv /tmp/udpscope-session.toml ~/.config/udpscope/127.0.0.1_8000.toml

Expected shape: for shape=1, type=1 cmp=1 and phase within a few sample periods of 0 — the square's own half-period pulses all satisfy > 0.010, so the latch is at a transition. For shape=2, type=2 and the same phase reading, because the band is crossed at every transition. A phase near a quarter of the half-period on either leg means the event is not being timed at a transition at all. No Gtk-CRITICAL. Record both measured figures for Task 9.

Then the dropout leg:

cd /home/martino/Projects/tools/udpscope
mv ~/.config/udpscope/127.0.0.1_8000.toml /tmp/udpscope-session.toml
python3 ./tools/mock_udpstreamer.py --port 8000 --duration 25 --freq 5 --quiet-at 4.26 &
sleep 1
UDPSCOPE_PROBE_CONDITION_MS=2000 UDPSCOPE_PROBE_QUIET_AT=4.26 UDPSCOPE_EXIT_MS=16000 \
  xvfb-run -a ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E 'condition-probe|Gtk-CRITICAL'
pkill -f 'mock_udpstrea[m]'
mv /tmp/udpscope-session.toml ~/.config/udpscope/127.0.0.1_8000.toml

Expected shape: [condition-probe] type=3 cmp=0 width=… t0=… phase=… mode=… with t0 just past 4.31 (= 4.26 + 0.05) and phase within a few sample periods of 0. No Gtk-CRITICAL. Record both measured figures for Task 9.

  • Step 15: Commit
git add src/condition.hpp src/condition.cpp src/ui.cpp
git commit -m "feat: a level band and a signal that stops can be the trigger's event too"

Task 5: The statistics accumulator, and what feeds and resets it

No UI yet: the accumulator, the one place that feeds it, and the three implicit resets.

Files:

  • Modify: src/measure.hpp (append before the closing } // namespace udpscope), src/ui.cpp (App near :257; rebuild_measures_pane() near :2563; the result loop at :3326-3348; apply_time_window() near :2115; the CONFIG hook at :5797)
  • Test: /tmp/check_stats.cpp

Interfaces:

  • Consumes: nothing from Tasks 1-4.

  • Produces:

    • struct udpscope::MeasureStatsAccum { std::uint64_t n; double mean; double m2; double lo; double hi; void add(double) noexcept; void reset() noexcept; double sd() const noexcept; };
    • App::measure_stats (std::vector<MeasureStatsAccum>), parallel to App::measures.
    • void reset_measure_stats(App&) in src/ui.cpp.
  • Step 1: Write the failing test

Create /tmp/check_stats.cpp:

// Standalone check of MeasureStatsAccum's Welford update.
#include <cmath>
#include <cstdio>

#include "measure.hpp"

using udpscope::MeasureStatsAccum;

namespace {
int fails = 0;
void expect(bool ok, const char* what) {
  if (!ok) {
    std::fprintf(stderr, "FAIL: %s\n", what);
    ++fails;
  }
}
}  // namespace

int main() {
  MeasureStatsAccum a;
  expect(a.n == 0, "empty: n is 0");
  expect(a.sd() == 0.0, "empty: sd is 0");

  a.add(2.0);
  expect(a.n == 1 && a.mean == 2.0 && a.lo == 2.0 && a.hi == 2.0, "one sample");
  expect(a.sd() == 0.0, "one sample: sd is 0, not NaN");

  // 2, 4, 4, 4, 5, 5, 7, 9: mean 5, sample sd 2.13809... (n-1 in the denominator).
  for (const double v : {4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0}) a.add(v);
  expect(a.n == 8, "eight samples");
  expect(std::fabs(a.mean - 5.0) < 1e-12, "mean is 5");
  expect(a.lo == 2.0 && a.hi == 9.0, "extremes are 2 and 9");
  expect(std::fabs(a.sd() - std::sqrt(32.0 / 7.0)) < 1e-12, "sd uses n-1");

  // A large offset is where a naive sum-of-squares loses every significant digit; Welford
  // must still produce the same spread.
  MeasureStatsAccum big;
  for (const double v : {2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0}) big.add(v + 1.0e9);
  expect(std::fabs(big.sd() - std::sqrt(32.0 / 7.0)) < 1e-6, "sd survives a 1e9 offset");

  a.reset();
  expect(a.n == 0 && a.mean == 0.0 && a.sd() == 0.0, "reset clears everything");

  std::fprintf(stderr, fails == 0 ? "ok\n" : "%d failure(s)\n", fails);
  return fails == 0 ? 0 : 1;
}
  • Step 2: Run it to verify it fails

Run: cd /home/martino/Projects/tools/udpscope && g++ -std=c++20 -O2 -I src /tmp/check_stats.cpp -o /tmp/check_stats Expected: error: 'MeasureStatsAccum' is not a member of 'udpscope'

  • Step 3: Add the accumulator

In src/measure.hpp, immediately before } // namespace udpscope, insert:

/// Running statistics over a measurement's successive results.
///
/// One observation per *recompute* of the measurements pane, never one per sample: a
/// measurement is a figure derived from a whole window, and counting its samples would
/// describe the window rather than the measurement's stability. `n` is therefore a count of
/// passes, which is what makes the readout comparable with a bench scope's.
///
/// The mean and spread are Welford's recurrence rather than running sums of `v` and `v*v`:
/// the results are absolute signal values, so a channel sitting at a large offset with a
/// small ripple -- the case the readout is *for* -- cancels to nothing in a naive
/// sum-of-squares long before the ripple is visible.
struct MeasureStatsAccum {
  std::uint64_t n = 0;
  double mean = 0.0;  ///< Welford's running mean.
  double m2 = 0.0;    ///< Welford's sum of squared deviations from the running mean.
  double lo = 0.0;
  double hi = 0.0;

  void add(double v) noexcept {
    if (n == 0) {
      lo = v;
      hi = v;
    } else {
      if (v < lo) lo = v;
      if (v > hi) hi = v;
    }
    ++n;
    const double delta = v - mean;
    mean += delta / static_cast<double>(n);
    m2 += delta * (v - mean);
  }

  void reset() noexcept { *this = MeasureStatsAccum{}; }

  /// The sample standard deviation (n-1), and 0 rather than NaN for fewer than two
  /// observations: the readout is a figure in a label, and a single pass has no spread.
  double sd() const noexcept {
    return n < 2 ? 0.0 : std::sqrt(m2 / static_cast<double>(n - 1));
  }
};

If src/measure.hpp does not already include <cmath> and <cstdint>, add them to its include block.

  • Step 4: Run the test to verify it passes

Run: cd /home/martino/Projects/tools/udpscope && g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -I src /tmp/check_stats.cpp -o /tmp/check_stats && /tmp/check_stats Expected: ok, no warnings.

  • Step 5: Hold one accumulator per measurement

In src/ui.cpp's App, replace:

  std::vector<MeasureResult> measure_last_valid;

with:

  std::vector<MeasureResult> measure_last_valid;
  /// Running statistics, one per entry of `measures` and reset whenever that vector is
  /// rebuilt. Not reset by a pause, a latch or a pan: those change what is measured *now*,
  /// not what the measurement means, and a statistic the user cannot keep across them is
  /// not worth having.
  std::vector<MeasureStatsAccum> measure_stats;
  • Step 6: Reset on a measurement edit

rebuild_measures_pane() is the one funnel for an add, an edit and a delete, so the reset belongs there. Replace:

  app.measure_labels.clear();
  app.measure_text.clear();

with:

  app.measure_labels.clear();
  app.measure_text.clear();
  // Editing a measurement changes what the figures *mean*, so the history behind them is no
  // longer one series. This is the implicit reset for an add, an edit and a delete alike.
  app.measure_stats.assign(app.measures.size(), MeasureStatsAccum{});
  • Step 7: Accumulate one observation per recompute

In the result loop, replace:

    app.measure_results[i] = r;
    if (r.valid) app.measure_last_valid[i] = r;
    if (label != nullptr) {
      std::string text = "--";
      if (r.valid) {
        // Time-valued types carry seconds and a frequency carries hertz whatever the
        // signal's own unit is; everything else carries that unit.
        const bool is_time = m.type == MeasureType::PulseDuration ||
                             m.type == MeasureType::Period;
        const std::string unit = is_time ? "s"
                                 : m.type == MeasureType::Frequency ? "Hz"
                                                                    : measure_unit(app, m.signal);
        text = format_measure(r.primary, unit);
        if (r.has_secondary) {
          // Pulse duration also reports the duty, which has no unit.
          text += "   " + format_measure(100.0 * r.secondary, "%");
        }
      }
      app.measure_text[i] = text;
      gtk_label_set_text(GTK_LABEL(label), text.c_str());
    }
  }

with:

    app.measure_results[i] = r;
    if (r.valid) app.measure_last_valid[i] = r;
    // Time-valued types carry seconds and a frequency carries hertz whatever the signal's
    // own unit is; everything else carries that unit. Hoisted out of the valid branch below
    // because the statistics line needs the same unit.
    const bool is_time =
        m.type == MeasureType::PulseDuration || m.type == MeasureType::Period;
    const std::string unit = is_time ? "s"
                             : m.type == MeasureType::Frequency ? "Hz"
                                                                : measure_unit(app, m.signal);
    if (label != nullptr) {
      std::string text = "--";
      if (r.valid) {
        text = format_measure(r.primary, unit);
        if (r.has_secondary) {
          // Pulse duration also reports the duty, which has no unit.
          text += "   " + format_measure(100.0 * r.secondary, "%");
        }
      }
      app.measure_text[i] = text;
      gtk_label_set_text(GTK_LABEL(label), text.c_str());
    }
    // One observation per recompute, and only when the pass produced one. An invalid pass
    // contributes nothing -- the marks' `measure_last_valid` fallback below is a *drawing*
    // convenience, and accumulating it would count one answer as many observations.
    if (r.valid && i < app.measure_stats.size()) app.measure_stats[i].add(r.primary);
  }

This sits inside the timed recompute, so the existing measure cost: dump line already accounts for the accumulation: no second cost line is added for it, and that is deliberate — a figure that is a handful of floating-point operations per measurement per pass does not deserve its own instrument, and measure cost: is where a regression in it would show.

  • Step 8: Reset on a window change and on a CONFIG

In src/ui.cpp, immediately above void apply_time_window(App& app, double seconds) {, insert:

/// Clears every measurement's statistics. Called explicitly by the pane's Reset, and
/// implicitly wherever the series of results stops being one series: a measurement edit
/// (`rebuild_measures_pane()`), a change of display window, and a CONFIG.
void reset_measure_stats(App& app) {
  for (MeasureStatsAccum& s : app.measure_stats) s.reset();
}

In apply_time_window(), replace:

  sync_window_display(app, clamped);
  schedule_layout_save(app);

with:

  sync_window_display(app, clamped);
  // A measurement over a different window is a different figure -- a mean over 20 ms and a
  // mean over 2 s are not observations of one quantity -- so the history behind it goes.
  reset_measure_stats(app);
  schedule_layout_save(app);

At the CONFIG hook, replace:

  if (model.generation() != app->signal_generation) rebuild_signal_panels(*app);

with:

  if (model.generation() != app->signal_generation) {
    // A CONFIG re-describes the stream and drops the buffer, so nothing measured before it
    // belongs in the same series as what comes after.
    reset_measure_stats(*app);
    rebuild_signal_panels(*app);
  }
  • Step 9: Build and check

Run: cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | grep -c 'warning:' Expected: 2. No error:.

  • Step 10: Check nothing regressed in the measurements pane
cd /home/martino/Projects/tools/udpscope
python3 ./tools/mock_udpstreamer.py --port 8000 --duration 15 &
sleep 1
UDPSCOPE_PROBE_MEASURE_MS=2000 UDPSCOPE_EXIT_MS=8000 \
  xvfb-run -a ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E 'measure-probe|Gtk-CRITICAL'
pkill -f 'mock_udpstrea[m]'

Expected: the [measure-probe] 4 measurements on '…' line and the repeating trace lines, reading exactly as docs/claude/probe-readings.md records them (peak to peak=2, top=0.636620). No Gtk-CRITICAL.

  • Step 11: Commit
git add src/measure.hpp src/ui.cpp
git commit -m "feat: a measurement accumulates how it varies, so one reading can be told from a trend"

Task 6: The statistics toggle, its second line and its session key

Files:

  • Modify: src/ui.cpp (App; measures_row() at :2495-2553; the result loop's label update; build_measures_panel() at :4942-4967; collect_layout() near :729; apply_layout() near :795; the probe block near :8280; the probe schedule near :9663), src/layout_store.hpp:19-63, src/layout_store.cpp:234-340 and :411-470
  • Test: the UDPSCOPE_PROBE_STATS_MS probe (a GTK pane is not testable outside the app)

Interfaces:

  • Consumes: Task 5's MeasureStatsAccum, App::measure_stats, reset_measure_stats().

  • Produces: App::measure_stats_on (bool), App::measure_stats_labels (std::vector<GtkWidget*>), App::measures_stats_check, App::measures_stats_reset; LayoutState::measure_stats (bool, default false), persisted as the top-level TOML key measure_stats; the probe UDPSCOPE_PROBE_STATS_MS printing [stats-probe].

  • Step 1: Hold the toggle's state and its labels

In App, replace:

  std::vector<MeasureStatsAccum> measure_stats;

with:

  std::vector<MeasureStatsAccum> measure_stats;
  /// The second line of each measurement's row, one per entry of `measures`. Hidden rather
  /// than removed when the toggle is off, so turning it on costs no rebuild -- and a rebuild
  /// is what would reset the statistics the user just asked to see.
  std::vector<GtkWidget*> measure_stats_labels;
  bool measure_stats_on = false;
  GtkWidget* measures_stats_check = nullptr;
  GtkWidget* measures_stats_reset = nullptr;
  • Step 2: Give each row a second line

In measures_row(), replace:

GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
  GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);

with:

GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
  // The row is two lines: the reading, and the statistics under it. A vertical wrapper
  // rather than more columns, because the statistics line is four figures wide and would
  // push the reading itself off the pane.
  GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
  GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);

and replace:

  gtk_box_append(GTK_BOX(box), edit);
  app.measure_labels.push_back(value);
  return box;
}

with:

  gtk_box_append(GTK_BOX(box), edit);
  gtk_box_append(GTK_BOX(outer), box);

  GtkWidget* stats = gtk_label_new("");
  gtk_widget_set_halign(stats, GTK_ALIGN_END);
  gtk_widget_add_css_class(stats, "dim-label");
  gtk_label_set_selectable(GTK_LABEL(stats), true);
  gtk_widget_set_tooltip_text(stats,
                              "how this reading has varied since the last reset: the number "
                              "of passes, the mean, the standard deviation, and the extremes");
  gtk_widget_set_visible(stats, app.measure_stats_on);
  gtk_box_append(GTK_BOX(outer), stats);

  app.measure_labels.push_back(value);
  app.measure_stats_labels.push_back(stats);
  return outer;
}

In rebuild_measures_pane(), replace:

  app.measure_labels.clear();
  app.measure_text.clear();

with:

  app.measure_labels.clear();
  app.measure_stats_labels.clear();
  app.measure_text.clear();
  • Step 3: Write the statistics into it

In the result loop, replace:

    if (r.valid && i < app.measure_stats.size()) app.measure_stats[i].add(r.primary);
  }

with:

    if (r.valid && i < app.measure_stats.size()) app.measure_stats[i].add(r.primary);
    if (i < app.measure_stats_labels.size() && app.measure_stats_labels[i] != nullptr) {
      GtkWidget* sl = app.measure_stats_labels[i];
      gtk_widget_set_visible(sl, app.measure_stats_on);
      if (app.measure_stats_on && i < app.measure_stats.size()) {
        const MeasureStatsAccum& s = app.measure_stats[i];
        std::string text = "no passes yet";
        if (s.n > 0) {
          // The same formatter the reading above uses, so the two lines are comparable at a
          // glance; sd and the extremes carry the measurement's unit, n does not.
          text = "n " + std::to_string(s.n) + "   mean " + format_measure(s.mean, unit) +
                 "   sd " + format_measure(s.sd(), unit) + "   " +
                 format_measure(s.lo, unit) + " .. " + format_measure(s.hi, unit);
        }
        gtk_label_set_text(GTK_LABEL(sl), text.c_str());
      }
    }
  }
  • Step 4: Add the toggle and the Reset button

In build_measures_panel(), replace:

  gtk_box_append(GTK_BOX(head), add);
  gtk_box_append(GTK_BOX(box), head);

with:

  gtk_box_append(GTK_BOX(head), add);
  gtk_box_append(GTK_BOX(box), head);

  // One toggle for the whole pane, not one per measurement: statistics are either what the
  // user is looking at or they are not, and a per-row switch is a column of chrome paid for
  // on every row to answer a question asked once.
  GtkWidget* stats_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
  app.measures_stats_check = gtk_check_button_new_with_label("Statistics");
  gtk_widget_set_hexpand(app.measures_stats_check, true);
  gtk_widget_set_tooltip_text(app.measures_stats_check,
                              "show how each reading has varied: passes, mean, standard "
                              "deviation and extremes, under the reading itself");
  g_signal_connect(app.measures_stats_check, "toggled",
                   G_CALLBACK(+[](GtkCheckButton* b, gpointer user) {
                     App* a = static_cast<App*>(user);
                     a->measure_stats_on = gtk_check_button_get_active(b);
                     // Deliberately *not* a rebuild: that would reset the statistics the
                     // user just asked to look at. The rows are already there and hidden.
                     for (GtkWidget* w : a->measure_stats_labels) {
                       if (w != nullptr) gtk_widget_set_visible(w, a->measure_stats_on);
                     }
                     gtk_widget_set_sensitive(a->measures_stats_reset, a->measure_stats_on);
                     update_measures(*a, true);
                     schedule_layout_save(*a);
                   }),
                   &app);
  gtk_box_append(GTK_BOX(stats_row), app.measures_stats_check);

  app.measures_stats_reset = gtk_button_new_with_label("Reset");
  gtk_widget_add_css_class(app.measures_stats_reset, "flat");
  gtk_widget_set_tooltip_text(app.measures_stats_reset,
                              "start every measurement's statistics again from the next pass");
  gtk_widget_set_sensitive(app.measures_stats_reset, false);
  g_signal_connect(app.measures_stats_reset, "clicked",
                   G_CALLBACK(+[](GtkButton*, gpointer user) {
                     App* a = static_cast<App*>(user);
                     reset_measure_stats(*a);
                     update_measures(*a, true);
                   }),
                   &app);
  gtk_box_append(GTK_BOX(stats_row), app.measures_stats_reset);
  gtk_box_append(GTK_BOX(box), stats_row);
  • Step 5: Persist the toggle

In src/layout_store.hpp, in LayoutState, immediately above the measures member, insert:

  /// Whether the measurements pane shows its statistics line. The only piece of the
  /// statistics that is persisted: the accumulated figures themselves describe a session's
  /// stream and mean nothing restored against another one.
  bool measure_stats = false;

In src/layout_store.cpp's load_layout, beside the existing top-level enabled key, add:

        if (key == "measure_stats") st.measure_stats = value == "true";

In save_layout, beside the existing top-level enabled write — and before any [[table]] header, because a bare key after a header belongs to that table — add:

  out << "measure_stats = " << (st.measure_stats ? "true" : "false") << "\n";

Match the surrounding lines' exact emit style; the rule that matters is the position, not the stream syntax.

  • Step 6: Carry it in and out

In collect_layout(), beside st.measures = app.measures;, add:

  st.measure_stats = app.measure_stats_on;

In apply_layout(), replace:

  app.measures = st.measures;
  rebuild_measures_pane(app);

with:

  app.measures = st.measures;
  app.measure_stats_on = st.measure_stats;
  if (app.measures_stats_check != nullptr) {
    gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
                                app.measure_stats_on);
  }
  rebuild_measures_pane(app);
  • Step 7: Build and check

Run: cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | grep -c 'warning:' Expected: 2. No error:.

  • Step 8: Write the statistics probe

In src/ui.cpp, immediately after measure_trace(), insert:

/// Headless self-check for the statistics line: turns the pane-wide toggle on over the
/// measurements `measure_probe` added, lets several recomputes land, and reports each
/// accumulator. `n` must grow with the passes and never with the samples, and a Reset must
/// put every `n` back to zero.
///
/// Three phases, because "one observation per recompute" needs two different witnesses. Phase 0
/// is the running count. Phase 1 is the same count after a **pause held for two seconds**: a
/// pinned window recomputes once when it settles and then never again, so `n` must advance by
/// *at most one* across the pause -- that is what separates accumulating per recompute from
/// accumulating per frame, which would add a hundred and twenty observations of the identical
/// window and collapse `sd`. Phase 2 is after a Reset.
int g_stats_phase = 0;
std::vector<std::uint64_t> g_stats_n_before;

gboolean stats_probe_report(gpointer user) {
  App* a = static_cast<App*>(user);
  if (a->window == nullptr) return G_SOURCE_REMOVE;
  std::fprintf(stderr, "[stats-probe] phase=%d on=%d passes=%" G_GUINT64_FORMAT,
               g_stats_phase, int(a->measure_stats_on),
               static_cast<std::uint64_t>(a->measure_passes));
  for (std::size_t i = 0; i < a->measure_stats.size(); ++i) {
    const MeasureStatsAccum& s = a->measure_stats[i];
    std::fprintf(stderr, " | n=%" G_GUINT64_FORMAT " mean=%.6g sd=%.6g lo=%.6g hi=%.6g", s.n,
                 s.mean, s.sd(), s.lo, s.hi);
    if (g_stats_phase == 1 && i < g_stats_n_before.size()) {
      std::fprintf(stderr, " dn=%" G_GUINT64_FORMAT, s.n - g_stats_n_before[i]);
    }
  }
  std::fprintf(stderr, "\n");
  std::fflush(stderr);
  if (g_stats_phase == 0) {
    // Hold a pause and come back: `dn` on the next line is the assertion.
    g_stats_n_before.clear();
    for (const MeasureStatsAccum& s : a->measure_stats) g_stats_n_before.push_back(s.n);
    g_stats_phase = 1;
    if (a->pause_button != nullptr) {
      gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), TRUE);
    }
    g_timeout_add(2000, stats_probe_report, a);
    return G_SOURCE_REMOVE;
  }
  if (g_stats_phase == 1) {
    // Resume -- a resume must not reset, so phase 2's `n` starts from phase 1's -- then reset
    // explicitly, which must.
    if (a->pause_button != nullptr) {
      gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), FALSE);
    }
    g_stats_phase = 2;
    reset_measure_stats(*a);
    update_measures(*a, true);
    g_timeout_add(50, stats_probe_report, a);
  }
  return G_SOURCE_REMOVE;
}

gboolean stats_probe(gpointer user) {
  App* a = static_cast<App*>(user);
  if (a->window == nullptr) return G_SOURCE_REMOVE;
  if (a->measures_stats_check != nullptr) {
    gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE);
  }
  g_stats_phase = 0;
  // Long enough for several throttled recomputes (one per 100 ms while the window moves).
  g_timeout_add(2000, stats_probe_report, a);
  return G_SOURCE_REMOVE;
}
  • Step 9: Schedule it

In run_ui(), immediately after the UDPSCOPE_PROBE_CONDITION_MS block, insert:

  if (const char* env = g_getenv("UDPSCOPE_PROBE_STATS_MS")) {
    g_timeout_add(static_cast<guint>(std::atoi(env)), stats_probe, &app);
  }
  • Step 10: Run the probe
cd /home/martino/Projects/tools/udpscope
python3 ./tools/mock_udpstreamer.py --port 8000 --duration 20 &
sleep 1
UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500 UDPSCOPE_EXIT_MS=12000 \
  xvfb-run -a ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E 'stats-probe|Gtk-CRITICAL'
pkill -f 'mock_udpstrea[m]'

Expected shape: three [stats-probe] lines. On phase=0, on=1 and an n= field per measurement, each a small count of passes (single or low double digits — not thousands, which would mean samples are being counted) with lo <= mean <= hi. On phase=1, every dn= is 0 or 1 across the two-second pause — a figure near 120 is the per-frame defect. On phase=2, every n is 0 or 1. No Gtk-CRITICAL. Record the measured counts and dn for Task 9.

  • Step 11: Check the session key round-trips
cd /home/martino/Projects/tools/udpscope
grep -c measure_stats ~/.config/udpscope/127.0.0.1_8000.toml

Expected: 1, with the key appearing before the first [[ header in the file.

  • Step 12: Commit
git add src/ui.cpp src/layout_store.hpp src/layout_store.cpp
git commit -m "feat: the measurements pane can show how each reading varies, and remembers that it was asked to"

Task 7: BufferSearch, the Search panel and navigation to a hit

Files:

  • Create: src/search.hpp, src/search.cpp
  • Modify: CMakeLists.txt (the source list), src/ui.cpp (App; a search_jump()/update_search_panel() pair after on_pause_toggled() near :2210; the Search expander at the end of build_trigger_panel(); the frame tick beside the CONFIG hook near :5797; the probe block; the probe schedule)
  • Test: the UDPSCOPE_PROBE_SEARCH_MS probe, whose cross-check against the live trigger is the assertion

Interfaces:

  • Consumes: Tasks 2-4's EventCondition/EventDetector; ScopeModel::live_window_locked(), ScopeModel::live(), ScopeModel::publish_pause_view(), ScopeModel::pause_view(), RowSet::pin_pause_window().

  • Produces:

    • class udpscope::BufferSearch with static constexpr std::size_t kHitsMax = 10000;, static constexpr u64 kChunk = 8192;, enum class State { Idle, Running, Done, Truncated, Capped, Cancelled }, void start(const EventCondition&, int channel), void cancel() noexcept, void clear() noexcept, bool pump(ScopeModel&), State state() const noexcept, const std::vector<double>& hits() const noexcept, int cursor() const noexcept, void select(int) noexcept, bool next() noexcept, bool prev() noexcept, double scanned_s() const noexcept, double total_s() const noexcept, std::uint64_t chunks() const noexcept, double worst_chunk_ms() const noexcept, double total_ms() const noexcept.
    • void search_jump(App&, double t) and void update_search_panel(App&) in src/ui.cpp.
    • App::search, App::search_run_button, search_cancel_button, search_status_label, search_pos_label, search_prev_button, search_next_button.
    • The probe UDPSCOPE_PROBE_SEARCH_MS, printing [search-probe].
  • Step 1: Write src/search.hpp

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari

#pragma once

#include <cstddef>
#include <cstdint>
#include <vector>

#include "condition.hpp"
#include "model.hpp"

namespace udpscope {

/// A scan of the whole retained buffer for every sample satisfying an `EventCondition`.
///
/// It runs an `EventDetector` -- the *same* evaluator the live trigger runs -- over the ring
/// in stream order, so a hit is by construction an instant the trigger would have fired on.
/// That is the claim the probe's armed-vs-scanned cross-check checks, and it is the reason
/// the detector was separated from the engine at all.
///
/// Everything here is in **ring-stamp space**, which is stream seconds relative to the
/// model's epoch: the clock `TriggerEngine::process()` is fed, the clock
/// `ScopeModel::live_window_locked()` searches, and the clock `publish_pause_view()` cuts in.
/// No conversion to display x happens in this class.
///
/// It runs on the GTK thread in chunks rather than on a thread of its own: the samples live
/// behind `ScopeModel::mutex()`, which the producer holds for each packet, and a scan that
/// took the lock once would stall acquisition for the whole buffer. One chunk per call holds
/// it for `kChunk` samples and no longer.
class BufferSearch {
 public:
  /// The hit list's ceiling. A condition matching a large share of the samples -- an edge
  /// trigger on a noisy channel -- would otherwise fill memory with a list nobody can
  /// navigate; the scan stops and says `Capped`, which is information rather than a hang.
  static constexpr std::size_t kHitsMax = 10000;
  /// Samples per `pump()`, and therefore the only latency knob: the producer waits on the
  /// lock for exactly one chunk's scan.
  static constexpr u64 kChunk = 8192;

  enum class State {
    Idle,       ///< nothing has been searched.
    Running,    ///< `pump()` has more to do.
    Done,       ///< the whole retained buffer was scanned.
    Truncated,  ///< the producer overwrote samples before they were reached.
    Capped,     ///< `kHitsMax` was reached.
    Cancelled,  ///< the user stopped it, or the channel went away.
  };

  /// Begins a scan of the channel for the condition. Discards any previous result.
  void start(const EventCondition& c, int channel);
  void cancel() noexcept;
  void clear() noexcept;

  /// Scans one chunk. Returns true while there is more to do. Takes `model.mutex()` itself,
  /// so the caller must *not* hold it.
  bool pump(ScopeModel& model);

  State state() const noexcept { return state_; }
  int channel() const noexcept { return channel_; }
  const std::vector<double>& hits() const noexcept { return hits_; }
  /// Which hit the view is on, or -1 for none.
  int cursor() const noexcept { return cursor_; }
  void select(int i) noexcept;
  /// Moves the cursor and returns whether it moved; the ends do not wrap, because a wrap
  /// from the last hit to the first looks exactly like the view not having moved at all.
  bool next() noexcept;
  bool prev() noexcept;

  /// How much of the buffer has been scanned, and how much there was, in stream seconds.
  double scanned_s() const noexcept { return scanned_s_; }
  double total_s() const noexcept { return total_s_; }
  /// Cost, for the probe: chunks scanned, the worst single chunk, and the whole scan.
  std::uint64_t chunks() const noexcept { return chunks_; }
  double worst_chunk_ms() const noexcept { return worst_chunk_ms_; }
  double total_ms() const noexcept { return total_ms_; }

 private:
  EventDetector detector_{};
  EventCondition condition_{};
  int channel_ = -1;
  State state_ = State::Idle;
  bool started_ = false;       ///< the index range has been established.
  u64 at_ = 0;                 ///< next absolute index to scan.
  u64 end_ = 0;                ///< one past the last absolute index to scan.
  double first_s_ = 0.0;       ///< stamp of `at_` when the scan began.
  double scanned_s_ = 0.0;
  double total_s_ = 0.0;
  std::vector<double> hits_;
  int cursor_ = -1;
  std::uint64_t chunks_ = 0;
  double worst_chunk_ms_ = 0.0;
  double total_ms_ = 0.0;
  std::vector<f64> t_scratch_;
  std::vector<f32> v_scratch_;
};

}  // namespace udpscope
  • Step 2: Write src/search.cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari

#include "search.hpp"

#include <chrono>
#include <cmath>
#include <mutex>

namespace udpscope {

namespace {
double ms_since(const std::chrono::steady_clock::time_point& t0) {
  const std::chrono::duration<double, std::milli> d = std::chrono::steady_clock::now() - t0;
  return d.count();
}
}  // namespace

void BufferSearch::start(const EventCondition& c, int channel) {
  clear();
  condition_ = c;
  channel_ = channel;
  detector_.reset();
  detector_.configure(c);
  // `configure()` is a no-op for an unchanged condition, so the explicit reset above is what
  // makes a re-run of the *same* search start from a clean machine.
  detector_.reset();
  state_ = State::Running;
  t_scratch_.resize(static_cast<std::size_t>(kChunk));
  v_scratch_.resize(static_cast<std::size_t>(kChunk));
}

void BufferSearch::cancel() noexcept {
  if (state_ == State::Running) state_ = State::Cancelled;
}

void BufferSearch::clear() noexcept {
  state_ = State::Idle;
  started_ = false;
  at_ = 0;
  end_ = 0;
  first_s_ = 0.0;
  scanned_s_ = 0.0;
  total_s_ = 0.0;
  hits_.clear();
  cursor_ = -1;
  chunks_ = 0;
  worst_chunk_ms_ = 0.0;
  total_ms_ = 0.0;
}

void BufferSearch::select(int i) noexcept {
  if (hits_.empty()) {
    cursor_ = -1;
    return;
  }
  if (i < 0) i = 0;
  if (static_cast<std::size_t>(i) >= hits_.size()) i = static_cast<int>(hits_.size()) - 1;
  cursor_ = i;
}

bool BufferSearch::next() noexcept {
  if (hits_.empty() || cursor_ + 1 >= static_cast<int>(hits_.size())) return false;
  ++cursor_;
  return true;
}

bool BufferSearch::prev() noexcept {
  if (hits_.empty() || cursor_ <= 0) return false;
  --cursor_;
  return true;
}

bool BufferSearch::pump(ScopeModel& model) {
  if (state_ != State::Running) return false;
  const auto chunk_t0 = std::chrono::steady_clock::now();
  bool truncated = false;
  u64 n = 0;
  {
    std::lock_guard<std::mutex> lock(model.mutex());
    if (channel_ < 0 || static_cast<std::size_t>(channel_) >= model.channel_count()) {
      state_ = State::Cancelled;
      return false;
    }
    BufferStore& store = model.live();
    if (!started_) {
      // The whole retained buffer: the bounds are deliberately absurd, because
      // `live_window_locked()` clamps them to the time ring and to *every* value ring, which
      // is the one cut that keeps the stamps and the values index-aligned by construction.
      u64 first = 0;
      u64 last = 0;
      model.live_window_locked(-1.0e300, 1.0e300, first, last);
      at_ = first;
      end_ = last;
      started_ = true;
      if (end_ > at_) {
        f64 stamp = 0.0;
        store.time().copy_from(at_, 1, &stamp);
        first_s_ = static_cast<double>(stamp);
        store.time().copy_from(end_ - 1, 1, &stamp);
        total_s_ = static_cast<double>(stamp) - first_s_;
      }
    }
    // The producer keeps pushing while we scan, so the oldest samples go. `copy_from()` fixes
    // *which* indices are read, not that the slots still hold them -- a read behind the base
    // would silently return whatever overwrote it, which would be a hit at a time that never
    // existed. Skipping forward and reporting `Truncated` is the honest answer.
    const u64 base = store.time().base();
    if (base > at_) {
      at_ = base;
      truncated = true;
      detector_.reset();  // the sequence has a gap in it; no pulse spans it
    }
    if (at_ >= end_) {
      state_ = truncated ? State::Truncated : State::Done;
      total_ms_ += ms_since(chunk_t0);
      return false;
    }
    n = end_ - at_ < kChunk ? end_ - at_ : kChunk;
    store.time().copy_from(at_, n, t_scratch_.data());
    store.values(static_cast<std::size_t>(channel_)).copy_from(at_, n, v_scratch_.data());
  }
  // The evaluation is outside the lock: it is the expensive half, and it touches only the
  // copies above.
  for (u64 i = 0; i < n; ++i) {
    const double t = static_cast<double>(t_scratch_[static_cast<std::size_t>(i)]);
    const double e =
        detector_.feed(static_cast<double>(v_scratch_[static_cast<std::size_t>(i)]), t);
    if (std::isfinite(e)) {
      hits_.push_back(e);
      if (hits_.size() >= kHitsMax) {
        at_ += i + 1;
        scanned_s_ = t - first_s_;
        state_ = State::Capped;
        ++chunks_;
        total_ms_ += ms_since(chunk_t0);
        return false;
      }
    }
    scanned_s_ = t - first_s_;
  }
  at_ += n;
  ++chunks_;
  const double cost = ms_since(chunk_t0);
  if (cost > worst_chunk_ms_) worst_chunk_ms_ = cost;
  total_ms_ += cost;
  if (at_ >= end_) {
    state_ = truncated ? State::Truncated : State::Done;
    return false;
  }
  return true;
}

}  // namespace udpscope
  • Step 3: Add it to the build

In CMakeLists.txt, in the add_executable(udpscope …) source list, insert src/search.cpp on the line immediately after src/export.cpp.

  • Step 4: Build to verify it compiles

Run: cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | grep -E 'warning:|error:' | sort | uniq -c Expected: the 2 existing -Wmissing-field-initializers warnings and no error:. (src/search.cpp is not referenced yet; this step is the compile check.)

  • Step 5: Hold the search and its widgets

In src/ui.cpp, add #include "search.hpp" to the include block, and in App replace:

  gint64 pause_started_us = 0;

with:

  gint64 pause_started_us = 0;
  /// The buffer search. Driven a few milliseconds at a time from the frame tick, because its
  /// samples live behind the model's mutex and the producer needs it back between chunks.
  BufferSearch search;
  GtkWidget* search_run_button = nullptr;
  GtkWidget* search_cancel_button = nullptr;
  GtkWidget* search_status_label = nullptr;
  GtkWidget* search_pos_label = nullptr;
  GtkWidget* search_prev_button = nullptr;
  GtkWidget* search_next_button = nullptr;
  • Step 6: Jump to a hit, and keep the panel honest

In src/ui.cpp, immediately after on_pause_toggled()'s closing brace, insert:

/// Freezes the view on a hit, centred, with the hit itself at x = 0.
///
/// This is the existing pause, not a fifth display mode: the toggle has to end up on, or the
/// next frame's `clamp_to_data()` would follow the live edge and walk the frozen window off
/// the hit entirely.
void search_jump(App& app, double t) {
  const double w = app.rows->time_window();
  if (app.pause_button != nullptr &&
      !gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.pause_button))) {
    // Its handler publishes the *current* window; the publish below replaces it.
    gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.pause_button), TRUE);
  }
  app.model->publish_pause_view(t - w / 2.0, t + w / 2.0, /*x_offset=*/t);
  app.pause_started_us = g_get_monotonic_time();
  // Pinned from the snapshot's *own* stamps, never from the window asked for: a cut is
  // clamped to what the ring holds, so a hit near the oldest sample yields a shorter
  // snapshot, and pinning the request would leave an empty margin that a zoom moves into.
  if (std::shared_ptr<const EventSnapshot> snap = app.model->pause_view()) {
    if (!snap->stamps.empty()) {
      app.rows->pin_pause_window(static_cast<double>(snap->stamps.front()),
                                 static_cast<double>(snap->stamps.back()));
    }
  }
}

void update_search_panel(App& app) {
  if (app.search_status_label == nullptr) return;
  const BufferSearch& s = app.search;
  const std::size_t n = s.hits().size();
  char text[192];
  switch (s.state()) {
    case BufferSearch::State::Idle:
      std::snprintf(text, sizeof(text), "not searched");
      break;
    case BufferSearch::State::Running:
      std::snprintf(text, sizeof(text), "scanning %s of %s, %zu found",
                    format_window(s.scanned_s()).c_str(), format_window(s.total_s()).c_str(), n);
      break;
    case BufferSearch::State::Done:
      std::snprintf(text, sizeof(text), "%zu found in %s", n, format_window(s.total_s()).c_str());
      break;
    case BufferSearch::State::Truncated:
      std::snprintf(text, sizeof(text), "%zu found; the stream overtook the oldest samples", n);
      break;
    case BufferSearch::State::Capped:
      std::snprintf(text, sizeof(text), "%zu found (the limit) in the newest %s", n,
                    format_window(s.scanned_s()).c_str());
      break;
    case BufferSearch::State::Cancelled:
      std::snprintf(text, sizeof(text), "stopped after %zu", n);
      break;
  }
  gtk_label_set_text(GTK_LABEL(app.search_status_label), text);

  char pos[64];
  if (s.cursor() < 0 || n == 0) {
    std::snprintf(pos, sizeof(pos), "--");
  } else {
    std::snprintf(pos, sizeof(pos), "%d of %zu", s.cursor() + 1, n);
  }
  gtk_label_set_text(GTK_LABEL(app.search_pos_label), pos);

  const bool running = s.state() == BufferSearch::State::Running;
  gtk_widget_set_sensitive(app.search_run_button, !running);
  gtk_widget_set_sensitive(app.search_cancel_button, running);
  gtk_widget_set_sensitive(app.search_prev_button, s.cursor() > 0);
  gtk_widget_set_sensitive(app.search_next_button,
                           n > 0 && s.cursor() + 1 < static_cast<int>(n));
}
  • Step 7: Build the Search section

In build_trigger_panel(), replace:

  g_signal_connect(app.trigger_mode_drop, "notify::selected",
                   G_CALLBACK(on_trigger_drop_changed), &app);

with:

  g_signal_connect(app.trigger_mode_drop, "notify::selected",
                   G_CALLBACK(on_trigger_drop_changed), &app);

  // Searching *is* the trigger's condition applied to history, so it belongs in this panel
  // rather than in one of its own: a second place to spell the same condition would be a
  // second place to get it wrong. Collapsed by default -- it is not part of running a trigger.
  GtkWidget* search_exp = gtk_expander_new("Search the buffer");
  gtk_widget_set_tooltip_text(search_exp,
                              "find every place in the retained buffer where the condition "
                              "above holds, and step through them");
  GtkWidget* sbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
  gtk_widget_set_margin_top(sbox, 4);

  GtkWidget* srun = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
  app.search_run_button = gtk_button_new_with_label("Search");
  gtk_widget_set_hexpand(app.search_run_button, true);
  g_signal_connect(app.search_run_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
                     App* a = static_cast<App*>(user);
                     // Published, not read back off the widgets: the producer's copy is the
                     // one the live trigger uses, so the search asks exactly its question.
                     publish_trigger_fields(a);
                     EventCondition c;
                     int channel = -1;
                     {
                       std::lock_guard<std::mutex> lock(a->model->mutex());
                       c = a->model->trigger().condition;
                       channel = a->model->trigger().channel;
                     }
                     a->search.start(c, channel);
                     update_search_panel(*a);
                   }),
                   &app);
  gtk_box_append(GTK_BOX(srun), app.search_run_button);
  app.search_cancel_button = gtk_button_new_with_label("Stop");
  gtk_widget_set_sensitive(app.search_cancel_button, false);
  g_signal_connect(app.search_cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
                     App* a = static_cast<App*>(user);
                     a->search.cancel();
                     update_search_panel(*a);
                   }),
                   &app);
  gtk_box_append(GTK_BOX(srun), app.search_cancel_button);
  gtk_box_append(GTK_BOX(sbox), srun);

  app.search_status_label = gtk_label_new("not searched");
  gtk_widget_set_halign(app.search_status_label, GTK_ALIGN_START);
  gtk_label_set_wrap(GTK_LABEL(app.search_status_label), true);
  gtk_widget_add_css_class(app.search_status_label, "dim-label");
  gtk_box_append(GTK_BOX(sbox), app.search_status_label);

  GtkWidget* snav = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
  app.search_prev_button = gtk_button_new_from_icon_name("go-previous-symbolic");
  gtk_widget_set_tooltip_text(app.search_prev_button, "the previous hit");
  gtk_widget_set_sensitive(app.search_prev_button, false);
  g_signal_connect(app.search_prev_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
                     App* a = static_cast<App*>(user);
                     if (a->search.prev()) search_jump(*a, a->search.hits()[static_cast<std::size_t>(
                                                              a->search.cursor())]);
                     update_search_panel(*a);
                   }),
                   &app);
  gtk_box_append(GTK_BOX(snav), app.search_prev_button);

  app.search_pos_label = gtk_label_new("--");
  gtk_widget_set_hexpand(app.search_pos_label, true);
  gtk_box_append(GTK_BOX(snav), app.search_pos_label);

  app.search_next_button = gtk_button_new_from_icon_name("go-next-symbolic");
  gtk_widget_set_tooltip_text(app.search_next_button, "the next hit");
  gtk_widget_set_sensitive(app.search_next_button, false);
  g_signal_connect(app.search_next_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
                     App* a = static_cast<App*>(user);
                     // The first press with no cursor goes to the first hit, not the second.
                     if (a->search.cursor() < 0) {
                       a->search.select(0);
                     } else if (!a->search.next()) {
                       update_search_panel(*a);
                       return;
                     }
                     search_jump(*a, a->search.hits()[static_cast<std::size_t>(
                                        a->search.cursor())]);
                     update_search_panel(*a);
                   }),
                   &app);
  gtk_box_append(GTK_BOX(snav), app.search_next_button);
  gtk_box_append(GTK_BOX(sbox), snav);

  gtk_expander_set_child(GTK_EXPANDER(search_exp), sbox);
  gtk_box_append(GTK_BOX(box), search_exp);
  • Step 8: Drive it from the frame tick

In the frame tick, replace:

  if (model.generation() != app->signal_generation) {

with:

  // A few milliseconds of scanning per frame: enough that a full buffer finishes in a
  // handful of seconds, little enough that neither the producer (which waits on the mutex
  // for one chunk) nor the frame budget notices.
  if (app->search.state() == BufferSearch::State::Running) {
    const gint64 budget_end_us = g_get_monotonic_time() + 5000;
    while (app->search.pump(*app->model) && g_get_monotonic_time() < budget_end_us) {
    }
    update_search_panel(*app);
  }
  if (model.generation() != app->signal_generation) {
  • Step 9: Build and check

Run: cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | grep -c 'warning:' Expected: 2. No error:.

  • Step 10: Write the search probe

In src/ui.cpp, immediately after condition_probe(), insert:

/// Headless self-check for the buffer search, and for the claim that makes it worth having:
/// a scanned hit is an instant the *live trigger* would have fired on.
///
/// It arms the same pulse-width condition `condition_probe` arms, waits for a real latch,
/// then searches the buffer for that condition and compares the latched t0 with the nearest
/// scanned hit. `agree=1` is the assertion; `agree=0` means the two evaluators have drifted
/// apart, which is the whole failure mode separating `EventDetector` out was meant to
/// prevent. It then steps to a hit and reports the pinned axis against it.
int g_search_phase = 0;
double g_search_t0 = 0.0;

gboolean search_probe_report(gpointer user) {
  App* a = static_cast<App*>(user);
  if (a->window == nullptr) return G_SOURCE_REMOVE;
  const BufferSearch& s = a->search;
  if (s.state() == BufferSearch::State::Running) return G_SOURCE_CONTINUE;

  double nearest = 0.0;
  double err = 0.0;
  bool have = false;
  for (const double h : s.hits()) {
    const double d = std::fabs(h - g_search_t0);
    if (!have || d < std::fabs(err)) {
      have = true;
      nearest = h;
      err = h - g_search_t0;
    }
  }
  // One stored sample period is the tolerance: the trigger saw the stored (filtered) stream
  // and so does the scan, so they must agree to the sample, not merely to the neighbourhood.
  const double tol = a->model->partition().stored_rate() > 0.0
                         ? 1.0 / a->model->partition().stored_rate()
                         : 1.0e-3;
  std::fprintf(stderr,
               "[search-probe] state=%d hits=%zu scanned=%.4f total=%.4f chunks=%" G_GUINT64_FORMAT
               " worst=%.3fms total=%.3fms armed t0=%.6f vs scanned first=%.6f err=%.6g agree=%d\n",
               int(s.state()), s.hits().size(), s.scanned_s(), s.total_s(), s.chunks(),
               s.worst_chunk_ms(), s.total_ms(), g_search_t0, have ? nearest : 0.0, err,
               int(have && std::fabs(err) <= tol));

  if (!s.hits().empty()) {
    a->search.select(0);
    search_jump(*a, a->search.hits()[0]);
    update_search_panel(*a);
    const plot::Range view = a->rows->view_range();
    std::fprintf(stderr,
                 "[search-probe] jumped to hit 0 t=%.6f mode=%d axis=[%.4f..%.4f] pos=%d\n",
                 a->search.hits()[0], int(a->model->display_mode()), view.min, view.max,
                 a->search.cursor() + 1);
  }
  std::fflush(stderr);
  return G_SOURCE_REMOVE;
}

gboolean search_probe_run(gpointer user) {
  App* a = static_cast<App*>(user);
  if (a->window == nullptr) return G_SOURCE_REMOVE;
  g_search_t0 = a->model->trigger_time();
  EventCondition c;
  int channel = -1;
  {
    std::lock_guard<std::mutex> lock(a->model->mutex());
    c = a->model->trigger().condition;
    channel = a->model->trigger().channel;
  }
  a->search.start(c, channel);
  std::fprintf(stderr, "[search-probe] started on channel %d, armed t0=%.6f\n", channel,
               g_search_t0);
  std::fflush(stderr);
  g_timeout_add(100, search_probe_report, a);
  return G_SOURCE_REMOVE;
}

gboolean search_probe(gpointer user) {
  App* a = static_cast<App*>(user);
  if (a->window == nullptr) return G_SOURCE_REMOVE;
  // Reuse the condition probe's arming entirely: the point is that one condition gives two
  // evaluators the same answer, so the condition must be set in exactly one place.
  g_search_phase = 0;
  condition_probe(a);
  g_timeout_add(6000, search_probe_run, a);
  return G_SOURCE_REMOVE;
}
  • Step 11: Schedule it

In run_ui(), immediately after the UDPSCOPE_PROBE_STATS_MS block, insert:

  if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) {
    g_timeout_add(static_cast<guint>(std::atoi(env)), search_probe, &app);
  }
  • Step 12: Run the probe
cd /home/martino/Projects/tools/udpscope
mv ~/.config/udpscope/127.0.0.1_8000.toml /tmp/udpscope-session.toml
python3 ./tools/mock_udpstreamer.py --port 8000 --duration 40 --freq 5 \
  --glitch-at 0.25 --glitch-width 0.002 &
sleep 1
UDPSCOPE_PROBE_SEARCH_MS=2000 UDPSCOPE_EXIT_MS=25000 \
  xvfb-run -a ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 \
  | grep -E 'search-probe|condition-probe|Gtk-CRITICAL'
pkill -f 'mock_udpstrea[m]'
mv /tmp/udpscope-session.toml ~/.config/udpscope/127.0.0.1_8000.toml

Expected shape: [search-probe] started on channel N, armed t0=…, then one report line with state=2 (Done) or state=3 (Truncated — legitimate on a long scan of a live stream), hits= a count close to total / 0.4 (one glitch per square period), and agree=1. Then [search-probe] jumped to hit 0 … with mode=4 (Paused) and an axis= bracketing 0, since the hit is published at x = 0. No Gtk-CRITICAL. Record every measured figure for Task 9.

  • Step 13: Commit
git add src/search.hpp src/search.cpp src/ui.cpp CMakeLists.txt
git commit -m "feat: the buffer can be searched for the trigger's own condition, and the hits stepped through"

Task 8: The hits are drawn on the plots

A hit list the user can only step through is a list, not a scope feature: the marks are what make "there are eleven glitches in the last two seconds, and they are bunched here" visible at a glance. The marks are a fixed pool of parked cursors, exactly like the measurements' marks, because a plot::Plot fixes its cursor set at construction and cannot be given one later — so the pool is sized once (kSearchMarks = 16) and the panel states how many of the hits it is showing.

Files:

  • Modify: src/plot_host.hpp (the constant beside kMeasureMarks at :55; Row's array beside measure_v at :113; the three public declarations beside set_measure_marks() at :253-256; the counter beside measure_marks_placed_ at :481)
  • Modify: src/plot_host.cpp (the colour beside kTriggerColor at :33; the creation loop inside install() after :204; set_search_marks() after set_measure_marks() at :545; readouts_locked() at :689-702)
  • Modify: src/ui.cpp (App::search_marks_label; place_search_marks() after place_measure_marks() near :3105; the frame tick at :5816; the status row in the Search expander; the dump line near :10170)
  • Test: the search_probe from Task 7, extended to report the placed count

Interfaces:

  • Consumes: BufferSearch::hits() and BufferSearch::cursor() from Task 7; App::search_status_label from Task 7; RowSet::set_measure_marks() as the model to follow.

  • Produces:

    • static constexpr int RowSet::kSearchMarks = 16;
    • void RowSet::set_search_marks(const std::vector<double>& xs, int current); — display x, not stream seconds; current indexes xs.
    • int RowSet::search_marks_placed() const noexcept;
    • void place_search_marks(App&); in src/ui.cpp.
  • Step 1: Add the constant and the row's pool

In src/plot_host.hpp, replace:

  static constexpr int kMeasureMarks = 6;

with:

  static constexpr int kMeasureMarks = 6;
  /// Search hits drawn at once, per row. A scan can return thousands; a plot cannot grow its
  /// cursor set, and sixteen lines over one window is already the most a reader can count. The
  /// panel states how many of the hits these are, so a capped display is never silently partial.
  static constexpr int kSearchMarks = 16;

Then replace:

    std::array<plot::u32, kMeasureMarks> measure_h{};
    std::array<plot::u32, kMeasureMarks> measure_v{};

with:

    std::array<plot::u32, kMeasureMarks> measure_h{};
    std::array<plot::u32, kMeasureMarks> measure_v{};

    /// The search hits' marks: the same arrangement as the measurement pool above, vertical only
    /// (a hit is an instant, never a level).
    std::array<plot::u32, kSearchMarks> search_v{};
  • Step 2: Declare the three members

In src/plot_host.hpp, replace:

  int measure_marks_placed() const noexcept { return measure_marks_placed_; }

with:

  int measure_marks_placed() const noexcept { return measure_marks_placed_; }

  /// Places the search hits' marks and parks the rest of the pool. `xs` are **display** x values
  /// (the caller has already taken `stream_at_x0()` off a stream time), the same convention
  /// `set_measure_marks()` takes a time mark in, and the marks are unselectable for the same
  /// reason: a hit is where the scope found the condition, not something the pointer may move.
  ///
  /// `current` indexes the one the navigation is standing on, which is drawn in its own colour, or
  /// is negative for none. The caller orders `xs`; this places them in the order it is given.
  void set_search_marks(const std::vector<double>& xs, int current);
  /// How many search marks the last call placed, which is `min(xs.size(), kSearchMarks)`.
  int search_marks_placed() const noexcept { return search_marks_placed_; }

Then replace:

  int measure_marks_placed_ = 0;

with:

  int measure_marks_placed_ = 0;
  int search_marks_placed_ = 0;
  • Step 3: Add the colour

In src/plot_host.cpp, replace:

constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;

with:

constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
/// A found hit's colour: the trigger marker's claim ("the scope fired here") in a cooler hue,
/// because a hit is the same condition found in *history* and the two are on screen together --
/// the live marker's red must stay the one that means "this is the event the capture is about".
constexpr plot::u32 kSearchColor = 0x7FB2FFu;
/// The *selected* hit's colour: the one the arrows are standing on, and the one a pause was taken
/// around. Sixteen identical lines with no way to tell which of them the readout is quoting is a
/// picture that cannot be read, so the current hit is brighter than its neighbours.
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu;
  • Step 4: Create the pool

In src/plot_host.cpp, inside install(), replace:

  r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));

with:

  // The search hits' pool, parked, created for the same reason the measurement pool above is:
  // `add_cursor()` only appends, so every mark a scan could ever want exists from the start.
  for (int m = 0; m < kSearchMarks; ++m) {
    const std::size_t mi = static_cast<std::size_t>(m);
    r.search_v[mi] = r.plot->add_cursor(kParked, plot::Color::hex(kSearchColor));
    r.plot->set_cursor_selectable(r.search_v[mi], false);
    r.plot->set_cursor_stroke(r.search_v[mi], kMeasureStrokeWidth,
                              plot::Vec2{kMeasureDashOn, kMeasureDashOff});
  }
  r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
  • Step 5: Place them

In src/plot_host.cpp, immediately after set_measure_marks()'s closing brace, insert:

void RowSet::set_search_marks(const std::vector<double>& xs, int current) {
  const int n = static_cast<int>(xs.size()) < kSearchMarks ? static_cast<int>(xs.size())
                                                           : kSearchMarks;
  search_marks_placed_ = n;
  for (int i = 0; i < kMaxRows; ++i) {
    Row& r = row(i);
    if (!r.plot) continue;
    // Every row, not the hit's own row: a hit is an *instant on the shared x window*, so the
    // mark lines up down the column the way the vertical cursors do. There is no "the hit's
    // row" to pick -- the condition was found on the trigger's channel, which may not be
    // plotted at all.
    for (int m = 0; m < n; ++m) {
      const std::size_t mi = static_cast<std::size_t>(m);
      r.plot->set_cursor_x(r.search_v[mi], xs[mi], plot::CursorPlacement::Free);
      // The colour is re-set every frame rather than only when the selection moves: the pool is
      // re-ordered each frame as the view rolls, so slot `m` is not the same hit it was.
      r.plot->set_cursor_color(r.search_v[mi],
                               plot::Color::hex(m == current ? kSearchCurrentColor
                                                            : kSearchColor));
    }
    for (int m = n; m < kSearchMarks; ++m) {
      r.plot->set_cursor_x(r.search_v[static_cast<std::size_t>(m)], kParked);
    }
  }
}
  • Step 6: Lock them against the pointer

In src/plot_host.cpp, replace:

    for (const plot::u32 id : r.measure_v) {
      if (r.plot->cursor(id).selectable) return false;
    }

with:

    for (const plot::u32 id : r.measure_v) {
      if (r.plot->cursor(id).selectable) return false;
    }
    for (const plot::u32 id : r.search_v) {
      if (r.plot->cursor(id).selectable) return false;
    }
  • Step 7: Add the panel's label

In src/ui.cpp, replace:

  GtkWidget* search_pos_label = nullptr;

with:

  GtkWidget* search_pos_label = nullptr;
  /// "16 of 38 marks shown" -- the pool is smaller than a scan's result, and a display that is
  /// showing a sixth of what it found has to say so.
  GtkWidget* search_marks_label = nullptr;
  • Step 8: Place the marks each frame

In src/ui.cpp, immediately after place_measure_marks()'s closing brace, insert:

/// Puts the search hits nearest the view on the plots, converting each from the stream seconds
/// `BufferSearch` records to the x the axis is reading this frame -- the same conversion
/// `place_measure_marks()` makes, and for the same reason. Called every frame: that is what
/// makes a mark read-only and what keeps it on its own sample as a live view rolls.
///
/// Only the hits **inside the visible window** are candidates, nearest the view's centre first, so
/// that a zoomed-out view with more hits than marks shows the middle of the cluster rather than an
/// arbitrary end of it. The selected hit is placed first regardless, so stepping to a hit never
/// leaves it unmarked, and it is the one drawn in the current colour.
void place_search_marks(App& app) {
  std::vector<double> xs;
  int current = -1;
  std::size_t in_view = 0;
  const std::vector<double>& hits = app.search.hits();
  if (!hits.empty()) {
    const double s0 = app.model->stream_at_x0();
    const plot::Range view = app.rows->view_range();
    const double centre = 0.5 * (view.min + view.max);
    const int sel = app.search.cursor();

    // Candidates: the display x of every hit in view, paired with its distance from the centre.
    std::vector<std::pair<double, double>> cand;
    for (std::size_t i = 0; i < hits.size(); ++i) {
      const double x = hits[i] - s0;
      if (x < view.min || x > view.max) continue;
      ++in_view;
      if (sel >= 0 && i == static_cast<std::size_t>(sel)) continue;  // placed first, below
      cand.push_back({std::fabs(x - centre), x});
    }
    std::sort(cand.begin(), cand.end());

    const std::size_t cap = static_cast<std::size_t>(RowSet::kSearchMarks);
    xs.reserve(cap);
    if (sel >= 0 && sel < static_cast<int>(hits.size())) {
      current = 0;
      xs.push_back(hits[static_cast<std::size_t>(sel)] - s0);
    }
    for (std::size_t i = 0; i < cand.size() && xs.size() < cap; ++i) xs.push_back(cand[i].second);
  }
  if (app.search_marks_label != nullptr) {
    char text[128];
    if (hits.empty()) {
      std::snprintf(text, sizeof(text), " ");
    } else if (in_view > xs.size()) {
      // A truncated display says so: "16 of 38" with no qualifier reads as "there are 38 and you
      // are seeing 16 of them *everywhere*", which is not the claim.
      std::snprintf(text, sizeof(text), "%zu of %zu marks shown \xE2\x80\x94 zoom in to see the rest",
                    xs.size(), in_view);
    } else {
      std::snprintf(text, sizeof(text), "%zu of %zu in view", xs.size(), hits.size());
    }
    gtk_label_set_text(GTK_LABEL(app.search_marks_label), text);
  }
  app.rows->set_search_marks(xs, current);
}

Then, in the frame tick, replace:

  update_measures(*app, false);

with:

  update_measures(*app, false);
  // Every frame, whether or not a scan is running: the marks are in stream seconds and the axis
  // is newest-relative, so a live window that has rolled since the last frame needs them moved.
  place_search_marks(*app);
  • Step 9: Show the label

In build_trigger_panel(), replace:

  gtk_box_append(GTK_BOX(snav), app.search_next_button);
  gtk_box_append(GTK_BOX(sbox), snav);

with:

  gtk_box_append(GTK_BOX(snav), app.search_next_button);
  gtk_box_append(GTK_BOX(sbox), snav);

  app.search_marks_label = gtk_label_new(" ");
  gtk_widget_set_halign(app.search_marks_label, GTK_ALIGN_START);
  gtk_widget_add_css_class(app.search_marks_label, "dim-label");
  gtk_box_append(GTK_BOX(sbox), app.search_marks_label);
  • Step 10: Report it in the dump

The measurements' dump line keeps its own fields; the search marks get a line of their own beside it. In src/ui.cpp, replace:

                  app.dbg_measures_pane_w, app.dbg_measures_pane_h);
    }
    std::printf("[udpscope]   header: window_button_w=%d window_label_w=%d (bar_w=%d)\n",

with:

                  app.dbg_measures_pane_w, app.dbg_measures_pane_h);
    }
    // `locked` is re-reported here rather than only in the readouts line: this task added a
    // whole new cursor pool, and a pool created selectable is exactly the regression that a
    // readout-vs-instrument check is for.
    std::printf("[udpscope]   search marks: placed=%d hits=%zu locked=%d\n",
                rows.search_marks_placed(), app.search.hits().size(),
                rows.readouts_locked() ? 1 : 0);
    std::printf("[udpscope]   header: window_button_w=%d window_label_w=%d (bar_w=%d)\n",
  • Step 11: Build and check

Run: cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | grep -c 'warning:' Expected: 2. No error:.

  • Step 12: Run the search probe and read the marks

Run, in one shell:

cd /home/martino/Projects/tools/udpscope
mv ~/.config/udpscope/127.0.0.1_8000.toml /tmp/udpscope-session.toml 2>/dev/null
python3 ./tools/mock_udpstreamer.py --port 8000 --rate 2000 --freq 5 \
  --glitch-at 0.5 --glitch-width 0.004 &
sleep 1
UDPSCOPE_PROBE_CONDITION_MS=1500 UDPSCOPE_PROBE_SEARCH_MS=4500 \
  UDPSCOPE_PROBE_DUMP_MS=6000 UDPSCOPE_PROBE_QUIT_MS=6500 \
  xvfb-run -a ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | \
  grep -E 'search|condition|marks'
pkill -f 'mock_udpstrea[m]'
mv /tmp/udpscope-session.toml ~/.config/udpscope/127.0.0.1_8000.toml 2>/dev/null

Expected: search marks: placed= a count between 1 and 16 — not 0, which would mean the pool was never filled, and never above 16, which the clamp forbids — with hits= the same count the [search-probe] line reported, and locked=1. Record the measured placed=/hits= pair for Task 9.

  • Step 13: Commit
git add src/plot_host.hpp src/plot_host.cpp src/ui.cpp
git commit -m "feat: a search's hits are marked on every row, and the panel says how many of them it is showing"

Task 9: The documentation says what was built

Three documents are the contract this repository keeps with its next reader, and each answers a different question: README.md says what exists, docs/claude/verification.md says how to run the checks, docs/claude/probe-readings.md says what each line of their output must read, and the invariant files say which of these rules cost a defect to learn. None of the three is optional — a probe nobody can invoke and a reading nobody can compare against are both worth nothing.

Files:

  • Modify: README.md (the trigger section; the measurements section; a Search subsection)
  • Modify: docs/claude/verification.md (the three new probe variables; the three new mock flags)
  • Modify: docs/claude/probe-readings.md (the [condition-probe], [stats-probe], [search-probe] and search marks: lines)
  • Modify: docs/claude/invariants-trigger-and-pinning.md (the detector/engine split; the gate measured from the event's own time)
  • Modify: docs/claude/invariants-measurements.md (one observation per recompute; what resets and what does not)
  • Test: none — read back by grep in Step 8

Interfaces:

  • Consumes: the measured probe readings recorded in Tasks 3, 4, 6 and 8.

  • Produces: nothing other tasks depend on. This is the last task.

  • Step 1: Describe the conditions in the README

In README.md, find the trigger section (grep -n '^## ' README.md and pick the trigger heading) and append to it:

### Qualified trigger conditions

The level-and-edge trigger is one of four shapes the **Shape** dropdown offers, and all four
are the *same* trigger: the arming, the hold-off, the pre/post event window and the single/auto
modes are unchanged, and only the question "did an event happen at this sample" differs.

- **edge** — a crossing of the level in the chosen direction. The original behaviour, bit for
  bit.
- **pulse width** — a crossing *and* its return, qualified by how long the excursion lasted:
  `<`, `>`, `within` or `outside` a pair of widths. The event is timed at the **closing** edge,
  because that is the first sample at which the width is known. `<` a width shorter than the
  signal's own pulses is the glitch trigger: nothing but a runt satisfies it.
- **window** — the signal entering or leaving a band between two levels. A band is two levels,
  so the panel refuses a zero-width one rather than quietly degrading to an edge.
- **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage:
  the condition re-arms only when a crossing returns.

A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
width to a window trigger. The condition is **not** persisted in the session file — the session
has never persisted the trigger — so a restart comes up on an edge trigger.
  • Step 2: Describe the statistics in the README

In README.md, find the measurements section and append to it:

### Statistics

**Statistics** under the measurements list adds a second line to every measurement: `n`, the
mean, the standard deviation, and the range. The observations are the pane's own **recomputes**
— one per refresh, not one per sample — so `n` is a count of *windows measured*, which is what
makes "the mean frequency over the last four hundred windows" the claim it reads as. The pane
refreshes at most ten times a second while the window scrolls and exactly once when it settles,
so a settled window contributes one observation and then stops.

The accumulator is Welford's, not a sum of squares: a measurement whose values sit at 1e9 with
a spread of 1e-3 is an ordinary case here (an absolute stream time is one), and the naive form
loses that spread entirely.

**Reset** clears every accumulator. So does editing, adding or deleting a measurement, a CONFIG
that renames the signals, and a change of the display window — each of those changes *what is
being measured*, and a mean across the change describes nothing. A pause, a resume, a trigger
latch, a pan and a zoom do **not** reset: they change which samples are in view, which is
exactly what a statistic over many windows is meant to average. The toggle itself does not
reset either, so turning the line off to read the plot and back on again keeps the history.
  • Step 3: Describe the search in the README

In README.md, immediately after the statistics text added in Step 2, append:

### Searching the buffer

**Search the buffer**, at the foot of the trigger panel, runs the trigger's *own* condition over
the whole retained ring and lists every place it holds. There is no second condition editor: a
second place to spell the same question would be a second place to get it wrong.

The scan runs on the GTK thread in a few milliseconds of work per frame, taking the model's
mutex for one chunk at a time, so neither the frame rate nor the producer stalls on it. Up to
10 000 hits are kept; past that the scan stops and says so. If the stream overtakes the oldest
samples mid-scan the search skips forward to what is still there and reports `the stream
overtook the oldest samples` rather than returning a hit at a time that no longer exists.

The arrows step through the hits. A step **pauses** the view on the hit, centred, exactly as the
Pause button does — so the frozen window is pinned and navigable within its own data, and
resuming returns to the live view. Up to 16 of the hits **in the visible window** are marked on
every row at once, nearest the centre first and the selected one always among them in its own
colour; when there are more in view than there are marks the panel says `16 of 38 marks shown —
zoom in to see the rest`, which is a true statement about the picture rather than a silent
truncation.
  • Step 4: Document the new probes

In docs/claude/verification.md, find the table or list of UDPSCOPE_PROBE_* variables and add three rows, matching the file's existing form:

| `UDPSCOPE_PROBE_CONDITION_MS` | Arms a qualified trigger against the mock and reports the latched `t0` as a **phase** against the known instant the condition should fire at. Schedules its own arm and report stages. `UDPSCOPE_PROBE_CONDITION_SHAPE` picks the leg: `0` pulse width `<` (the glitch, the default, needs `--glitch-at`/`--glitch-width`), `1` pulse width `>`, `2` window, `3` dropout (needs `--quiet-at` and `UDPSCOPE_PROBE_QUIET_AT`). `Edge` needs no leg here — the four existing trigger probes are its check. |
| `UDPSCOPE_PROBE_STATS_MS` | Reports every measurement's accumulator (`n`, mean, sd, range), then presses Reset and reports again. Two phases, `phase=0` and `phase=1`. |
| `UDPSCOPE_PROBE_SEARCH_MS` | Runs a buffer search for the armed condition, cross-checks the live trigger's own `t0` against the nearest hit, and jumps to hit 0. Reports `agree=`, the resulting `mode=` and `axis=`. |

Then find the mock's flag list and add:

- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the start of
  every square period from stream second `S` onwards — a runt pulse of a known width, which is
  what a `<` pulse-width trigger and a glitch search are checked against.
- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it stops
  crossing zero: the dropout trigger's input.
  • Step 5: Document the readings

In docs/claude/probe-readings.md, append a section, matching the file's existing form and substituting the figures measured in Tasks 3, 4, 6 and 8:

## `[condition-probe]`

`UDPSCOPE_PROBE_CONDITION_MS`, with the mock's `--glitch-at`/`--glitch-width`.

Four legs, selected by `UDPSCOPE_PROBE_CONDITION_SHAPE`. Every leg prints one line and the
reading on every one of them is `phase`, which must be within a few **stored** sample periods of
0 — stored, because the trigger sees the decimated stream, so the tolerance is `1 / rate` from the
status line and not the wire rate.

- `type=1 cmp=0 … phase=…` (shape 0, the default) — `phase` is the latched `t0`'s offset from the
  *end* of an injected runt, modulo the square's period. A phase near half a period means the
  trigger caught the signal's own edge instead of the runt's, i.e. the width qualifier did not
  qualify anything.
- `type=1 cmp=1 … phase=…` (shape 1) — the same, against a square **transition**, modulo the
  half-period: every one of the square's own pulses satisfies `> 0.010`.
- `type=2 … phase=…` (shape 2) — the window band `[-0.5, +0.5]` is entered and left at each
  transition, so the same half-period phase applies.
- `type=3 … phase=…` (shape 3) — here `phase` is the *absolute* error `t0 - (quiet_at + timeout)`,
  because a dropout happens once and there is no period to fold it into. One latch only: a second
  `t0` means the dropout re-armed without a crossing returning.

## `[stats-probe]`

`UDPSCOPE_PROBE_STATS_MS`.

Three phases, one line each; every line carries one `| n=… mean=… sd=… lo=… hi=…` group per
measurement.

- `phase=0` — the running count. `n` is a count of **recomputes**, so at a 100 ms cadence it is a
  low tens over a few seconds of run; thousands would mean the accumulator is being fed per
  sample, which is the first defect this probe names. `lo` and `hi` must bracket `mean`, and `sd`
  must be 0 exactly when `n < 2`.
- `phase=1` — after a **two-second pause**, with an extra `dn=` per measurement. `dn` must be
  **0 or 1**: a pinned window recomputes once when it settles and then never again. A `dn` near
  120 is the second defect — accumulating per *frame*, which adds a hundred-odd observations of
  the identical window and collapses `sd` towards zero. Note also that `n` here is *not* reset by
  the pause or the resume, which is the rule about what resets and what does not.
- `phase=2` — after Reset. `n` must be 0 or 1 (0 if the report runs before the next recompute,
  1 if after); anything larger means Reset cleared nothing.

## `[search-probe]`

`UDPSCOPE_PROBE_SEARCH_MS`, scheduled after `UDPSCOPE_PROBE_CONDITION_MS`.

- `state=done hits=… scanned=… agree=1` — `agree` compares the live trigger's own `t0` with the
  nearest hit, to within one stored sample period. **This is the load-bearing line of the whole
  feature**: the search and the trigger run the same `EventDetector` over the same stamps, so a
  `agree=0` means one of the two is reading a different clock — which is exactly the class of
  defect a search that merely "returns plausible times" hides.
- `mode=2 axis=[…]` after the jump — `2` is `Paused`, and the axis must lie inside the
  snapshot's own stamps, the same rule every pinned view obeys.
- `search marks: placed=… hits=… locked=1` — `placed` is between 1 and 16 whenever `hits` is
  non-zero (0 means the pool was never filled; above 16 is impossible and would mean the clamp
  is gone), and `locked=1` says the marks are out of the pointer's reach, like every other
  readout.
  • Step 6: Add the trigger invariants

In docs/claude/invariants-trigger-and-pinning.md, append to the bullet list:

- **`EventDetector` owns every condition shape and holds no arming state; `TriggerEngine` owns
  the arming, the hold-off and the post window and holds no condition.** The split is what lets
  the buffer search ask the trigger's exact question without owning a trigger, and it is only
  true as long as neither half leaks into the other: a detector that knew about the hold-off
  would answer a search differently from the live trigger, which is the one thing the
  `agree=1` cross-check in `[search-probe]` exists to catch.
- **The detector is fed on *every* sample, including during the hold-off and during a capture.**
  A pulse width, a window excursion and a dropout timeout are all *stateful over several
  samples*: skipping the feed while the engine is busy loses the opening edge, and the condition
  then measures from whatever sample the engine happened to resume on. The hold-off is applied
  to the detector's *answer*, not to its input — `process()` feeds first and tests
  `now_s < holdoff_until_s_` afterwards.
- **An event's time is the detector's, not the sample's.** A pulse-width event is detected at
  the closing edge but could in principle be timed at the opening one, and a dropout is detected
  one sample after the timeout expires. `feed()` therefore returns the event's *own* time and
  `process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
  all three, or the marker, the cut and the gate would disagree with each other by the width of
  a pulse.
  • Step 7: Add the measurement invariants

In docs/claude/invariants-measurements.md, append to the bullet list:

- **A statistic's observation is one *recompute*, not one sample.** The pane already refreshes on
  a cadence (at most one pass per 100 ms while the window scrolls, exactly once when it
  settles), and the accumulator is fed from that pass's result — so `n` counts windows measured.
  Feeding it per sample would make `n` a sample count and the mean a mean of *samples*, which is
  not what any of these measurements measure: a frequency over a window is not a property of a
  sample. The reading that names this defect is `[stats-probe]`'s `n` in the thousands rather
  than the tens.
- **The accumulator is Welford's, and the reason is the same one the time ring is `f64` for.** A
  measurement's values are routinely large with a small spread — an absolute stream time is the
  obvious case — and `Σx²  - (Σx)²/n` cancels to noise there. The standalone check is that the
  spread of `{2,4,4,4,5,5,7,9}` survives a `+1e9` offset unchanged.
- **What resets the statistics is "what is being measured changed", not "what is in view
  changed".** An explicit Reset, an add/edit/delete (all three funnel through
  `rebuild_measures_pane()`), a CONFIG, and a change of display window each change the
  *question*, so the history is cleared. A pause, a resume, a trigger latch, a pan and a zoom do
  not: they change which samples answer it, which is precisely what averaging over many windows
  is for. The pane-wide toggle does not reset either — it only shows and hides labels that are
  always created and always fed, so reading the plot for a moment does not cost the history.
  • Step 8: Read the documentation back

Run:

cd /home/martino/Projects/tools/udpscope
grep -c 'UDPSCOPE_PROBE_CONDITION_MS\|UDPSCOPE_PROBE_STATS_MS\|UDPSCOPE_PROBE_SEARCH_MS' \
  docs/claude/verification.md docs/claude/probe-readings.md
grep -c 'pulse width\|dropout\|Welford' README.md
grep -n 'TBD\|TODO' README.md docs/claude/verification.md docs/claude/probe-readings.md \
  docs/claude/invariants-trigger-and-pinning.md docs/claude/invariants-measurements.md

Expected: the first grep -c reports at least 3 for each file; the second at least 3; the third prints nothing and exits 1.

  • Step 9: Full clean build against the baseline

Run:

cd /home/martino/Projects/tools/udpscope
rm -rf build && cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo >/dev/null && \
  cmake --build build 2>&1 | grep -c 'warning:'

Expected: 4 — the clean-build baseline. Any other number is a new warning and must be fixed before the commit, not documented.

  • Step 10: Commit
git add README.md docs/claude/verification.md docs/claude/probe-readings.md \
  docs/claude/invariants-trigger-and-pinning.md docs/claude/invariants-measurements.md
git commit -m "docs: the three new features are described where each of their readers looks"