feat(vcv): reunify module onto core MLP — thin iml.hpp adapter (P6)

Replace the vendored runtime MLP in vcv/src/iml.hpp (DetRng + 3D-weight-store
MLP + Dataset + IML) with a THIN, Rack-free adapter over the shared core:
nisps::ml::MLPCore<nisps::ml::DynamicStorage> (8->[16,24,16]->16, the P2 dynamic
case), nisps::Rng, and the core MLP's own FIFO dataset. Behaviour changes from
the vendored approximation to core-exact firmware/WASM semantics.

- MEMLNaut.cpp: staged/pending weight buffers and patch JSON now use the core's
  flat [weights..][biases..] vector (nisps::IML<float>::Weights); patch version
  bumped to 3. Double-buffer / single-writer threading discipline unchanged.
- New ctest tests/cpp/test_vcv_iml_parity.cpp: seeded train/infer/move_weights
  session through the adapter is memcmp-equal to a bare MLPCore<DynamicStorage>.
- Docs: vcv-module.md delta #5 marked CLOSED (2026-07-18); MAP.md vcv/ updated.

Closes vcv-module.md delta #5.
This commit is contained in:
monkey-w1n5t0n 2026-07-18 13:01:28 +02:00
parent e1713ae41f
commit 1b69254de2
7 changed files with 350 additions and 400 deletions

12
MAP.md
View file

@ -86,11 +86,13 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
`tests/pipeline-golden.test.ts` (in `bun run test`). `manifold/osc-bridge/` — Deno WS↔UDP-OSC bridge. `tests/pipeline-golden.test.ts` (in `bun run test`). `manifold/osc-bridge/` — Deno WS↔UDP-OSC bridge.
### `vcv/` — VCV Rack 2 plugin (MEMLNaut module, WIP) ### `vcv/` — VCV Rack 2 plugin (MEMLNaut module, WIP)
Native C++ Rack module: ML CV-mapper with RL feedback + a browser bridge. Currently 2→12 (being evolved to Native C++ Rack module: ML CV-mapper with RL feedback + a browser bridge. **8 inputs × 16 outputs + per-output
**8 inputs × 16 outputs + per-output LED rings**, palette from the frontend tokens, WS↔OSC browser bridge — see LED rings**, palette from the frontend tokens, WS↔OSC browser bridge (see `docs/specs/vcv-module.md`).
the "BUILD DELTAS" block at the top of `docs/specs/vcv-module.md`). `src/MEMLNaut.cpp` (module), `src/osc_server.hpp` (bridge), `src/MEMLNaut.cpp` (module, 8→[16,24,16]→16), `src/iml.hpp` (**thin adapter over `nisps::ml::MLPCore<DynamicStorage>`
`src/plugin.{hpp,cpp}`, `res/*.svg` (panels), `Makefile` (needs `RACK_DIR`). Was built against the retired + core `nisps::Rng`** — P6 reunification 2026-07-18, closes vcv-module.md delta #5; behaviour is now core-exact,
`nisps-core`; the core include path is being repointed. pinned by `tests/cpp/test_vcv_iml_parity.cpp`), `src/osc_server.hpp` (bridge, transport-only), `src/plugin.{hpp,cpp}`,
`res/*.svg` (panels), `Makefile` (needs `RACK_DIR`). Builds against the current `../nisps/` core via relative
includes; no `nisps-core`.
### `schemas/` — JSON parameter contracts (firmware/browser source of truth) ### `schemas/` — JSON parameter contracts (firmware/browser source of truth)
- `schemas/schema.json` — Draft 2020-12 meta-schema validating mode files. - `schemas/schema.json` — Draft 2020-12 meta-schema validating mode files.

View file

@ -174,9 +174,19 @@ The architecture is **fixed at compile time**; no runtime reconfiguration of inp
### Core Library Integration ### Core Library Integration
The MLP uses a **runtime-shaped IML** (not the fixed-size WASM template). Point the build at the current `../nisps/` (not the retired `nisps-core`) and either: **Delta #5 CLOSED (2026-07-18, one-core-engine-refactor P6).** The module no longer
1. Reuse `nisps/ml/mlp.hpp` and compile with `MLP<8, 24, 32, 16, 16>` type, or vendors its own MLP. `vcv/src/iml.hpp` is now a THIN, Rack-free adapter over the shared
2. Vendor a minimal self-contained 8→16 IML in `vcv/src/`, ensuring it shares the firmware/browser training semantics (spread-aware `DrawWeights`/`MoveWeights`, deterministic RNG). If templated-API constraints block option 1, option 2 is acceptable with a note of alignment as a follow-up. core: `nisps::ml::MLPCore<nisps::ml::DynamicStorage>` (the runtime-shaped branch of the one
core MLP — fixed 4-layer ReLU×3 + Sigmoid topology, three runtime hidden sizes) with the
module's real `[16, 24, 16]` shape, `nisps::Rng` (nisps/core/rng.hpp) replacing the vendored
`DetRng`, and the core MLP's own FIFO dataset replacing the vendored `Dataset`. The adapter
includes only nisps headers + the standard library (no Rack includes) so the host ctest can
compile it. Behaviour therefore **changed** from the vendored *approximation* of firmware/
browser training semantics to **core-exact**: weight init, RL `move_weights`, SGD training,
activations and RNG are now bit-identical to the firmware/WASM engine — pinned by
`tests/cpp/test_vcv_iml_parity.cpp` (adapter == bare `MLPCore<DynamicStorage>`, memcmp-equal).
Persisted weights are now the core's FLAT `[weights…][biases…]` vector (patch `version` = 3);
old 3D vendored weight blobs will not load.
### Spread Parameter ### Spread Parameter

View file

@ -63,6 +63,7 @@ if(NOT EMSCRIPTEN)
${NISPS_TEST_DIR}/test_mlp_geo_dislike.cpp ${NISPS_TEST_DIR}/test_mlp_geo_dislike.cpp
${NISPS_TEST_DIR}/test_mlp_serialize.cpp ${NISPS_TEST_DIR}/test_mlp_serialize.cpp
${NISPS_TEST_DIR}/test_pipeline.cpp ${NISPS_TEST_DIR}/test_pipeline.cpp
${NISPS_TEST_DIR}/test_vcv_iml_parity.cpp
) )
target_link_libraries(nisps_core_tests PRIVATE nisps_core) target_link_libraries(nisps_core_tests PRIVATE nisps_core)

View file

@ -0,0 +1,123 @@
// tests/cpp/test_vcv_iml_parity.cpp — the VCV module's IML adapter is a THIN
// wrapper over the shared core (one-core-engine-refactor P6 gate; closes
// vcv-module.md delta #5).
//
// A seeded train/infer session driven through the adapter
// (`nisps::IML<float>`, vcv/src/iml.hpp) must be BIT-IDENTICAL to driving a
// bare `MLPCore<DynamicStorage>` of the same shape/seed with the same examples
// and ops. Not 1e-5-near — memcmp-equal. This is what proves the module now
// runs core-exact semantics rather than the retired vendored approximation.
#include <cstdint>
#include <cstring>
#include <span>
#include <vector>
#include "../../nisps/ml/dynamic_storage.hpp"
#include "../../nisps/ml/mlp.hpp"
#include "../../vcv/src/iml.hpp"
#include "test_helpers.hpp"
namespace {
// The module's real runtime shape: 8 inputs → [16, 24, 16] → 16 outputs.
constexpr std::size_t kIn = 8u;
constexpr std::size_t kH1 = 16u, kH2 = 24u, kH3 = 16u;
constexpr std::size_t kOut = 16u;
constexpr std::uint64_t kSeed = 0xC0FFEEu;
using DynamicMLP = nisps::ml::MLPCore<nisps::ml::DynamicStorage>;
bool bit_equal(std::span<const float> a, std::span<const float> b) {
if (a.size() != b.size()) return false;
if (a.empty()) return true;
return std::memcmp(a.data(), b.data(), a.size() * sizeof(float)) == 0;
}
} // namespace
NISPS_TEST(vcv_iml_adapter_matches_core_bitexact) {
nisps::IML<float> adapter(kIn, kOut, {kH1, kH2, kH3},
/*max_iterations=*/200u,
/*learning_rate=*/0.1f,
/*convergence_threshold=*/0.00001f,
kSeed);
// The bare core the adapter is supposed to be a thin skin over: same seed,
// same dims, same capacities (kMaxExamples / max_iter_train) the adapter
// hands its own MLPCore at construction.
const std::size_t hidden[3] = {kH1, kH2, kH3};
DynamicMLP ref(kSeed, kIn, std::span<const std::size_t>(hidden), kOut,
nisps::IML<float>::kMaxExamples, adapter.train_max_iter());
NISPS_ASSERT(ref.valid());
// Construction alone (MLPCore ctor draws weights(1.f) from the seed).
{
auto aw = adapter.get_weights();
auto rw = ref.get_weights();
NISPS_ASSERT(aw.size() == rw.size());
NISPS_EXPECT(bit_equal(std::span<const float>(aw.data(), aw.size()), rw));
}
// Draw at a fixed interior spread.
adapter.randomise_weights(0.6f);
ref.draw_weights(0.6f);
{
auto aw = adapter.get_weights();
NISPS_EXPECT(bit_equal(std::span<const float>(aw.data(), aw.size()), ref.get_weights()));
}
// Add a fixed set of examples through both paths.
for (std::size_t e = 0; e < 6u; ++e) {
float feat[kIn];
float lab[kOut];
for (std::size_t i = 0; i < kIn; ++i)
feat[i] = 0.1f * static_cast<float>((e + i) % 10u);
for (std::size_t i = 0; i < kOut; ++i)
lab[i] = 0.05f * static_cast<float>((e * 3u + i) % 20u);
adapter.add_example(feat, kIn, lab, kOut);
ref.add_example(std::span<const float>(feat), std::span<const float>(lab));
}
NISPS_ASSERT(adapter.get_example_count() == ref.example_count());
// Train a fixed number of iterations. The adapter trains via the module's
// real Training→Inference transition; the bare core uses the identical
// (lr, max_iter, min_err) the adapter would.
adapter.set_mode(nisps::IML<float>::Mode::Training);
adapter.set_mode(nisps::IML<float>::Mode::Inference);
ref.train(adapter.train_lr(), adapter.train_max_iter(), adapter.train_min_err());
{
auto aw = adapter.get_weights();
NISPS_EXPECT(bit_equal(std::span<const float>(aw.data(), aw.size()), ref.get_weights()));
}
// Inference outputs at a fixed probe input.
const float probe[kIn] = {0.1f, 0.9f, 0.25f, 0.75f, 0.5f, 0.33f, 0.66f, 0.42f};
for (std::size_t i = 0; i < kIn; ++i) {
adapter.set_input(i, probe[i]);
ref.set_input(i, probe[i]);
}
adapter.process();
ref.process();
NISPS_EXPECT(bit_equal(std::span<const float>(adapter.get_outputs(), kOut), ref.outputs()));
// RL move_weights with an output pin mask (thumbs-down perturbation path).
std::uint8_t mask[kOut] = {};
mask[2] = 1u;
mask[5] = 1u;
adapter.move_weights(0.3f, 0.4f, std::span<const std::uint8_t>(mask));
ref.move_weights(0.3f, 0.4f, std::span<const std::uint8_t>(mask));
{
auto aw = adapter.get_weights();
NISPS_EXPECT(bit_equal(std::span<const float>(aw.data(), aw.size()), ref.get_weights()));
}
// And outputs stay identical after the perturbation.
for (std::size_t i = 0; i < kIn; ++i) {
adapter.set_input(i, probe[i]);
ref.set_input(i, probe[i]);
}
adapter.process();
ref.process();
NISPS_EXPECT(bit_equal(std::span<const float>(adapter.get_outputs(), kOut), ref.outputs()));
}

View file

@ -11,8 +11,9 @@ endif
FLAGS += -std=c++20 FLAGS += -std=c++20
FLAGS += -I$(RACK_DIR)/include -I$(RACK_DIR)/dep/include FLAGS += -I$(RACK_DIR)/include -I$(RACK_DIR)/dep/include
# The retired nisps-core header tree is gone; the runtime IML/MLP is vendored # src/iml.hpp is a THIN adapter over the shared C++ core: it pulls in
# self-contained in src/iml.hpp (see that file's header for the rationale). # nisps/ml/{mlp,dynamic_storage}.hpp + nisps/core/rng.hpp via relative includes
# (../../nisps/…), so no extra -I for the core is needed here (P6, 2026-07-18).
FLAGS += -Isrc FLAGS += -Isrc
SOURCES += src/plugin.cpp SOURCES += src/plugin.cpp

View file

@ -82,12 +82,12 @@ struct MEMLNaut : Module {
nisps::IML<float> iml{NUM_ML_INPUTS, NUM_ML_OUTPUTS, {16, 24, 16}}; nisps::IML<float> iml{NUM_ML_INPUTS, NUM_ML_OUTPUTS, {16, 24, 16}};
nisps::IML<float> imlShadow{NUM_ML_INPUTS, NUM_ML_OUTPUTS, {16, 24, 16}}; nisps::IML<float> imlShadow{NUM_ML_INPUTS, NUM_ML_OUTPUTS, {16, 24, 16}};
// Worker → Audio: staged weights ready for swap // Worker → Audio: staged weights ready for swap (core-exact flat layout)
nisps::MLP<float>::mlp_weights pendingWeights; nisps::IML<float>::Weights pendingWeights;
std::atomic<bool> weightsPending{false}; std::atomic<bool> weightsPending{false};
// Audio → Worker: staged weight snapshot for the worker to start from // Audio → Worker: staged weight snapshot for the worker to start from
nisps::MLP<float>::mlp_weights stagedWeightsForWorker; nisps::IML<float>::Weights stagedWeightsForWorker;
std::vector<std::vector<float>> stagedFeatures; std::vector<std::vector<float>> stagedFeatures;
std::vector<std::vector<float>> stagedLabels; std::vector<std::vector<float>> stagedLabels;
std::mutex stagingMutex; std::mutex stagingMutex;
@ -624,7 +624,7 @@ struct MEMLNaut : Module {
// ── Serialization ───────────────────────────────────────────────── // ── Serialization ─────────────────────────────────────────────────
json_t* dataToJson() override { json_t* dataToJson() override {
json_t* root = json_object(); json_t* root = json_object();
json_object_set_new(root, "version", json_integer(2)); json_object_set_new(root, "version", json_integer(3));
json_object_set_new(root, "inputCount", json_integer(NUM_ML_INPUTS)); json_object_set_new(root, "inputCount", json_integer(NUM_ML_INPUTS));
json_object_set_new(root, "outputCount", json_integer(NUM_ML_OUTPUTS)); json_object_set_new(root, "outputCount", json_integer(NUM_ML_OUTPUTS));
json_object_set_new(root, "noiseLevel", json_real(noiseLevel)); json_object_set_new(root, "noiseLevel", json_real(noiseLevel));
@ -643,17 +643,10 @@ struct MEMLNaut : Module {
json_array_append_new(inRanges, json_boolean(inputRangeUnipolar[i])); json_array_append_new(inRanges, json_boolean(inputRangeUnipolar[i]));
json_object_set_new(root, "inputRangeUnipolar", inRanges); json_object_set_new(root, "inputRangeUnipolar", inRanges);
// Core-exact FLAT weight vector: [layer0_w … layer3_w][layer0_b … layer3_b].
auto weights = iml.get_weights(); auto weights = iml.get_weights();
json_t* jWeights = json_array(); json_t* jWeights = json_array();
for (auto& layer : weights) { for (float w : weights) json_array_append_new(jWeights, json_real(w));
json_t* jLayer = json_array();
for (auto& node : layer) {
json_t* jNode = json_array();
for (float w : node) json_array_append_new(jNode, json_real(w));
json_array_append_new(jLayer, jNode);
}
json_array_append_new(jWeights, jLayer);
}
json_object_set_new(root, "weights", jWeights); json_object_set_new(root, "weights", jWeights);
auto features = iml.get_example_features(); auto features = iml.get_example_features();
@ -675,9 +668,11 @@ struct MEMLNaut : Module {
json_object_set_new(jExamples, "labels", jLabels); json_object_set_new(jExamples, "labels", jLabels);
json_object_set_new(root, "examples", jExamples); json_object_set_new(root, "examples", jExamples);
// Core topology: explicit biases (no trailing bias node), so the layer
// node counts are [n_in, 16, 24, 16, n_out].
json_t* jConfig = json_object(); json_t* jConfig = json_object();
json_t* jLayers = json_array(); json_t* jLayers = json_array();
json_array_append_new(jLayers, json_integer(NUM_ML_INPUTS + 1)); // + bias json_array_append_new(jLayers, json_integer(NUM_ML_INPUTS));
for (int h : {16, 24, 16}) json_array_append_new(jLayers, json_integer(h)); for (int h : {16, 24, 16}) json_array_append_new(jLayers, json_integer(h));
json_array_append_new(jLayers, json_integer(NUM_ML_OUTPUTS)); json_array_append_new(jLayers, json_integer(NUM_ML_OUTPUTS));
json_object_set_new(jConfig, "layers", jLayers); json_object_set_new(jConfig, "layers", jLayers);
@ -707,21 +702,15 @@ struct MEMLNaut : Module {
for (int i = 0; i < MAX_ML_INPUTS && i < (int)json_array_size(inRanges); i++) for (int i = 0; i < MAX_ML_INPUTS && i < (int)json_array_size(inRanges); i++)
inputRangeUnipolar[i] = json_boolean_value(json_array_get(inRanges, i)); inputRangeUnipolar[i] = json_boolean_value(json_array_get(inRanges, i));
// Core-exact FLAT weight vector (patch version ≥ 3). Old 3D weight
// blobs from the vendored model are a different shape and are ignored
// by set_weights (size guard) — see iml.hpp header.
json_t* jWeights = json_object_get(root, "weights"); json_t* jWeights = json_object_get(root, "weights");
if (jWeights && json_is_array(jWeights)) { if (jWeights && json_is_array(jWeights)) {
nisps::MLP<float>::mlp_weights weights; nisps::IML<float>::Weights weights;
for (size_t li = 0; li < json_array_size(jWeights); li++) { weights.reserve(json_array_size(jWeights));
json_t* jLayer = json_array_get(jWeights, li); for (size_t wi = 0; wi < json_array_size(jWeights); wi++)
std::vector<std::vector<float>> layer; weights.push_back(json_real_value(json_array_get(jWeights, wi)));
for (size_t ni = 0; ni < json_array_size(jLayer); ni++) {
json_t* jNode = json_array_get(jLayer, ni);
std::vector<float> node;
for (size_t wi = 0; wi < json_array_size(jNode); wi++)
node.push_back(json_real_value(json_array_get(jNode, wi)));
layer.push_back(node);
}
weights.push_back(layer);
}
iml.set_weights(weights); iml.set_weights(weights);
} }

View file

@ -1,409 +1,233 @@
// iml.hpp — Self-contained runtime IML/MLP for the MEMLNaut VCV module. // iml.hpp — THIN adapter over the real NISPS core for the MEMLNaut VCV module.
// //
// This is a VENDORED, runtime-shaped re-implementation of the nisps core // P6 REUNIFICATION (one-core-engine-refactor, 2026-07-18): this file USED to
// `nisps::IML<float>` / `nisps::MLP<float>` surface the VCV module relies on. // be a self-contained, vendored runtime MLP (a `DetRng` + 3D-weight-store MLP
// The retired `nisps-core` header tree (`-I../nisps-core/include`) is gone, and // with a trailing-bias-node model + its own Dataset). That vendored copy has
// the templated firmware/WASM `nisps/ml` core is fixed-size — neither is a clean // been DELETED. The module now consumes the shared engine directly:
// fit for a runtime 8→16 module. So we ship a small native MLP here that matches
// the firmware/browser TRAINING SEMANTICS as closely as a runtime form allows:
// //
// • ReLU hidden layers, sigmoid output (sigmoid maps to [0,1]). // • `nisps::ml::MLPCore<nisps::ml::DynamicStorage>` — the runtime-shaped
// • A trailing bias node (1.0) appended to the input vector. // branch of the ONE core MLP (fixed 4-layer topology: ReLU×3 + Sigmoid,
// • spread-aware weight init: uniform [-1,1] (spread=0) → Xavier 1/√fan_in // three runtime hidden sizes). This is exactly the P2 dynamic case.
// (spread=1), interpolated per layer. // • `nisps::Rng` (nisps/core/rng.hpp, xoshiro256+) — owned by MLPCore,
// • spread-aware perturbation (RL "move weights"): flat noise (spread=0) → // replacing the vendored `DetRng` (xorshift128).
// per-layer Xavier-scaled noise + 10%·spread weight decay (spread=1). // • The core MLP's own FIFO dataset (add_example / train / clear_examples),
// • Plain SGD / MSE training over the example dataset. // replacing the vendored `Dataset`.
// • DETERMINISTIC per-instance RNG (seeded), so behaviour is reproducible and
// the threading double-buffer stays race-free (each MLP owns its own RNG).
// //
// It is NOT bit-identical to the firmware core (different optimiser internals), // BEHAVIOUR CHANGED (this is the point of the phase): outputs are no longer the
// and that divergence is an accepted follow-up (see docs/specs/vcv-module.md delta #5). The // vendored *approximation* of firmware/browser training semantics — they are
// public method names mirror the core so `MEMLNaut.cpp` is unchanged in spirit. // now CORE-EXACT. Weight init, RL `move_weights`, SGD training, activations and
// RNG are bit-identical to what the firmware and the WASM/browser build run
// (see tests/cpp/test_vcv_iml_parity.cpp, which pins adapter == direct
// MLPCore<DynamicStorage> bit-for-bit). Old `.nisps`/patch weight blobs written
// by the vendored 3D model will NOT load — the persisted weight vector is now
// the core's FLAT [weights…][biases…] layout (patch `version` bumped to 3).
//
// This adapter includes ONLY nisps headers + the standard library (no Rack
// includes) so the host ctest can compile it standalone. It keeps the public
// surface as close to the old vendored `IML` as practical so `MEMLNaut.cpp`
// stays mechanical; the one deliberate surface change is `get_weights` /
// `set_weights`, which now speak the core's flat `std::vector<float>` instead
// of the old 3D `mlp_weights` node tree.
// //
// MPL-2.0 in spirit with the rest of nisps; wrapper code under the VCV module's // MPL-2.0 in spirit with the rest of nisps; wrapper code under the VCV module's
// licence. British spelling in comments where it reads naturally. // licence. British spelling in comments where it reads naturally.
#pragma once #pragma once
#include <vector> #include <algorithm>
#include <cstddef> #include <array>
#include <cmath> #include <cmath>
#include <cstddef>
#include <cstdint> #include <cstdint>
#include <limits> #include <limits>
#include <algorithm> #include <span>
#include <vector>
#include "../../nisps/core/rng.hpp"
#include "../../nisps/ml/dynamic_storage.hpp"
#include "../../nisps/ml/mlp.hpp"
namespace nisps { namespace nisps {
// ── Tiny deterministic RNG (xorshift128) ────────────────────────────── // ── IML — runtime-shaped interactive-ML adapter ───────────────────────
// Per-instance state; seeded in the constructor. No std::random_device, no //
// shared global generator — this is what keeps the audio/worker MLP pair free // One instance owns one `MLPCore<DynamicStorage>` (its own weights, RNG and
// of data races and makes parity reproducible. // dataset — nothing is shared). The MEMLNaut module keeps two instances (an
class DetRng { // audio-thread `iml` and a worker-thread `imlShadow`) and hands flat weight
public: // snapshots between them; because each instance is fully self-contained the
explicit DetRng(uint32_t seed = 0x1234567u) { reseed(seed); } // core's lack of internal locking is fine — the module's single-writer
void reseed(uint32_t seed) { // double-buffer discipline is preserved around this adapter unchanged.
s_[0] = seed ? seed : 0xA5A5A5A5u;
s_[1] = s_[0] ^ 0x9E3779B9u;
s_[2] = s_[0] * 0x85EBCA6Bu + 1u;
s_[3] = s_[0] * 0xC2B2AE35u + 0x27D4EB2Fu;
}
uint32_t next_u32() {
uint32_t t = s_[3];
uint32_t const u = s_[0];
s_[3] = s_[2]; s_[2] = s_[1]; s_[1] = u;
t ^= t << 11;
t ^= t >> 8;
s_[0] = t ^ u ^ (u >> 19);
return s_[0];
}
// Uniform in [0,1)
float uniform01() { return (next_u32() >> 8) * (1.0f / 16777216.0f); }
// Uniform in [-1,1)
float uniform_pm1() { return uniform01() * 2.0f - 1.0f; }
// Approx standard-normal: sum of 3 uniforms (matches the core's gen_randn shape)
float gaussian() {
return (uniform_pm1() + uniform_pm1() + uniform_pm1()) * 0.5773502692f; // /√3 → unit-ish variance
}
private:
uint32_t s_[4];
};
// ── MLP ───────────────────────────────────────────────────────────────
template<typename T = float>
class MLP {
public:
// 3D weight store: [layer][node][weight] where the final weight per node is
// the bias (the previous layer's activations get a trailing 1.0).
using mlp_weights = std::vector<std::vector<std::vector<T>>>;
// layers_nodes: full sizes including input (with bias) and output, e.g.
// {n_in + 1, h0, h1, h2, n_out}
MLP(const std::vector<size_t>& layers_nodes, uint32_t seed)
: layers_nodes_(layers_nodes), rng_(seed) {
build_();
draw_weights_spread_(static_cast<T>(0));
}
size_t num_layers() const { return weights_.size(); }
// Forward pass. `input_with_bias` has the trailing 1.0 already appended.
void forward(const std::vector<T>& input_with_bias, std::vector<T>& out) const {
std::vector<T> act = input_with_bias;
for (size_t l = 0; l < weights_.size(); ++l) {
const auto& layer = weights_[l];
const bool is_output = (l + 1 == weights_.size());
std::vector<T> next(layer.size());
for (size_t n = 0; n < layer.size(); ++n) {
const auto& w = layer[n];
T sum = 0;
// w has act.size()+1 entries? No: w spans the *current* input
// size (which already includes the bias slot of `act`).
const size_t lim = std::min(w.size(), act.size());
for (size_t k = 0; k < lim; ++k) sum += w[k] * act[k];
next[n] = is_output ? sigmoid_(sum) : relu_(sum);
}
// For hidden layers, append a bias term for the next layer's input.
if (!is_output) next.push_back(static_cast<T>(1));
act = std::move(next);
}
out = std::move(act);
}
// ── spread-aware weight init ──────────────────────────────────────
void draw_weights_spread(T spread) { draw_weights_spread_(spread); }
// ── spread-aware perturbation (RL move_weights) ───────────────────
void move_weights_spread(T speed, T spread) {
const T decay = static_cast<T>(1) - static_cast<T>(0.1) * spread;
for (size_t l = 0; l < weights_.size(); ++l) {
const T fan_in = static_cast<T>(input_size_of_layer_(l));
const T xavier = (fan_in > 0) ? static_cast<T>(1) / std::sqrt(fan_in) : static_cast<T>(1);
// spread=0 → flat noise (scale 1); spread=1 → per-layer Xavier scale
const T noiseScale = (static_cast<T>(1) - spread) + spread * xavier;
for (auto& node : weights_[l]) {
for (auto& w : node) {
if (spread > 0) w *= decay; // weight decay only when spread>0
w += rng_.gaussian() * speed * noiseScale;
}
}
}
}
// ── plain SGD / MSE training ──────────────────────────────────────
// features: each row is input WITHOUT bias; labels: target outputs in [0,1].
void train(const std::vector<std::vector<T>>& features,
const std::vector<std::vector<T>>& labels,
int max_iterations, T learning_rate, T convergence) {
const size_t n = std::min(features.size(), labels.size());
if (n == 0) return;
for (int iter = 0; iter < max_iterations; ++iter) {
T epoch_loss = 0;
for (size_t s = 0; s < n; ++s) {
std::vector<T> in = features[s];
in.push_back(static_cast<T>(1)); // bias
epoch_loss += backprop_(in, labels[s], learning_rate);
}
epoch_loss /= static_cast<T>(n);
if (epoch_loss < convergence) break;
}
}
mlp_weights get_weights() const { return weights_; }
void set_weights(const mlp_weights& w) {
// Only adopt if the topology matches; otherwise ignore (keeps the audio
// path safe against malformed snapshots from the bridge / patch files).
if (w.size() != weights_.size()) return;
for (size_t l = 0; l < w.size(); ++l) {
if (w[l].size() != weights_[l].size()) return;
}
weights_ = w;
}
private:
static T relu_(T x) { return x > 0 ? x : 0; }
static T sigmoid_(T x) { return static_cast<T>(1) / (static_cast<T>(1) + std::exp(-x)); }
static T dsigmoid_from_out_(T y) { return y * (static_cast<T>(1) - y); }
size_t input_size_of_layer_(size_t l) const {
// The number of weights per node in layer l (incl. bias slot).
return weights_[l].empty() ? 0 : weights_[l][0].size();
}
void build_() {
weights_.clear();
// layers_nodes_[0] is the input layer WITH bias already counted.
for (size_t l = 1; l < layers_nodes_.size(); ++l) {
const size_t in_sz = layers_nodes_[l - 1]; // includes bias slot
const size_t out_sz = layers_nodes_[l];
std::vector<std::vector<T>> layer(out_sz, std::vector<T>(in_sz, 0));
weights_.push_back(std::move(layer));
}
}
void draw_weights_spread_(T spread) {
for (size_t l = 0; l < weights_.size(); ++l) {
const T fan_in = static_cast<T>(layers_nodes_[l]); // incl. bias
const T xavier = (fan_in > 0) ? static_cast<T>(1) / std::sqrt(fan_in) : static_cast<T>(1);
const T scale = (static_cast<T>(1) - spread) + spread * xavier;
for (auto& node : weights_[l]) {
for (size_t k = 0; k < node.size(); ++k) {
// bias (last weight) initialised to 0, like the core
const bool is_bias = (k + 1 == node.size());
node[k] = is_bias ? static_cast<T>(0) : rng_.uniform_pm1() * scale;
}
}
}
}
// One SGD step on a single example; returns the MSE for this example.
T backprop_(const std::vector<T>& in_with_bias, const std::vector<T>& target,
T lr) {
// Forward, caching activations per layer.
std::vector<std::vector<T>> acts;
acts.reserve(weights_.size() + 1);
acts.push_back(in_with_bias);
std::vector<T> act = in_with_bias;
for (size_t l = 0; l < weights_.size(); ++l) {
const auto& layer = weights_[l];
const bool is_output = (l + 1 == weights_.size());
std::vector<T> next(layer.size());
for (size_t nidx = 0; nidx < layer.size(); ++nidx) {
const auto& w = layer[nidx];
T sum = 0;
const size_t lim = std::min(w.size(), act.size());
for (size_t k = 0; k < lim; ++k) sum += w[k] * act[k];
next[nidx] = is_output ? sigmoid_(sum) : relu_(sum);
}
if (!is_output) next.push_back(static_cast<T>(1));
acts.push_back(next);
act = next;
}
// Output error.
const size_t L = weights_.size();
std::vector<T>& out = acts[L];
T loss = 0;
std::vector<T> delta(out.size());
for (size_t o = 0; o < out.size(); ++o) {
const T t = (o < target.size()) ? target[o] : static_cast<T>(0);
const T e = out[o] - t;
loss += e * e;
delta[o] = e * dsigmoid_from_out_(out[o]); // MSE × sigmoid'
}
loss /= static_cast<T>(out.size() ? out.size() : 1);
// Backprop through layers L-1 .. 0.
std::vector<T> nextDelta;
for (size_t li = L; li-- > 0;) {
const auto& prevAct = acts[li]; // input activations to layer li
auto& layer = weights_[li];
const bool is_output = (li + 1 == L);
// Compute delta to propagate to the previous layer (excludes bias node).
const size_t prevSize = prevAct.size(); // includes bias slot
std::vector<T> propagate(prevSize, 0);
for (size_t nidx = 0; nidx < layer.size(); ++nidx) {
const T d = delta[nidx];
auto& w = layer[nidx];
const size_t lim = std::min(w.size(), prevSize);
for (size_t k = 0; k < lim; ++k) {
propagate[k] += d * w[k];
w[k] -= lr * d * prevAct[k]; // gradient step
}
}
// Turn `propagate` into next-layer delta via ReLU' (skip for input).
if (li > 0) {
const auto& actPrev = acts[li]; // activations of layer li-1's output
nextDelta.assign(actPrev.size(), 0);
for (size_t k = 0; k < actPrev.size(); ++k) {
const T relud = actPrev[k] > 0 ? static_cast<T>(1) : static_cast<T>(0);
nextDelta[k] = propagate[k] * relud;
}
// Drop the trailing bias slot's delta (it has no upstream weights).
if (!nextDelta.empty()) nextDelta.pop_back();
delta = nextDelta;
}
(void)is_output;
}
return loss;
}
std::vector<size_t> layers_nodes_;
mlp_weights weights_;
mutable DetRng rng_;
};
// ── Dataset (FIFO ring, max 100 examples) ─────────────────────────────
template<typename T = float>
class Dataset {
public:
static constexpr size_t kMax_examples = 100;
void add(const std::vector<T>& feat, const std::vector<T>& label) {
if (features_.size() >= kMax_examples) {
features_.erase(features_.begin());
labels_.erase(labels_.begin());
}
features_.push_back(feat);
labels_.push_back(label);
}
void clear() { features_.clear(); labels_.clear(); }
size_t count() const { return features_.size(); }
const std::vector<std::vector<T>>& features() const { return features_; }
const std::vector<std::vector<T>>& labels() const { return labels_; }
void load(const std::vector<std::vector<T>>& f, const std::vector<std::vector<T>>& l) {
clear();
const size_t n = std::min(f.size(), l.size());
for (size_t i = 0; i < n; ++i) add(f[i], l[i]);
}
private:
std::vector<std::vector<T>> features_;
std::vector<std::vector<T>> labels_;
};
// ── IML ───────────────────────────────────────────────────────────────
template <typename Float = float> template <typename Float = float>
class IML { class IML {
static_assert(std::is_same<Float, float>::value,
"the shared MLP core is float-only");
public: public:
// Flat weight vector: the core's [layer0_w … layer3_w][layer0_b … layer3_b]
// layout. Cheap to copy between the audio and worker threads.
using Weights = std::vector<float>;
using Examples = std::vector<std::vector<float>>;
enum class Mode { Inference, Training }; enum class Mode { Inference, Training };
IML(size_t n_inputs, size_t n_outputs, // Matches the old vendored capacity so `get_max_examples()` and the FIFO
std::vector<size_t> hidden_layers = {16, 24, 16}, // eviction threshold are unchanged for the UI / persistence.
size_t max_iterations = 200, static constexpr std::size_t kMaxExamples = 100u;
// Signature mirrors the old vendored IML so the module's construction sites
// are unchanged. `hidden` MUST carry exactly three sizes (the core topology
// is fixed at three hidden layers); fewer are padded from the default,
// extras ignored.
explicit IML(std::size_t n_inputs, std::size_t n_outputs,
std::vector<std::size_t> hidden = {16u, 24u, 16u},
std::size_t max_iterations = 200u,
Float learning_rate = static_cast<Float>(0.1), Float learning_rate = static_cast<Float>(0.1),
Float convergence_threshold = static_cast<Float>(0.00001), Float convergence_threshold = static_cast<Float>(0.00001),
uint32_t seed = 0xC0FFEEu) std::uint64_t seed = 0xC0FFEEu)
: n_inputs_(n_inputs), n_outputs_(n_outputs), : n_inputs_(n_inputs),
max_iterations_(max_iterations), learning_rate_(learning_rate), n_outputs_(n_outputs),
convergence_threshold_(convergence_threshold) { max_iterations_(max_iterations),
std::vector<size_t> sizes; learning_rate_(learning_rate),
sizes.push_back(n_inputs_ + 1); // + bias convergence_threshold_(convergence_threshold),
for (size_t h : hidden_layers) sizes.push_back(h); hidden_(to_hidden3_(hidden)),
sizes.push_back(n_outputs_); core_(seed, n_inputs, std::span<const std::size_t>(hidden_.data(), hidden_.size()),
mlp_ = std::make_unique<MLP<Float>>(sizes, seed); n_outputs, kMaxExamples, max_iterations) {}
input_state_.assign(n_inputs_, static_cast<Float>(0.5));
output_state_.assign(n_outputs_, static_cast<Float>(0));
}
size_t num_inputs() const { return n_inputs_; } std::size_t num_inputs() const { return n_inputs_; }
size_t num_outputs() const { return n_outputs_; } std::size_t num_outputs() const { return n_outputs_; }
void set_input(size_t i, Float v) { // ── Inference ─────────────────────────────────────────────────────
if (i >= n_inputs_) return; void set_input(std::size_t i, Float v) { core_.set_input(i, v); }
input_state_[i] = std::clamp(v, static_cast<Float>(0), static_cast<Float>(1)); void process() { core_.process(); }
input_updated_ = true; const Float* get_outputs() const { return core_.outputs().data(); }
}
const Float* get_outputs() const { return output_state_.data(); }
void process() {
if (!input_updated_) return;
std::vector<Float> in = input_state_;
in.push_back(static_cast<Float>(1));
mlp_->forward(in, output_state_);
if (output_state_.size() < n_outputs_) output_state_.resize(n_outputs_, 0);
input_updated_ = false;
}
// ── Mode / training ───────────────────────────────────────────────
// Preserves the vendored two-step semantics the module relies on: a
// Training→Inference transition trains the network over the current
// dataset. (The module drives this on both the audio `iml` — for immediate
// local feedback — and the worker `imlShadow`.)
void set_mode(Mode m) { void set_mode(Mode m) {
if (m == Mode::Inference && mode_ == Mode::Training) train_(); if (m == Mode::Inference && mode_ == Mode::Training) train_();
mode_ = m; mode_ = m;
} }
Mode get_mode() const { return mode_; } Mode get_mode() const { return mode_; }
void add_example(const Float* inputs, size_t n_in, const Float* outputs, size_t n_out) { // Direct training entry (used by the parity test); returns final epoch loss.
std::vector<Float> in(inputs, inputs + std::min(n_in, n_inputs_)); float train() {
in.resize(n_inputs_, static_cast<Float>(0)); if (features_.empty()) return 0.f;
std::vector<Float> out(outputs, outputs + std::min(n_out, n_outputs_)); return core_.train(learning_rate_, max_iterations_, convergence_threshold_);
out.resize(n_outputs_, static_cast<Float>(0));
dataset_.add(in, out);
} }
void clear_dataset() { dataset_.clear(); }
void randomise_weights(Float spread) { mlp_->draw_weights_spread(spread); refresh_(); } // ── Dataset ───────────────────────────────────────────────────────
void move_weights(Float speed, Float spread) { mlp_->move_weights_spread(speed, spread); refresh_(); } void add_example(const Float* inputs, std::size_t n_in,
const Float* outputs, std::size_t n_out) {
std::vector<float> in(n_inputs_, 0.f);
for (std::size_t i = 0; i < n_inputs_ && i < n_in; ++i) in[i] = inputs[i];
std::vector<float> out(n_outputs_, 0.f);
for (std::size_t i = 0; i < n_outputs_ && i < n_out; ++i) out[i] = outputs[i];
add_example_(in, out);
}
void clear_dataset() {
features_.clear();
labels_.clear();
core_.clear_examples();
}
std::size_t get_example_count() const { return core_.example_count(); }
std::size_t get_max_examples() const { return kMaxExamples; }
Examples get_example_features() const { return features_; }
Examples get_example_labels() const { return labels_; }
void load_examples(const Examples& f, const Examples& l) {
clear_dataset();
const std::size_t n = std::min(f.size(), l.size());
for (std::size_t i = 0; i < n; ++i) {
std::vector<float> in(n_inputs_, 0.f);
for (std::size_t k = 0; k < n_inputs_ && k < f[i].size(); ++k) in[k] = f[i][k];
std::vector<float> out(n_outputs_, 0.f);
for (std::size_t k = 0; k < n_outputs_ && k < l[i].size(); ++k) out[k] = l[i][k];
add_example_(in, out);
}
}
typename MLP<Float>::mlp_weights get_weights() const { return mlp_->get_weights(); } // ── RL / weight ops ───────────────────────────────────────────────
void set_weights(typename MLP<Float>::mlp_weights& w) { mlp_->set_weights(w); } void randomise_weights(Float spread) { core_.draw_weights(spread); }
void move_weights(Float speed, Float spread,
std::span<const std::uint8_t> pin_mask = {}) {
core_.move_weights(speed, spread, pin_mask);
}
size_t get_example_count() const { return dataset_.count(); } // ── Flat weight get/set (core-exact layout) ───────────────────────
size_t get_max_examples() const { return Dataset<Float>::kMax_examples; } // Non-const: the core regenerates a scratch copy on each `get_weights`.
std::vector<std::vector<Float>> get_example_features() const { return dataset_.features(); } Weights get_weights() {
std::vector<std::vector<Float>> get_example_labels() const { return dataset_.labels(); } auto s = core_.get_weights();
void load_examples(const std::vector<std::vector<Float>>& f, return Weights(s.begin(), s.end());
const std::vector<std::vector<Float>>& l) { dataset_.load(f, l); } }
void set_weights(const Weights& w) {
core_.set_weights(std::span<const float>(w.data(), w.size()));
}
Float nearest_example_distance(const Float* input, size_t n_in) const { // Novelty helper (used for the display / derived outputs) — nearest
const auto& feats = dataset_.features(); // Euclidean distance from `input` to any stored example, or -1 when empty.
if (feats.empty()) return static_cast<Float>(-1); Float nearest_example_distance(const Float* input, std::size_t n_in) const {
if (features_.empty()) return static_cast<Float>(-1);
Float best = std::numeric_limits<Float>::max(); Float best = std::numeric_limits<Float>::max();
const size_t dims = std::min(n_in, n_inputs_); const std::size_t dims = std::min(n_in, n_inputs_);
for (const auto& f : feats) { for (const auto& f : features_) {
Float d = 0; Float d = 0;
for (size_t k = 0; k < dims && k < f.size(); ++k) { for (std::size_t k = 0; k < dims && k < f.size(); ++k) {
const Float diff = f[k] - input[k]; const Float diff = f[k] - input[k];
d += diff * diff; d += diff * diff;
} }
best = std::min(best, std::sqrt(d)); best = std::min(best, static_cast<Float>(std::sqrt(d)));
} }
return best; return best;
} }
// Training parameters (exposed so a parity harness can drive a bare
// MLPCore with the same values the adapter's `train()` uses).
float train_lr() const { return learning_rate_; }
std::size_t train_max_iter() const { return max_iterations_; }
float train_min_err() const { return convergence_threshold_; }
private: private:
void refresh_() { input_updated_ = true; process(); } static std::array<std::size_t, 3> to_hidden3_(const std::vector<std::size_t>& h) {
void train_() { std::array<std::size_t, 3> a{16u, 24u, 16u};
if (dataset_.count() == 0) return; for (std::size_t i = 0; i < 3u && i < h.size(); ++i) a[i] = h[i];
mlp_->train(dataset_.features(), dataset_.labels(), return a;
static_cast<int>(max_iterations_), learning_rate_,
convergence_threshold_);
refresh_();
} }
size_t n_inputs_, n_outputs_, max_iterations_; // Adds to the core dataset AND to the insertion-order mirror. The mirror
Float learning_rate_, convergence_threshold_; // exists because MLPCore's FIFO ring keeps its head index private and its
// slot order scrambles once the buffer fills — but the module needs the
// examples enumerable *in insertion order* for JSON persistence and for
// cross-thread staging (get_example_features/labels → load_examples). The
// core stays the single source of truth for TRAINING; the mirror is purely
// a serialisation/enumeration view and never feeds the network. Both evict
// at kMaxExamples so counts stay equal.
void add_example_(const std::vector<float>& in, const std::vector<float>& out) {
if (features_.size() >= kMaxExamples) {
features_.erase(features_.begin());
labels_.erase(labels_.begin());
}
features_.push_back(in);
labels_.push_back(out);
core_.add_example(std::span<const float>(in.data(), in.size()),
std::span<const float>(out.data(), out.size()));
}
void train_() {
if (features_.empty()) return;
core_.train(learning_rate_, max_iterations_, convergence_threshold_);
}
std::size_t n_inputs_;
std::size_t n_outputs_;
std::size_t max_iterations_;
Float learning_rate_;
Float convergence_threshold_;
std::array<std::size_t, 3> hidden_;
nisps::ml::MLPCore<nisps::ml::DynamicStorage> core_;
Mode mode_ = Mode::Inference; Mode mode_ = Mode::Inference;
bool input_updated_ = false; Examples features_;
std::vector<Float> input_state_, output_state_; Examples labels_;
Dataset<Float> dataset_;
std::unique_ptr<MLP<Float>> mlp_;
}; };
} // namespace nisps } // namespace nisps