memlnaut-nisps/docs/specs/plans/hardware-editor-spec.md
monkey-w1n5t0n 60584606a8 docs(specs): P5 architectural specs — 5a, 5b, 5c, 5d
Plan §6 says each P5 item is spec-first with its own session. These are the
specs; no implementation is authorised by them and none was written.

  plans/mode-layer-reunification.md   5a — ALIGNMENT defect 1, the largest
                                      architectural gap. Storage-policies the
                                      ModeBase orchestration the way P2 did
                                      MLPCore, rather than binding monolithic
                                      mode objects into WASM (which would
                                      contradict the locked two-instance RT
                                      architecture).
  plans/browser-mode-coverage-spec.md 5b — an audio-topology notion so Manifold
                                      stops cataloguing 4 modes that
                                      structurally cannot run in a browser.
  plans/curated-presets-spec.md       5c — ALIGNMENT defect 2, built on the
                                      operator's §7.6 definition: a curated
                                      preset is configuration only, network
                                      untrained.
  plans/hardware-editor-spec.md       5d — ALIGNMENT defect 3, applying
                                      useq-celium's existing discipline (C
                                      header as wire truth + TS mirror + parity
                                      test) to a MEMLNaut serial protocol.

2434 lines. I spot-checked their path citations mechanically against
git ls-files: of 170 backticked paths, every unresolved one is either a file
the spec proposes to create or a cross-reference to a sibling spec in this same
commit. None describes deleted code as live — which is the failure mode that
made half the existing corpus untrustworthy, and the reason the §8 pass earlier
today had so much to do.

The preset spec is the most valuable byproduct: en route it found five places
where existing docs still describe a deleted world, including
manifold-parity-features-spec.md §1.1 specifying a PipelineLayer over
engine/input-pipeline.ts and output-pipeline.ts, both deleted at one-core P4.

Also carries the doc sync for the telemetry and benchmark work in a77770f:
AGENT-REFERENCE gains the throughput and loss-history entries, and dock-spec
§1.3 records that its long-deferred diagnostics suite shipped PARTLY — the loss
curve and weight-health table are real, GradientFlow is not built and is not
planned as drawn (the core records no per-layer gradient magnitudes, and the
fabricated version was deleted in Phase 1).
2026-07-21 22:03:39 +02:00

32 KiB
Raw Blame History

kind status
plan active

Manifold as Hardware Editor — MEMLNaut USB-Serial Protocol, Firmware Command Surface, On-Device Persistence

Dated 2026-07-21. Spec for plan item §6.5d (simplification-plan.md:130), audit findings A4 / S14 / L29, ALIGNMENT defect 3 (and the live half of defect 5). Vision bullet 5: "Manifold doubles as interface/editor for the hardware MEMLNaut (settings, presets, training, examples, visualisation)."

Nothing here is implemented. No code was written for this document. Every file:line below was read from the working tree on 2026-07-21 and is marked ✓ (verified by reading the file) or ✗ (could not verify — stated as unknown, never asserted). The tree was mid-flight with concurrent edits from other sessions (git status showed 20+ modified files including src/main.cpp, codegen/generate.ts, ConsoleApp.tsx); re-check citations in those files before relying on an exact line number.


1. Ground truth — what exists today

1.1 The browser end is a connect-lifecycle shell

Fact Evidence
MemlnautSerial handles requestPort/open/close and a 5-state status store, nothing else manifold/src/serial/memlnaut-serial.ts (140 lines, not the 237 the audit reports — stale)
saveModel returns false, restoreModel returns null, getSettings returns {}, each with a TODO: real protocol ✓ same file, the three methods under // ---- Protocol stubs ----
Baud 115200 is explicitly a placeholder ✓ same file, TODO(memlnaut-serial): negotiate the real baud rate
The only consumer is EditorPanel.tsx; the only consumer of that is the Settings-drawer editor case manifold/src/console/Drawers.tsx:33 (import), :579 (render). Repo-wide grep for memlnaut-serial/getMemlnautSerial finds no other importer.
editor is a top-level dock output mode, audio: false, description already promises "configure / save / restore" manifold/src/console/output-mode.ts:69-75
The panel already carries an honest "not yet wired" note EditorPanel.tsx, final <p>

manifold/src/serial/web-serial.d.ts is an ambient (global) declaration file. Its header comment claims it declares "just the surface memlnaut-serial.ts uses" — false: manifold/src/backends/cv-backend.ts:56,95,123,133 type-checks against the same global SerialPort/navigator.serial. There is one Web Serial type surface and two transport implementations, not three (✓ grep for navigator.serial|SerialPort over manifold/src returns exactly web-serial.d.ts, cv-backend.ts, memlnaut-serial.ts).

1.2 The firmware end has a USB CDC port, and nothing listening on it

Fact Evidence
Serial (USB CDC) is opened at boot and used only for diagnostics firmware/MEMLNaut-NISPS/src/main.cpp:125 (Serial.begin(115200))
Core 0's loop() prints "." ~10×/s and a perf line ~1×/s onto that same stream src/main.cpp:166, :172 (Serial.printf("ml: %d, aud: %d, q: %f\n", …))
The USB CDC port is otherwise free: MIDI runs on Serial2 (UART1), not USB lib/memllib/src/interface/MIDIInOut.cpp:15 MIDI_CREATE_CUSTOM_INSTANCE(HardwareSerial, Serial2, MIDI, …), :81-88
USB-MIDI (TinyUSB composite) is compiled out — MIDI_USB_CLIENT is defined nowhere MIDIInOut.cpp:55 is #ifdef MIDI_USB_CLIENT; grep across platformio.ini, src/, glue/ finds no definition. The device enumerates as a plain CDC port.
There is no serial reader anywhere in firmware — no Serial.read(), no parser, no command dispatch ✓ grep over src/ + glue/
Core 0's control cadence is 5 ms (ML_INFERENCE_PERIOD_US 5000), core 1's drain 1 ms src/main.cpp:152-160, :208-216

1.3 There is no on-device persistence, but two mechanisms are already compiled in

  • LittleFS (internal flash). memllib wraps it in lib/memllib/src/hardware/FlashFS.hpp ✓ — which has zero consumers in this repo (✓ grep for FlashFS hits only vendored TFT_eSPI example sketches under .pio/). Two problems with that wrapper: it defines void begin() and bool exists() as non-inline free functions in a header (✓ read in full — 22 lines), so including it from two translation units is a duplicate-symbol link error; and using it at all buys nothing over calling LittleFS directly.
  • The filesystem is currently sized to zero. platformio.ini does not set board_build.filesystem_size (✓ grep), and the platform's default is "0MB" (✓ ~/.platformio/platforms/raspberrypi/builder/main.py:63). With _size == 0, LittleFS.begin() returns false immediately (✓ ~/.platformio/packages/framework-arduinopico/libraries/LittleFS/src/LittleFS.h:173). On-device flash persistence therefore requires a platformio.ini change that alters the flash layout of all 16 envs.
  • LittleFS writes park core 1.…/LittleFS/src/LittleFS.cpp:186-193 and :203-209: noInterrupts(); rp2040.idleOtherCore(); flash_range_program/flash_range_erase(...); rp2040.resumeOtherCore(); interrupts();. Core 1 is the audio core. Any flash write stalls audio for the duration of the erase + program. This is the single hardest constraint in this document.
  • SD card. memllib ships interface/SDCard.{hpp,cpp} (SdFat over hardware SPI) ✓, the MEMLNaut board has dedicated SD pins ✓ lib/memllib/src/hardware/memlnaut/Pins.hpp:87-90 (SD_CS 13, SD_SCK 14, SD_MISO 12, SD_MOSI 15), and SDCard.cpp already compiles into every variant ✓ (.pio/build/pafsynth/…/interface/ SDCard.cpp.o exists). SPI writes do not lock out core 1. It needs a physical card fitted; whether the operator's units have one is ✗ unknown.

1.4 The model is transferable — the shapes already agree

  • Firmware instantiates a compile-time MLP<…> whose dims come from the mode's generated schema; the browser reshapes its MLPCore<DynamicStorage> to the same schema dims on every mode switch ✓ manifold/src/console/ConsoleApp.tsx:252-257 (engine.reshape({ inputSize, outputSize, hidden }, defaultSpread) from mode.ml). So for a given mode_id the two nets have the same weight_count by construction.

  • But not unconditionally. The browser also offers an axis-count reshape that changes inputSize alone ✓ ConsoleApp.tsx:290-322, and nisps_ml_create's default shape is a 32-input head ✓ manifold/src/inputs/input-layer.ts:17,38. A live browser net can therefore be at 6→[10,10,14]→33 while the device is at 4→[10,10,14]→33. The protocol must carry the shape and refuse mismatches; it must never infer compatibility from mode_id alone.

  • Flat weight layout is already specified and identical on both targets: [l0_w][l1_w][l2_w][l3_w][l0_b][l1_b] [l2_b][l3_b], little-endian float32nisps/wasm/bindings.cpp:25-34 (comment), nisps/ml/mlp.hpp:385-414 (get_weights/set_weights).

  • Per-mode blob sizes (computed from schemas/modes/*.json ✓):

    mode dims weights+biases bytes
    sound_analysis_midi 10→[10,10,14]→8 494 1 976
    channel_strip, xiasri 4→[10,10,14]→24 674 2 696
    paf_synth 4→[10,10,14]→33 809 3 236
    elysiamorf 4→[10,14,18]→40 1 234 4 936
    verb_fx 4→[10,14,18]→47 1 367 5 468
    breakor, memlcelium, slp_workshop 4→[10,14,18]→56 1 538 6 152

    Worst case is ~6 KB. Small — but not small enough to sit in one frame without either a 6 KB RX buffer or chunking.

1.5 Training examples are not readable out of the core today

MLPCore exposes example_count()nisps/ml/mlp.hpp:441 and clear_examples():443, but the per-example accessors sample_features_(s) / sample_labels_(s) are private:577, :581. Exporting the training set from the device is therefore a real (small) addition to nisps/ml/mlp.hpp, not a wiring job.

Worse for the editor: the firmware's FeedbackController — which owns explore/place state and drives add_example — is a function-local static inside bind_peripherals()firmware/MEMLNaut-NISPS/glue/peripherals.hpp:152. It is unreachable from main.cpp or any command handler. Any command that touches feedback state requires hoisting it first.

1.6 The discipline to copy already exists in-repo

firmware/useq-celium/shared/protocol.h ✓ is a C header of static const constants + static inline helpers, mirrored constant-for-constant by manifold/src/backends/useq-protocol.ts ✓, pinned by manifold/src/backends/useq-protocol.test.ts ✓ (test('frame sizes match the C header')), documented as kind: spec in docs/specs/useq-cv-protocol.md ✓. The device-side parser is a ~20-line resync state machine ✓ firmware/useq-celium/main/src/main.cpp:93-110. Verified live: bun test src tests/*.test.ts in manifold/23 pass, 0 fail, 6 files, 65 ms.

1.7 L29 — InputChain/OutputChain have no firmware consumer

✓ Repo-wide grep: the only non-test, non-self references are nisps/wasm/bindings.cpp:76-77,194-195,968,1011 and tests/cpp/{test_pipeline,parity_check}.cpp. nisps/pipeline/output_chain.hpp:13 says "firmware would pick its mode's NOut" — aspirational, present-tense-adjacent, and currently false. InputChain already serialises (state_size(), round-trip test ✓ tests/cpp/test_pipeline.cpp:131-150), which matters below.


2. Invariants

Beyond the repo-wide hard constraints (platform-neutral allocation-free nisps/; native↔WASM parity ≤1e-5; schema changes ship both codegen outputs; RT-safe worklet + SPSC dual-core discipline), this area adds:

  • I1 — The wire format has exactly one source of truth. A single C header under firmware/, mirrored in TS, pinned by a test that fails when they diverge. No constant is written twice by hand in the same language.
  • I2 — No hand-maintained per-mode table anywhere in the protocol. Mode identity, net shape, param count and ordering reach the wire from codegen output on both sides, or they do not reach it at all.
  • I3 — A model blob is applied only against a proven-identical contract. Shape and schema fingerprint must match. On mismatch the device NAKs; it never partially applies, never truncates, never zero-pads.
  • I4 — Serial servicing is bounded. The core-0 pump reads at most a fixed byte budget per loop() iteration. The 5 ms tick_control() cadence ✓ (src/main.cpp:152,160) is not to be perturbed by a host that streams.
  • I5 — Serial handling never runs on core 1. Core 1 is audio + MIDI drain. All parsing, buffering, filesystem and ML mutation happen on core 0.
  • I6 — Flash writes are an explicit, announced, user-initiated act. Because they park core 1 (§1.3), a persist is never automatic, never periodic, and never a side effect of another command.
  • I7 — Diagnostics and protocol do not share the stream unframed. Either the diagnostics are silenced for the session or they are carried inside a frame. Never raw ASCII interleaved with binary payloads.
  • I8 — The device is authoritative about itself. The browser asks and believes; it never assumes a firmware build from a mode name, a port name, or a USB descriptor.
  • I9 — No compat shim. The stubs are deleted, not deprecated. EditorPanel is the only consumer (§1.1).

3. Design

3.1 Transport and channel

USB CDC (Serial), 8N1; the CDC baud parameter is ignored by the hardware but the host must supply one — 115200, matching what the firmware already calls and what cv-backend.ts:133 uses. No second UART, no composite USB device, no TinyUSB reconfiguration.

The diagnostics collision (I7) is resolved by session state. On HELLO the firmware sets an editor_session flag; while set, loop()'s Serial.println(".") and perf printf (✓ src/main.cpp:166,172) are suppressed and the same information is emitted as a TELEMETRY frame instead. BYE, a host disconnect (DTR drop), or a 5 s silence timeout clears the flag and restores the prints. Boot-time prints ("Serial initialised.", "Bound peripherals to mode.") happen before any session and are harmless — the host-side parser drops non-sync bytes anyway.

Rejected: moving diagnostics to a second UART (costs pins and a second cable) and CDC-composite dual ports (complicates the Web Serial port picker for one debug convenience).

3.2 Framing

Variable-length, because payloads range from 0 bytes to a 6 KB model (§1.4). Fixed-length-per-type (the useq choice) does not survive that.

 byte 0      sync      0xA5 host→device, 0x5A device→host
 byte 1      type      u8
 bytes 2..3  length    u16 LE, payload bytes, 0..MEMLED_MAX_PAYLOAD
 bytes 4..   payload
 last 2      crc       u16 LE, CRC-16/CCITT-FALSE over bytes [1 .. 3+length]
  • CRC-16, not XOR-8. useq's XOR-8 is right for a 26-byte frame streamed at 100 Hz where a corrupt frame is discarded and replaced 10 ms later. It is wrong for a 6 KB model transfer, where a 1-in-256 undetected corruption silently installs wrong weights and the user hears an inexplicable instrument. Bitwise CRC-16 is ~15 lines, table-free, and identical on both sides.
  • MEMLED_MAX_PAYLOAD = 512. Bounds the device RX buffer at 518 bytes and each frame's service time at ~45 ms of wire time worst case — hence I4's byte budget. Larger objects chunk (§3.4).
  • Both directions use the same frame shape; only the sync byte differs, so one parser implementation serves both ends.

Home: firmware/shared/memlnaut-editor-protocol.h — deliberately not under firmware/MEMLNaut-NISPS/, because it is shared with the browser exactly as firmware/useq-celium/shared/protocol.h is. Mirror: manifold/src/serial/memlnaut-protocol.ts. Parity test: manifold/src/serial/memlnaut-protocol.test.ts (picked up by the existing bun test src tests/*.test.ts glob — do not touch that script).

3.3 Identity: a codegen-emitted schema fingerprint (discharges I2, I3)

The one genuinely new idea in this spec, and the thing that makes "derive from schema codegen, not hand-defined tables" concrete.

codegen/generate.ts gains one emitted constant per mode: a FNV-1a 64 hash over a canonical serialisation of the mode's contract-bearing schema fieldsmode_id, engine_id, ml (input_size, hidden_layers, output_size, default_spread), params[] as (name, min, max, default, curve, group) in order, and voice_spaces (names + curve overrides) in order. Deliberately excluded: label, _note, ui — cosmetic, and a label edit must not invalidate a user's trained model.

Emitted into both generated worlds in the same change (hard constraint):

  • nisps/modes/generated/<mode>_schema.hppinline constexpr std::uint64_t k<Mode>SchemaHash = 0x…ull; alongside the existing k<Mode>ModeId ✓ (nisps/modes/generated/paf_synth_schema.hpp:10)
  • manifold/src/modes/generated/<mode>_schema.tsschema_hash: '0x…' on the existing ModeSchema const

CI already fails on a stale generated tree (.github/workflows/ci.yml, step "Codegen is committed and idempotent":129), and codegen/tests/golden/paf_synth_schema.{hpp,ts} ✓ will need regenerating in the same commit.

The device reports its hash in DESCRIBE; the browser compares against its own generated hash for that mode. Equal ⇒ weights, settings and examples are interchangeable. Unequal ⇒ the UI says "this MEMLNaut is running a different build of paf_synth" and disables transfer. No table, no version negotiation matrix, no per-mode special case — and the check is exact rather than heuristic.

3.4 Command set (protocol v1)

Types are static const uint8_t in the shared header. ACK/NAK are the universal replies; NAK carries a one-byte reason code from an enum in the same header.

Host → device

Type Payload Reply
HELLO u16 host_proto_ver DESCRIBE — opens the session, silences diagnostics
BYE ACK — closes the session, restores diagnostics
GET_MODEL MODEL_BEGIN, then MODEL_CHUNK×N
PUT_MODEL_BEGIN u64 schema_hash, u16 dims[5], u32 weight_count, u32 crc32 ACK/NAK
PUT_MODEL_CHUNK u16 index, bytes ACK
PUT_MODEL_END ACK (applied) / NAK (CRC or count mismatch — nothing applied)
GET_SETTINGS SETTINGS
SET_SETTING u16 id, f32 value ACK/NAK
PERSIST u8 slot ACK/NAK — see I6
LOAD_PERSISTED u8 slot ACK/NAK
CLEAR_EXAMPLES ACK
SUBSCRIBE u16 mask, u8 rate_hz ACK — telemetry stream on/off

Device → host

DESCRIBE, MODEL_BEGIN, MODEL_CHUNK, SETTINGS, TELEMETRY, LOG (UTF-8 text, framed — the session replacement for the raw prints), ACK, NAK.

DESCRIBE payload: u16 proto_ver, u64 schema_hash, mode_id (fixed 24-byte NUL-padded ASCII, from generated::k<Mode>ModeId ✓), u16 dims[5], u32 weight_count, u16 max_examples, u16 capability_bits, char fw_build[16]. The capability bits are what let one protocol serve 16 firmware variants without a per-variant table: HAS_PERSIST, HAS_SD, HAS_EXAMPLE_EXPORT, HAS_TELEMETRY, IS_SEQUENCER.

PUT_MODEL is atomic (I3). Chunks land in a staging buffer sized weight_count * 4 — for the largest mode 6 152 bytes of core-0 RAM, allocated as a static array sized from MLPType::weight_count() at compile time, so no heap. set_weights is called once, from PUT_MODEL_END, only after the CRC-32 over the whole blob matches and weight_count and dims[5] equal the device's own.

Settings (I2, honestly). Not everything can come from codegen, and pretending otherwise would be the hand-defined table under a different name. The split:

  • Derived from codegen, on both sides: the training triple learning_rate / max_iterations / min_error (defaults + identity from schemas/ml_defaults.jsonnisps::ml::generated::kMlTrainDefaults ✓ and manifold/src/modes/generated/ml_defaults.ts ✓ — both mirrors already exist), and voice_space_index whose legal range is the generated voice_spaces list ✓.
  • Genuinely device-side, enumerated in the shared header: pin_value, joystick_single (the Dual/Single toggle ✓ glue/settings_view.hpp:54-58), explore_intensity (RVX1 ✓ glue/peripherals.hpp:124-126), master_volume (RVGain1 ✓ :115-117 — note it drives AudioDriver::SetMasterVolume directly, not the mode). These are ModeBase/peripheral concepts with no schema home; there are four of them; they live in one enum in the shared header and nowhere else.

Telemetry closes ALIGNMENT defect 5's live half. The TELEMETRY payload carries PERF_GET_MEAN(MLSTATS) and AUDIOLOOP_MEAN — numbers the firmware already computes and printssrc/main.cpp:172 — plus example_count(), eval_loss(), and (paged) the loss history the operator deliberately kept on device (audit L25). That is the on-device timing report ALIGNMENT defect 5 asks for, at near-zero marginal cost, displayed in a browser panel that already knows how to draw a loss curve (console/TrainingHealth.tsx ✓).

3.5 Persistence

Primary: LittleFS on internal flash, because it needs no accessory and every unit has it.

Required changes: board_build.filesystem_size in platformio.ini's [env] block (§1.3 — currently absent, default 0MB, begin() fails), a glue/persistence.hpp that calls LittleFS directly (not memllib's FlashFS.hpp, whose non-inline header functions are a duplicate-symbol trap and which adds nothing — §1.3), and a fixed on-disk record:

/memlnaut/<mode_id>/<slot>.mdl   magic, u16 record_ver, u64 schema_hash, u16 dims[5],
                                 u32 weight_count, f32 weights[], settings block, u32 crc32

schema_hash on disk is checked on load exactly as on the wire (I3), so a firmware reflash that changes the schema invalidates old saves loudly instead of loading garbage.

The audio stall is real and must be surfaced, not hidden. Sequence for PERSIST: mute via AudioDriver::SetMasterVolume(0), emit LOG "saving…", write, restore volume, ACK. The browser shows a determinate "Saving to device — audio will pause" state. The alternative — pretending a flash erase is free — would be exactly the kind of plausible-looking lie this repo has been deleting all week.

Not chosen, but noted: the SD path (interface/SDCard.hpp ✓, already compiled ✓, pins ✓) does not park core 1 and is the right home for bulk artefacts (full example sets, session recordings) if the operator's units have cards fitted. Left behind the HAS_SD capability bit rather than built speculatively.

3.6 Browser side

manifold/src/serial/memlnaut-serial.ts is rewritten, not extended: the three stub methods and their TODOs are deleted (I9 — sole consumer is EditorPanel.tsx, §1.1). The class becomes a framed-transport driver: a read loop over port.readable, a resync parser identical in shape to the TS mirror's encoder, a promise-keyed request/reply map, and the same subscribe() store it already has (which EditorPanel already consumes via useSyncExternalStore ✓).

EditorPanel.tsx grows: a device card (mode, build, dims, compatibility verdict), Save-to-device / Load-from-device with the compatibility gate and a progress state, a settings section, and a live telemetry strip. The "not yet wired" note is deleted in the same commit as the code that wires it — a note that outlives its truth is the failure mode this repo keeps finding.

output-mode.ts:72's description ("configure / save / restore") becomes true and needs no edit. Two small hygiene items ride along: fix web-serial.d.ts's false header comment (§1.1) and move it to a shared location now that two modules provably depend on it.


4. Deletions

Deleted Named consumers Why safe
MemlnautSerial.saveModel / .restoreModel / .getSettings bodies + TODOs EditorPanel.tsx only (✓ grep) Replaced in the same change; no external caller
EditorPanel's "not yet wired" <p> none Becomes false the moment step 6 lands
The unconditional Serial.println(".") / perf printf in src/main.cpp:166-175 none (human eyeball on a serial monitor) Not deleted — made session-conditional. Outside a session behaviour is unchanged.
lib/memllib/src/hardware/FlashFS.hppnot deleted none (✓ zero consumers) Vendored upstream code; deleting it diverges the vendor tree for no gain (VENDORED.md re-sync). We simply do not use it, and persistence.hpp says why.

Nothing else. In particular the cvgate backend's Web Serial code stays untouched — different device, different protocol, no shared transport worth extracting for two implementations.


5. Sequenced implementation plan

Each step is independently landable and independently verifiable. Steps 14 are agent-safe with the existing gates. Steps 58 need hardware and are honestly marked.

Step 0 — L29 disposition (do first, it is one line either way). nisps/pipeline/output_chain.hpp:13 and input_chain.hpp's header currently imply a firmware consumer that does not exist (§1.7). If the operator answers Q4 (below) with "not now", soften both comments to say the chains are browser-side today, in this step. If "yes", they stay and step 8 fulfils them. Either way L29 stops being an open lie after step 0. Verification: bash scripts/lint-cpp.sh (comment-only change; the lint strips comments before matching ✓).

Step 1 — the shared header + TS mirror + parity test. No behaviour anywhere. firmware/shared/memlnaut-editor-protocol.h, manifold/src/serial/memlnaut-protocol.ts, manifold/src/serial/memlnaut-protocol.test.ts. Encoder/decoder + CRC-16 in both languages; the test asserts frame offsets, MEMLED_MAX_PAYLOAD, every type byte, and CRC-16 against fixed vectors. Verification: cd manifold && bun run typecheck && bun run test (the new test is picked up by the existing glob — do not change the test script). Additionally register a host C++ test that includes the header and asserts the same vectors: add a source to nisps_modes_tests, which already puts the repo root on its include path for exactly this reason ✓ (nisps/CMakeLists.txt:134). Then bash scripts/build-cpp-tests.sh proves the C and TS encoders agree on the same vectors on both sides. Note: scripts/parity-check.sh says nothing about any of this — it exercises PAFSynth and ChannelStrip at all-params-0.5. A green parity run is not evidence for this step.

Step 2 — codegen emits the schema fingerprint. codegen/generate.ts + codegen/lib.ts; C++ and TS in the same commit; codegen/tests/golden/* regenerated. Verification: cd codegen && bun run generate.ts twice (idempotence), bun run test (golden + curve-drift), then bash scripts/build-cpp-tests.sh (the generated headers are compiled by nisps_modes_tests ✓). CI's "Codegen is committed and idempotent" step ✓ is the backstop.

Step 3 — firmware command surface, read-only commands. glue/editor_serial.hpp: the bounded pump (I4), the parser, HELLO/DESCRIBE/GET_SETTINGS/GET_MODEL/ SUBSCRIBE/BYE, the session flag gating the diagnostics. Called from loop() on core 0 next to tick_control(). Deliberately no mutation yet. Verification (weak, be honest): pio run -e slpworkshop -e pafsynth -e selftest compiles and the flash/RAM delta is reported (CI's firmware job already does this ✓ .github/workflows/ci.yml:213,223). Compilation is all that is verified without hardware. The parser itself should be structured so its byte-level core is Arduino-free and host-testable — the precedent is glue/codec_config.hpp, deliberately Arduino-free and covered by tests/cpp/test_mode_driver_config.cpp ✓. Follow it: put the state machine in the shared header or a pure sibling, and the Serial reads in the glue.

Step 4 — browser transport + read-only editor UI. Rewrite memlnaut-serial.ts onto the framed protocol; EditorPanel renders DESCRIBE + telemetry + the compatibility verdict. Save/Load still absent. Verification: bun run typecheck && bun run test && bun run build. A Playwright e2e can cover the panel's disconnected rendering only — Web Serial cannot be driven headlessly. Do not claim more. The frame codec is covered by step 1's unit test; the round trip is not covered by anything until hardware.

Step 5 — firmware mutating commands. PUT_MODEL_* (staging buffer + atomic apply), SET_SETTING, CLEAR_EXAMPLES. Requires hoisting the FeedbackController out of bind_peripherals's function-local static (§1.5) if any command touches feedback state; if v1 avoids feedback entirely, say so and leave it. Verification: compile + size. The atomicity logic (CRC-32 accumulate, count check, single set_weights) is host-testable if written as a pure function over spans — do that, and cover it in nisps_modes_tests.

Step 6 — Save-to-device / Load-from-device in the UI, gated on the schema hash + dims match, with progress and the explicit incompatibility message. Delete the "not yet wired" note here. Verification: typecheck + unit + build; e2e for the gate (a fake DESCRIBE with a wrong hash must disable the buttons) is worth writing because it is pure logic.

Step 7 — persistence. board_build.filesystem_size in platformio.ini (changes the flash layout of all 16 envs — rebuild and re-report sizes), glue/persistence.hpp on LittleFS directly, the record format, PERSIST/LOAD_PERSISTED, the mute-around-write sequence. Verification: all 16 envs build and the size table is re-baselined (scripts/build-firmware.sh --all). The record encode/decode is pure and host-testable; the flash behaviour, the stall duration, and whether the audio interruption is acceptable are operator chokepoints — no automated gate reaches them.

Step 8 — (conditional on Q4) InputChain/OutputChain firmware wiring. InputChain in bind_peripherals between the joystick callbacks and mode.set_input; OutputChain<MLPType:: kOutput> in ModeBase::tick_control before engine_.set_params. Both configs become editor settings, which is the only reason firmware would want them configurable at all. Note the cost honestly: OutputChain<NMax> adds an NMax-float state array to every mode instance and a per-tick pass at 200 Hz. Verification: host tests already exist for both chains ✓ (tests/cpp/test_pipeline.cpp) and parity stage 7 covers them ✓ (tests/cpp/parity_check.cpp:341-394) — but neither proves the firmware wiring, and the thing that actually changes (how the joystick feels) can only be judged by ear on hardware.


6. Open questions — operator only

  1. Persistence medium and the audio stall. Internal flash (always present, but every save parks core 1 and interrupts audio for the erase+program — §1.3) or SD (no stall, but needs a card fitted, and it is ✗ unknown whether your units have one)? If flash: is a brief, announced audio interruption on an explicit save acceptable, or does that rule flash out?
  2. What "presets" means for the device. Vision bullet 5 says settings, presets, training, examples, visualisation. This spec covers settings, model transfer, telemetry, and persistence slots. It does not define a preset object — and §6.5c (curated/advanced split) is supposed to define exactly that, and is scheduled before this item. Does the editor wait for 5c's preset model, or ship slots-of-weights now and absorb presets later?
  3. Do examples need to leave the device? Exporting the training set requires a new public accessor on MLPCore (§1.5) and probably hoisting the FeedbackController (§1.5). "Train on device, curate in browser" implies yes; "save/restore a model" implies no. This is the difference between a small v1 and a much larger one.
  4. L29 / step 8: should firmware gain the browser's input pipeline? Deadzone, circular clamp, momentum zoom and EMA would change the feel of every hardware mode, and only your ears can judge it. If the answer is "not now", step 0 softens the comments and the question closes cleanly.
  5. Whose model wins on connect? When the browser connects and both ends hold a trained net for the same mode, does the editor do nothing until told (proposed default), or offer/auto-pull the device's?

7. Decisions taken here (not questions)

  • CRC-16 for frames, CRC-32 for whole-model transfers, not useq's XOR-8 — §3.2.
  • Variable-length frames with a 512-byte cap and chunked models, not one large frame — §3.2, I4.
  • Diagnostics silenced by session flag rather than moved to another port — §3.1, I7.
  • Compatibility by codegen-emitted schema fingerprint + explicit dims, never by mode_id alone — §3.3, I3.
  • LittleFS called directly; memllib's FlashFS.hpp not used and not deleted — §3.5, §4.
  • The four device-side settings live in an enum in the shared header, and the spec says plainly that they are not codegen-derived rather than pretending — §3.4.
  • Protocol header at firmware/shared/, not inside firmware/MEMLNaut-NISPS/, mirroring useq-celium.

8. Where verification is weak

Say this out loud in any PR description that lands these steps:

  • scripts/parity-check.sh proves nothing here. It exercises PAFSynth and ChannelStrip with all params at 0.5. It touches no serial code, no framing, no persistence.
  • Nothing in CI executes firmware. The firmware job compiles three envs and reports sizes ✓ (.github/workflows/ci.yml:213,223). A protocol that compiles is not a protocol that works.
  • Web Serial cannot be exercised headlessly. Playwright can cover the panel's disconnected and incompatible-device states, and the frame codec is unit-tested on both sides — the round trip over a real cable is untested until someone plugs a MEMLNaut in.
  • Mitigation worth building early: a tiny host-side loopback harness — the TS encoder feeding the C parser compiled natively into nisps_modes_tests — turns "the two ends agree about bytes" from a hope into a gate, and costs about an hour. It still says nothing about USB CDC, flash timing, or feel.
  • Step 7 has no automated gate at all beyond "16 envs still build". Flash layout, stall duration and recovery-after-power-loss are hardware chokepoints.