docs: the node editor's gestures, its new probe assertions and its invariants

Each invariant records what went wrong rather than only the rule: a port hit
that lost its node, a hit test at the identity view that asserts nothing, a
`Delete` that must not fall through to the selected node, and a column pitch
too narrow for a wire's own tread. The warning baseline drops to four.
This commit is contained in:
Martino Ferrari committed 2026-10-07 02:56:36 +02:00
1 parent 8918494acb
commit 300b33159e
5 files changed
+123 -34

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| File | What it covers | | File | What it covers |
| --- | --- | | --- | --- |
| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. | | [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. |
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly five build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. | | [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly four build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. | | [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. |
### How the application is put together ### How the application is put together
+15 -9
View File
@@ -703,18 +703,24 @@ is what makes branching — the sum of a raw and a filtered channel — expressi
The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge
and its output the square on its right. Click a box to select it and its parameters appear in the and its output the square on its right. Click a box to select it and its parameters appear in the
form beside the canvas; click an output square and then an input square to wire them; drag a box to form beside the canvas; **drag from an output square to an input square to wire them** (the old
move it. The graph is a *list* walked in order, so a node can only read the ones above it — a cycle two-click protocol is gone); drag a wired input square off its port to detach it; click a wire and
is not detected, it is unrepresentable, and an input square is only offered the nodes that qualify. press `Delete` to remove it; drag a box to move it. **Wheel zooms about the pointer, drag the
The node carrying the channel's value is the **last one** of a graph built top to bottom (as the background to pan, `f` to fit, `Ctrl+0` to reset.** `Tidy` in the toolbar lays out all nodes in
orange dot on the canvas says, and settable with *set as output*): it defaulted to the first node — topological columns. The op is chosen when the node is added — the picker appears at the `+` — so
the source — so every operation was computed and discarded, and a gain or a filter produced a there is no opless placeholder to build past. The graph is a *list* walked in order, so a node can
channel identical to its input. Numeric fields are seeded with their own identity values too, since only read the ones above it; a wire drawn right to left re-sorts the list silently, so the graph
stays evaluable without requiring the user to add nodes in order. A cycle is unrepresentable:
`math_connect()` checks the trial edge set with Kahn's algorithm before writing anything. The node
carrying the channel's value is the **last one** of a graph built top to bottom (as the orange dot
on the canvas says, and settable with *set as output*): it defaulted to the first node — the source
— so every operation was computed and discarded, and a gain or a filter produced a channel identical
to its input. Numeric fields are seeded with their own identity values too, since
`gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started `gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started
at -1e6, which turned `x*2` into `x*2 - 2000000`. at -1e6, which turned `x*2` into `x*2 - 2000000`.
Wiring is checked before the channel is created, and the reason is shown rather than the dialog A live validation line beneath the form says why the graph cannot be saved; Apply is insensitive
closing on the work. while it is non-empty. The two modal alert boxes that used to appear are gone.
A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled
signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The
+82 -9
View File
@@ -21,8 +21,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below
Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing. Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing.
The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its
parameters in the form beside it), click an output port then an input port to wire, drag to move; parameters in the form beside it), **drag from an output port to an input port to wire** (the old
no pan/zoom. Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, two-click protocol is gone), drag a box to move it; **wheel zooms about the pointer, background
drag pans, `f` fits, `Ctrl+0` resets**. Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`,
so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog so no rebuild can invalidate a live callback handle -- the same shape the measurements dialog
uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test uses. `UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
as much as a state check), and the dump prints each derived channel's extremes beside the as much as a state check), and the dump prints each derived channel's extremes beside the
@@ -36,8 +37,43 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing. `ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the `math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
anywhere in 78--101 ns/slot is one reading of the same thing. Four rules here were wrong first anywhere in 78--101 ns/slot is one reading of the same thing.
and are worth keeping: The probe also asserts the new editor mechanics. Each line carries `(want ...)` and the reading
taken from a real run is the reference:
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
output square straddles the box edge, so a hit test that recovers the node by a box test misses
every pixel past it. `past-edge node=-1` is the silent disarm that was "edges cannot be added".
* `[math-probe] cancel: dialog=cleared (want cleared)` — Cancel must `gtk_window_close()`, not
`gtk_window_destroy()`. The probe clicks the **button**, because the defect was in the button's
connection and a direct handler call cannot see it.
* `[math-probe] view: zoom scale=1.210 anchor kept dx=0.0000 dy=0.0000 (want 0.0000 0.0000)` —
a wheel zoom that does not keep the graph point under the pointer reads as the canvas sliding
away from the hand. Both deltas must be `0.0000`.
* `[math-probe] view: pan+zoom hit node=0 which=-1 (want 0 -1)` — hit tests go through
`math_to_graph()`. At the identity view a test that forgot the inverse passes anyway, so this
one is taken after a pan *and* a zoom.
* `[math-probe] view: fit scale=1.591 all_inside=1 (want 1)` and
`[math-probe] view: reset scale=1.000 offset=(0.0,0.0) (want 1.000 (0.0,0.0))`.
* `[math-probe] view: pan mode=3 dview=30.0 dnode=0.0 (want 3 30.0 0.0)` — a background drag
pans the view and moves no node.
* `[math-probe] wire: connect mode=2 in_a=0 (want 2 0)`,
`[math-probe] detach: emptied_at_begin=-1 in_a=-1 (want -1 -1)`,
`[math-probe] detach: escape restores in_a=0 (want 0)`.
* `[math-probe] wire: pick node=1 which=0 -> in_a=-1 (want 1 0 -1)`.
* `[math-probe] wire: backwards ok=1 order=[0:-1,1:0,2:1] output=2 valid=1 why=[] (want 1 ... 1 [])` —
the re-sort. `valid=1` is the assertion; the `order=` string is the reading that explains it.
* `[math-probe] wire: self refused=1 why=[...]` and
`[math-probe] wire: cycle refused=1 why=[...]` — and **nothing is written** on either.
* `[math-probe] validate: broken problem=[..] label=[..] apply=0; fixed problem=[] apply=1 (want non-empty 0; empty 1)`,
`[math-probe] validate: no-selection label=[..] (want non-empty)` — the label is written
**above** `math_dialog_sync()`'s early return; a stale message when there is no selection to
show is the failure mode.
* `[math-probe] validate: source list=6 acquired=6 signals=7 (want list==acquired)` — the
dropdown lists exactly the acquired channels, not all signals.
* `[math-probe] op pick: nodes 3->4 op_index=7 q_row=1 (want 4 7 1)` and
`[math-probe] op pick: index round trip=1 (want 1)`.
* `[math-probe] tidy: xs=[24,204,384,564] overlaps=0 (want 0)`.
Four rules here were wrong first and are worth keeping:
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the * **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
dialog's dropdown lists, **and both places that create a node must take one from there**. dialog's dropdown lists, **and both places that create a node must take one from there**.
`signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not `signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not
@@ -45,8 +81,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces
only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved
to what the form is showing, rather than the form showing one channel while the node holds to what the form is showing, rather than the form showing one channel while the node holds
another, and `Apply` refuses a source that is not acquired (the engine's validator is about the another, and while a source that is not acquired is in the graph the **live validation line is
graph -- an output, every input wired above it -- and cannot see the acquired list). non-empty and Apply is insensitive** (the engine's validator is about the graph -- an output,
every input wired above it -- and cannot see the acquired list).
* **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and * **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and
reads each one from the packet, so a math channel with no signal read *zero* and pushed it -- reads each one from the packet, so a math channel with no signal read *zero* and pushed it --
while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a
@@ -66,9 +103,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a * **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a
132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always 132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always
returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog
ignoring the pointer. Two columns spaced 160 px apart, rows 58. The probe prints the box the ignoring the pointer. The **add-node grid** places in two columns spaced 160 px apart, rows 58;
drag *began over* for exactly this reason. Offering the whole signal list let a graph name something nobody is the **tidy layout** uses a column pitch of `kNodeW + 48` (narrower), which is enough clearance
acquiring (no data for ever) and put the stream's timestamp signal, which has no samples, in it. for a wire's 40-unit horizontal tread — at 160 the bezier's control points cross and the wire
doubles back on itself. The probe prints the box the drag *began over* for exactly this reason.
Offering the whole signal list let a graph name something nobody is acquiring (no data for ever)
and put the stream's timestamp signal, which has no samples, in it.
* **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the * **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the
filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path
is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored* is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored*
@@ -92,6 +132,39 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began, gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began,
so adding each update's delta to a value the handler then writes back is a runaway box -- it so adding each update's delta to a value the handler then writes back is a runaway box -- it
must record the position at `drag-begin` and ignore a drag that did not begin on a box. must record the position at `drag-begin` and ignore a drag that did not begin on a box.
* **A port hit must name its own node.** The output square is drawn centred on `n.x + kNodeW`,
straddling the box's right edge, with a `±kPort` hit radius against a `±kPort*0.5` drawn
half-width — so recovering the node with `math_node_at()`, which matches only inside the box,
returns `-1` for every pixel past the edge. `math_port_at()` returns the node directly for
that reason.
* **Every hit test goes through `math_to_graph()`, and every tolerance is a screen distance
divided by `view_scale`.** A tolerance left in graph units makes a port unhittable zoomed out
and greedy zoomed in. **A probe that tests a hit at the identity view asserts nothing** — the
check has to be taken after a pan and a zoom.
* **A drag's mode is decided once, at `drag-begin`, and the precedence is port, box, background.**
Asking the box first makes the output square unreachable, which is the same geometry as the
bullet above.
* **A detach empties the input at `drag-begin`.** That is what makes a release over nothing the
detach gesture rather than a second verb, and it is why `Escape` has something to restore.
* **A backwards wire re-sorts the list; it is not refused.** The evaluator walks the nodes in
order, and until now that made the order the *user's* problem — an earlier node's input was not
even drawn as connectable. `math_connect()` runs Kahn's algorithm over the **trial** edge set,
so a cycle is found before anything is written, breaks ties by the current list index so a graph
already in order is not permuted, and remaps **`output`, `selected`, every `in_a`/`in_b`,
`drag_node`, `wire_from`, `wire_detach` and `selected_wire.node`** in one block. A missed index
silently renames an operation or re-points a wire. Its `edges_of` masks each input by the op's
**arity**: a node changed from binary to unary keeps a stale `in_b`, and counting it makes an
indegree that never reaches zero and so reports a loop that is not there.
* **The validation line is written above `math_dialog_sync()`'s early return.** An out-of-range
selection is exactly the state in which a stale message misleads most: the graph can still be
broken and there is no form to read instead.
* **`math_op_index()`, never a cast.** `math_op_at()`'s order is the dialog's — what a dropdown
lists and what a session file stores — and `static_cast<guint>(op)` worked only because the
two happen to agree today. The session-file rule in this file already says an op is written as
its index through `math_op_at()`; this is the other direction of the same rule.
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
A key whose target depends on an invisible selection state is how a graph gets destroyed by
accident; the `delete node` button stays the only way to remove a node.
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.** - **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression `push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
here looks like is `gaps`, never a frame time. Three things were taken off that path and must here looks like is `gaps`, never a frame time. Three things were taken off that path and must
+8 -6
View File
@@ -13,12 +13,14 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8000 ./build/udpscope --server 127.0.0.1 --port 8000
``` ```
Two baselines every run is read against. A clean build emits **five** warnings and no more: two Two baselines every run is read against. A clean build emits **four** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source`, one unused `output_built`, and `-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
`-Woverlength-strings` on **both** generated GResource `.c` files — the help pages' and the generated GResource `.c` files — the help pages' and the icons', whose blob crossed C99's
icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the 4095-byte string-literal limit when the two app icons joined the four action ones (a note about a
four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run generated file, which GCC itself supports). The fifth warning that used to appear here was an
emits **no** `Gtk-CRITICAL` at all. A sixth warning, or any critical, is a finding. unused `output_built` in `on_math_apply()` — Task 6's live validation check reads that value, so
the variable is no longer unused. And an exit-dump run emits **no** `Gtk-CRITICAL` at all. A
fifth warning, or any critical, is a finding.
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`, That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
+17 -9
View File
@@ -5,15 +5,22 @@ nodes, operations, one output — and udpscope then treats the result as an ordi
<b>How to reach it</b> <b>How to reach it</b>
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel; opening one The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel — a picker
shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside chooses the op — and opening one shows the graph editor: nodes as boxes, click a box to select it
the canvas, click an output port then an input port to wire them, drag a box to move it. The and edit its parameters beside the canvas.
orange dot marks the output node.
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <tt>Tidy</tt> in the
toolbar lays the graph out in topological columns. The orange dot marks the output node; click
<i>set as output</i> to move it.
<b>Worth knowing</b> <b>Worth knowing</b>
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A • The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
cycle is impossible by construction rather than rejected by a check. wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
unrepresentable: the connection is refused rather than created.
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted, • A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
measured, exported, and used as a source for another math channel. measured, exported, and used as a source for another math channel.
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in • The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
@@ -40,10 +47,11 @@ and the last node added becomes the output -- but a graph whose output is left o
valid and simply passes its input through, which is the usual reason a gain or a filter appears to valid and simply passes its input through, which is the usual reason a gain or a filter appears to
have no effect. <i>Set as output</i> moves the dot. have no effect. <i>Set as output</i> moves the dot.
<b>Save refuses an incomplete graph</b>, and says why: an input that is not connected, no output <b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph scope is not acquiring. Only the channels being acquired are offered, because a source that is not
that reads NaN is harder to diagnose than one that would not save. acquired reads as no data for ever — and a graph that reads NaN is harder to diagnose than one that
would not save.
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives, <b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is