docs: CLAUDE.md is a goal and a table of contents, so a context pays for what it reads

The file was 1967 lines and is auto-loaded into every context, so every
session paid for the whole of it to reach one invariant. It now carries the
project's goal, the three facts that are always relevant, and a table of
contents; the body is fourteen files under docs/claude/ reached by link
rather than by import, so nothing is loaded until it is asked for.

The content is moved verbatim -- the only edits are each file's own header
and the cross-references that became cross-file ("see the invariant below"
is now a link to the file that owns it).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-07 01:49:28 +02:00
1 parent 0b5ace9585
commit a7702acb63
15 files changed
+2085 -1949

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
# 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), 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()`,
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. 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 `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).
* **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. 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.
* **`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.
- **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.