refactor(ml)!: P2.1 storage-policy split — MLPCore<Storage>, fixed + dynamic models
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).
2026-07-13 23:47:03 +02:00
|
|
|
// nisps/ml/storage.hpp — storage policies for the MLP core (fixed flavour).
|
|
|
|
|
//
|
|
|
|
|
// The MLP algorithms (nisps/ml/mlp.hpp `MLPCore<Storage>`) are written ONCE
|
|
|
|
|
// against a storage concept; the storage supplies every dimension and every
|
|
|
|
|
// buffer. Two models exist:
|
|
|
|
|
//
|
|
|
|
|
// * `FixedStorage<NIn, NH1, NH2, NH3, NOut, NMaxExamples, NMaxIterTrain>`
|
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
|
|
|
// (this file) — all buffers are template-sized `std::array`, zero heap.
|
|
|
|
|
// This is the firmware model; the classic
|
refactor(ml)!: P2.1 storage-policy split — MLPCore<Storage>, fixed + dynamic models
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).
2026-07-13 23:47:03 +02:00
|
|
|
// `MLP<...>` template is an alias over it and its compile-time constants
|
|
|
|
|
// (`kInput`, `kHidden1..3`, `kOutput`, `weight_count()`) are preserved.
|
|
|
|
|
//
|
|
|
|
|
// * `DynamicStorage` (nisps/ml/dynamic_storage.hpp) — dimensions chosen at
|
|
|
|
|
// construction, one arena allocation, no allocation after construction.
|
|
|
|
|
// Compile-time EXCLUDED from embedded builds (see NISPS_TARGET_EMBEDDED
|
|
|
|
|
// in nisps/core/perf.hpp).
|
|
|
|
|
//
|
|
|
|
|
// STORAGE SURFACE (both models; L is the layer index 0..3)
|
|
|
|
|
// dims: n_in(), n_out(), fan_in_l<L>(), fan_out_l<L>(),
|
|
|
|
|
// max_examples(), max_iter_train(), weight_count()
|
|
|
|
|
// layers: weights_l<L>(), biases_l<L>(), pre_act_l<L>(), act_l<L>(),
|
fix(ml): port RMSProp — ported learning rates were landing in SGD
Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit
upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239
ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601,
StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the
difference as an optimiser-choice research question. It was not one.
RMSProp divides each step by the running gradient magnitude, so an upstream
lr is a NORMALISED step; under SGD the same number multiplies the raw
gradient. Every learning rate ported from upstream therefore landed in an
optimiser that reads it differently — most visibly feedback.hpp's
`geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted
into a single SGD step.
rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq +
0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The
adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative
lr used by train_targets' "train away from this target" path behaves as it
does upstream. The per-weight squared-gradient average is new persistent
state and lives in the storage policies (FixedStorage arrays /
DynamicStorage arena) so nisps/ stays allocation-free and the firmware's
zero-heap contract holds. It is optimiser state, not model state: excluded
from weight_count()/get_weights()/set_weights(), matching upstream, and
cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState).
draw_weights() deliberately does NOT clear it — upstream's DrawWeights
doesn't either.
Measured with tests/cpp/ml_bench.cpp:
D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x),
and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10,
0.56 at 100) instead of creeping linearly forever.
A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press.
U4 the upstream-LR positive path actually trains now (range_util 0.71 at
100 ticks/gesture, was 0.016 — it was inert under SGD).
Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose
asymmetry. lurch_max is still ~1.08 against a [0,1] output range.
Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training
and did not move, which is the cross-check that only the update rule
changed. manifold/public/nisps.wasm rebuilt so parity-check compares like
with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at
2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is
missing, never when it is stale; noted in MAP.md and filed separately.
ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser
cross-reference updated; new defect 6d for the positive-training dose.
Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp
clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
|
|
|
// grad_w_l<L>(), grad_b_l<L>(),
|
|
|
|
|
// sq_grad_w_l<L>(), sq_grad_b_l<L>() [RMSProp running
|
|
|
|
|
// squared-gradient averages, same shape as the gradient
|
|
|
|
|
// accumulators — see nisps/ml/training.hpp],
|
|
|
|
|
// delta_l<L>() [backprop scratch,
|
refactor(ml)!: P2.1 storage-policy split — MLPCore<Storage>, fixed + dynamic models
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).
2026-07-13 23:47:03 +02:00
|
|
|
// sized fan_in(L)], eval_act_l<L>() [const-eval scratch,
|
|
|
|
|
// sized fan_out(L), mutable]
|
|
|
|
|
// global: input_buf(), output_buf(), ds_features(), ds_labels(),
|
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
|
|
|
// flat_buf(), loss_hist_buf(), copy_weights_to(dst)
|
refactor(ml)!: P2.1 storage-policy split — MLPCore<Storage>, fixed + dynamic models
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).
2026-07-13 23:47:03 +02:00
|
|
|
//
|
|
|
|
|
// For `FixedStorage` every dim accessor is constexpr-foldable, so the
|
|
|
|
|
// algorithms compile to the same fully-unrolled/constant-bound code the old
|
|
|
|
|
// hand-fixed MLP produced (verified against the RP2350 `.text` budget —
|
|
|
|
|
// chokepoint B of docs/specs/plans/one-core-engine-refactor.md).
|
|
|
|
|
//
|
|
|
|
|
// Bit-parity contract: for identical shapes and seeds, MLPCore over
|
|
|
|
|
// FixedStorage and DynamicStorage must produce bit-identical results — the
|
|
|
|
|
// algorithm code is shared and the buffers are just memory. A ctest enforces
|
|
|
|
|
// this (tests/cpp/test_mlp_storage_parity.cpp).
|
|
|
|
|
|
|
|
|
|
#pragma once
|
|
|
|
|
|
|
|
|
|
#include <array>
|
|
|
|
|
#include <cstddef>
|
|
|
|
|
#include <span>
|
|
|
|
|
|
|
|
|
|
#include "../core/perf.hpp"
|
|
|
|
|
|
|
|
|
|
namespace nisps::ml {
|
|
|
|
|
|
|
|
|
|
inline constexpr std::size_t kMlpNumLayers = 4u;
|
|
|
|
|
|
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
|
|
|
// Default example-store capacity, named ONCE and shared by FixedStorage's
|
|
|
|
|
// compile-time default (below) and DynamicStorage's runtime-default
|
|
|
|
|
// constructor argument (nisps/ml/dynamic_storage.hpp). `nisps_ml_describe`
|
|
|
|
|
// (nisps/wasm/bindings.cpp) reports the live instance's max_examples() so
|
|
|
|
|
// the Manifold TS side (manifold/src/engine/wasm-iml.ts) can size its JS
|
|
|
|
|
// Dataset mirror to match instead of hardcoding a second, divergent number
|
|
|
|
|
// (see docs/specs/recon/simplification-audit-2026-07.md S35).
|
|
|
|
|
inline constexpr std::size_t kDefaultMaxExamples = 128u;
|
|
|
|
|
|
refactor(ml)!: P2.1 storage-policy split — MLPCore<Storage>, fixed + dynamic models
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).
2026-07-13 23:47:03 +02:00
|
|
|
template <std::size_t NIn,
|
|
|
|
|
std::size_t NHidden1,
|
|
|
|
|
std::size_t NHidden2,
|
|
|
|
|
std::size_t NHidden3,
|
|
|
|
|
std::size_t NOut,
|
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
|
|
|
std::size_t NMaxExamples = kDefaultMaxExamples,
|
refactor(ml)!: P2.1 storage-policy split — MLPCore<Storage>, fixed + dynamic models
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).
2026-07-13 23:47:03 +02:00
|
|
|
std::size_t NMaxIterTrain = 4096u>
|
|
|
|
|
class FixedStorage {
|
|
|
|
|
public:
|
|
|
|
|
static constexpr std::size_t kInput = NIn;
|
|
|
|
|
static constexpr std::size_t kHidden1 = NHidden1;
|
|
|
|
|
static constexpr std::size_t kHidden2 = NHidden2;
|
|
|
|
|
static constexpr std::size_t kHidden3 = NHidden3;
|
|
|
|
|
static constexpr std::size_t kOutput = NOut;
|
|
|
|
|
static constexpr std::size_t kMaxExamples = NMaxExamples;
|
|
|
|
|
static constexpr std::size_t kMaxIterTrain = NMaxIterTrain;
|
|
|
|
|
static constexpr std::size_t kNumLayers = kMlpNumLayers;
|
|
|
|
|
|
|
|
|
|
static constexpr std::size_t weight_count() noexcept {
|
|
|
|
|
return NIn * NHidden1 + NHidden1 * NHidden2 + NHidden2 * NHidden3 + NHidden3 * NOut
|
|
|
|
|
+ NHidden1 + NHidden2 + NHidden3 + NOut;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- dims -----------------------------------------------------------
|
|
|
|
|
static constexpr std::size_t n_in() noexcept { return NIn; }
|
|
|
|
|
static constexpr std::size_t n_out() noexcept { return NOut; }
|
|
|
|
|
static constexpr std::size_t max_examples() noexcept { return NMaxExamples; }
|
|
|
|
|
static constexpr std::size_t max_iter_train() noexcept { return NMaxIterTrain; }
|
|
|
|
|
|
|
|
|
|
template <std::size_t L>
|
|
|
|
|
static constexpr std::size_t fan_in_l() noexcept {
|
|
|
|
|
static_assert(L < kNumLayers);
|
|
|
|
|
if constexpr (L == 0u) return NIn;
|
|
|
|
|
else if constexpr (L == 1u) return NHidden1;
|
|
|
|
|
else if constexpr (L == 2u) return NHidden2;
|
|
|
|
|
else return NHidden3;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L>
|
|
|
|
|
static constexpr std::size_t fan_out_l() noexcept {
|
|
|
|
|
static_assert(L < kNumLayers);
|
|
|
|
|
if constexpr (L == 0u) return NHidden1;
|
|
|
|
|
else if constexpr (L == 1u) return NHidden2;
|
|
|
|
|
else if constexpr (L == 2u) return NHidden3;
|
|
|
|
|
else return NOut;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- per-layer buffers ------------------------------------------------
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> weights_l() noexcept {
|
|
|
|
|
if constexpr (L == 0u) return w0_; else if constexpr (L == 1u) return w1_;
|
|
|
|
|
else if constexpr (L == 2u) return w2_; else return w3_;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<const float> weights_l() const noexcept {
|
|
|
|
|
if constexpr (L == 0u) return w0_; else if constexpr (L == 1u) return w1_;
|
|
|
|
|
else if constexpr (L == 2u) return w2_; else return w3_;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> biases_l() noexcept {
|
|
|
|
|
if constexpr (L == 0u) return b0_; else if constexpr (L == 1u) return b1_;
|
|
|
|
|
else if constexpr (L == 2u) return b2_; else return b3_;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<const float> biases_l() const noexcept {
|
|
|
|
|
if constexpr (L == 0u) return b0_; else if constexpr (L == 1u) return b1_;
|
|
|
|
|
else if constexpr (L == 2u) return b2_; else return b3_;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> pre_act_l() noexcept {
|
|
|
|
|
if constexpr (L == 0u) return pa0_; else if constexpr (L == 1u) return pa1_;
|
|
|
|
|
else if constexpr (L == 2u) return pa2_; else return pa3_;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> act_l() noexcept {
|
|
|
|
|
if constexpr (L == 0u) return a0_; else if constexpr (L == 1u) return a1_;
|
|
|
|
|
else if constexpr (L == 2u) return a2_; else return a3_;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<const float> act_l() const noexcept {
|
|
|
|
|
if constexpr (L == 0u) return a0_; else if constexpr (L == 1u) return a1_;
|
|
|
|
|
else if constexpr (L == 2u) return a2_; else return a3_;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> grad_w_l() noexcept {
|
|
|
|
|
if constexpr (L == 0u) return gw0_; else if constexpr (L == 1u) return gw1_;
|
|
|
|
|
else if constexpr (L == 2u) return gw2_; else return gw3_;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> grad_b_l() noexcept {
|
|
|
|
|
if constexpr (L == 0u) return gb0_; else if constexpr (L == 1u) return gb1_;
|
|
|
|
|
else if constexpr (L == 2u) return gb2_; else return gb3_;
|
|
|
|
|
}
|
fix(ml): port RMSProp — ported learning rates were landing in SGD
Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit
upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239
ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601,
StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the
difference as an optimiser-choice research question. It was not one.
RMSProp divides each step by the running gradient magnitude, so an upstream
lr is a NORMALISED step; under SGD the same number multiplies the raw
gradient. Every learning rate ported from upstream therefore landed in an
optimiser that reads it differently — most visibly feedback.hpp's
`geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted
into a single SGD step.
rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq +
0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The
adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative
lr used by train_targets' "train away from this target" path behaves as it
does upstream. The per-weight squared-gradient average is new persistent
state and lives in the storage policies (FixedStorage arrays /
DynamicStorage arena) so nisps/ stays allocation-free and the firmware's
zero-heap contract holds. It is optimiser state, not model state: excluded
from weight_count()/get_weights()/set_weights(), matching upstream, and
cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState).
draw_weights() deliberately does NOT clear it — upstream's DrawWeights
doesn't either.
Measured with tests/cpp/ml_bench.cpp:
D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x),
and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10,
0.56 at 100) instead of creeping linearly forever.
A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press.
U4 the upstream-LR positive path actually trains now (range_util 0.71 at
100 ticks/gesture, was 0.016 — it was inert under SGD).
Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose
asymmetry. lurch_max is still ~1.08 against a [0,1] output range.
Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training
and did not move, which is the cross-check that only the update rule
changed. manifold/public/nisps.wasm rebuilt so parity-check compares like
with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at
2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is
missing, never when it is stale; noted in MAP.md and filed separately.
ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser
cross-reference updated; new defect 6d for the positive-training dose.
Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp
clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
|
|
|
// RMSProp running squared-gradient averages (training.hpp). Optimiser
|
|
|
|
|
// state, not model state: excluded from weight_count()/copy_weights_to().
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> sq_grad_w_l() noexcept {
|
|
|
|
|
if constexpr (L == 0u) return sw0_; else if constexpr (L == 1u) return sw1_;
|
|
|
|
|
else if constexpr (L == 2u) return sw2_; else return sw3_;
|
|
|
|
|
}
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> sq_grad_b_l() noexcept {
|
|
|
|
|
if constexpr (L == 0u) return sb0_; else if constexpr (L == 1u) return sb1_;
|
|
|
|
|
else if constexpr (L == 2u) return sb2_; else return sb3_;
|
|
|
|
|
}
|
refactor(ml)!: P2.1 storage-policy split — MLPCore<Storage>, fixed + dynamic models
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).
2026-07-13 23:47:03 +02:00
|
|
|
// Backprop scratch (delta into layer L's input), sized fan_in(L).
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> delta_l() noexcept {
|
|
|
|
|
if constexpr (L == 0u) return d0_; else if constexpr (L == 1u) return d1_;
|
|
|
|
|
else if constexpr (L == 2u) return d2_; else return d3_;
|
|
|
|
|
}
|
|
|
|
|
// Const-eval scratch (activation of layer L), sized fan_out(L). Mutable
|
|
|
|
|
// so `eval_loss() const` can run the shared forward code without touching
|
|
|
|
|
// the real activation caches.
|
|
|
|
|
template <std::size_t L> NISPS_FORCE_INLINE std::span<float> eval_act_l() const noexcept {
|
|
|
|
|
if constexpr (L == 0u) return e0_; else if constexpr (L == 1u) return e1_;
|
|
|
|
|
else if constexpr (L == 2u) return e2_; else return e3_;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- global buffers ---------------------------------------------------
|
|
|
|
|
NISPS_FORCE_INLINE std::span<float> input_buf() noexcept { return input_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<const float> input_buf() const noexcept { return input_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<float> output_buf() noexcept { return output_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<const float> output_buf() const noexcept { return output_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<float> ds_features() noexcept { return dsf_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<const float> ds_features() const noexcept { return dsf_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<float> ds_labels() noexcept { return dsl_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<const float> ds_labels() const noexcept { return dsl_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<float> flat_buf() noexcept { return flat_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<float> loss_hist_buf() noexcept { return lh_; }
|
|
|
|
|
NISPS_FORCE_INLINE std::span<const float> loss_hist_buf() const noexcept { return lh_; }
|
|
|
|
|
|
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
|
|
|
// Copies the live weights+biases directly into `dst` in the same flat
|
|
|
|
|
// layout as MLPCore::get_weights() (weights layer-major, then biases
|
|
|
|
|
// layer-major) — but writes straight from the layer buffers, with no
|
|
|
|
|
// intermediate flat_/flat_buf() hop. `dst` must be at least
|
|
|
|
|
// weight_count() long. Lets a caller that only needs a transient copy
|
|
|
|
|
// (feedback.hpp's snapshot/undo/nudge ops) take a single copy instead of
|
|
|
|
|
// double-copying through get_weights()'s scratch buffer.
|
|
|
|
|
void copy_weights_to(std::span<float> dst) const noexcept {
|
|
|
|
|
std::size_t k = 0u;
|
|
|
|
|
for (float v : weights_l<0u>()) dst[k++] = v;
|
|
|
|
|
for (float v : weights_l<1u>()) dst[k++] = v;
|
|
|
|
|
for (float v : weights_l<2u>()) dst[k++] = v;
|
|
|
|
|
for (float v : weights_l<3u>()) dst[k++] = v;
|
|
|
|
|
for (float v : biases_l<0u>()) dst[k++] = v;
|
|
|
|
|
for (float v : biases_l<1u>()) dst[k++] = v;
|
|
|
|
|
for (float v : biases_l<2u>()) dst[k++] = v;
|
|
|
|
|
for (float v : biases_l<3u>()) dst[k++] = v;
|
|
|
|
|
}
|
|
|
|
|
|
refactor(ml)!: P2.1 storage-policy split — MLPCore<Storage>, fixed + dynamic models
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).
2026-07-13 23:47:03 +02:00
|
|
|
private:
|
|
|
|
|
std::array<float, NIn * NHidden1> w0_{};
|
|
|
|
|
std::array<float, NHidden1 * NHidden2> w1_{};
|
|
|
|
|
std::array<float, NHidden2 * NHidden3> w2_{};
|
|
|
|
|
std::array<float, NHidden3 * NOut> w3_{};
|
|
|
|
|
std::array<float, NHidden1> b0_{};
|
|
|
|
|
std::array<float, NHidden2> b1_{};
|
|
|
|
|
std::array<float, NHidden3> b2_{};
|
|
|
|
|
std::array<float, NOut> b3_{};
|
|
|
|
|
std::array<float, NHidden1> pa0_{};
|
|
|
|
|
std::array<float, NHidden2> pa1_{};
|
|
|
|
|
std::array<float, NHidden3> pa2_{};
|
|
|
|
|
std::array<float, NOut> pa3_{};
|
|
|
|
|
std::array<float, NHidden1> a0_{};
|
|
|
|
|
std::array<float, NHidden2> a1_{};
|
|
|
|
|
std::array<float, NHidden3> a2_{};
|
|
|
|
|
std::array<float, NOut> a3_{};
|
|
|
|
|
std::array<float, NIn * NHidden1> gw0_{};
|
|
|
|
|
std::array<float, NHidden1 * NHidden2> gw1_{};
|
|
|
|
|
std::array<float, NHidden2 * NHidden3> gw2_{};
|
|
|
|
|
std::array<float, NHidden3 * NOut> gw3_{};
|
|
|
|
|
std::array<float, NHidden1> gb0_{};
|
|
|
|
|
std::array<float, NHidden2> gb1_{};
|
|
|
|
|
std::array<float, NHidden3> gb2_{};
|
|
|
|
|
std::array<float, NOut> gb3_{};
|
fix(ml): port RMSProp — ported learning rates were landing in SGD
Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit
upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239
ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601,
StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the
difference as an optimiser-choice research question. It was not one.
RMSProp divides each step by the running gradient magnitude, so an upstream
lr is a NORMALISED step; under SGD the same number multiplies the raw
gradient. Every learning rate ported from upstream therefore landed in an
optimiser that reads it differently — most visibly feedback.hpp's
`geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted
into a single SGD step.
rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq +
0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The
adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative
lr used by train_targets' "train away from this target" path behaves as it
does upstream. The per-weight squared-gradient average is new persistent
state and lives in the storage policies (FixedStorage arrays /
DynamicStorage arena) so nisps/ stays allocation-free and the firmware's
zero-heap contract holds. It is optimiser state, not model state: excluded
from weight_count()/get_weights()/set_weights(), matching upstream, and
cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState).
draw_weights() deliberately does NOT clear it — upstream's DrawWeights
doesn't either.
Measured with tests/cpp/ml_bench.cpp:
D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x),
and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10,
0.56 at 100) instead of creeping linearly forever.
A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press.
U4 the upstream-LR positive path actually trains now (range_util 0.71 at
100 ticks/gesture, was 0.016 — it was inert under SGD).
Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose
asymmetry. lurch_max is still ~1.08 against a [0,1] output range.
Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training
and did not move, which is the cross-check that only the update rule
changed. manifold/public/nisps.wasm rebuilt so parity-check compares like
with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at
2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is
missing, never when it is stale; noted in MAP.md and filed separately.
ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser
cross-reference updated; new defect 6d for the positive-training dose.
Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp
clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
|
|
|
std::array<float, NIn * NHidden1> sw0_{};
|
|
|
|
|
std::array<float, NHidden1 * NHidden2> sw1_{};
|
|
|
|
|
std::array<float, NHidden2 * NHidden3> sw2_{};
|
|
|
|
|
std::array<float, NHidden3 * NOut> sw3_{};
|
|
|
|
|
std::array<float, NHidden1> sb0_{};
|
|
|
|
|
std::array<float, NHidden2> sb1_{};
|
|
|
|
|
std::array<float, NHidden3> sb2_{};
|
|
|
|
|
std::array<float, NOut> sb3_{};
|
refactor(ml)!: P2.1 storage-policy split — MLPCore<Storage>, fixed + dynamic models
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).
2026-07-13 23:47:03 +02:00
|
|
|
std::array<float, NIn> d0_{};
|
|
|
|
|
std::array<float, NHidden1> d1_{};
|
|
|
|
|
std::array<float, NHidden2> d2_{};
|
|
|
|
|
std::array<float, NHidden3> d3_{};
|
|
|
|
|
mutable std::array<float, NHidden1> e0_{};
|
|
|
|
|
mutable std::array<float, NHidden2> e1_{};
|
|
|
|
|
mutable std::array<float, NHidden3> e2_{};
|
|
|
|
|
mutable std::array<float, NOut> e3_{};
|
|
|
|
|
|
|
|
|
|
std::array<float, NIn> input_{};
|
|
|
|
|
std::array<float, NOut> output_{};
|
|
|
|
|
|
|
|
|
|
std::array<float, NMaxExamples * NIn> dsf_{};
|
|
|
|
|
std::array<float, NMaxExamples * NOut> dsl_{};
|
|
|
|
|
|
|
|
|
|
std::array<float, weight_count()> flat_{};
|
|
|
|
|
std::array<float, NMaxIterTrain> lh_{};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
} // namespace nisps::ml
|