Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Read `docs/STEERING.md` before non-trivial work (new operations, transport or fr
## Conventions

- Dev setup: `uv venv && uv pip install -e ".[dev]"` (or plain venv + pip, see contributing.md). Run tests with `.venv/bin/python -m pytest`. The suite passes offline - no hardware, no `hid` import, no `DYLD_LIBRARY_PATH`.
- Two namespaces, one package (ADR-0006): `pyquadcortex` is the model of the unit, `pyquadcortex.protocol` is the message-level API. The model imports the protocol layer; nothing under `pyquadcortex/protocol/` may import from `pyquadcortex/model/`.
- Two namespaces, one package (ADR-0006): `pyquadcortex` is the model of the unit, `pyquadcortex.protocol` is the message-level API. The model's code lives in `pyquadcortex/device/` - not `model/`, because in this codebase the identifier `model` means an amp or pedal block (`protocol/models.py`, `catalog.Model`, `ModelCatalog`, `set_block(model=...)`). The model imports the protocol layer; nothing under `pyquadcortex/protocol/` may import from `pyquadcortex/device/`.
- Every conversion between a screen value and a wire value lives in `pyquadcortex/device/translate.py` and nowhere else in the package outside `pyquadcortex/protocol/` - the whole package, not just `device/`, because a rule scoped to a directory is satisfied by moving the code one directory up. It covers rows 1-4, slots 1-8, scene and footswitch letters, preset addresses and display units. Outside the boundary that means no `+1`/`-1` on a coordinate AND none of the other spellings of the same conversion (`ord`/`chr`, a letter table in any container, `divmod` on a position, a one-based `enumerate`, `ROWS.index(...)`), and no module reaching past the boundary for a protocol-layer name that carries a coordinate or a raw scale - the converters and also the readers that hand back wire indexes, such as `protocol.stomp_assignments`. `tests/test_translation.py` reads the source and proves both, and pins where each check stops seeing rather than implying it sees everything. A model API takes `FootswitchLetter`, never a bare footswitch integer, because a footswitch index and a block's column are different numbers that usually agree. A new conversion goes in that module with its own test, however small it is, and its protocol-layer name joins that file's allowlist in the same commit.
- The model represents what the unit shows, in the unit's own words, and never guesses. A control we understand but cannot yet drive is modelled and REFUSES the operation (ADR-0007); a control we do not understand is omitted, with the reason recorded in `docs/domain-model.md`'s appendix. Nothing ships with a "this might be stale or wrong" caveat.
- A model property that reads a device field checks the field is PRESENT (`protocol.field_present`) before reporting it. Most of this schema sits in synthetic `oneof`s, so protobuf returns `""` or `0` for a field the unit never sent, and reporting that as the answer is the guess the rule above forbids. Never cache a reply that came back incomplete - a retry has to be able to recover.
- Anything the model caches is valid only while its connection is. A closed `Device` refuses reads rather than answering from cache, because a model that reports the unit's state through an object with no unit behind it is the failure the whole layer exists to avoid.
Expand Down
28 changes: 28 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,34 @@ with protocol.connect(before_handshake=lambda t: t.add_listener(watch)) as qc:
The decision behind the two rules is ADR-0009. This is the groundwork for the
model keeping itself current without asking twice.

### Groundwork: the model will talk in the numbers on your screen

Rows will be 1 to 4, slots 1 to 8, scenes and footswitches letters, levels the dB
the unit displays, and the tempo the bpm it displays. The wire counts from zero
and stores raw scales, and the model now converts in exactly one place so nothing
else has to remember to.

**Nothing that reads a row or a level exists yet** - the preset and grid surfaces
are still being built - so what you can use today is the three value types this
groundwork brought with it, exported from `pyquadcortex`:

```python
from pyquadcortex import PresetAddress, FootswitchLetter, SceneLetter

PresetAddress.parse("28C") # bank 28, position C
PresetAddress.parse("28X") # ValueError, here rather than at write time
FootswitchLetter.E # a footswitch is a letter, never a number
```

The footswitch rule is worth the sentence it costs. A footswitch index and a
block's column are different numbers that agree most of the time, which is how a
bug hid for months: a block in column 3 assigned to footswitch E is stored under
key 4. No model API takes a bare footswitch number, so a column cannot be passed
where a footswitch belongs.

Until the Directory arrives, `PresetAddress` is most useful for checking an
address before you hand it to the protocol layer.

### Withdrawn: the Tempo menu's MODE is "not on the wire"

The 0.23.0 entry below records, under **Settled**, that the Tempo menu's MODE
Expand Down
107 changes: 105 additions & 2 deletions docs/STEERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **What this is:** durable technical context for the pyquadcortex library - what the system is and why it is shaped this way.
> **What this is not:** coding rules (see the repo-root `CLAUDE.md`) or decision rationale (see [`ADR.md`](ADR.md)).
> **Last reviewed:** 2026-08-03 by Stokes
> **Last reviewed:** 2026-08-13 by Stokes
> **Owners:** Stokes

## 1. Purpose
Expand Down Expand Up @@ -41,7 +41,7 @@ The protocol layer is stateless between calls: every read is a live exchange, an

## 4. Owned Paths

- `pyquadcortex/` - the package. Two namespaces: `pyquadcortex/model/` (the model of the unit) and `pyquadcortex/protocol/` (the message-level API, including the committed generated bindings in `pyquadcortex/protocol/proto/`)
- `pyquadcortex/` - the package. Two namespaces: `pyquadcortex/device/` (the model of the unit) and `pyquadcortex/protocol/` (the message-level API, including the committed generated bindings in `pyquadcortex/protocol/proto/`)
- `protocol/` - the recovered `.proto` schema and its tooling
- `tests/` - the fully offline suite and its fixtures
- `examples/` - runnable scripts, also used as hardware-verification shapes
Expand All @@ -57,6 +57,7 @@ The protocol layer is stateless between calls: every read is a live exchange, an
| Fake-per-layer offline tests | Each layer has a purpose-built double: golden captured frames for `framing`, `FakeHid` for `transport`, `FakeTransport` for `client` | see ADR-0002 | `FakeTransport` in `tests/test_client.py` | Hardware verification happens manually via `examples/`, outside the suite |
| Evidence-bearing docstrings | Each operation's docstring states what is confirmed on hardware vs inferred from the schema | The device gives no errors for wrong writes, so recorded evidence is the only trail | `QuadCortex.read_preset` in `pyquadcortex/protocol/client.py` | Non-protocol helpers (pure functions) carry ordinary docstrings |
| Keyed grid edits | Mutations are row/column-keyed `Grid` UPDATEs | The device applies grid updates by key; wholesale preset writes are silently ignored (see [`architecture.md`](architecture.md), "write_preset is a trap") | `QuadCortex.set_bypass` in `pyquadcortex/protocol/client.py` | Read paths, and non-grid operations |
| One translation boundary | Screen values become wire values in exactly one module, and a source-reading test proves no other module in the package does it - the whole package outside `protocol/`, not just `device/` | An off-by-one row is silent - the write lands on a real row and reads back perfectly - so a convention cannot be trusted to hold (design principle 5 in [`domain-model.md`](domain-model.md)) | `pyquadcortex/device/translate.py` | The protocol layer, which keeps its zero-based indexes and raw scales |

## 6. Constraints

Expand Down Expand Up @@ -122,6 +123,108 @@ Single-device, single-connection USB HID at interactive rates (129-byte reports)

## Change Log

### 2026-08-13 - One translation boundary, and the model package is `device/`

**What changed:**
- `pyquadcortex/device/translate.py`: the one module where a screen value becomes a wire
value and back - rows 1-4, slots 1-8, scene and footswitch letters, preset addresses,
and five display-unit mappings (input gain dB, lane and mixer dB, tempo bpm, tuner
reference Hz, hold timing ms). The two level scales and the tempo call the protocol
helper that carries the measurement; the other two have no helper to call, so they are
pinned against what the protocol write method expects, and the tuner's docstring says
how thin its evidence is - one observed pair. `PresetAddress`, `FootswitchLetter` and
`SceneLetter` are its public value types, re-exported from `pyquadcortex`
- Section 5 gained the pattern row; section 4's owned-paths line and CLAUDE.md name the
new rule. `architecture.md` carries the module in its layer map and a section on it;
`domain-model.md` marks principle 5, `PresetAddress` and `FootswitchLetter` as built
- **The model package directory is `pyquadcortex/device/`, renamed from `model/`.** Done
as its own commit so the story's diff stays readable

**Why:**
- M1 Epic (stokes-audio/pyquadcortex#8), Story #10. It ships before the surfaces that use
it so no later story invents its own conversion. The Intent Brief names off-by-one as a
silent failure mode, and the protocol layer's own header agrees: an edit to the wrong
row still succeeds and still reads back correctly, so nothing tells you. A centralized,
exhaustively tested boundary is the whole mitigation, which is why two of its tests read
the model package's source instead of calling it
- The rename is an owner decision. In this codebase the identifier `model` means an amp or
pedal block - `protocol/models.py`, `catalog.Model`, `ModelCatalog`,
`set_block(model=...)`. `domain-model.md` §5 renamed that concept to *virtual device* in
the model's vocabulary, because that is what the screen calls it, but the protocol layer
still spells it `model` and will keep doing so. A directory named `model/` therefore
collides with real code a reader is looking at, whatever the design doc calls the
concept

**Scope of impact:**
- **Updated:** STEERING.md, CLAUDE.md, architecture.md, domain-model.md, roadmap.md,
changelog.md, `pyquadcortex/device/`, `pyquadcortex/__init__.py`,
`scripts/check_artifacts.py`, `tests/test_translation.py` (new),
`tests/test_namespace.py`, `tests/test_import_cleanliness.py`, `tests/test_docs.py`
- **Not updated (intentionally):** ADR.md - neither change reverses or refines a recorded
decision. The boundary IS design principle 5, already written and reviewed in
`domain-model.md`; the rename is a directory name, chosen to stop colliding with an
identifier the protocol layer uses. README.md and api.md - the new value types have
no surface handing them out yet (the Directory is story #12), and the readme tour should
show what a caller can do, not what exists. The protocol layer - it keeps its zero-based
indexes, its `Footswitch` enum and its measured scales, and nothing below the seam
changed
- **No deprecation shim for the rename.** `pyquadcortex.__all__` lists `protocol` and never
listed `model`, and the model namespace has not been released - 0.40.0 predates the flip
- so nothing published points at the old path

**Downstream to consider:**
- Stories #11 through #16 convert through this module rather than doing their own
arithmetic, and the source-reading tests will fail them if they do not
- The conversions M1 does not need yet land here too, with the surface that needs them.
A parameter whose display mapping is unverified stays out of the model entirely
(principle 3), so no mapping is ever invented in this module
- The arithmetic check is deliberately blunt and deliberately wide: a literal one in any
spelling (`1`, `1.0`, `True`, `-1`), `ord`/`chr`, the literal 65, a letter table as a
string, tuple, list or dict, `string.ascii_uppercase`, `divmod`, a one-based
`enumerate`, and `.index()` on `ROWS` or `SLOTS` all fail it, anywhere in the package
outside the boundary and the protocol layer. If a future module has a genuine counter,
narrowing the check is a deliberate edit with a reason, not a quiet one. What it cannot
see - a one behind a name, a table built at run time, arithmetic inside somebody else's
helper - is pinned as a failing-if-it-changes list in the same file, because a sample
table where every case passes reads like a completeness proof and is not one
- The scan is scoped to the whole package rather than to `pyquadcortex/device/`, because
a rule scoped to a directory is satisfiable by moving the code one directory up - which
is precisely what a failure message naming a directory invites
- A protocol conversion can be delegated to and still not be movable. `bpm_to_tempo`
(PR #22) is called by `QuadCortex.set_tempo_param` from inside the protocol layer, so
the helper stays there and the boundary wraps it, the same way it wraps the level
scales. Adding the name to the boundary's allowlist is the half that matters: without
it, a model module reaching for `protocol.tempo_bpm` passes the check
- The allowlist is judgement, and its criterion is what a name HANDS OVER rather than
whether it reads like a conversion. `protocol.stomp_assignments` returns three raw wire
indexes including the footswitch one, so a model module could key a mapping by it and
reintroduce the exact bug `FootswitchLetter` exists to prevent, without writing a `- 1`
anywhere. The readers are therefore listed next to the converters

**Also in this branch:**
- Merged main (PRs #19, #20, #22) up. The three change logs conflicted in the same place
and both sides were kept in the order they landed
- The review found the boundary's own front door open: `translate.slot_to_position`
accepted non-ASCII digits, so `"٢٨C"` returned preset 218. Only `PresetAddress.parse`
carried the ASCII pattern, while a comment and a test both read as though the module
was covered. The two doors now share one pattern, and one list of malformed names is
run through both
- Both source-reading checks were narrower than they read, again. The arithmetic check
now sees a letter table in a tuple, list or dict, `string.ascii_uppercase`, the literal
65, and `ROWS.index(row)` - a coordinate conversion written with the boundary's own
exported table and no arithmetic in it at all. The allowlist gained the protocol
readers that hand back wire coordinates. A file doing all of that at once passed both
checks before and fails both now
- The backstop that proves the boundary still converts is anchored to the four converters
by name. It had been satisfied by an error-message formatter elsewhere in the file, so
the converters could have gone arithmetic-free with nothing failing
- Each check now pins its 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 ever closes, which is the edit where the prose gets corrected too
- The layering check could not see `from pyquadcortex import PresetAddress`, a hole this
story opened by re-exporting the value types at top level. It reads `device.__all__`
now, so it follows the code

### 2026-08-12 - TEMPO MODE closes, and ADR-0010

**What changed:**
Expand Down
Loading
Loading