Skip to content

feat: TEMPO MODE is the device tempo block's parameter 1 - #22

Merged
jonathanstokes merged 4 commits into
mainfrom
claude/dreamy-gates-603621
Aug 13, 2026
Merged

feat: TEMPO MODE is the device tempo block's parameter 1#22
jonathanstokes merged 4 commits into
mainfrom
claude/dreamy-gates-603621

Conversation

@jonathanstokes

Copy link
Copy Markdown
Contributor

What this is

The Tempo menu's GLOBAL/PRESET switch is readable and writable. It is the device tempo block's parameter 1, carried in GlobalTempo.params: 0.0 PRESET, 1.0 GLOBAL.

from pyquadcortex.protocol import TempoMode

qc.tempo_mode()                        # TempoMode.PRESET
qc.set_tempo_mode(TempoMode.GLOBAL)    # every preset now runs on the device's tempo

This closes the first bullet of domain-model.md §13 and the TEMPO MODE dependency on #8. It was a prerequisite of M3's device-settings work; it never gated M1, and nothing user-visible ships at M1 as a result.

Why the old answer was wrong

Three tests had watched for a broadcast when the switch moves and seen nothing. That negative holds - the unit genuinely never announces the switch - and for eight releases, 0.33.0 through 0.40.0, the docs carried it as "not on the wire". Every one of those tests listened, and none of them asked.

A second, subtler failure kept it hidden after the first was spotted. GlobalTempo had been the leading candidate and had been READ once; the reply carried only a running clock, and that went down as a dead end. GlobalTempo alternates two shapes, one push each - clock, params, clock, params - and that READ landed on the clock. The candidate was right; the instrument sampled once, and one sample of an alternating source is a coin flip.

How it was found

By diffing, not hunting. Earlier attempts looked for the field they expected (GeneralSettings, BinaryPreset.tempo) in the messages they expected. MODE was one index away inside a message shape already written off, so looking harder in the chosen places would never have found it.

tests/hardware/state_snapshot.py READs twelve state types, taps the RX path for a window, and flattens every SET field of everything that arrives to path -> value - including field numbers the recovered schema does not know, since GeneralSettingsMessage uses 1-39 with no gaps and anything new there would decode to nothing. Across PRESET -> GLOBAL -> PRESET exactly one field moved, and moved back; the return capture differed from the baseline in nothing at all.

Evidence

Three confirmations, because one flip in one direction is what produced this project's last false result:

  1. Wire - 0.0 -> 1.0 -> 0.0, nothing else in the device's readable state moving either way.
  2. The unit's own screen - a host write moved the menu's switch and the restore moved it back, watched at the unit.
  3. The tempo in effect - 111 bpm under PRESET with the preset block holding 0.355, 120 under GLOBAL with the device block holding 0.400.

A host write left the preset's own parameter 1 at 0.0, measured before and after, so the write's scope is known rather than assumed. That is the failure ADR-0007 named: the device accepts a write it does not understand and says nothing, so a guess and a success are indistinguishable.

Negatives recorded too, since they are results: GeneralSettings identical in both positions, no unknown field number in any message, BinaryPreset.tempo absent in both.

Second finding: TEMPO's span is 40 to 240 bpm

protocol.md listed this span as not recoverable, because the catalog publishes a placeholder range for it. Measured off the screen instead, each point exact to the displayed integer: 59 bpm at 0.095, 111 at 0.355, 120 at 0.400. So bpm = 40 + 200 * value.

set_tempo_param("TEMPO", real=120) now takes bpm, via new tempo_bpm() / bpm_to_tempo() helpers - the same shape as lane_level_db(). The 59 is what earns the fit: 111 and 120 sit 9 bpm apart, and two close points cannot distinguish spans, which is exactly how the lane levels carried a wrong span for two releases. The endpoints are the fit's rather than driven, and they agree with the unit's documented range.

ADR-0008

ADR-0007's rule is unchanged: a control we understand but cannot drive is modelled and refuses. What changes is what has to happen first - a differential state capture before a control is recorded as having no wire path. "No broadcast was observed" is not a finding about a wire path.

ADR-0007 now has no instance, which is the healthy state for it.

Tests

  • tests/test_state_snapshot.py (new) proves offline that the harness can see the three things it must not miss: an unknown field number, a presence-tracked field set to zero, and a value appearing in only one of two message shapes. Each is a way this question could have come back falsely negative again.
  • tests/test_client.py pins the exact wire shape of both new operations. One test feeds the reader's predicate a clock-shaped GlobalTempo and fails if it accepts it - that acceptance is what manufactured the original dead end.
  • tests/hardware/test_tempo_mode.py (new) drives the shipped methods, holds the written value long enough to be seen at the unit, and asserts the preset's block did not move. State-neutral per ADR-0005.

530 passed offline; hardware suite run against d14e / CorOS 4.0.1.

Left open, on purpose

The device block carries 25 parameters to the preset's 24, and the catalog describes 23. Index 24 is device-only and described nowhere. It held 0.0 through a MODE flip in both directions and through a preset tempo edit that moved seven other parameters, so it is not MODE and it is not preset-tempo-related. Recorded as unattributed rather than quietly dropped.

The Tempo menu's GLOBAL/PRESET switch is readable and writable:
tempo_mode() / set_tempo_mode(), on GlobalTempo.params[1] with 0.0 PRESET
and 1.0 GLOBAL. The unit keeps two tempo blocks at once - the preset's in
tempoProgramData and the device's in GlobalTempo.params - and MODE picks
which one plays, touching neither.

Three tests had established that the unit never BROADCASTS the switch,
which the docs carried for eight releases as "not on the wire". The
silence is real; the inference was not. Every one of those tests
listened, and none asked.

Found by diffing rather than hunting: every set field of every message
the device answers, captured in each switch position, including field
numbers the recovered schema does not know. Exactly one field moved, and
moved back. Earlier attempts looked in GeneralSettings and in the preset,
and MODE was one index away inside a GlobalTempo shape a single earlier
READ had written off after landing on its clock shape rather than its
params shape.

Confirmed on the wire, on the unit's own screen, and by the tempo in
effect. A host write left the preset's own parameter 1 untouched, so the
scope is known rather than assumed - the failure ADR-0007 names.

Second finding from the same session: TEMPO's span is 40..240 bpm, three
screen-vs-wire points each exact to the displayed integer (59 at 0.095,
111 at 0.355, 120 at 0.400). set_tempo_param("TEMPO", real=) now takes
bpm via tempo_bpm() / bpm_to_tempo(), so protocol.md's list of
unrecoverable placeholder spans is down to two of four.

ADR-0008: a control gets a differential state capture before it is
recorded as having no wire path. ADR-0007's rule is unchanged and now has
no instance. tests/hardware/state_snapshot.py is the harness, with
tests/test_state_snapshot.py proving offline that it can see an unknown
field number, a presence-tracked zero, and a value present in only one of
two message shapes.
The two that changed technical content:

MODE is not "never broadcast". The device emits no CHANGE EVENT when the
switch moves - which is all three earlier tests ever measured - but the
value itself rides the ambient GlobalTempo params push, twice per
14-second window against 63 clock pushes. So a state tracker CAN follow
it, and docs/api.md said the opposite, which is the sentence M3's cache
design would have been built on. Worse and sharper: that push is the
first thing capture.md's own listener recipe filters out by TYPE, so the
answer was very probably discarded by the instrument three times. The
recipe now filters the clock SHAPE instead, and carries the lesson that a
noise filter is an unchecked claim that a type cannot hold the answer.

tempo_mode() read positionally while the device keys by index. Checked
against the captures: the unit sets index on all 25 params, and there it
equals position - so the read was right by luck and would return a
neighbouring tempo parameter from a sparse push. Neighbours are 0.0/1.0
floats too, so the wrong answer would have rounded cleanly. Now prefers
the explicit index, same fallback as set_block.echoes_cell. It also read
.float_value off a REAL oneof without checking the member, and rounded
anything to an enum - 0.4 answered PRESET. Both closed; out-of-range now
raises quoting the value, matching beats()'s policy.

The test that was supposed to prevent all this did not: gutting the
predicate to "any params at all" left it green. Mutation-checked three
ways now, and the index case was rebuilt after the first version passed
under mutation too. _Tap and capture() had no tests at all despite being
the producer the offline diff tests assume; they have two now.

Harness: _is_noise matched substrings, so "meter" inside "parameters"
buried every GlobalEQ param and "position" buried SetlistPosition - the
field that reveals the operator changed preset and invalidated the
comparison. Matched on whole segments now. The capture asserts the
params shape actually arrived BEFORE writing the file, since a blind
capture would diff to "nothing differed" - verbatim the wrong answer this
exists to overturn. The diff asserts, refuses stale cross-session pairs,
and the operator-driven capture skips rather than failing, so the
readme's own invocation can be green again. The restore reads back,
because MODE is global and survives a recall.

Docs: the placeholder-span accounting named a denominator matching
nothing (8 parameters, 2 spans measured, splitter FREQUENCY still open);
"none of them is a tap" over-claimed against 2 unattributed indices; the
40..240 endpoints are the fit's and three surfaces had dropped that
caveat; "every preset is affected" and the audibility claim are labelled
as inferences.

Verified: 538 offline. The reworked reader was replayed against all
three real captured pushes and returns PRESET/GLOBAL/PRESET correctly.
The live hardware suite could NOT be re-run - the unit dropped off USB
- so the wire shapes remain hardware-verified from the original run
(set_tempo_mode's bytes are unchanged and pinned offline) but the
reworked hardware test itself is unrun.
@jonathanstokes

Copy link
Copy Markdown
Contributor Author

Self-review before opening this up

Ran four reviewers over the diff (correctness, silent failures, tests, docs/comments) and fixed 24 findings in d03c0b4. Two of them changed technical content rather than wording, so they're worth reading before the rest of the PR.

1. "The device never broadcasts this switch" was wrong

What the three historical tests measured is that the unit emits no change event when the switch moves. The value is a different matter: it rides the ambient GlobalTempo params push, which arrived twice per 14-second window in each capture (against 63 clock-shaped pushes in the same window).

So a state tracker can follow this field from pushes. docs/api.md said it couldn't, and that's the sentence M3's cache design would have been built on.

Sharper still: that params push is the first thing capture.md's own listener recipe filters out, by type, because GlobalTempoMessage is the heaviest chatter on the link. A 420-second run should have caught params[1] flipping. The most likely account of all three historical runs is not that the device stayed silent but that the instrument discarded the answer before it reached the log — three times, using a filter this repo recommends. capture.md now filters the clock shape instead of the type, and carries the general lesson: a noise filter is an unchecked claim that a message type cannot hold the answer.

2. The reader was right by luck

tempo_mode() read params[1] by position. Checked against the captures: the device sets index explicitly on all 25 params, and it happens to equal position on this firmware. A sparse or reordered push would have returned a neighbouring tempo parameter — and the neighbours are 0.0/1.0 floats too (LED LIGHT, START), so the wrong answer would have rounded cleanly and looked valid. It now prefers the explicit index with a positional fallback, the same pattern as set_block.echoes_cell.

Two more in the same read path: it took .float_value off a real oneof without checking which member was set (an int-valued param would have reported PRESET with total confidence), and it rounded anything to an enum — 0.4 answered PRESET. Out-of-range now raises quoting the observed value, matching the policy beats() already sets.

3. The test meant to prevent exactly this didn't

test_tempo_mode_skips_the_clock_shaped_push claimed "the predicate is the whole instrument, so it is pinned here." Gutting the predicate to len(m.params) > 0 left it green — all three of its cases were separated by "params non-empty" alone. It's mutation-checked three ways now.

Worth admitting: the replacement index test also passed under mutation on the first attempt, because the sparse message I built gave the same answer either way. Rebuilt so the two readings disagree.

_Tap and capture() had no tests at all, despite being the producer whose output shape every offline diff test assumes. Two now.

Other fixes

  • _is_noise matched substrings: "meter" inside "parameters" buried every GlobalEQ parameter, and "position" buried SetlistPosition.position — the field that would reveal the operator changed preset between captures and invalidated the comparison. Whole path segments now.
  • The capture asserts the params shape actually arrived before writing the file. A blind capture would otherwise pass, persist, and diff to "nothing differed" — verbatim the wrong answer this harness exists to overturn.
  • test_diff_captured_snapshots asserted nothing; now it does, and refuses stale cross-session pairs.
  • QC_SNAPSHOT_LABEL made pytest tests/hardware --hardware permanently red. The operator-driven capture skips instead, with the regression test always running.
  • The restore reads back. MODE is global and survives a preset recall, so a silently failed restore leaves the unit changed for good.
  • Docs: the placeholder-span accounting cited a denominator matching nothing (8 parameters, 2 spans measured, splitter FREQUENCY still open); "none of them is a tap" over-claimed against 2 unattributed indices; three surfaces stated 40..240 as measured when the endpoints are the fit's; "every preset is affected" and the audibility claim are now labelled as inferences.

One reviewer finding I rejected

The "~7 seconds" cadence was called a misreading of fingerprint-deduped shape variety. It isn't — the harness records arrival count, and the params shape genuinely arrived twice per 14 s in all three captures. The cadence is measured. What was fair is that the test printed distinct-shape count; that print now reports arrivals.

Verification status — one gap

538 offline. The reworked reader was replayed against all three real captured device pushes and returns PRESET / GLOBAL / PRESET correctly, with the shipped predicate accepting each.

The live hardware suite could not be re-run: the unit dropped off USB mid-session. set_tempo_mode's bytes are unchanged from the run that was confirmed on the unit's own screen, and they're pinned offline — but the reworked hardware test itself is unrun, and the read path's changes are verified against captured data rather than a live device. Worth a hardware pass before merge.

@jonathanstokes
jonathanstokes marked this pull request as ready for review August 13, 2026 04:21
Caught on hardware. A restore wrote PRESET, waited 3 s, read back
GLOBAL - and the write had in fact landed: four reads two seconds apart
afterwards all said PRESET.

tempo_mode() cannot correlate its reply, because this message type never
echoes request_id, so it returns the next AMBIENT params push. That
shape arrives about every seven seconds, so a 3 s settle can hand back a
push generated before the write. SETTLE_SECONDS was 3.0 and the hardware
test had been passing on luck. Now 10.0, and the docstring and api.md
say the requirement in terms of the interval rather than "a moment".

Also from the same run: free_storage_size_kb drifts between captures 30 s
apart with nothing touching storage, so it joins NOISE_FIELDS - named
from the wire this time. The earlier list guessed "available_disk_space",
which is a real field on GeneralSettings but not the one that moves.

Hardware, on the reworked code: the regression test passes, a
self-driven capture pair (set_tempo_mode between them, no operator at
the touchscreen) diffs to exactly params[1] 1.0 -> 0.0, and the unit is
left in PRESET where it started. The params shape arrived 2x per
14 s window again on a fresh session, which is the third independent
measurement of that cadence.
@jonathanstokes

Copy link
Copy Markdown
Contributor Author

Hardware verification gap closed — and it found one more bug

The unit came back. Everything in the review-fix commit is now verified on d14e / CorOS 4.0.1, and the run turned up a real defect that the offline suite could not have caught.

The bug: the settle was too short, and the test was passing on luck

A restore wrote PRESET, waited 3 seconds, read back GLOBAL — and the write had in fact landed. Four reads two seconds apart afterwards all said PRESET.

tempo_mode() cannot correlate its reply, because this message type never echoes request_id. So it returns the next ambient params push, and that shape arrives only about every seven seconds. A 3-second settle can hand back a push generated before the write.

SETTLE_SECONDS was 3.0. test_tempo_mode_is_writable had been passing because the timing happened to fall the right way. It is 10.0 now, and tempo_mode()'s docstring and docs/api.md state the requirement in terms of the measured interval instead of the earlier, wrong "allow a second or two".

This is the exact hazard the previous commit added to the docs. Writing it down did not stop me from getting it wrong two hours later in the restore path — which is a decent argument for the read-back assertion in the restore, since that is what caught it.

Verified on hardware

  • test_tempo_mode_is_writable passes with the reworked reader: PRESET → wrote GLOBAL → read back GLOBAL, preset tempoProgramData param 1 unmoved at 0.0, restored to PRESET with the read-back confirming it.
  • The full harness, driven end to end with no operator at the touchscreen. Since MODE is confirmed writable, the capture pair can now flip itself: capture in PRESET, set_tempo_mode(GLOBAL), capture again, diff. Result:
=== global -> preset ===
--- 2 field(s) moved ---
  GeneralSettingsMessage.storage_info.free_storage_size_kb: 25522400 -> 25522404
  GlobalTempoMessage.params[1].param_values[0].float_value: 1.0 -> 0.0

The answer, found again, this time by the shipped API rather than by hand.

  • The reworked reader on real pushes: replayed against all three original captures, returns PRESET / GLOBAL / PRESET, with the shipped predicate accepting each.
  • State-neutral: the unit is in PRESET, where it started.

Two smaller things the run produced

free_storage_size_kb drifts between captures 30 seconds apart with nothing touching storage, so it joins NOISE_FIELDS — named from the wire this time. The earlier list guessed available_disk_space, which is a real field on GeneralSettings but not the one that actually moves. That is the same substring-guessing habit the "meter"/"parameters" collision came from.

And the params shape arrived 2x per 14-second window again on a fresh session — a third independent measurement of the cadence a reviewer had challenged as an artifact of shape deduplication.

538 offline, hardware suite green. No known verification gaps left.

Two text conflicts and one substantive one.

ADR numbering: main took 0008 (grpcio-tools floor) and 0009 (persistent
listeners), so this branch's record renumbers to ADR-0010. The lifecycle
is append-only, and theirs landed first. Every reference that meant this
branch's ADR moved with it; the grpcio ones are left alone.

The substantive conflict is that PR #20 shipped exactly the hook this
branch's harness needed. state_snapshot._Tap monkey-patched
transport._dispatch, reaching past the public surface and unhookable
out of order; it now subscribes through Transport.add_listener
(ADR-0009). Verified the two are equivalent rather than assuming it: run
side by side against the same 14-second window they saw the identical
478 messages, the same 23 types, in the same order.

docs/capture.md's listener recipe monkey-patched _dispatch too, and it
is the document that teaches this. Rewritten onto add_listener, with the
two rules that come with a listener - do not block, do not read from the
device - both of which the transport now enforces.

That comparison turned up a real defect. A capture's type coverage
varies run to run, because the handshake's reply burst lands lazily (the
File enumeration takes 10-25 s) and a fixed window catches a different
tail each time: two runs here saw 23 types and 12. diff() was rendering
a type present in one snapshot and absent from the other field by field
as "<absent> -> value", which reads exactly like a discovery. It now
reports one line in the noise bucket naming what actually happened.

Hardware, after the merge: the capture and the write regression both
pass through the listener-based harness, and the unit is left in PRESET.
557 offline.
@jonathanstokes

Copy link
Copy Markdown
Contributor Author

Merged main (PR #19, #20). Two text conflicts, one substantive one, and a bug the merge exposed.

CI green, hardware re-verified after the merge.

ADR numbering

main took ADR-0008 (grpcio-tools floor) and ADR-0009 (persistent listeners). The lifecycle is append-only and theirs landed first, so this branch's record renumbers to ADR-0010. Every reference that meant this branch's ADR moved with it; the grpcio ones are untouched.

The substantive one: PR #20 shipped exactly the hook this branch needed

state_snapshot._Tap monkey-patched transport._dispatch — reaching past the public surface, and unhookable if two taps were torn down out of order. It now subscribes through Transport.add_listener (ADR-0009).

I verified the two are equivalent rather than reasoning about it. Run side by side against the same 14-second window:

listener saw 478 messages, 23 types
_dispatch   saw 478 messages, 23 types
types only _dispatch saw: none
types only listener saw:  none
identical message sequence: True

docs/capture.md's listener recipe monkey-patched _dispatch too, and it's the document that teaches this technique. Rewritten onto add_listener, carrying the two rules that come with a listener — don't block, don't read from the device — both of which the transport now enforces.

The bug that comparison exposed

Two captures of the same window length saw 23 message types and 12. That is not the mechanism (see above) — it's the connect handshake's reply burst landing lazily, with the File enumeration alone taking 10-25 s, so a fixed window catches a different tail each run.

diff() was rendering a type present in one snapshot and absent from the other field by field as <absent> -> value. That reads exactly like a discovery, which is the failure mode this whole harness exists to prevent — and it would have been most likely to bite on precisely the large, slow-arriving types.

It now emits one line, in the noise bucket, naming what actually happened:

FileMessage: NOT CAPTURED in the after snapshot (1874 field path(s) seen in the
other). Not a difference - the type simply did not arrive in that window.

Pinned by a test, with the measured 23-vs-12 numbers in its docstring so the reason survives.

Verified after the merge

  • 557 offline, CI green on 3.11 / 3.12 / 3.13 plus the build job.
  • Hardware: the capture and the write regression both pass through the listener-based harness; the unit is left in PRESET where it started.

Worth noting for whoever reviews: protocol.connect(before_handshake=...) from PR #20 would make capture coverage deterministic rather than merely honest about being variable, since a listener registered that early sees the whole handshake burst. I've left that out of this PR — it changes who owns the connection in capture(), which is a design change rather than a merge fix, and the diff is now truthful either way.

@jonathanstokes
jonathanstokes merged commit dd61e2b into main Aug 13, 2026
4 checks passed
@jonathanstokes
jonathanstokes deleted the claude/dreamy-gates-603621 branch August 13, 2026 17:53
jonathanstokes added a commit that referenced this pull request Aug 13, 2026
Three change logs conflicted, all in the same place: main's Unreleased section
and this branch's both grew an entry after the `Device` one. Kept both, in the
order they landed.

- changelog.md: the broadcast listener (#20) then the translation groundwork
- docs/STEERING.md: the boundary entry sits above TEMPO MODE (#22) and the
  listener (#20), which is where the newest entry goes in that file
- docs/domain-model.md: TEMPO MODE closing then principle 5 being built, which
  is the order that file reads in
jonathanstokes added a commit that referenced this pull request Aug 13, 2026
PR #22 added `tempo_bpm()` / `bpm_to_tempo()` next to the level helpers in
protocol/client.py. They belong at the model boundary the same way the level
scales do, so `device/translate.py` wraps them.

They cannot MOVE. `set_tempo_param(real=)` calls `bpm_to_tempo` from inside
protocol/client.py, so relocating the helper would make the protocol layer
import the model. Checked by patching that call site to import from
`pyquadcortex.device.translate` and watching
`test_the_protocol_layer_never_imports_the_model` name it.

So the pair delegates, exactly as `input_level_db` and `lane_level_db` do: one
copy of the measured span, with the measurement attributed to the protocol layer
rather than restated here, plus the type guard this seam adds because the
protocol helpers are arithmetic and will happily multiply a bool.

`tempo_bpm` and `bpm_to_tempo` also join the protocol-conversion allowlist in
tests/test_translation.py, which is the half that does the work: without it, a
model module reaching for `protocol.tempo_bpm` passes the check. Verified by
dropping a file into the package that does both a `row - 1` and a
`protocol.tempo_bpm(...)`, watching both structural checks name it, and removing
it again.

New in the tests: a guard that every name on that allowlist resolves in the
protocol layer. A typo there reads like a rule and protects nothing.
jonathanstokes added a commit that referenced this pull request Aug 13, 2026
The blocker first. `translate.slot_to_position("٢٨C")` returned 218 - a real
preset, from a name no screen shows, through a public function of the module
whose whole job is to stop that. Only `PresetAddress.parse` carried the
ASCII-digit pattern, while a comment and a test both read as though the module
was covered. Both doors share one pattern now, and one list of malformed names
runs through both. The protocol helper still accepts those digits by design
(`str.isdigit()` is true for them), so there is a test pinning that too - if it
ever tightens, the comment saying the boundary holds the line stops being true.

Then the guard cluster, which was the same finding as the last two reviews, one
layer down: the checks were narrower than they read.

- the arithmetic check now sees a letter table as a tuple, list or dict, not
  only as a string; `string.ascii_uppercase`; the literal 65; and
  `ROWS.index(row)`, which converts a coordinate using the boundary's own
  exported table with no arithmetic in it anywhere
- the allowlist gained the protocol readers that hand back raw wire coordinates,
  `stomp_assignments` among them - it returns the footswitch index whose
  confusion with a column is why `FootswitchLetter` exists. Verified: a file
  doing all of that at once passed both checks before and fails both now
- `test_the_boundary_itself_does_the_arithmetic` was anchored to the file, and
  was satisfied by an error-message formatter in `_screen_number`. It names the
  four converters now, so they cannot quietly stop converting
- a new derived check: everything the boundary delegates to must be on the
  allowlist. That is the direction the list actually rots, and it is what
  missed #22's tempo helpers

Both checks now pin their KNOWN blind spots as blind spots. A sample table where
every "should be caught" case is caught reads like a completeness proof; these
fail if a listed gap closes, which is the edit where the prose gets fixed too.

Also:
- the layering check could not see `from pyquadcortex import PresetAddress`, a
  hole this story opened by re-exporting the value types. It reads
  `device.__all__` now, so it follows the code
- `tests/test_docs.py` still exempted the dead `model` path and flagged the live
  `device` one - the rename missed it, and it would have fired at story #12
- the hold-timing and tuner tests said more than they prove. Both are delegation
  checks; they say so now, and say where the numbers are actually pinned
- `check_artifacts.py` did not require the two `__init__.py` files that decide
  what `import pyquadcortex` hands back
- the prose in CLAUDE.md, STEERING, architecture.md and domain-model.md scoped
  the rule to the model directory while the test scans the whole package
- roadmap.md's illustrative snippet still showed `preset.rows[0]`
- architecture.md said compile_protos.sh is the only script in the repo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant