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

+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
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
parameters in the form beside it), click an output port then an input port to wire, drag to move;
no pan/zoom. Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`,
parameters in the form beside it), **drag from an output port to an input port to wire** (the old
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
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
@@ -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.
`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
anywhere in 78--101 ns/slot is one reading of the same thing. Four rules here were wrong first
and are worth keeping:
anywhere in 78--101 ns/slot is one reading of the same thing.
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
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
@@ -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
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
another, and `Apply` refuses a source that is not acquired (the engine's validator is about the
graph -- an output, every input wired above it -- and cannot see the acquired list).
another, and while a source that is not acquired is in the graph the **live validation line is
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
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
@@ -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
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
ignoring the pointer. Two columns spaced 160 px apart, rows 58. 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.
ignoring the pointer. The **add-node grid** places in two columns spaced 160 px apart, rows 58;
the **tidy layout** uses a column pitch of `kNodeW + 48` (narrower), which is enough clearance
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
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*
@@ -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,
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.
* **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.**
`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