diff --git a/docs/superpowers/plans/2026-10-07-scope-features.md b/docs/superpowers/plans/2026-10-07-scope-features.md new file mode 100644 index 0000000..c0a5bac --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-scope-features.md @@ -0,0 +1,4038 @@ + + +# 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](../specs/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 `, `--glitch-width `, `--quiet-at `. + +- [ ] **Step 1: Write the failing test** + +Create `/tmp/check_mock_waveforms.py`: + +```python +#!/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 "= 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: + +```python +def build_data( + counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY, noise=0.0, time_spike=0.0 +): +``` + +with: + +```python +def build_data( + counter, + t0, + dt, + batch, + freq, + time_mode=TIME_FULL_ARRAY, + noise=0.0, + time_spike=0.0, + glitch_at=-1.0, + glitch_width=0.0, + quiet_at=-1.0, +): +``` + +- [ ] **Step 4: Shape the two waveforms** + +In the same function, replace: + +```python + ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps] +``` + +with: + +```python + 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: + +```python + 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: + +```python + sock.sendto( + build_data( + counter, + t_stream, + pdt, + args.batch, + args.freq, + time_mode, + args.noise, + spike, + ), + peer, + ) +``` + +with: + +```python + 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** + +```bash +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`: + +```cpp +// Standalone check of EventDetector's Edge path against the engine's old expression. +#include +#include +#include + +#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 run(const EventCondition& c, const std::vector& v, double dt) { + EventDetector d; + d.configure(c); + std::vector out; + for (std::size_t i = 0; i < v.size(); ++i) { + const double t = static_cast(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 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 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 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 touch = {-1.0, 0.0, 0.0, 1.0}; + const std::vector 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`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include + +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::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`** + +```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: + +```cpp +#include +#include + +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: + +```cpp +#include +#include + +#include "condition.hpp" + +namespace udpscope { + +/// How an armed trigger re-arms after it fired. +``` + +and replace: + +```cpp + 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: + +```cpp + 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: + +```cpp + double last_value() const noexcept { return prev_; } + bool have_last() const noexcept { return have_prev_; } +``` + +with: + +```cpp + double last_value() const noexcept { return detector_.last_value(); } + bool have_last() const noexcept { return detector_.have_last(); } +``` + +and replace: + +```cpp + bool armed_ = false; + bool have_prev_ = false; + double prev_ = 0.0; + bool pending_ = false; ///< an edge was seen; collecting the post window +``` + +with: + +```cpp + 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 ` with: + +```cpp +#include +#include +``` + +Replace the body of `configure()`: + +```cpp + 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: + +```cpp + 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()`: + +```cpp + holdoff_until_s_ = now_s; + have_prev_ = false; +``` + +with: + +```cpp + 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: + +```cpp + 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: + +```cpp + tc.level = level; + tc.pretrigger_pct = pre; + tc.holdoff_s = holdoff; + tc.edge = edge == 0 ? TriggerEdge::Rising + : edge == 1 ? TriggerEdge::Falling + : TriggerEdge::Both; +``` + +with: + +```cpp + 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`: + +```cpp + tc.level = mid; +``` + +with: + +```cpp + tc.condition.level = mid; +``` + +Replace `src/ui.cpp:6347`: + +```cpp + trigger_level = model.trigger().level; +``` + +with: + +```cpp + trigger_level = model.trigger().condition.level; +``` + +In the trigrenum probe, replace: + +```cpp + tc.post_span_s(a->model->event_span()), tc.holdoff_s, tc.level, int(tc.edge), +``` + +with: + +```cpp + tc.post_span_s(a->model->event_span()), tc.holdoff_s, tc.condition.level, + int(tc.condition.edge), +``` + +In the fields probe, replace: + +```cpp + int(a->model->trigger().mode), int(a->model->trigger().edge), +``` + +with: + +```cpp + 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]'`: + +```bash +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** + +```bash +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 …)`: + +```cpp + // 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 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 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 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 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: + +```cpp + double feed_edge(double value, double t) noexcept; + + EventCondition cond_{}; + bool have_prev_ = false; + double prev_ = 0.0; +``` + +with: + +```cpp + 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()`: + +```cpp + have_prev_ = false; + prev_ = 0.0; +``` + +with: + +```cpp + have_prev_ = false; + prev_ = 0.0; + in_pulse_ = false; + pulse_up_ = false; + pulse_start_s_ = 0.0; +``` + +Replace in `feed()`: + +```cpp + case TriggerType::PulseWidth: + case TriggerType::Window: +``` + +with: + +```cpp + case TriggerType::PulseWidth: + event = feed_pulse(value, t); + break; + case TriggerType::Window: +``` + +And append, before the closing `} // namespace udpscope`: + +```cpp +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: + +```cpp + GtkWidget* trigger_edge_drop = nullptr; +``` + +with: + +```cpp + 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: + +```cpp +/// 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: + +```cpp + const guint mode = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_mode_drop)); +``` + +with: + +```cpp + 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: + +```cpp + tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single; +``` + +with: + +```cpp + 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: + +```cpp +/// 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: + +```cpp +/// 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(user); + publish_trigger_fields(app); + sync_trigger_rows(*app); +} +``` + +- [ ] **Step 9: Build the rows** + +In `build_trigger_panel()`, replace: + +```cpp + 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: + +```cpp + 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: + +```cpp + for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin}) { +``` + +with: + +```cpp + 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: + +```cpp + g_signal_connect(app.trigger_mode_drop, "notify::selected", + G_CALLBACK(on_trigger_drop_changed), &app); + return box; +``` + +with: + +```cpp + 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: + +```cpp +/// 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(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(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& 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(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(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(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& 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: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_CONDITION_MS")) { + g_timeout_add(static_cast(std::atoi(env)), condition_probe, &app); + } +``` + +- [ ] **Step 13: Run the probe** + +```bash +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** + +```bash +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`: + +```cpp + // 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 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 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 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 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 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 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: + +```cpp + double feed_pulse(double value, double t) noexcept; +``` + +with: + +```cpp + 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: + +```cpp + bool pulse_up_ = false; ///< PulseWidth: its polarity, so we know which edge closes it. + double pulse_start_s_ = 0.0; +``` + +with: + +```cpp + 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: + +```cpp + if (!have_prev_) { + prev_ = value; + have_prev_ = true; + return kNoEvent; + } +``` + +with: + +```cpp + 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: + +```cpp + in_pulse_ = false; + pulse_up_ = false; + pulse_start_s_ = 0.0; +``` + +with: + +```cpp + 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()`: + +```cpp + case TriggerType::Window: + case TriggerType::Dropout: + break; // implemented in the tasks that add their panel rows +``` + +with: + +```cpp + case TriggerType::Window: + event = feed_window(value, t); + break; + case TriggerType::Dropout: + event = feed_dropout(value, t); + break; +``` + +Append, before the closing `} // namespace udpscope`: + +```cpp +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: + +```cpp +TriggerType trigger_type_at(guint i) { + switch (i) { + case 1: + return TriggerType::PulseWidth; + default: + return TriggerType::Edge; + } +} +``` + +with: + +```cpp +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: + +```cpp + GtkWidget* trigger_width2_spin = nullptr; +``` + +with: + +```cpp + GtkWidget* trigger_width2_spin = nullptr; + GtkWidget* trigger_level2_spin = nullptr; + GtkWidget* trigger_timeout_spin = nullptr; +``` + +and replace: + +```cpp + GtkWidget* trigger_width2_row = nullptr; +``` + +with: + +```cpp + GtkWidget* trigger_width2_row = nullptr; + GtkWidget* trigger_level2_row = nullptr; + GtkWidget* trigger_timeout_row = nullptr; +``` + +In `build_trigger_panel()`, replace: + +```cpp + const char* types[] = {"edge", "pulse width", nullptr}; +``` + +with: + +```cpp + const char* types[] = {"edge", "pulse width", "window", "dropout", nullptr}; +``` + +and replace: + +```cpp + app.trigger_width2_row = labeled_box("and s", app.trigger_width2_spin); + gtk_box_append(GTK_BOX(box), app.trigger_width2_row); +``` + +with: + +```cpp + 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: + +```cpp + app.trigger_width_spin, app.trigger_width2_spin}) { +``` + +with: + +```cpp + 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: + +```cpp + 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()`: + +```cpp + g_signal_connect(app.trigger_cmp_drop, "notify::selected", + G_CALLBACK(on_trigger_shape_changed), &app); +``` + +with: + +```cpp + 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(u)); + }), + &app); + } +``` + +- [ ] **Step 10: Publish the two new fields** + +In `publish_trigger_fields()`, replace: + +```cpp + const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin)); +``` + +with: + +```cpp + 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: + +```cpp + tc.condition.width2_s = width2; +``` + +with: + +```cpp + 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: + +```cpp +struct ConditionProbe { + double glitch_at = 0.25; + double glitch_width = 0.002; + double square_period = 0.4; + int waited = 0; +}; +``` + +with: + +```cpp +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: + +```cpp + 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: + +```cpp + 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: + +```cpp + 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: + +```cpp + 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: + +```cpp + if (channels[i].name == "square") sel = static_cast(i); +``` + +with: + +```cpp + if (channels[i].name == (dropout ? "sine" : "square")) sel = static_cast(i); +``` + +In `condition_probe()`, replace: + +```cpp + const std::vector& 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: + +```cpp + // `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& 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: + +```cpp + /// 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: + +```cpp + 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: + +```cpp + 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: + +```cpp + 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: + +```cpp + 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: + +```cpp + gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0); +``` + +with: + +```cpp + // 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: + +```cpp + double phase = 0.0; + if (g_condition.quiet_at >= 0.0) { +``` + +with: + +```cpp + 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: + +```cpp + 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: + +```cpp + // 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: + +```bash +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: + +```bash +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** + +```bash +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`), parallel to `App::measures`. + - `void reset_measure_stats(App&)` in `src/ui.cpp`. + +- [ ] **Step 1: Write the failing test** + +Create `/tmp/check_stats.cpp`: + +```cpp +// Standalone check of MeasureStatsAccum's Welford update. +#include +#include + +#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: + +```cpp +/// 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(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(n - 1)); + } +}; +``` + +If `src/measure.hpp` does not already include `` and ``, 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: + +```cpp + std::vector measure_last_valid; +``` + +with: + +```cpp + std::vector 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 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: + +```cpp + app.measure_labels.clear(); + app.measure_text.clear(); +``` + +with: + +```cpp + 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: + +```cpp + 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: + +```cpp + 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: + +```cpp +/// 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: + +```cpp + sync_window_display(app, clamped); + schedule_layout_save(app); +``` + +with: + +```cpp + 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: + +```cpp + if (model.generation() != app->signal_generation) rebuild_signal_panels(*app); +``` + +with: + +```cpp + 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** + +```bash +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** + +```bash +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`), `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: + +```cpp + std::vector measure_stats; +``` + +with: + +```cpp + std::vector 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 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: + +```cpp +GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); +``` + +with: + +```cpp +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: + +```cpp + gtk_box_append(GTK_BOX(box), edit); + app.measure_labels.push_back(value); + return box; +} +``` + +with: + +```cpp + 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: + +```cpp + app.measure_labels.clear(); + app.measure_text.clear(); +``` + +with: + +```cpp + 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: + +```cpp + if (r.valid && i < app.measure_stats.size()) app.measure_stats[i].add(r.primary); + } +``` + +with: + +```cpp + 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: + +```cpp + gtk_box_append(GTK_BOX(head), add); + gtk_box_append(GTK_BOX(box), head); +``` + +with: + +```cpp + 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(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(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: + +```cpp + /// 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: + +```cpp + 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: + +```cpp + 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: + +```cpp + st.measure_stats = app.measure_stats_on; +``` + +In `apply_layout()`, replace: + +```cpp + app.measures = st.measures; + rebuild_measures_pane(app); +``` + +with: + +```cpp + 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: + +```cpp +/// 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 g_stats_n_before; + +gboolean stats_probe_report(gpointer user) { + App* a = static_cast(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(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(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: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_STATS_MS")) { + g_timeout_add(static_cast(std::atoi(env)), stats_probe, &app); + } +``` + +- [ ] **Step 10: Run the probe** + +```bash +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** + +```bash +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** + +```bash +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& 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`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include + +#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& 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 hits_; + int cursor_ = -1; + std::uint64_t chunks_ = 0; + double worst_chunk_ms_ = 0.0; + double total_ms_ = 0.0; + std::vector t_scratch_; + std::vector v_scratch_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: Write `src/search.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "search.hpp" + +#include +#include +#include + +namespace udpscope { + +namespace { +double ms_since(const std::chrono::steady_clock::time_point& t0) { + const std::chrono::duration 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(kChunk)); + v_scratch_.resize(static_cast(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(i) >= hits_.size()) i = static_cast(hits_.size()) - 1; + cursor_ = i; +} + +bool BufferSearch::next() noexcept { + if (hits_.empty() || cursor_ + 1 >= static_cast(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 lock(model.mutex()); + if (channel_ < 0 || static_cast(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(stamp); + store.time().copy_from(end_ - 1, 1, &stamp); + total_s_ = static_cast(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(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(t_scratch_[static_cast(i)]); + const double e = + detector_.feed(static_cast(v_scratch_[static_cast(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: + +```cpp + gint64 pause_started_us = 0; +``` + +with: + +```cpp + 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: + +```cpp +/// 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 snap = app.model->pause_view()) { + if (!snap->stamps.empty()) { + app.rows->pin_pause_window(static_cast(snap->stamps.front()), + static_cast(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(n)); +} +``` + +- [ ] **Step 7: Build the Search section** + +In `build_trigger_panel()`, replace: + +```cpp + g_signal_connect(app.trigger_mode_drop, "notify::selected", + G_CALLBACK(on_trigger_drop_changed), &app); +``` + +with: + +```cpp + 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(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 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(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(user); + if (a->search.prev()) search_jump(*a, a->search.hits()[static_cast( + 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(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( + 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: + +```cpp + if (model.generation() != app->signal_generation) { +``` + +with: + +```cpp + // 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: + +```cpp +/// 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(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(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + g_search_t0 = a->model->trigger_time(); + EventCondition c; + int channel = -1; + { + std::lock_guard 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(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: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) { + g_timeout_add(static_cast(std::atoi(env)), search_probe, &app); + } +``` + +- [ ] **Step 12: Run the probe** + +```bash +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** + +```bash +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& 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: + +```cpp + static constexpr int kMeasureMarks = 6; +``` + +with: + +```cpp + 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: + +```cpp + std::array measure_h{}; + std::array measure_v{}; +``` + +with: + +```cpp + std::array measure_h{}; + std::array 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 search_v{}; +``` + +- [ ] **Step 2: Declare the three members** + +In `src/plot_host.hpp`, replace: + +```cpp + int measure_marks_placed() const noexcept { return measure_marks_placed_; } +``` + +with: + +```cpp + 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& 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: + +```cpp + int measure_marks_placed_ = 0; +``` + +with: + +```cpp + int measure_marks_placed_ = 0; + int search_marks_placed_ = 0; +``` + +- [ ] **Step 3: Add the colour** + +In `src/plot_host.cpp`, replace: + +```cpp +constexpr plot::u32 kTriggerColor = 0xFF5B5Bu; +``` + +with: + +```cpp +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: + +```cpp + r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor)); +``` + +with: + +```cpp + // 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(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: + +```cpp +void RowSet::set_search_marks(const std::vector& xs, int current) { + const int n = static_cast(xs.size()) < kSearchMarks ? static_cast(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(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(m)], kParked); + } + } +} +``` + +- [ ] **Step 6: Lock them against the pointer** + +In `src/plot_host.cpp`, replace: + +```cpp + for (const plot::u32 id : r.measure_v) { + if (r.plot->cursor(id).selectable) return false; + } +``` + +with: + +```cpp + 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: + +```cpp + GtkWidget* search_pos_label = nullptr; +``` + +with: + +```cpp + 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: + +```cpp +/// 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 xs; + int current = -1; + std::size_t in_view = 0; + const std::vector& 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> 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(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(RowSet::kSearchMarks); + xs.reserve(cap); + if (sel >= 0 && sel < static_cast(hits.size())) { + current = 0; + xs.push_back(hits[static_cast(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: + +```cpp + update_measures(*app, false); +``` + +with: + +```cpp + 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: + +```cpp + gtk_box_append(GTK_BOX(snav), app.search_next_button); + gtk_box_append(GTK_BOX(sbox), snav); +``` + +with: + +```cpp + 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: + +```cpp + 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: + +```cpp + 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: + +```bash +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** + +```bash +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: + +```markdown +### 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: + +```markdown +### 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: + +```markdown +### 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: + +```markdown +| `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: + +```markdown +- `--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: + +```markdown +## `[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: + +```markdown +- **`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: + +```markdown +- **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: + +```bash +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: + +```bash +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** + +```bash +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" +```