diff --git a/CLAUDE.md b/CLAUDE.md index 41e5188..cfb1ab0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 26a899d..b2b16a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/claude/invariants-math-channels.md b/docs/claude/invariants-math-channels.md index 7af2b62..d264663 100644 --- a/docs/claude/invariants-math-channels.md +++ b/docs/claude/invariants-math-channels.md @@ -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 '' ... 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(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 diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 2687e58..01470a1 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -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 diff --git a/src/help/11-math.txt b/src/help/11-math.txt index c32615a..5254348 100644 --- a/src/help/11-math.txt +++ b/src/help/11-math.txt @@ -5,15 +5,22 @@ nodes, operations, one output — and udpscope then treats the result as an ordi How to reach it -The math button in the toolbar, or g. The pane's + 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 g. The pane's + 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. + +Graph gestures: drag 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 Delete to remove +it; drag a box to move it. Wheel zooms about the pointer. Drag the background to +pan. f fits all nodes into view; Ctrl+0 resets the view. Tidy in the +toolbar lays the graph out in topological columns. The orange dot marks the output node; click +set as output to move it. Worth knowing • The graph is evaluated as an ordered list, so a node may only read ones above 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 is 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 stored 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. Set as output moves the dot. -Save refuses an incomplete graph, and says why: an input that is not connected, no output -chosen, or a channel 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. +Apply is insensitive while the graph is incomplete. A line beneath the form says why: +an input that is not connected, no output chosen, or a channel 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. The filters are real filters. 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