Moving the editor's frame into math-editor.ui left `math_body` and `math_actions` as <child> elements of the *window* rather than of `math_outer`, because those edits were anchored on the file's tail and closed the outer box two elements early. A GtkWindow takes exactly one default <child>, so nothing failed loudly: the body -- with the canvas and the parameter form inside it -- was left parentless, visible but never mapped, 0x0, drawing nothing. Two symptoms, one cause: the canvas is blank, and the window is sized around the head row and the buttons alone. Measured against the pre-move revision in a worktree, same probe, same environment: before this fix `canvas=0x0 mapped=0 window=756x940`; after it `canvas=420x836 mapped=1 window=938x940`, which is the old revision's own reading exactly. It survived because that slice's probe read the editor's *values* (nodes, gain, output, name, unit) and nothing about its structure. Every other panel moved that day has a structural reading -- `controls_inside=13/13`, `nested=1`, `labels=2 list=1` -- and none of them was affected. `[math-probe] editor frame:` is that reading now: the canvas's allocation, its mapped state, and whether the canvas and the name entry are inside the dialog at all, delayed 500 ms because an allocation only exists after a layout. In the broken build it read `0x0 mapped=0 in_dialog=0`. check_ui passes both files: the XML was valid and every property type-checked.
260 lines
22 KiB
Markdown
260 lines
22 KiB
Markdown
# Invariants: the math channels and the node editor
|
|
|
|
A derived channel is an ordinary channel; the engine runs on the producer thread, so its cost is measured in packets.
|
|
|
|
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
|
|
|
- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic*
|
|
`SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch,
|
|
the plot binding, the measurements and the export all handle a derived channel without knowing it
|
|
is derived -- and another math channel is a valid `Source` for exactly the same reason. The
|
|
*samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the
|
|
slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred
|
|
latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample
|
|
pushed later would be missing from the capture's newest slot). The math therefore runs on the
|
|
*stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() *
|
|
time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at.
|
|
`src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node
|
|
may only read ones *before* it and a cycle is impossible by construction rather than by a check
|
|
(`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style
|
|
checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002;
|
|
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), **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
|
|
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
|
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
|
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
|
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
|
(order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is
|
|
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
|
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
|
`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.
|
|
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
|
|
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
|
|
channel being edited, so a row that stopped being the one the code writes shows there.
|
|
`editor frame:` is the same idea one level down: it reads the canvas's allocation, whether that canvas is
|
|
*mapped*, and whether the canvas and the name entry are inside the dialog at all -- delayed by 500 ms, because
|
|
an allocation only exists after a layout. A canvas that is visible but parentless never maps and stays `0x0`,
|
|
and reading values off the widgets *inside* the editor cannot see that. It is the reading that catches
|
|
`math_body` written as a child of the window instead of the box above it. 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 off_sample=8.4 tol=3.0 (want 1 0 -1,
|
|
off_sample > tol)` — the pick is taken at `t = 0.52`, **between** samples 12 and 13 of the 24 the
|
|
curve is walked at, on a wire whose two boxes are several rows apart. `off_sample` is the measured
|
|
distance from the pick to the nearest sample point and `tol` the pick radius in the same units:
|
|
with `off_sample` the larger, a hit test that measured to the sample *points* rather than to the
|
|
*segments* between them would miss. Taken at `t = 0.5` — exactly sample 12 — the line passed for
|
|
both implementations and asserted nothing, the same mistake as a hit test taken at the identity
|
|
view.
|
|
* `[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=[a node cannot read itself]` and
|
|
`[math-probe] wire: cycle refused=1 why=[that wire would make a loop]` — and **nothing is
|
|
written** on either. Both call `math_connect()` directly, so both assert its out-parameter and
|
|
neither says anything about the UI.
|
|
* `[math-probe] wire: self through drag-end node=1 problem=[a node cannot read itself] (want the
|
|
refusal text, not empty)` — the other half, and the one that matters: the refusal has to reach
|
|
the **validation line**. The UI path used to screen the self case out before `math_connect()` and
|
|
then drop its `why` on the floor, so the user drew a wire, nothing appeared and nothing was said
|
|
while the two lines above passed. No synthetic GTK gesture is reachable headlessly, so the probe
|
|
sets `drag_mode`/`wire_from`/`press_cx`/`press_cy` and calls `math_canvas_drag_end()` — which is
|
|
still the handler under test. An empty `problem=[]` here is also how the sync **re-entrancy** was
|
|
found: setting the op dropdown emits `notify::selected`, whose handler syncs, so the form's own
|
|
write re-entered `math_dialog_sync()` and the inner call rewrote the label the outer had just
|
|
written. `MathDialog::syncing` guards it.
|
|
* `[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] add node: after a delete cells=[(24,20) (24,78) (184,78) (184,20)] overlaps=0
|
|
(want 0)` — the boxes are put back on the grid, the middle one deleted and one added. The new box
|
|
takes cell 1, `(184,20)`, which the deletion freed; placing by `nodes.size()` would have put it on
|
|
`(184,78)`, which is occupied, and an overlapped box is an unclickable one. `overlaps` is the
|
|
assertion, `cells` the reading that explains it.
|
|
* `[math-probe] tidy: xs=[24,24,204,384] overlaps=0 (want 0)` — four nodes in three depth columns
|
|
(the delete above left two roots), so two share the first column and the x values repeat. Only
|
|
`overlaps=0` is the assertion.
|
|
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
|
|
acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's
|
|
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 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
|
|
ring whose time ring advances once: the values alternated between the real one and 0, and once
|
|
both rings wrapped the value ring had drifted a whole window off the time ring, so the derived
|
|
trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on
|
|
it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one
|
|
push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly
|
|
this: the arithmetic on the plot can look plausible long after the pairing is wrong.
|
|
* **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared
|
|
by a `close-request` handler, so a Cancel that *destroyed* the window left `app.math_dialog`
|
|
pointing at a dead widget: every later `open_math_dialog()` took the "already open" branch and
|
|
presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel
|
|
again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear
|
|
the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic
|
|
destroy is not a user close, and only one of them runs the handlers.
|
|
* **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. The **add-node grid** places in two columns spaced 160 px apart, rows 58,
|
|
and `math_add_node()` takes the **first free cell** rather than cell `nodes.size()`: placing by
|
|
the count reuses an occupied cell as soon as anything has been deleted — delete the middle of
|
|
three and add one and the new box lands exactly on an existing one. The free-cell search uses the
|
|
same overlap test the tidy probe asserts with, and falls through to cell `nodes.size()` after 64
|
|
cells rather than searching for ever. The **tidy layout** uses a column pitch of `kNodeW + 48` =
|
|
180, which is *wider* than that 160 and is what gives a wire's 40-unit horizontal tread its
|
|
clearance — 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*
|
|
interval and the filter's state is the old rate's history, so both are rebuilt together.
|
|
Preparing only on an edit left the cutoff wrong and the state ringing after any window change --
|
|
that is what "I tested a low pass and it was a mess" was.
|
|
* **The graph's output defaults to the *last* node, and a node added to a linear graph becomes
|
|
it.** The output began at index 0 -- the *source* -- so every operation a user built was
|
|
computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its
|
|
input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen
|
|
deliberately is kept; this is the default for a graph that has never been given a choice.
|
|
* **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset
|
|
field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as
|
|
`x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum
|
|
default is never a useful starting point for a gain or a corner frequency.
|
|
* **The form's rows are grouped per *op*, not per field**: gain and offset are one group because
|
|
`gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as
|
|
a property of every node.
|
|
* **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits
|
|
(4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a
|
|
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.
|
|
* **The line is written from `math_dialog_sync()` and from nowhere else, it ellipsizes, and its
|
|
tooltip carries the full text.** Three consequences. (1) A *refusal* — "a node cannot read
|
|
itself", a cycle — is not a state `math_dialog_problem()` can describe, since it is a thing that
|
|
did not happen, so `drag-end` leaves it on `MathDialog::refusal`, which the sync shows in
|
|
preference to the graph's own problem and **consumes as it writes**: the message lasts until the
|
|
next sync, and every mutation syncs. `Apply`'s sensitivity still follows the graph alone, because
|
|
a refused wire wrote nothing. (2) The label **ellipsizes rather than wraps**, and the tooltip is
|
|
set beside the text so the two cannot disagree — layout must not depend on content, and this
|
|
label sits in a dialog whose canvas is `hexpand`. (3) The sync is **not re-entrant**
|
|
(`MathDialog::syncing`): writing the op dropdown emits `notify::selected`, whose handler syncs,
|
|
and the inner call rewrote the label the outer one had just written.
|
|
* **`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
|
|
not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member
|
|
`math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a
|
|
linear scan with a `std::string` compare per candidate, per node, per slot, behind a
|
|
type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild
|
|
re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the
|
|
measured figure: a `lock xadd` costs about what a two-node graph's whole arithmetic does, so
|
|
counting the slots cost more than the work being counted. The counters are producer-written
|
|
only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and
|
|
friends). Two things about the figure itself:
|
|
* **It is sampled, one slot in `kMathCostSample`, and it is an upper bound.** A slot's work is
|
|
the same order as one `clock_gettime`, so the measured slot carries a clock read. Timing a
|
|
*run* of consecutive slots to amortise that away **is wrong and was tried**: the producer gets
|
|
its slots a packet at a time, so a run long enough to be worth timing straddles a packet
|
|
boundary now and then and charges the measured slots the **wait for the next packet**. At 2000
|
|
samples a packet a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64
|
|
slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet.
|
|
* **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot
|
|
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
|
|
here. Converting `MathRuntime` from eight parallel `std::vector<double>` to one array of
|
|
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
|
|
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
|
|
argument to pay at this size.
|
|
|
|
## A gap is a gap in a filter's output, never the end of its state
|
|
|
|
A reserved hole is NaN in the source channel, and a biquad that integrates it poisons `z1`/`z2`
|
|
for ever: every later sample comes out NaN, so a filtered trace died at the first drop and never
|
|
came back. Relative time made it systematic — a counter gap reserves NaN slots by construction,
|
|
where a stream-time gap is often absorbed by the stamps — but EMI caused it in both modes, and
|
|
the reading was unambiguous: the memoryless channels held 95% finite samples while every
|
|
`LowPass`/`HighPass`/`BandPass` channel held `finite=0`.
|
|
|
|
`biquad_step()` therefore returns NaN **without touching the state words** when its input is not
|
|
finite. The hole stays visible in the filtered trace (the gap is the truth about the data), the
|
|
filter's history remains the last *real* samples it saw, and the trace resumes after the hole
|
|
with at most the transient a re-start costs. The exit dump's `math '<name>': finite=N/M` line is
|
|
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
|
|
`hole_slots` is this defect returned.
|