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:
1 parent
8918494acb
commit
300b33159e
5 files changed
+123
-34
No files matched your search
@@ -38,7 +38,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
|
||||
| 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. |
|
||||
| [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. |
|
||||
|
||||
### How the application is put together
|
||||
|
||||
@@ -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
|
||||
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
|
||||
move it. The graph is a *list* walked in order, so a node can only read the ones above it — a cycle
|
||||
is not detected, it is unrepresentable, and an input square is only offered the nodes that qualify.
|
||||
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
|
||||
form beside the canvas; **drag from an output square to an input square to wire them** (the old
|
||||
two-click protocol is gone); drag a wired input square off its port to detach it; click a wire and
|
||||
press `Delete` to remove it; drag a box to move it. **Wheel zooms about the pointer, drag the
|
||||
background to pan, `f` to fit, `Ctrl+0` to reset.** `Tidy` in the toolbar lays out all nodes in
|
||||
topological columns. The op is chosen when the node is added — the picker appears at the `+` — so
|
||||
there is no opless placeholder to build past. The graph is a *list* walked in order, so a node can
|
||||
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
|
||||
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
|
||||
closing on the work.
|
||||
A live validation line beneath the form says why the graph cannot be saved; Apply is insensitive
|
||||
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
|
||||
signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Two baselines every run is read against. A clean build emits **five** warnings and no more: two
|
||||
`-Wmissing-field-initializers` on `MathNode::source`, one unused `output_built`, and
|
||||
`-Woverlength-strings` on **both** generated GResource `.c` files — the help pages' and the
|
||||
icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the
|
||||
four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run
|
||||
emits **no** `Gtk-CRITICAL` at all. A sixth warning, or any critical, is a finding.
|
||||
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
|
||||
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
|
||||
generated GResource `.c` files — the help pages' and the icons', whose blob crossed C99's
|
||||
4095-byte string-literal limit when the two app icons joined the four action ones (a note about a
|
||||
generated file, which GCC itself supports). The fifth warning that used to appear here was an
|
||||
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`,
|
||||
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
||||
|
||||
+17
-9
@@ -5,15 +5,22 @@ nodes, operations, one output — and udpscope then treats the result as an ordi
|
||||
|
||||
<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
|
||||
shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
|
||||
the canvas, click an output port then an input port to wire them, drag a box to move it. The
|
||||
orange dot marks the output node.
|
||||
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel — a picker
|
||||
chooses the op — and opening one shows the graph editor: nodes as boxes, click a box to select it
|
||||
and edit its parameters beside the canvas.
|
||||
|
||||
<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>
|
||||
|
||||
• 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,
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being
|
||||
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph
|
||||
that reads NaN is harder to diagnose than one that would not save.
|
||||
<b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
|
||||
an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
|
||||
scope is not acquiring. Only the channels being acquired are offered, because a source that is not
|
||||
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,
|
||||
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
|
||||
|
||||
Reference in new issue
Block a user