memlnaut-nisps/nisps/CMakeLists.txt

237 lines
11 KiB
Text
Raw Normal View History

cmake_minimum_required(VERSION 3.20)
project(nisps_core CXX)
# ---------------------------------------------------------------------------
# Toolchain / standard
# ---------------------------------------------------------------------------
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Default to a Release build when invoked without -DCMAKE_BUILD_TYPE so host
# tests get optimized math; flip with `-DCMAKE_BUILD_TYPE=Debug` for stepping.
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()
# ---------------------------------------------------------------------------
# Core interface library — header-only. Other parts of the build (ml/, dsp/,
# engines/, modes/) will link against this once they exist.
# ---------------------------------------------------------------------------
add_library(nisps_core INTERFACE)
target_include_directories(nisps_core
INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}
)
target_compile_features(nisps_core INTERFACE cxx_std_20)
# ---------------------------------------------------------------------------
# Emscripten / WASM target detection
# ---------------------------------------------------------------------------
# When building for WASM via emcmake, ${EMSCRIPTEN} is set automatically. We
# don't actually emit a WASM binary from this CMakeLists yet — the WASM build
refactor(nisps): delete dead core/ML mass; keep the legacy feedback modes Phase 1 group 2 (L27, L26, L28, S21, L13, ST6, S20). - L27: fixed_buffer.hpp + its test + the CMake entry — no consumers. - L26: dislike_multiplier_ and its doubling/halving bookkeeping — upstream InterfaceRL residue that drove nothing. The audit pointed at the wrong test file for the surviving reference; the actual assert was in test_mlp_geo_dislike.cpp:211, removed here. - L28: added copy_weights_to(std::span<float>) to FixedStorage and DynamicStorage and switched feedback.hpp's take_snapshot/push_undo/nudge to it. Drops the permanent whole-net flat_ scratch buffer from FixedStorage and the per-gesture double copy. Behaviour-identical: same source values, same write order, same RNG draw order in nudge(). - S21 + L13: deleted NISPS_AUDIO_MEM / NISPS_APP_SRAM / NISPS_AUDIO_FUNC — zero use sites outside perf.hpp and comments — and rewrote midi_io.hpp's one misshapen NISPS_AUDIO_FUNC use as a plain `inline void`. perf.hpp now documents only the inlining/hotness macros that actually exist, and audio_driver.hpp no longer claims an SRAM discipline the code never had. - ST6: feedback.hpp's header now describes the four current modes and the Geometric default, dropping the retracted "geometric push NOT ported" claim. S20 — OPERATOR DECISION (§7.1): the four legacy feedback behaviours (RandomiseOutputs, RandomiseMlp, AvoidStyle::Diffuse, the RandomiseMlp branch of on_drag) are KEPT, not deleted. They are wanted as building blocks for experimenting with how different instruments feel under different behaviours. Each is now marked at its definition as deliberately-retained research reserve so future audits stop flagging it as dead code. L25 (the 16 KB firmware loss-history buffer) is NOT done here — see the phase report; it turned out to be coupled into the shared mlp.hpp, and its fate belongs with the browser telemetry build (§7.3 / plan §6.5e). Gates: run-all-tests.sh ALL GREEN.
2026-07-21 12:48:27 +02:00
# script (scripts/build-wasm.sh) assembles its own
# Emscripten link command. Here we just gate the host-only test executable so
# `emcmake cmake -S nisps -B build-wasm` configures cleanly.
if(EMSCRIPTEN)
message(STATUS "nisps_core: configuring for Emscripten/WASM target")
endif()
# ---------------------------------------------------------------------------
# Test scaffold — only built on the host. We use a hand-rolled assertion-
# based harness (see tests/cpp/test_helpers.hpp) rather than Catch2/doctest;
# the rationale is in test_helpers.hpp.
# ---------------------------------------------------------------------------
if(NOT EMSCRIPTEN)
set(NISPS_TEST_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../tests/cpp)
add_executable(nisps_core_tests
${NISPS_TEST_DIR}/test_main.cpp
${NISPS_TEST_DIR}/test_ring_buffer.cpp
${NISPS_TEST_DIR}/test_rng.cpp
${NISPS_TEST_DIR}/test_math.cpp
feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
${NISPS_TEST_DIR}/test_mlp_init.cpp
${NISPS_TEST_DIR}/test_mlp_inference.cpp
${NISPS_TEST_DIR}/test_mlp_training.cpp
${NISPS_TEST_DIR}/test_mlp_loss.cpp
${NISPS_TEST_DIR}/test_mlp_rl.cpp
${NISPS_TEST_DIR}/test_mlp_storage_parity.cpp
fix(ml): one named example capacity; train() and trainAsync() no longer diverge 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.
2026-07-21 13:22:38 +02:00
${NISPS_TEST_DIR}/test_mlp_storage_defaults.cpp
${NISPS_TEST_DIR}/test_mlp_jolt.cpp
${NISPS_TEST_DIR}/test_mlp_ou_noise.cpp
${NISPS_TEST_DIR}/test_mlp_feedback.cpp
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
${NISPS_TEST_DIR}/test_mlp_geo_dislike.cpp
test(ml): behavioural benchmark + 20 invariants for the control mapping NISPS is a controller, not a synth: the object of study is the mapping f: control-space -> parameter-space and how a musician's gestures deform it. Loss measures fit to points the user dictated, which is the one thing they never experience. So this measures geometry and gesture-response. tests/cpp/ml_bench.cpp 61 scenarios, REPORTS never asserts (same discipline as engine_bench.cpp). Shape- agnostic via MLPCore<DynamicStorage> (--shape, default 2,16,16,16,8), seeded RNG throughout, branch points replayed from scratch rather than snapshotted. tests/cpp/test_ml_behaviour.cpp 20 asserting invariants, wired into nisps_core_tests. scripts/bench-ml.sh native + WASM from one source; --compare, --sweep-shape, --smoke, --scenario, --seed. Documents two contracts that fail SILENTLY (both now pinned by tests): a thumbs-up must call BOTH mlp.add_example() and fb.store_positive(), since dislike_geometric k-NNs the replay buffer and not the MLP dataset; and placed_output() is valid only while state == Placing, after which an empty span whose l2() is 0 scores a broken lifecycle as a perfect place. ALIGNMENT defect 6 re-ranked (SGD-vs-RMSProp is not a research axis - it silently invalidated every ported hyperparameter) and split into 6b (the geometric dislike was ported from a superseded upstream design) and 6c (InterfaceRL, the reference impl, is not in the tree). Gates: build-cpp-tests (138 tests, ctest 4/4), parity-check PASS, lint-cpp clean, bench-ml.sh --smoke runs end to end.
2026-07-25 11:02:24 +02:00
${NISPS_TEST_DIR}/test_ml_behaviour.cpp
feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
${NISPS_TEST_DIR}/test_mlp_serialize.cpp
${NISPS_TEST_DIR}/test_pipeline.cpp
${NISPS_TEST_DIR}/test_vcv_iml_parity.cpp
)
target_link_libraries(nisps_core_tests PRIVATE nisps_core)
# Chris's rule: the core compiles cleanly under -Wall -Wextra -Werror.
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
target_compile_options(nisps_core_tests PRIVATE
-Wall -Wextra -Werror -Wpedantic
)
elseif(MSVC)
target_compile_options(nisps_core_tests PRIVATE /W4 /WX)
endif()
enable_testing()
add_test(NAME nisps_core_tests COMMAND nisps_core_tests)
feat(nisps/engines): port firmware audio engines to AudioEngine concept (meml-1v6) Concept-based, no virtual dispatch, per-engine voice spaces as inline methods. Each engine satisfies nisps::AudioEngine via static_assert. - NoOpEngine: silent passthrough; used for sequencer-only modes and for the SoundAnalysisMIDI mode's audio path. - PAFSynthEngine (33 params, 7 voice spaces): 4-voice PAF synth with detune cascade, ring-mod, sine-shaper, ADSR, feedback delay. note_on/ note_off interface for MIDI keyboard. - ChannelStripEngine (24 params, 6 voice spaces): stereo console strip (pre-gain/HPF/LPF/2x peak/low-shelf/high-shelf/comp/post-gain). Voice spaces: WannabeNeve66, SSL4K, SSL9K, MaleVox, FemaleVox, Neve80 (stepped-frequency). - XIASRIEngine (24 params, "Direct" voice space): pitch-shift + 6 allpass + 2 comb + 4 delays. Direct NN→param mapping per firmware semantics. - VerbFXEngine (47 params, 12 voice spaces): 8-band SVF filterbank + 3-lane dynamic delay + 8-lpcomb/4-allpass Freeverb-style tail with cross-fades. All 12 voice spaces ported from voicespaces/VerbFX/*.hpp. - MEMLCeliumEngine (56 params): 2-track ratio sequencer + dual-voice PAF synth (7+7+22+20 layout). Sequencer triggers V0/V1 ADSR. - BreakOrEngine (56 params): 8-track ratio sequencer; emits NoteOn/ NoteOff/Clock events via pop_events(span). process() returns silence. - ElysiamorfEngine (40 params): 8-track FM-pair sequencer; emits CC events on CCs {1,2,3,4,5,9,11,12}. Silent audio path. - AnalysisEngine (0 params, 6 features): port of XiasriAnalysis (pitch via zero-crossing, aperiodicity via MAD, log-domain energy + attack derivative + brightness ratio). Inputs to ML on SoundAnalysisMIDI mode. All param_count() values match schemas/modes/*.json output_size. 4074 LOC total. CMake adds nisps_dsp_engine_tests target with 38 passing tests under -Wall -Wextra -Werror -Wpedantic.
2026-04-29 15:09:12 +02:00
# ---------------------------------------------------------------------
# DSP + engines tests (stream 3). Built as a separate executable so
# failures here don't take core/ML tests with them.
# ---------------------------------------------------------------------
add_executable(nisps_dsp_engine_tests
${NISPS_TEST_DIR}/test_main.cpp
${NISPS_TEST_DIR}/test_dsp_biquad.cpp
${NISPS_TEST_DIR}/test_dsp_delay.cpp
${NISPS_TEST_DIR}/test_dsp_reverb.cpp
${NISPS_TEST_DIR}/test_dsp_pitch_shift.cpp
${NISPS_TEST_DIR}/test_dsp_seq_shared.cpp
feat(nisps/engines): port firmware audio engines to AudioEngine concept (meml-1v6) Concept-based, no virtual dispatch, per-engine voice spaces as inline methods. Each engine satisfies nisps::AudioEngine via static_assert. - NoOpEngine: silent passthrough; used for sequencer-only modes and for the SoundAnalysisMIDI mode's audio path. - PAFSynthEngine (33 params, 7 voice spaces): 4-voice PAF synth with detune cascade, ring-mod, sine-shaper, ADSR, feedback delay. note_on/ note_off interface for MIDI keyboard. - ChannelStripEngine (24 params, 6 voice spaces): stereo console strip (pre-gain/HPF/LPF/2x peak/low-shelf/high-shelf/comp/post-gain). Voice spaces: WannabeNeve66, SSL4K, SSL9K, MaleVox, FemaleVox, Neve80 (stepped-frequency). - XIASRIEngine (24 params, "Direct" voice space): pitch-shift + 6 allpass + 2 comb + 4 delays. Direct NN→param mapping per firmware semantics. - VerbFXEngine (47 params, 12 voice spaces): 8-band SVF filterbank + 3-lane dynamic delay + 8-lpcomb/4-allpass Freeverb-style tail with cross-fades. All 12 voice spaces ported from voicespaces/VerbFX/*.hpp. - MEMLCeliumEngine (56 params): 2-track ratio sequencer + dual-voice PAF synth (7+7+22+20 layout). Sequencer triggers V0/V1 ADSR. - BreakOrEngine (56 params): 8-track ratio sequencer; emits NoteOn/ NoteOff/Clock events via pop_events(span). process() returns silence. - ElysiamorfEngine (40 params): 8-track FM-pair sequencer; emits CC events on CCs {1,2,3,4,5,9,11,12}. Silent audio path. - AnalysisEngine (0 params, 6 features): port of XiasriAnalysis (pitch via zero-crossing, aperiodicity via MAD, log-domain energy + attack derivative + brightness ratio). Inputs to ML on SoundAnalysisMIDI mode. All param_count() values match schemas/modes/*.json output_size. 4074 LOC total. CMake adds nisps_dsp_engine_tests target with 38 passing tests under -Wall -Wextra -Werror -Wpedantic.
2026-04-29 15:09:12 +02:00
${NISPS_TEST_DIR}/test_engine_no_op.cpp
${NISPS_TEST_DIR}/test_engine_paf_synth.cpp
${NISPS_TEST_DIR}/test_engine_channel_strip.cpp
${NISPS_TEST_DIR}/test_engine_xiasri.cpp
${NISPS_TEST_DIR}/test_engine_verb_fx.cpp
${NISPS_TEST_DIR}/test_engine_memlcelium.cpp
${NISPS_TEST_DIR}/test_engine_breakor.cpp
${NISPS_TEST_DIR}/test_engine_elysiamorf.cpp
${NISPS_TEST_DIR}/test_engine_analysis.cpp
)
target_link_libraries(nisps_dsp_engine_tests PRIVATE nisps_core)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
target_compile_options(nisps_dsp_engine_tests PRIVATE
-Wall -Wextra -Werror -Wpedantic
)
elseif(MSVC)
target_compile_options(nisps_dsp_engine_tests PRIVATE /W4 /WX)
endif()
add_test(NAME nisps_dsp_engine_tests COMMAND nisps_dsp_engine_tests)
# ---------------------------------------------------------------------
# Mode tests (stream 4). Cover concept satisfaction, voice space
# dispatch, control-event pumping, and end-to-end inference→audio.
# ---------------------------------------------------------------------
add_executable(nisps_modes_tests
${NISPS_TEST_DIR}/test_main.cpp
${NISPS_TEST_DIR}/test_mode_concepts.cpp
${NISPS_TEST_DIR}/test_mode_paf_synth.cpp
${NISPS_TEST_DIR}/test_mode_voice_space.cpp
${NISPS_TEST_DIR}/test_mode_breakor_events.cpp
${NISPS_TEST_DIR}/test_mode_learning.cpp
feat: curve truth, DriverConfig, real telemetry, engine benchmark Four items from one workflow, committed together because their build and CI wiring genuinely interleaves — nisps/CMakeLists.txt, run-all-tests.sh and ci.yml each carry hunks from two of them, and the stage renumbering (1/5 -> 1/6) touches every line. Splitting would produce commits that do not build, which is worse than a commit that does four things and says so. S26 part 2 — the curve declaration now matches reality. params[].curve stays the mode-wide DEFAULT; a voice_spaces entry may now be {name, curve_overrides} declaring only the slots where THAT voice space deviates. The 6 modes with one voice space are byte-identical. The values were derived MECHANICALLY by a new codegen/curve-audit.ts that models the four idioms a p[N]*p[N] regex misses (alias form, memlcelium's implicit-counter sq() lambda, loop-generated indices, smooth_params_), inlines helpers, and RAISES rather than guessing when it cannot reduce an expression. A drift gate cross-checks 1179 (voice space x param) slots against engine source on every run and was proved to fail loudly on three drift classes. Application stays in the engine: nisps/engines, nisps/pipeline and nisps/core are untouched, generated output is pure insertion (755 insertions, 0 deletions), and the rebuilt nisps.wasm was byte-identical. S4 / 7.2 — firmware reads the active mode's driver config at mode start, and mic/line is real. My brief assumed the engine owns this; the code disagreed and the code was right. sound_analysis_midi's EngineT is NoOpEngine — the mic lives on a separately-composed AnalysisEngine member — so engine-level wiring would have compiled, passed every gate, and left the one mic mode on line input. Hence a mode-level seam defaulting to engine().driver_config(). Separately, DriverConfig's defaults (line_level 0, output_volume 1.0) had drifted from memllib's actual 3/0.8 because nothing had ever read them; wiring them as-is would have made every silent mode louder and its line input maximally insensitive — a behaviour change disguised as plumbing. Now pinned by a test. Also: GetSysClockSpeed() panic()s on unsupported sample rates and runs on the first line of setup(), so sample_rate needed a fallback ahead of clock setup. CI's firmware env list gains soundanalysismidi — it is the only mic variant and nothing else compiles that path. Plan 5e — telemetry is real. A loss_history C-API entry across the full 5-layer chain lets the browser read the per-iteration loss the core already records. The audit named one fabrication site; there were two — wasm-iml.ts's synchronous train() published lossHistory: [loss] as well. A third, ctx.loss, was not merely dead but actively synthetic (fallbacks of prev * 0.82 and a literal 0.5, rendered by nothing) and is deleted. The firmware buffer stays untouched, per the L25 call. EngineApi.lossHistory() reads spine state rather than the MLP handle, because trainAsync() fits on the worker's mirror net and the handle would give a subtly-wrong second answer. Plan 5f — engine throughput is measurable. One source compiled twice (CMake natively, emcc for WASM) so the targets compare directly and no WASM export is added. Sequencers are driven into a working state, and every row prints its own working-state evidence so a number produced by an idle engine is visible rather than plausible. Reports, never asserts: a wall-clock threshold on shared hardware is meaningless or flaky, same call as the firmware size job. ALIGNMENT: the telemetry defect is deleted (built, not deferred); the performance defect is rewritten to what is actually left — these are HOST numbers, and nothing measures the RP2350 at 150 MHz, which is the target the mission's constraint is about. Q4 (memllib ownership) and Q5 (legacy feedback modes) are closed. Corrections to my own earlier claims, both found by agents contradicting the brief: manifold/ONBOARDING.md was NOT "now accurate" — its primitives list still named five deleted primitives and cited a seededGradient() that does not exist. And the parity harness misses the sequencer engines because it runs 128 frames while their sequencers evaluate every 400-500 samples, NOT because all-params-0.5 fails to trigger them (it does trigger: 0.5 maps to ratio 2, firing three times per bar). The fix is a longer window, not different params. Gates: run-all-tests.sh ALL GREEN — 4/4 ctest, parity PASS, lint clean, curve drift 1179 slots ok, 39 e2e (was 33). Firmware: 5 envs built including the mic variant.
2026-07-21 22:02:23 +02:00
${NISPS_TEST_DIR}/test_mode_curve_overrides.cpp
${NISPS_TEST_DIR}/test_mode_driver_config.cpp
)
target_link_libraries(nisps_modes_tests PRIVATE nisps_core)
feat: curve truth, DriverConfig, real telemetry, engine benchmark Four items from one workflow, committed together because their build and CI wiring genuinely interleaves — nisps/CMakeLists.txt, run-all-tests.sh and ci.yml each carry hunks from two of them, and the stage renumbering (1/5 -> 1/6) touches every line. Splitting would produce commits that do not build, which is worse than a commit that does four things and says so. S26 part 2 — the curve declaration now matches reality. params[].curve stays the mode-wide DEFAULT; a voice_spaces entry may now be {name, curve_overrides} declaring only the slots where THAT voice space deviates. The 6 modes with one voice space are byte-identical. The values were derived MECHANICALLY by a new codegen/curve-audit.ts that models the four idioms a p[N]*p[N] regex misses (alias form, memlcelium's implicit-counter sq() lambda, loop-generated indices, smooth_params_), inlines helpers, and RAISES rather than guessing when it cannot reduce an expression. A drift gate cross-checks 1179 (voice space x param) slots against engine source on every run and was proved to fail loudly on three drift classes. Application stays in the engine: nisps/engines, nisps/pipeline and nisps/core are untouched, generated output is pure insertion (755 insertions, 0 deletions), and the rebuilt nisps.wasm was byte-identical. S4 / 7.2 — firmware reads the active mode's driver config at mode start, and mic/line is real. My brief assumed the engine owns this; the code disagreed and the code was right. sound_analysis_midi's EngineT is NoOpEngine — the mic lives on a separately-composed AnalysisEngine member — so engine-level wiring would have compiled, passed every gate, and left the one mic mode on line input. Hence a mode-level seam defaulting to engine().driver_config(). Separately, DriverConfig's defaults (line_level 0, output_volume 1.0) had drifted from memllib's actual 3/0.8 because nothing had ever read them; wiring them as-is would have made every silent mode louder and its line input maximally insensitive — a behaviour change disguised as plumbing. Now pinned by a test. Also: GetSysClockSpeed() panic()s on unsupported sample rates and runs on the first line of setup(), so sample_rate needed a fallback ahead of clock setup. CI's firmware env list gains soundanalysismidi — it is the only mic variant and nothing else compiles that path. Plan 5e — telemetry is real. A loss_history C-API entry across the full 5-layer chain lets the browser read the per-iteration loss the core already records. The audit named one fabrication site; there were two — wasm-iml.ts's synchronous train() published lossHistory: [loss] as well. A third, ctx.loss, was not merely dead but actively synthetic (fallbacks of prev * 0.82 and a literal 0.5, rendered by nothing) and is deleted. The firmware buffer stays untouched, per the L25 call. EngineApi.lossHistory() reads spine state rather than the MLP handle, because trainAsync() fits on the worker's mirror net and the handle would give a subtly-wrong second answer. Plan 5f — engine throughput is measurable. One source compiled twice (CMake natively, emcc for WASM) so the targets compare directly and no WASM export is added. Sequencers are driven into a working state, and every row prints its own working-state evidence so a number produced by an idle engine is visible rather than plausible. Reports, never asserts: a wall-clock threshold on shared hardware is meaningless or flaky, same call as the firmware size job. ALIGNMENT: the telemetry defect is deleted (built, not deferred); the performance defect is rewritten to what is actually left — these are HOST numbers, and nothing measures the RP2350 at 150 MHz, which is the target the mission's constraint is about. Q4 (memllib ownership) and Q5 (legacy feedback modes) are closed. Corrections to my own earlier claims, both found by agents contradicting the brief: manifold/ONBOARDING.md was NOT "now accurate" — its primitives list still named five deleted primitives and cited a seededGradient() that does not exist. And the parity harness misses the sequencer engines because it runs 128 frames while their sequencers evaluate every 400-500 samples, NOT because all-params-0.5 fails to trigger them (it does trigger: 0.5 maps to ratio 2, firing three times per bar). The fix is a longer window, not different params. Gates: run-all-tests.sh ALL GREEN — 4/4 ctest, parity PASS, lint clean, curve drift 1179 slots ok, 39 e2e (was 33). Firmware: 5 envs built including the mic variant.
2026-07-21 22:02:23 +02:00
# test_mode_driver_config.cpp also covers the firmware-side codec clamping
# (firmware/MEMLNaut-NISPS/glue/codec_config.hpp — deliberately Arduino-free
# so it is host-testable). That header includes "nisps/core/types.hpp" the
# way the firmware does, so the repo root has to be on the include path.
target_include_directories(nisps_modes_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/..)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
target_compile_options(nisps_modes_tests PRIVATE
-Wall -Wextra -Werror -Wpedantic
)
elseif(MSVC)
target_compile_options(nisps_modes_tests PRIVATE /W4 /WX)
endif()
add_test(NAME nisps_modes_tests COMMAND nisps_modes_tests)
# ---------------------------------------------------------------------
# Stream 11 verification suite (golden vectors + engine impulse).
# Lives in tests/cpp/, registered separately so a regression here can
# be diagnosed without rebuilding the world.
# ---------------------------------------------------------------------
add_executable(nisps_golden_tests
${NISPS_TEST_DIR}/test_main.cpp
${NISPS_TEST_DIR}/ml_golden_vectors.cpp
${NISPS_TEST_DIR}/engine_impulse.cpp
)
target_link_libraries(nisps_golden_tests PRIVATE nisps_core)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
target_compile_options(nisps_golden_tests PRIVATE
-Wall -Wextra -Werror -Wpedantic
)
elseif(MSVC)
target_compile_options(nisps_golden_tests PRIVATE /W4 /WX)
endif()
add_test(NAME nisps_golden_tests COMMAND nisps_golden_tests)
# Run the impulse test from the repo root so the relative baseline path
# in `engine_impulse.cpp` resolves to tests/cpp/engine_impulse_baseline.bin.
set_tests_properties(nisps_golden_tests PROPERTIES
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..
)
feat: curve truth, DriverConfig, real telemetry, engine benchmark Four items from one workflow, committed together because their build and CI wiring genuinely interleaves — nisps/CMakeLists.txt, run-all-tests.sh and ci.yml each carry hunks from two of them, and the stage renumbering (1/5 -> 1/6) touches every line. Splitting would produce commits that do not build, which is worse than a commit that does four things and says so. S26 part 2 — the curve declaration now matches reality. params[].curve stays the mode-wide DEFAULT; a voice_spaces entry may now be {name, curve_overrides} declaring only the slots where THAT voice space deviates. The 6 modes with one voice space are byte-identical. The values were derived MECHANICALLY by a new codegen/curve-audit.ts that models the four idioms a p[N]*p[N] regex misses (alias form, memlcelium's implicit-counter sq() lambda, loop-generated indices, smooth_params_), inlines helpers, and RAISES rather than guessing when it cannot reduce an expression. A drift gate cross-checks 1179 (voice space x param) slots against engine source on every run and was proved to fail loudly on three drift classes. Application stays in the engine: nisps/engines, nisps/pipeline and nisps/core are untouched, generated output is pure insertion (755 insertions, 0 deletions), and the rebuilt nisps.wasm was byte-identical. S4 / 7.2 — firmware reads the active mode's driver config at mode start, and mic/line is real. My brief assumed the engine owns this; the code disagreed and the code was right. sound_analysis_midi's EngineT is NoOpEngine — the mic lives on a separately-composed AnalysisEngine member — so engine-level wiring would have compiled, passed every gate, and left the one mic mode on line input. Hence a mode-level seam defaulting to engine().driver_config(). Separately, DriverConfig's defaults (line_level 0, output_volume 1.0) had drifted from memllib's actual 3/0.8 because nothing had ever read them; wiring them as-is would have made every silent mode louder and its line input maximally insensitive — a behaviour change disguised as plumbing. Now pinned by a test. Also: GetSysClockSpeed() panic()s on unsupported sample rates and runs on the first line of setup(), so sample_rate needed a fallback ahead of clock setup. CI's firmware env list gains soundanalysismidi — it is the only mic variant and nothing else compiles that path. Plan 5e — telemetry is real. A loss_history C-API entry across the full 5-layer chain lets the browser read the per-iteration loss the core already records. The audit named one fabrication site; there were two — wasm-iml.ts's synchronous train() published lossHistory: [loss] as well. A third, ctx.loss, was not merely dead but actively synthetic (fallbacks of prev * 0.82 and a literal 0.5, rendered by nothing) and is deleted. The firmware buffer stays untouched, per the L25 call. EngineApi.lossHistory() reads spine state rather than the MLP handle, because trainAsync() fits on the worker's mirror net and the handle would give a subtly-wrong second answer. Plan 5f — engine throughput is measurable. One source compiled twice (CMake natively, emcc for WASM) so the targets compare directly and no WASM export is added. Sequencers are driven into a working state, and every row prints its own working-state evidence so a number produced by an idle engine is visible rather than plausible. Reports, never asserts: a wall-clock threshold on shared hardware is meaningless or flaky, same call as the firmware size job. ALIGNMENT: the telemetry defect is deleted (built, not deferred); the performance defect is rewritten to what is actually left — these are HOST numbers, and nothing measures the RP2350 at 150 MHz, which is the target the mission's constraint is about. Q4 (memllib ownership) and Q5 (legacy feedback modes) are closed. Corrections to my own earlier claims, both found by agents contradicting the brief: manifold/ONBOARDING.md was NOT "now accurate" — its primitives list still named five deleted primitives and cited a seededGradient() that does not exist. And the parity harness misses the sequencer engines because it runs 128 frames while their sequencers evaluate every 400-500 samples, NOT because all-params-0.5 fails to trigger them (it does trigger: 0.5 maps to ratio 2, firing three times per bar). The fix is a longer window, not different params. Gates: run-all-tests.sh ALL GREEN — 4/4 ctest, parity PASS, lint clean, curve drift 1179 slots ok, 39 e2e (was 33). Firmware: 5 envs built including the mic variant.
2026-07-21 22:02:23 +02:00
# ---------------------------------------------------------------------
# Standalone engine throughput benchmark. NOT registered with ctest: it
# measures time and asserts nothing, so it has no pass/fail to report and
# a per-block timing loop has no place in a gate every change pays for.
# Driven by scripts/bench-engines.sh, which also compiles the SAME source
# to WASM via emcc for the cross-target comparison.
# ---------------------------------------------------------------------
add_executable(nisps_engine_bench
${NISPS_TEST_DIR}/engine_bench.cpp
)
target_link_libraries(nisps_engine_bench PRIVATE nisps_core)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
target_compile_options(nisps_engine_bench PRIVATE
-Wall -Wextra -Werror -Wpedantic -O3
)
elseif(MSVC)
target_compile_options(nisps_engine_bench PRIVATE /W4 /WX)
endif()
test(ml): behavioural benchmark + 20 invariants for the control mapping NISPS is a controller, not a synth: the object of study is the mapping f: control-space -> parameter-space and how a musician's gestures deform it. Loss measures fit to points the user dictated, which is the one thing they never experience. So this measures geometry and gesture-response. tests/cpp/ml_bench.cpp 61 scenarios, REPORTS never asserts (same discipline as engine_bench.cpp). Shape- agnostic via MLPCore<DynamicStorage> (--shape, default 2,16,16,16,8), seeded RNG throughout, branch points replayed from scratch rather than snapshotted. tests/cpp/test_ml_behaviour.cpp 20 asserting invariants, wired into nisps_core_tests. scripts/bench-ml.sh native + WASM from one source; --compare, --sweep-shape, --smoke, --scenario, --seed. Documents two contracts that fail SILENTLY (both now pinned by tests): a thumbs-up must call BOTH mlp.add_example() and fb.store_positive(), since dislike_geometric k-NNs the replay buffer and not the MLP dataset; and placed_output() is valid only while state == Placing, after which an empty span whose l2() is 0 scores a broken lifecycle as a perfect place. ALIGNMENT defect 6 re-ranked (SGD-vs-RMSProp is not a research axis - it silently invalidated every ported hyperparameter) and split into 6b (the geometric dislike was ported from a superseded upstream design) and 6c (InterfaceRL, the reference impl, is not in the tree). Gates: build-cpp-tests (138 tests, ctest 4/4), parity-check PASS, lint-cpp clean, bench-ml.sh --smoke runs end to end.
2026-07-25 11:02:24 +02:00
# ---------------------------------------------------------------------
# Standalone ML BEHAVIOUR benchmark. Same discipline as nisps_engine_bench:
# it reports numbers and asserts nothing, so it is not a ctest gate. It
# measures the shape of the control mapping and how interaction journeys
# deform it — see MAP.md § `tests/cpp/`. Driven by
# scripts/bench-ml.sh, which compiles the SAME source to WASM via emcc.
#
# Uses MLPCore<DynamicStorage>, so it is host-only by construction
# (dynamic_storage.hpp #errors on RP2350). That is correct: the point is
# arbitrary runtime shapes, which firmware deliberately cannot have.
# ---------------------------------------------------------------------
add_executable(nisps_ml_bench
${NISPS_TEST_DIR}/ml_bench.cpp
)
target_link_libraries(nisps_ml_bench PRIVATE nisps_core)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
target_compile_options(nisps_ml_bench PRIVATE
-Wall -Wextra -Werror -Wpedantic -O3
)
elseif(MSVC)
target_compile_options(nisps_ml_bench PRIVATE /W4 /WX)
endif()
# Standalone parity-check runner. NOT registered with ctest — it's
# invoked from scripts/parity-check.sh which orchestrates native+WASM
# together.
add_executable(nisps_parity_check
${NISPS_TEST_DIR}/parity_check.cpp
)
target_link_libraries(nisps_parity_check PRIVATE nisps_core)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
target_compile_options(nisps_parity_check PRIVATE
-Wall -Wextra -Werror -Wpedantic
feat(manifold): MIDI + game controller inputs; widen ML net to N-D 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.
2026-06-28 21:05:30 +02:00
# Disable FP multiply-add contraction so native matches the WASM
# build, which has no FMA instruction. Without this, native clang/gcc
# fuses MACs in the training backprop and the (chaotic) loop amplifies
# the rounding difference past the 1e-5 parity tolerance — pronounced
# since the input layer widened to 32 for mix-and-match inputs.
-ffp-contract=off
)
elseif(MSVC)
feat(manifold): MIDI + game controller inputs; widen ML net to N-D 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.
2026-06-28 21:05:30 +02:00
target_compile_options(nisps_parity_check PRIVATE /W4 /WX /fp:precise)
endif()
endif()