Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
Phase 2, S35. Two real defects from one root cause, both confirmed by trace
rather than taken from the audit:
1. Divergence. WasmIML built its TS Dataset mirror with a cap of 100 while
every addExample() ALSO pushed into the C++ FIFO ring, capped at 128. Since
train() reads the C++ ring and trainAsync() reads the TS mirror, past 100
examples the two trained on different datasets — silently.
2. Latent OOB read. nisps_ml_train sizes its sample-weight span by the C++
side's example_count() (up to 128), but wasm-iml.ts allocates that heap
buffer from the TS dataset's size (<=100). Once the ring exceeds the mirror,
the span reads past the end of the caller's allocation.
Fix: name the capacity ONCE as nisps::ml::kDefaultMaxExamples = 128, used by
FixedStorage's default template arg, DynamicStorage's default ctor arg, and the
MLP<> alias (which is the only real FixedStorage instantiation path and carried
its own independent 128 literal — the last copy of this dual truth). Expose it
through nisps_ml_describe and have the TS side read it instead of hardcoding.
Dataset's constructor default is removed entirely: a default was what invited
this bug class, and the sole call site now always supplies the describe() value.
ABI NOTE: this extends nisps_ml_describe from a 6-int to a 7-int descriptor.
nisps_ml_describe always writes 7 ints regardless of the caller's buffer, so
every call site had to grow in the same change or it would overflow the WASM
heap by 4 bytes per call. All five sites updated: three in wasm-iml.ts (init
defaults, init per-instance, reshape re-describe — the finding said there were
two), one in wasm-worker.ts, one in tests/cpp/parity_wasm.mjs. The parity
harness's expected-dims check now also pins the new max_examples slot.
Regression test: tests/cpp/test_mlp_storage_defaults.cpp — pins the two storage
policies to one constant, and drives MLPCore<DynamicStorage> exactly as
bindings.cpp does past the old TS cap, asserting it saturates at 128 and not at
100. Fail-before/pass-after confirmed by temporarily setting the constant to
100: 2 failures, named. Reverted: green.
Audit correction: the cited dataset.ts:81 is the FIFO eviction check; the
hardcoded default was at dataset.ts:45.
Gates: run-all-tests.sh ALL GREEN, parity PASS.
Phase 1 group 4 (S33, S34, L54).
- S33: removed 12 dead entries across the full 5-layer registration chain
(bindings.cpp KEEPALIVE -> EXPORTED_FUNCTIONS -> the NispsModule declaration
table -> the WasmIML wrapper -> the EngineApi facade): nisps_ml_reset,
example_count, move_weights, feedback_learning_paused, feedback_drag,
jolt_lr_scale, jolt_tick_lr_ramp, pipeline_state_size/save_state/load_state,
feedback_placing and feedback_state. Each was grepped against manifold/src,
manifold/tests, the e2e specs, manifold/tests/wasm-load.ts and
tests/cpp/parity_wasm.mjs — the parity gate builds its own API via cwrap and
is a real consumer, so it counts.
KEPT deliberately: EXPORTED_RUNTIME's heap views + ccall/cwrap (the parity
harness and wasm-load.ts depend on them), and nisps_ml_feedback_static_output,
whose C export IS called directly by parity_wasm.mjs even though no TS
wrapper reaches it. Also dropped parity_wasm.mjs's moveWeights cwrap, which
was declared but never invoked.
- S34: deleted the publishWeights_ channel — EngineSink.setWeights,
Spine.setWeights/weights()/liveWeights and every call site. It ran a C->heap
copy plus a fresh Float32Array allocation at up to 200 Hz into a field
nothing read. getWeights survives for persistence and the debug probe.
- L54: worklet loader — deleted the unused imports object, the 'c' branch,
exMap and the duplicate second loop, and replaced the silent `() => 0` stub
with one that throws, so a missing import fails loudly instead of returning
plausible zeros into the audio path.
manifold/public/nisps.{js,wasm} rebuilt with the trimmed export list (emcc
3.1.69, the CI-pinned version) and committed — the freshness gate added in
Phase 0 requires it, and the webhook ships this artifact to production.
Gates: run-all-tests.sh ALL GREEN, parity 1273 floats within 1e-5.
Operator-approved ABI change (P2 stop-point). The WASM MLP is now
MLPCore<DynamicStorage>:
- nisps_ml_create(input, output, hidden[3], n, seed) honours its args;
non-positive/null fall back to the historical 32→[10,14,18]→126, so
every pre-P2 caller (manifold, worker, parity harness) stays
bit-identical. Invalid/oversized dims (>4096) → null.
- NEW nisps_ml_reshape(ml, in, out, hidden, n, spread): fresh net at the
new dims, warm-started via nisps/ml/warm_start.hpp (overlapping region
copied; rest keeps spread init); feedback controller re-created (state
resets — reset-on-reshape modal is the front-end contract). Failure
leaves the old net untouched.
- nisps_ml_describe(ml, out): takes the handle; null reports defaults.
- FeedbackController got the same storage split: algorithms in
FeedbackControllerCore<FbStorage>; FixedFeedbackStorage keeps firmware/
tests source-identical via the old alias; DynamicFeedbackStorage (one
arena) sizes to the runtime net. Firmware .text unchanged (122692).
- MLHandle: per-instance scratch vectors; dropped the dead 2MB
batch_out_scratch.
- TS: types.ts decls (+_nisps_ml_reshape), wasm-iml re-describes the
created instance, worker carries a shape-contract note for P2.3.
Verified: ctest 4/4 incl. new warm-start grow/shrink test; reshape ABI
smoke (dims honoured, overlap survives, invalid rejected, outputs
bounded); parity PASS unchanged (2.4e-7); lint clean; manifold 9 unit +
20 e2e green; firmware .text 122692 (+0.30% vs pre-P2 baseline).
Algorithms (forward, backprop/SGD, init, move_weights, diagnostics) now live
once in MLPCore<Storage> (nisps/ml/mlp.hpp). Storage models:
- FixedStorage (storage.hpp): template-sized std::array, zero heap. The
classic MLP<NIn,H1,H2,H3,NOut,...> is an alias preserving kInput/kHidden*/
kOutput/kNumLayers/weight_count() constexpr — firmware + bindings + modes
compile unchanged.
- DynamicStorage (dynamic_storage.hpp): runtime dims, ONE arena allocation
at construction, nothing per-call. #error under NISPS_TARGET_EMBEDDED
(new macro in core/perf.hpp); sole lint-cpp.sh heap-allowlist entry, plus
a lint check that fails if the #error guard disappears.
Verification:
- new ctest test_mlp_storage_parity: fixed↔dynamic BIT-identical across
init/draw/inference/train(FIFO)/move_weights(pin mask)/eval_loss/
layer_stats/set_weights/infer_batch/reset; invalid+moved-from inert
- golden ML vectors (pre-refactor constants) pass → bit-stable refactor
- native↔WASM parity PASS, max delta unchanged (2.4e-7)
- chokepoint B compile: PAFSynth .text 122324→122692 (+0.30%, ±1% budget);
RAM +416B (eval scratch)
- fix: firmware-common.sh used bare 'python' (absent here) → ${PYTHON:-python3}
Part of one-core-engine-refactor P2. nisps_ml_create ABI untouched (P2.2 is
an operator stop-point).
Wire the modular input layer into the Console and reshape the browser
engine so input axes are genuine independent dimensions.
Inputs (manifold/src/inputs/):
- gamepad-source: emit press+release edges with standard-mapping labels
(enables hold-and-move); single/double-stick already present.
- midi-input-source: single-device selection + batch "MIDI Learn"
(every CC swept while armed becomes an axis); notes stay discrete.
- input-layer: compose() forwards each axis 1:1 (no mean-blend);
add onReducedInput so the manifold tracks gamepad/MIDI position.
- types: InputAction.phase, InputMode.
Console (manifold/src/console/):
- ConsoleApp: bind gamepad buttons to verdicts (RB up / LB down /
X randomise / Y nudge / B undo / A-hold reposition); mirror composed
position onto the manifold.
- Drawers: rebuilt Inputs drawer (source picker, gamepad legend, MIDI
device picker + batch-learn flow, learned-control meters).
Engine (nisps/wasm, manifold/src/engine):
- DefaultMLP widened MLP<2,..> -> MLP<32,..> (32 = MAX_AXES); each
active axis gets a dedicated slot, unused slots held at 0 (inert).
Rebuilt nisps.wasm (playground + manifold).
- spine/engine-api: setInputs writes the full N-D vector (was dropping
arr[2+]); primary pair keeps the 2-D pipeline; process() re-ticks the
whole vector via spine.reprocess().
Tests:
- parity_check/parity_wasm: ParityMLP -> 32 inputs, widen example bufs.
- CMakeLists: build parity binary with -ffp-contract=off so native
matches FMA-free WASM (training amplified the gap past 1e-5).
Inputs dock is still an exclusive picker; mixing toggles, reshape modal,
and the >2-D slider view (inputs-spec.md) are groundwork-laid but not
yet wired. See docs/redesign/midi-gamepad-inputs-worklog.md.
Adds the Manifold app (manifold/) — the convertible-mode React front-end on
the real NISPS ML+audio engine, served live at meml.lnfinitemonkeys.org/next/.
Console chrome trimmed per UI pass:
- drop mode label + subtitle from the top-left overlay (keep MEMLNaut wordmark)
- remove the composite split preset/ratio readout (top-centre)
- remove the OUTPUT corner tag above the bars
- remove the A/B compare toggle from the verdict cluster
- remove the follow button + input/noise readout (bottom-left)
- remove AltitudeNav focus switcher (bottom-right)