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

@@ -0,0 +1,73 @@
# Invariants: readouts, cursors and the value formatter
What a readout is allowed to quote, how the cursors are read back, and the one formatter both panes share.
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 readout quotes a stored sample, never a value between two of them.** The hover
crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it
reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream
interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar
average* over `time_stride` samples, so a number between two stored samples is a second
estimate laid over the first that nothing on screen or in an export holds; and past the sample
spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The
change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host
whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and
**udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a
regression, not a feature.
**Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns
the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is
*snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's
`mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not
`crosshair_value()`) are both placed from it. Two consequences that look like defects and are
not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the
dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own
vertical cursor line** — up to half a sample period, invisible at a whole-window view and the
width of the panel at a few samples across, which is the only zoom at which anyone is asking
which point it is. The line says where the *marker* is; the dot says which *datum* is quoted.
The hover mark is also deliberately *larger* than a trace's own sample dots
(`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them
and at their size reads as one more sample. The guards are the library's
`crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`,
`crosshair_interpolated_mode_blends_the_two_samples` and
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
y=..` agreeing with the pane's first value.
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse —
and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal
whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not
see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes
**`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching
this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against
the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
that range falls back to `%.3g`.
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
every frame therefore adopts its own echo back, and -- because the vertical pair is one value
shared by every row -- a row that has not been re-written yet hands the *previous* shared
time straight back. That is the "drag it and it springs back" defect, and the fix is
`RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports
`grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed
back with `CursorPlacement::Free` for the same reason -- the controller snaps at the end of
the gesture (`Controller::end_drag()`), and snapping the echo fights the hand that is still
moving the cursor.
- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one
piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something
`insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is
how a row added while the cursors were already on came up with its instruments parked
while every other row showed them. The same goes for the vertical pair's `x`: it is one
stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is
genuinely per row (the rulers' values, the traces, the colours).
- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and
the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor
is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside
the axis is not enough**: the controller holds a marker for the gesture's life and follows the
pointer between frames, so a readout parked at `kParked` could still be dragged. The trigger
marker says where the scope put the edge and a measurement mark says what a measurement measured;
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.