feat(wasm)!: P2.2 — nisps_ml_create honours dims; runtime-shaped browser MLP + reshape

Operator-approved ABI change (P2 stop-point). The WASM MLP is now
MLPCore<DynamicStorage>:

- nisps_ml_create(input, output, hidden[3], n, seed) honours its args;
  non-positive/null fall back to the historical 32→[10,14,18]→126, so
  every pre-P2 caller (manifold, worker, parity harness) stays
  bit-identical. Invalid/oversized dims (>4096) → null.
- NEW nisps_ml_reshape(ml, in, out, hidden, n, spread): fresh net at the
  new dims, warm-started via nisps/ml/warm_start.hpp (overlapping region
  copied; rest keeps spread init); feedback controller re-created (state
  resets — reset-on-reshape modal is the front-end contract). Failure
  leaves the old net untouched.
- nisps_ml_describe(ml, out): takes the handle; null reports defaults.
- FeedbackController got the same storage split: algorithms in
  FeedbackControllerCore<FbStorage>; FixedFeedbackStorage keeps firmware/
  tests source-identical via the old alias; DynamicFeedbackStorage (one
  arena) sizes to the runtime net. Firmware .text unchanged (122692).
- MLHandle: per-instance scratch vectors; dropped the dead 2MB
  batch_out_scratch.
- TS: types.ts decls (+_nisps_ml_reshape), wasm-iml re-describes the
  created instance, worker carries a shape-contract note for P2.3.

Verified: ctest 4/4 incl. new warm-start grow/shrink test; reshape ABI
smoke (dims honoured, overlap survives, invalid rejected, outputs
bounded); parity PASS unchanged (2.4e-7); lint clean; manifold 9 unit +
20 e2e green; firmware .text 122692 (+0.30% vs pre-P2 baseline).
This commit is contained in:
monkey-w1n5t0n 2026-07-14 03:38:06 +02:00
parent 8a19e5b52c
commit b6819fd26f
13 changed files with 640 additions and 218 deletions

View file

@ -110,8 +110,14 @@ Each phase ends green on its test gate and is independently landable. File phase
- `DynamicStorage` (`nisps/ml/dynamic_storage.hpp`) — sizes at construction, single arena allocation, - `DynamicStorage` (`nisps/ml/dynamic_storage.hpp`) — sizes at construction, single arena allocation,
nothing per-call. `#error`s under `NISPS_TARGET_EMBEDDED`; sole `lint-cpp.sh` heap-allowlist entry, with nothing per-call. `#error`s under `NISPS_TARGET_EMBEDDED`; sole `lint-cpp.sh` heap-allowlist entry, with
a lint check that fails if the guard is ever removed. a lint check that fails if the guard is ever removed.
- `nisps_ml_create(input, output, hidden[])` honours its arguments. Reshape = new instance + warm-start - ✅ (landed 2026-07-14, operator-approved) `nisps_ml_create(input, output, hidden[])` honours its
copy of overlapping weights (the BUILD-PLAN warm-start idea, now runtime). arguments (non-positive/null → the historical 32→[10,14,18]→126 defaults, keeping pre-P2 callers
bit-identical). `nisps_ml_reshape` = new instance + warm-start copy of overlapping weights
(`nisps/ml/warm_start.hpp`); feedback controller re-created (state resets — front-end modal).
`FeedbackController` got the same storage split (`FeedbackControllerCore<FbStorage>`, fixed alias for
firmware/tests, `DynamicFeedbackStorage` for the browser). `nisps_ml_describe` now takes the handle
(null → default shape). Verified: reshape ABI smoke (dims honoured, overlap survives, invalid dims
rejected), warm-start ctest (grow+shrink), parity PASS unchanged, firmware `.text` unchanged.
- Manifold drops input clamping/phantom-channel handling; XIASRI/sound-analysis multi-input modes become - Manifold drops input clamping/phantom-channel handling; XIASRI/sound-analysis multi-input modes become
browser-viable. browser-viable.
- **Gate:** parity — fixed and dynamic storage produce bit-identical outputs for identical shapes/seeds - **Gate:** parity — fixed and dynamic storage produce bit-identical outputs for identical shapes/seeds

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -28,9 +28,14 @@ export interface NispsModule {
_free(ptr: number): void; _free(ptr: number): void;
// ML lifecycle. Seed is uint32_t (not 64-bit) — see bindings.cpp file comment. // ML lifecycle. Seed is uint32_t (not 64-bit) — see bindings.cpp file comment.
// Since one-core-engine P2 the dims are HONOURED (runtime-shaped MLP);
// non-positive/null args fall back to the compiled defaults (32→[10,14,18]→126).
_nisps_ml_create(input_size: number, output_size: number, hidden_ptr: number, n_hidden: number, seed: number): number; _nisps_ml_create(input_size: number, output_size: number, hidden_ptr: number, n_hidden: number, seed: number): number;
_nisps_ml_destroy(ml: number): void; _nisps_ml_destroy(ml: number): void;
_nisps_ml_reset(ml: number): void; _nisps_ml_reset(ml: number): void;
// Reshape = new net at the new dims, warm-started with the overlapping
// weights; feedback state resets. Returns 1 on success (0 = no change).
_nisps_ml_reshape(ml: number, input_size: number, output_size: number, hidden_ptr: number, n_hidden: number, spread: number): number;
// ML inference. // ML inference.
_nisps_ml_set_input(ml: number, idx: number, v: number): void; _nisps_ml_set_input(ml: number, idx: number, v: number): void;
@ -54,7 +59,8 @@ export interface NispsModule {
_nisps_ml_draw_weights(ml: number, spread: number): void; _nisps_ml_draw_weights(ml: number, spread: number): void;
_nisps_ml_move_weights(ml: number, speed: number, spread: number, mask_ptr: number): void; _nisps_ml_move_weights(ml: number, speed: number, spread: number, mask_ptr: number): void;
_nisps_ml_get_layer_stats(ml: number, out_ptr: number): void; _nisps_ml_get_layer_stats(ml: number, out_ptr: number): void;
_nisps_ml_describe(out_ptr: number): void; // Null ml reports the DEFAULT shape; a handle reports its runtime shape.
_nisps_ml_describe(ml: number, out_ptr: number): void;
// ML feedback — the "Down Action" state machine (Avoid / RandomiseOutputs / // ML feedback — the "Down Action" state machine (Avoid / RandomiseOutputs /
// RandomiseMlp). Mode ints: 0=Avoid 1=RandomiseOutputs 2=RandomiseMlp. // RandomiseMlp). Mode ints: 0=Avoid 1=RandomiseOutputs 2=RandomiseMlp.

View file

@ -164,8 +164,20 @@ export class WasmIML {
locateFile: (path: string) => (path.endsWith('.wasm') ? assetUrl('nisps.wasm') : path), locateFile: (path: string) => (path.endsWith('.wasm') ? assetUrl('nisps.wasm') : path),
}); });
// Default shape (null handle). Since one-core-engine P2 the MLP is
// runtime-shaped: create() honours requested dims; we pass the caller's
// sizes (falling back to the defaults) and re-describe the instance.
this.describePtr = this.module._malloc(6 * 4); this.describePtr = this.module._malloc(6 * 4);
this.module._nisps_ml_describe(this.describePtr); this.module._nisps_ml_describe(0, this.describePtr);
const defaults = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6);
const wantedIn = opts.inputSize ?? defaults[0];
const wantedOut = opts.outputSize ?? defaults[4];
const seed = (opts.seed ?? (Date.now() >>> 0)) >>> 0;
this.mlHandle = this.module._nisps_ml_create(wantedIn, wantedOut, 0, 0, seed);
if (!this.mlHandle) throw new Error('[wasm-iml] nisps_ml_create returned null');
this.module._nisps_ml_describe(this.mlHandle, this.describePtr);
const dims = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6); const dims = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6);
this.arch_ = { this.arch_ = {
inputSize: dims[0], inputSize: dims[0],
@ -174,25 +186,6 @@ export class WasmIML {
numLayers: dims[5], numLayers: dims[5],
}; };
const wantedIn = opts.inputSize ?? this.arch_.inputSize;
const wantedOut = opts.outputSize ?? this.arch_.outputSize;
if (wantedIn !== this.arch_.inputSize || wantedOut !== this.arch_.outputSize) {
console.warn(
`[wasm-iml] requested ${wantedIn}->${wantedOut} but WASM build is fixed at ` +
`${this.arch_.inputSize}->${this.arch_.outputSize}; extras are ignored.`,
);
}
const seed = (opts.seed ?? (Date.now() >>> 0)) >>> 0;
this.mlHandle = this.module._nisps_ml_create(
this.arch_.inputSize,
this.arch_.outputSize,
0,
0,
seed,
);
if (!this.mlHandle) throw new Error('[wasm-iml] nisps_ml_create returned null');
this.weightCount_ = this.module._nisps_ml_weight_count(this.mlHandle); this.weightCount_ = this.module._nisps_ml_weight_count(this.mlHandle);
this.featuresBuf = new HeapBuffer(this.module, this.arch_.inputSize); this.featuresBuf = new HeapBuffer(this.module, this.arch_.inputSize);

View file

@ -194,6 +194,11 @@ if (isWorker) {
mod = await factory({ mod = await factory({
locateFile: (path: string) => (path.endsWith('.wasm') ? assetUrl('nisps.wasm') : path), locateFile: (path: string) => (path.endsWith('.wasm') ? assetUrl('nisps.wasm') : path),
}); });
// Default shape (0,0 → 32→[10,14,18]→126). The worker's net MUST match
// the main thread's shape — weights are exchanged as flat vectors. When
// the main thread creates/reshapes with non-default dims (one-core-engine
// P2.3+), the init/train messages must carry those dims and this call
// must pass them through.
mlHandle = mod._nisps_ml_create(0, 0, 0, 0, seed >>> 0); mlHandle = mod._nisps_ml_create(0, 0, 0, 0, seed >>> 0);
weightCount = mod._nisps_ml_weight_count(mlHandle); weightCount = mod._nisps_ml_weight_count(mlHandle);
} }

View file

@ -23,6 +23,7 @@
#endif #endif
#include <cstddef> #include <cstddef>
#include <cstdint>
#include <new> #include <new>
#include <span> #include <span>
@ -191,4 +192,88 @@ class DynamicStorage {
std::size_t total_ = 0u; std::size_t total_ = 0u;
}; };
// ---------------------------------------------------------------------------
// Runtime-sized feedback-controller storage (see nisps/ml/feedback.hpp for
// the surface contract). One arena allocation at construction; nothing
// per-call. The focus mask lives in a byte region carved from the same
// arena (aliased through the float arena's tail, kept byte-aligned by
// allocating whole floats for it).
// ---------------------------------------------------------------------------
class DynamicFeedbackStorage {
public:
DynamicFeedbackStorage(std::size_t n_out,
std::size_t n_weights,
std::size_t undo_depth = 4u) noexcept
: n_out_(n_out), n_weights_(n_weights), undo_cap_(undo_depth) {
if (n_out == 0u || n_weights == 0u || undo_depth == 0u) return;
// float regions: static_out, placed_out, snapshot, scratch, undo ring
// byte region: focus mask (n_out bytes, rounded up to whole floats)
const std::size_t focus_floats = (n_out + sizeof(float) - 1u) / sizeof(float);
const std::size_t total = n_out * 2u // static_out + placed_out
+ n_weights * 2u // snapshot + scratch
+ n_weights * undo_depth // undo ring
+ focus_floats;
arena_ = new (std::nothrow) float[total]();
if (!arena_) return;
off_placed_ = n_out_;
off_snap_ = off_placed_ + n_out_;
off_scratch_ = off_snap_ + n_weights_;
off_undo_ = off_scratch_ + n_weights_;
off_focus_ = off_undo_ + n_weights_ * undo_cap_;
}
~DynamicFeedbackStorage() { delete[] arena_; }
DynamicFeedbackStorage(const DynamicFeedbackStorage&) = delete;
DynamicFeedbackStorage& operator=(const DynamicFeedbackStorage&) = delete;
DynamicFeedbackStorage(DynamicFeedbackStorage&& o) noexcept { move_from_(o); }
DynamicFeedbackStorage& operator=(DynamicFeedbackStorage&& o) noexcept {
if (this != &o) {
delete[] arena_;
move_from_(o);
}
return *this;
}
bool valid() const noexcept { return arena_ != nullptr; }
std::size_t n_out() const noexcept { return n_out_; }
std::size_t n_weights() const noexcept { return n_weights_; }
std::size_t undo_cap() const noexcept { return undo_cap_; }
std::span<float> static_out() noexcept { return {arena_, n_out_}; }
std::span<const float> static_out() const noexcept { return {arena_, n_out_}; }
std::span<float> placed_out() noexcept { return {arena_ + off_placed_, n_out_}; }
std::span<const float> placed_out() const noexcept { return {arena_ + off_placed_, n_out_}; }
std::span<float> snapshot() noexcept { return {arena_ + off_snap_, n_weights_}; }
std::span<const float> snapshot() const noexcept { return {arena_ + off_snap_, n_weights_}; }
std::span<float> scratch_buf() noexcept { return {arena_ + off_scratch_, n_weights_}; }
std::span<float> undo_slot(std::size_t i) noexcept {
return {arena_ + off_undo_ + i * n_weights_, n_weights_};
}
std::span<const float> undo_slot(std::size_t i) const noexcept {
return {arena_ + off_undo_ + i * n_weights_, n_weights_};
}
std::span<std::uint8_t> focus() noexcept {
return {reinterpret_cast<std::uint8_t*>(arena_ + off_focus_), n_out_};
}
std::span<const std::uint8_t> focus() const noexcept {
return {reinterpret_cast<const std::uint8_t*>(arena_ + off_focus_), n_out_};
}
private:
void move_from_(DynamicFeedbackStorage& o) noexcept {
n_out_ = o.n_out_; n_weights_ = o.n_weights_; undo_cap_ = o.undo_cap_;
off_placed_ = o.off_placed_; off_snap_ = o.off_snap_;
off_scratch_ = o.off_scratch_; off_undo_ = o.off_undo_; off_focus_ = o.off_focus_;
arena_ = o.arena_;
o.arena_ = nullptr;
}
std::size_t n_out_ = 0u, n_weights_ = 0u, undo_cap_ = 0u;
std::size_t off_placed_ = 0u, off_snap_ = 0u, off_scratch_ = 0u,
off_undo_ = 0u, off_focus_ = 0u;
float* arena_ = nullptr;
};
} // namespace nisps::ml } // namespace nisps::ml

View file

@ -21,12 +21,21 @@
// then restores the original net (the kept example then // then restores the original net (the kept example then
// trains the original net toward the audition). // trains the original net toward the audition).
// //
// Design: header-only class template over the concrete MLP type. The controller // STORAGE POLICY (one-core-engine-refactor P2): like MLPCore, the controller
// does NOT own the MLP — every mutating method takes `MLP_T&`. It owns only the // algorithms are written once in `FeedbackControllerCore<FbStorage>` against a
// exploration state, all fixed-size (no heap), with its OWN per-instance Rng so // storage surface. Two models:
// * `FixedFeedbackStorage<NOut, NWeights, UndoDepth>` — std::array, zero
// heap. The classic `FeedbackController<MLP_T, UndoDepth>` alias derives
// the sizes from the fixed MLP type; firmware + tests compile unchanged.
// * `DynamicFeedbackStorage` (nisps/ml/dynamic_storage.hpp) — sizes at
// construction for the runtime-shaped browser MLP. Non-embedded only.
//
// Design: the controller does NOT own the MLP — every mutating method takes
// the MLP by reference (method-level template, so fixed and dynamic MLPs both
// work). It owns only the exploration state, with its OWN per-instance Rng so
// re-rolling outputs is deterministic and never perturbs the MLP's RNG stream. // re-rolling outputs is deterministic and never perturbs the MLP's RNG stream.
// Honours the RP2350 perf contract: no heap, no virtual dispatch, deterministic // Honours the RP2350 perf contract in the fixed model: no heap, no virtual
// per-instance RNG. // dispatch, deterministic per-instance RNG.
// //
// The C++/JS boundary: the controller decides *what transition happened* // The C++/JS boundary: the controller decides *what transition happened*
// (returns a FeedbackAction); the caller decides *what to persist* (add example, // (returns a FeedbackAction); the caller decides *what to persist* (add example,
@ -40,6 +49,7 @@
#include <cstdint> #include <cstdint>
#include <span> #include <span>
#include "../core/perf.hpp"
#include "../core/rng.hpp" #include "../core/rng.hpp"
namespace nisps::ml { namespace nisps::ml {
@ -59,7 +69,7 @@ enum class FeedbackMode : std::uint8_t {
// Exploring — real net snapshotted aside; a random SCRATCHPAD net is live and // Exploring — real net snapshotted aside; a random SCRATCHPAD net is live and
// the user auditions it (reroll / nudge / undo). NEVER trained. // the user auditions it (reroll / nudge / undo). NEVER trained.
// Placing — the user liked the current scratchpad sound; its output vector // Placing — the user liked the current scratchpad sound; its output vector
// is FROZEN in placed_out_ and held while they choose WHERE to // is FROZEN in placed_out and held while they choose WHERE to
// place it. The caller drives inference at the chosen input but // place it. The caller drives inference at the chosen input but
// the audition stays the frozen vector. // the audition stays the frozen vector.
enum class ExploreState : std::uint8_t { enum class ExploreState : std::uint8_t {
@ -84,28 +94,68 @@ enum class FeedbackAction : std::uint8_t {
ScratchReroll = 8, // scratchpad re-randomised (Exploring); pure audition, no store. ScratchReroll = 8, // scratchpad re-randomised (Exploring); pure audition, no store.
ScratchNudge = 9, // scratchpad nudged (bounded perturb, Exploring); undoable. ScratchNudge = 9, // scratchpad nudged (bounded perturb, Exploring); undoable.
ScratchUndo = 10, // last reroll/nudge undone (Exploring). ScratchUndo = 10, // last reroll/nudge undone (Exploring).
BeginPlace = 11, // Exploring→Placing; placed_out_ captured + frozen (no store yet). BeginPlace = 11, // Exploring→Placing; placed_out captured + frozen (no store yet).
CommitPlace = 12, // Placing→Idle; real net restored. CALLER adds +1 (input→placed_output) + trains. CommitPlace = 12, // Placing→Idle; real net restored. CALLER adds +1 (input→placed_output) + trains.
CancelPlace = 13, // Placing→Exploring; backed out of placing (no store). CancelPlace = 13, // Placing→Exploring; backed out of placing (no store).
}; };
// ---------------------------------------------------------------------------
// Fixed feedback storage — std::array, zero heap. Sizes are compile-time.
// UndoDepth = number of scratchpad ops (reroll/nudge) that can be undone in // UndoDepth = number of scratchpad ops (reroll/nudge) that can be undone in
// ExploreAndPlace. The undo ring is a fixed std::array of weight snapshots // ExploreAndPlace; each undo slot is NWeights floats. WASM historically used
// (no heap); each slot is kWeights floats. WASM uses depth 4, firmware 2 (per // depth 4, firmware 2 (per rl-feedback-design §2.2 — SRAM budget).
// rl-feedback-design §2.2 — SRAM budget). Default 4 (the WASM depth). // ---------------------------------------------------------------------------
template <typename MLP_T, std::size_t UndoDepth = 4u> template <std::size_t NOut, std::size_t NWeights, std::size_t UndoDepth = 4u>
class FeedbackController { class FixedFeedbackStorage {
public: public:
static constexpr std::size_t kNOut = MLP_T::kOutput; static constexpr std::size_t kNOut = NOut;
static constexpr std::size_t kWeights = MLP_T::weight_count(); static constexpr std::size_t kWeights = NWeights;
static constexpr std::size_t kUndoDepth = UndoDepth; static constexpr std::size_t kUndoDepth = UndoDepth;
explicit FeedbackController(std::uint64_t seed) noexcept : rng_(seed) {} static constexpr std::size_t n_out() noexcept { return NOut; }
static constexpr std::size_t n_weights() noexcept { return NWeights; }
static constexpr std::size_t undo_cap() noexcept { return UndoDepth; }
NISPS_FORCE_INLINE std::span<float> static_out() noexcept { return static_out_; }
NISPS_FORCE_INLINE std::span<const float> static_out() const noexcept { return static_out_; }
NISPS_FORCE_INLINE std::span<float> snapshot() noexcept { return snapshot_; }
NISPS_FORCE_INLINE std::span<const float> snapshot() const noexcept { return snapshot_; }
NISPS_FORCE_INLINE std::span<std::uint8_t> focus() noexcept { return focus_; }
NISPS_FORCE_INLINE std::span<const std::uint8_t> focus() const noexcept { return focus_; }
NISPS_FORCE_INLINE std::span<float> placed_out() noexcept { return placed_out_; }
NISPS_FORCE_INLINE std::span<const float> placed_out() const noexcept { return placed_out_; }
NISPS_FORCE_INLINE std::span<float> scratch_buf() noexcept { return scratch_buf_; }
NISPS_FORCE_INLINE std::span<float> undo_slot(std::size_t i) noexcept { return undo_ring_[i]; }
NISPS_FORCE_INLINE std::span<const float> undo_slot(std::size_t i) const noexcept {
return undo_ring_[i];
}
private:
std::array<float, NOut> static_out_{};
std::array<float, NWeights> snapshot_{};
std::array<std::uint8_t, NOut> focus_{};
std::array<float, NOut> placed_out_{};
std::array<std::array<float, NWeights>, UndoDepth> undo_ring_{};
std::array<float, NWeights> scratch_buf_{};
};
// ---------------------------------------------------------------------------
// The controller algorithms, written once against the feedback storage
// surface: n_out(), n_weights(), undo_cap(), static_out(), snapshot(),
// focus(), placed_out(), scratch_buf(), undo_slot(i).
// ---------------------------------------------------------------------------
template <typename FbStorage>
class FeedbackControllerCore : public FbStorage {
public:
template <typename... StorageArgs>
explicit FeedbackControllerCore(std::uint64_t seed, StorageArgs&&... storage_args) noexcept
: FbStorage(static_cast<StorageArgs&&>(storage_args)...), rng_(seed) {}
// ---- mode --------------------------------------------------------------- // ---- mode ---------------------------------------------------------------
// Switching mode mid-exploration cleanly tears down: restores the net (in // Switching mode mid-exploration cleanly tears down: restores the net (in
// RandomiseMlp) and resumes learning, so we never strand a randomised net. // RandomiseMlp) and resumes learning, so we never strand a randomised net.
void set_mode(FeedbackMode m, MLP_T& mlp) noexcept { template <typename M>
void set_mode(FeedbackMode m, M& mlp) noexcept {
if (explore_active_) abort_explore(mlp); if (explore_active_) abort_explore(mlp);
if (ep_state_ != ExploreState::Idle) abort_explore_place(mlp); if (ep_state_ != ExploreState::Idle) abort_explore_place(mlp);
mode_ = m; mode_ = m;
@ -126,33 +176,35 @@ class FeedbackController {
bool placing() const noexcept { return ep_state_ == ExploreState::Placing; } bool placing() const noexcept { return ep_state_ == ExploreState::Placing; }
// True while a REPOSITION hold is active (grab→move→drop). Distinguishes a // True while a REPOSITION hold is active (grab→move→drop). Distinguishes a
// reposition (real net never set aside) from an Explore→Place (scratchpad + // reposition (real net never set aside) from an Explore→Place (scratchpad +
// snapshot). Both sit in ExploreState::Placing and both hold placed_out_ via // snapshot). Both sit in ExploreState::Placing and both hold placed_out via
// static_output(); only commit/teardown differ (reposition does NOT restore // static_output(); only commit/teardown differ (reposition does NOT restore
// weights — there is nothing to restore). // weights — there is nothing to restore).
bool repositioning() const noexcept { return reposition_; } bool repositioning() const noexcept { return reposition_; }
// Depth of the scratchpad undo ring currently available to pop (0..UndoDepth). // Depth of the scratchpad undo ring currently available to pop (0..undo_cap).
std::size_t undo_depth() const noexcept { return undo_count_; } std::size_t undo_depth() const noexcept { return undo_count_; }
// The output vector frozen at like()/begin-place time. Valid only while // The output vector frozen at like()/begin-place time. Valid only while
// placing(); empty span otherwise. The caller adds this as the +1 example // placing(); empty span otherwise. The caller adds this as the +1 example
// label at commit (input → placed_output). // label at commit (input → placed_output).
std::span<const float> placed_output() const noexcept { std::span<const float> placed_output() const noexcept {
if (ep_state_ != ExploreState::Placing) return {}; if (ep_state_ != ExploreState::Placing) return {};
return std::span<const float>(placed_out_.data(), kNOut); return this->placed_out();
} }
// ---- focus mask: 1 byte per output; 0 == frozen (unfocused). Copied into a // ---- focus mask: 1 byte per output; 0 == frozen (unfocused). Copied into a
// fixed buffer (no heap, no dangling span). Empty ⇒ all outputs active. // fixed buffer (no heap, no dangling span). Empty ⇒ all outputs active.
void set_focus_mask(std::span<const std::uint8_t> mask) noexcept { void set_focus_mask(std::span<const std::uint8_t> mask) noexcept {
focus_count_ = (mask.size() < kNOut) ? mask.size() : kNOut; auto focus = this->focus();
for (std::size_t i = 0; i < focus_count_; ++i) focus_[i] = mask[i]; focus_count_ = (mask.size() < focus.size()) ? mask.size() : focus.size();
for (std::size_t i = 0; i < focus_count_; ++i) focus[i] = mask[i];
} }
void clear_focus_mask() noexcept { focus_count_ = 0; } void clear_focus_mask() noexcept { focus_count_ = 0; }
// ---- press handlers ----------------------------------------------------- // ---- press handlers -----------------------------------------------------
// `current_out` is the live (post-pipeline) output the user is hearing // `current_out` is the live (post-pipeline) output the user is hearing
// (kNOut floats). `pin_mask` may be empty. Returns the FeedbackAction the // (n_out floats). `pin_mask` may be empty. Returns the FeedbackAction the
// caller must act on. // caller must act on.
FeedbackAction on_down(MLP_T& mlp, std::span<const float> current_out, template <typename M>
FeedbackAction on_down(M& mlp, std::span<const float> current_out,
float speed, float spread, float speed, float spread,
std::span<const std::uint8_t> pin_mask) noexcept { std::span<const std::uint8_t> pin_mask) noexcept {
switch (mode_) { switch (mode_) {
@ -197,7 +249,8 @@ class FeedbackController {
// Up = thumbs-up / "keep". While exploring it commits: the CALLER must have // Up = thumbs-up / "keep". While exploring it commits: the CALLER must have
// captured the heard output BEFORE calling this (on_up restores the original // captured the heard output BEFORE calling this (on_up restores the original
// net in RandomiseMlp), then stores it as a +1 example at the current input. // net in RandomiseMlp), then stores it as a +1 example at the current input.
FeedbackAction on_up(MLP_T& mlp) noexcept { template <typename M>
FeedbackAction on_up(M& mlp) noexcept {
if (mode_ == FeedbackMode::ExploreAndPlace) { if (mode_ == FeedbackMode::ExploreAndPlace) {
// SOFTWARE DEFAULT POLICY (browser): up begins place from // SOFTWARE DEFAULT POLICY (browser): up begins place from
// Exploring (freeze the heard output), then commits from Placing // Exploring (freeze the heard output), then commits from Placing
@ -225,7 +278,8 @@ class FeedbackController {
// Drag-store (joystick freeze→reposition→release). In RandomiseMlp this is // Drag-store (joystick freeze→reposition→release). In RandomiseMlp this is
// the "reposition-commit": the caller has already stored the +1 at the new // the "reposition-commit": the caller has already stored the +1 at the new
// input; we just restore the original net and end exploration. // input; we just restore the original net and end exploration.
FeedbackAction on_drag(MLP_T& mlp) noexcept { template <typename M>
FeedbackAction on_drag(M& mlp) noexcept {
if (explore_active_ && mode_ == FeedbackMode::RandomiseMlp) { if (explore_active_ && mode_ == FeedbackMode::RandomiseMlp) {
restore_after_explore(mlp); restore_after_explore(mlp);
return FeedbackAction::Restore; return FeedbackAction::Restore;
@ -235,18 +289,21 @@ class FeedbackController {
// Inference hook: fills `out` with the held static vector and returns true // Inference hook: fills `out` with the held static vector and returns true
// when RandomiseOutputs is bypassing the MLP; else returns false (the caller // when RandomiseOutputs is bypassing the MLP; else returns false (the caller
// should run mlp.process() normally). `out` should hold at least kNOut. // should run mlp.process() normally). `out` should hold at least n_out.
bool static_output(std::span<float> out) const noexcept { bool static_output(std::span<float> out) const noexcept {
const std::size_t n_out = this->n_out();
// ExploreAndPlace: while PLACING, the audition is the frozen vector the // ExploreAndPlace: while PLACING, the audition is the frozen vector the
// user liked, held steady as they aim at a location. // user liked, held steady as they aim at a location.
if (mode_ == FeedbackMode::ExploreAndPlace && ep_state_ == ExploreState::Placing) { if (mode_ == FeedbackMode::ExploreAndPlace && ep_state_ == ExploreState::Placing) {
const std::size_t n = (out.size() < kNOut) ? out.size() : kNOut; const auto placed = this->placed_out();
for (std::size_t i = 0; i < n; ++i) out[i] = placed_out_[i]; const std::size_t n = (out.size() < n_out) ? out.size() : n_out;
for (std::size_t i = 0; i < n; ++i) out[i] = placed[i];
return true; return true;
} }
if (!(mode_ == FeedbackMode::RandomiseOutputs && explore_active_)) return false; if (!(mode_ == FeedbackMode::RandomiseOutputs && explore_active_)) return false;
const std::size_t n = (out.size() < kNOut) ? out.size() : kNOut; const auto held = this->static_out();
for (std::size_t i = 0; i < n; ++i) out[i] = static_out_[i]; const std::size_t n = (out.size() < n_out) ? out.size() : n_out;
for (std::size_t i = 0; i < n; ++i) out[i] = held[i];
return true; return true;
} }
@ -264,11 +321,11 @@ class FeedbackController {
// Idle→Exploring. Snapshot the real (trained) net aside, randomise a // Idle→Exploring. Snapshot the real (trained) net aside, randomise a
// scratchpad net the user auditions. No-op if not Idle. // scratchpad net the user auditions. No-op if not Idle.
void enter_explore(MLP_T& mlp, float spread) noexcept { template <typename M>
void enter_explore(M& mlp, float spread) noexcept {
if (mode_ != FeedbackMode::ExploreAndPlace) return; if (mode_ != FeedbackMode::ExploreAndPlace) return;
if (ep_state_ != ExploreState::Idle) return; if (ep_state_ != ExploreState::Idle) return;
auto w = mlp.get_weights(); // flat snapshot (size == kWeights) take_snapshot(mlp);
for (std::size_t i = 0; i < kWeights; ++i) snapshot_[i] = w[i];
learning_paused_ = true; learning_paused_ = true;
ep_state_ = ExploreState::Exploring; ep_state_ = ExploreState::Exploring;
undo_count_ = 0u; undo_count_ = 0u;
@ -278,78 +335,85 @@ class FeedbackController {
// Exploring→Idle. Restore the real net, discard the scratchpad. No example // Exploring→Idle. Restore the real net, discard the scratchpad. No example
// stored. (The hardware "enter/exit explore toggle" off-path.) // stored. (The hardware "enter/exit explore toggle" off-path.)
void exit_explore(MLP_T& mlp) noexcept { template <typename M>
void exit_explore(M& mlp) noexcept {
if (mode_ != FeedbackMode::ExploreAndPlace) return; if (mode_ != FeedbackMode::ExploreAndPlace) return;
if (ep_state_ == ExploreState::Idle) return; if (ep_state_ == ExploreState::Idle) return;
restore_real_net(mlp); restore_real_net(mlp);
} }
// Exploring scratchpad op: re-randomise the scratchpad. Undoable. // Exploring scratchpad op: re-randomise the scratchpad. Undoable.
void reroll(MLP_T& mlp, float spread) noexcept { template <typename M>
void reroll(M& mlp, float spread) noexcept {
if (!can_scratch_op()) return; if (!can_scratch_op()) return;
push_undo(mlp); push_undo(mlp);
mlp.draw_weights(spread); mlp.draw_weights(spread);
} }
// Exploring scratchpad op: small bounded perturbation of the scratchpad via // Exploring scratchpad op: small bounded perturbation of the scratchpad via
// move_weights on the controller's OWN Rng-free path — move_weights uses the // the controller's OWN Rng (move_weights uses the MLP's Rng; to keep the
// MLP's Rng, so to keep the controller's Rng stream out of the MLP stream we // controller's Rng stream out of the MLP stream we draw the perturbation
// draw the perturbation here and apply it. Undoable. `amount` is the noise // here and apply it). Undoable. `amount` is the noise stddev (e.g. 0.05).
// stddev (small, e.g. 0.05). template <typename M>
void nudge(MLP_T& mlp, float amount) noexcept { void nudge(M& mlp, float amount) noexcept {
if (!can_scratch_op()) return; if (!can_scratch_op()) return;
push_undo(mlp); push_undo(mlp);
auto scratch = this->scratch_buf();
const std::size_t n_weights = this->n_weights();
auto w = mlp.get_weights(); auto w = mlp.get_weights();
for (std::size_t i = 0; i < kWeights; ++i) { for (std::size_t i = 0; i < n_weights; ++i) {
scratch_buf_[i] = w[i] + rng_.next_float_gaussian(amount); scratch[i] = w[i] + rng_.next_float_gaussian(amount);
} }
mlp.set_weights(std::span<const float>(scratch_buf_.data(), kWeights)); mlp.set_weights(std::span<const float>(scratch.data(), n_weights));
} }
// Exploring scratchpad op: undo the last reroll/nudge (bounded ring). // Exploring scratchpad op: undo the last reroll/nudge (bounded ring).
void undo(MLP_T& mlp) noexcept { template <typename M>
void undo(M& mlp) noexcept {
if (!can_scratch_op()) return; if (!can_scratch_op()) return;
if (undo_count_ == 0u) return; if (undo_count_ == 0u) return;
undo_head_ = (undo_head_ + kUndoDepth - 1u) % kUndoDepth; const std::size_t cap = this->undo_cap();
undo_head_ = (undo_head_ + cap - 1u) % cap;
--undo_count_; --undo_count_;
mlp.set_weights(std::span<const float>(undo_ring_[undo_head_].data(), kWeights)); const auto slot = this->undo_slot(undo_head_);
mlp.set_weights(std::span<const float>(slot.data(), this->n_weights()));
} }
// Exploring→Placing. Capture + FREEZE the current scratchpad output the user // Exploring→Placing. Capture + FREEZE the current scratchpad output the user
// is auditioning. The caller MUST have run mlp.process() at the audition // is auditioning. The caller MUST have run mlp.process() at the audition
// input first; pass that output here. While placing, static_output() holds // input first; pass that output here. While placing, static_output() holds
// this vector and the caller chooses WHERE to place it. // this vector and the caller chooses WHERE to place it.
void begin_place(MLP_T& mlp, std::span<const float> current_out) noexcept { template <typename M>
void begin_place(M& mlp, std::span<const float> current_out) noexcept {
(void)mlp;
if (mode_ != FeedbackMode::ExploreAndPlace) return; if (mode_ != FeedbackMode::ExploreAndPlace) return;
if (ep_state_ != ExploreState::Exploring) return; if (ep_state_ != ExploreState::Exploring) return;
const std::size_t n = (current_out.size() < kNOut) ? current_out.size() : kNOut; capture_placed(current_out);
for (std::size_t i = 0; i < n; ++i) placed_out_[i] = current_out[i];
ep_state_ = ExploreState::Placing; ep_state_ = ExploreState::Placing;
} }
// Convenience: freeze the scratchpad's output at its CURRENT input (runs the // Convenience: freeze the scratchpad's output at its CURRENT input (runs the
// forward pass on the live scratchpad net). Equivalent to process()+capture. // forward pass on the live scratchpad net). Equivalent to process()+capture.
void begin_place(MLP_T& mlp) noexcept { template <typename M>
void begin_place(M& mlp) noexcept {
if (mode_ != FeedbackMode::ExploreAndPlace) return; if (mode_ != FeedbackMode::ExploreAndPlace) return;
if (ep_state_ != ExploreState::Exploring) return; if (ep_state_ != ExploreState::Exploring) return;
mlp.process(); mlp.process();
const auto outs = mlp.outputs(); capture_placed(mlp.outputs());
const std::size_t n = (outs.size() < kNOut) ? outs.size() : kNOut;
for (std::size_t i = 0; i < n; ++i) placed_out_[i] = outs[i];
ep_state_ = ExploreState::Placing; ep_state_ = ExploreState::Placing;
} }
// Placing→Idle. Restore the real net. The CALLER then adds a +1 example at // Placing→Idle. Restore the real net. The CALLER then adds a +1 example at
// (chosen input → placed_output()) and trains. Returns the placed output so // (chosen input → placed_output()) and trains.
// the caller can read it after the restore (it survives the restore). template <typename M>
void commit_place(MLP_T& mlp) noexcept { void commit_place(M& mlp) noexcept {
if (mode_ != FeedbackMode::ExploreAndPlace) return; if (mode_ != FeedbackMode::ExploreAndPlace) return;
if (ep_state_ != ExploreState::Placing) return; if (ep_state_ != ExploreState::Placing) return;
// Restore the real net but KEEP placed_out_ valid for the caller until // Restore the real net but KEEP placed_out valid for the caller until
// it transitions to Idle; expose via a separate accessor that does not // it transitions to Idle; expose via a separate accessor that does not
// gate on Placing. // gate on Placing.
mlp.set_weights(std::span<const float>(snapshot_.data(), kWeights)); restore_snapshot(mlp);
last_placed_valid_ = true; // placed_out_ holds the just-committed vector last_placed_valid_ = true; // placed_out holds the just-committed vector
learning_paused_ = false; learning_paused_ = false;
ep_state_ = ExploreState::Idle; ep_state_ = ExploreState::Idle;
undo_count_ = 0u; undo_count_ = 0u;
@ -360,7 +424,7 @@ class FeedbackController {
// AFTER commit_place has restored the real net. // AFTER commit_place has restored the real net.
std::span<const float> committed_output() const noexcept { std::span<const float> committed_output() const noexcept {
if (!last_placed_valid_) return {}; if (!last_placed_valid_) return {};
return std::span<const float>(placed_out_.data(), kNOut); return this->placed_out();
} }
// Placing→Exploring. Back out of placing without storing; resume auditioning // Placing→Exploring. Back out of placing without storing; resume auditioning
@ -393,8 +457,7 @@ class FeedbackController {
void begin_reposition(std::span<const float> current_out) noexcept { void begin_reposition(std::span<const float> current_out) noexcept {
if (mode_ != FeedbackMode::ExploreAndPlace) return; if (mode_ != FeedbackMode::ExploreAndPlace) return;
if (ep_state_ != ExploreState::Idle) return; if (ep_state_ != ExploreState::Idle) return;
const std::size_t n = (current_out.size() < kNOut) ? current_out.size() : kNOut; capture_placed(current_out);
for (std::size_t i = 0; i < n; ++i) placed_out_[i] = current_out[i];
reposition_ = true; reposition_ = true;
learning_paused_ = true; learning_paused_ = true;
last_placed_valid_ = false; last_placed_valid_ = false;
@ -403,13 +466,12 @@ class FeedbackController {
// Convenience: capture the trained net's output at its CURRENT input // Convenience: capture the trained net's output at its CURRENT input
// (process + capture). Equivalent to begin_reposition(mlp.outputs()). // (process + capture). Equivalent to begin_reposition(mlp.outputs()).
void begin_reposition(MLP_T& mlp) noexcept { template <typename M>
void begin_reposition(M& mlp) noexcept {
if (mode_ != FeedbackMode::ExploreAndPlace) return; if (mode_ != FeedbackMode::ExploreAndPlace) return;
if (ep_state_ != ExploreState::Idle) return; if (ep_state_ != ExploreState::Idle) return;
mlp.process(); mlp.process();
const auto outs = mlp.outputs(); capture_placed(mlp.outputs());
const std::size_t n = (outs.size() < kNOut) ? outs.size() : kNOut;
for (std::size_t i = 0; i < n; ++i) placed_out_[i] = outs[i];
reposition_ = true; reposition_ = true;
learning_paused_ = true; learning_paused_ = true;
last_placed_valid_ = false; last_placed_valid_ = false;
@ -429,48 +491,74 @@ class FeedbackController {
} }
private: private:
void capture_placed(std::span<const float> src) noexcept {
auto placed = this->placed_out();
const std::size_t n = (src.size() < placed.size()) ? src.size() : placed.size();
for (std::size_t i = 0; i < n; ++i) placed[i] = src[i];
}
template <typename M>
void take_snapshot(M& mlp) noexcept {
auto snap = this->snapshot();
auto w = mlp.get_weights(); // flat snapshot (size == n_weights)
const std::size_t n = this->n_weights();
for (std::size_t i = 0; i < n; ++i) snap[i] = w[i];
}
template <typename M>
void restore_snapshot(M& mlp) noexcept {
const auto snap = this->snapshot();
mlp.set_weights(std::span<const float>(snap.data(), this->n_weights()));
}
void enter_randomise_outputs(std::span<const float> seed_out) noexcept { void enter_randomise_outputs(std::span<const float> seed_out) noexcept {
explore_active_ = true; explore_active_ = true;
learning_paused_ = true; learning_paused_ = true;
// Seed every dim with the live output the user is hearing, so unfocused // Seed every dim with the live output the user is hearing, so unfocused
// (frozen) dims hold that value through the exploration — matching the // (frozen) dims hold that value through the exploration — matching the
// firmware `staticRandomOut_ = action; _roll_static_outputs();`. The // firmware `staticRandomOut_ = action; _roll_static_outputs();`. The
// CALLER CONTRACT is to pass the full kNOut live output. Any dims beyond // CALLER CONTRACT is to pass the full n_out live output. Any dims beyond
// a short seed keep their previous static value (we have no live value // a short seed keep their previous static value (we have no live value
// to freeze them to); they are only observable if a focus mask freezes // to freeze them to); they are only observable if a focus mask freezes
// a dim the short seed did not cover — an out-of-contract corner. // a dim the short seed did not cover — an out-of-contract corner.
const std::size_t n = (seed_out.size() < kNOut) ? seed_out.size() : kNOut; auto held = this->static_out();
for (std::size_t i = 0; i < n; ++i) static_out_[i] = seed_out[i]; const std::size_t n = (seed_out.size() < held.size()) ? seed_out.size() : held.size();
for (std::size_t i = 0; i < n; ++i) held[i] = seed_out[i];
roll_static_outputs(); roll_static_outputs();
} }
void roll_static_outputs() noexcept { void roll_static_outputs() noexcept {
for (std::size_t i = 0; i < kNOut; ++i) { auto held = this->static_out();
const bool active = (focus_count_ == 0u) || (i < focus_count_ && focus_[i] != 0u); const auto focus = this->focus();
if (active) static_out_[i] = rng_.next_float_uniform(); // [0, 1) for (std::size_t i = 0; i < held.size(); ++i) {
const bool active = (focus_count_ == 0u) || (i < focus_count_ && focus[i] != 0u);
if (active) held[i] = rng_.next_float_uniform(); // [0, 1)
// inactive dims keep their seeded entry value // inactive dims keep their seeded entry value
} }
} }
void enter_randomise_mlp(MLP_T& mlp, float spread) noexcept { template <typename M>
void enter_randomise_mlp(M& mlp, float spread) noexcept {
explore_active_ = true; explore_active_ = true;
learning_paused_ = true; learning_paused_ = true;
auto w = mlp.get_weights(); // flat snapshot (size == kWeights) take_snapshot(mlp);
for (std::size_t i = 0; i < kWeights; ++i) snapshot_[i] = w[i];
mlp.draw_weights(spread); // randomise the live net mlp.draw_weights(spread); // randomise the live net
} }
void restore_after_explore(MLP_T& mlp) noexcept { template <typename M>
void restore_after_explore(M& mlp) noexcept {
if (mode_ == FeedbackMode::RandomiseMlp) { if (mode_ == FeedbackMode::RandomiseMlp) {
mlp.set_weights(std::span<const float>(snapshot_.data(), kWeights)); restore_snapshot(mlp);
} }
learning_paused_ = false; learning_paused_ = false;
explore_active_ = false; explore_active_ = false;
} }
// Cancel and abort share restore semantics; the caller stores nothing. // Cancel and abort share restore semantics; the caller stores nothing.
void cancel_explore(MLP_T& mlp) noexcept { restore_after_explore(mlp); } template <typename M>
void abort_explore(MLP_T& mlp) noexcept { restore_after_explore(mlp); } void cancel_explore(M& mlp) noexcept { restore_after_explore(mlp); }
template <typename M>
void abort_explore(M& mlp) noexcept { restore_after_explore(mlp); }
// ---- ExploreAndPlace helpers -------------------------------------------- // ---- ExploreAndPlace helpers --------------------------------------------
bool can_scratch_op() const noexcept { bool can_scratch_op() const noexcept {
@ -480,28 +568,34 @@ class FeedbackController {
// Push the CURRENT scratchpad weights onto the bounded undo ring before a // Push the CURRENT scratchpad weights onto the bounded undo ring before a
// mutating op, so undo() restores the pre-op candidate. // mutating op, so undo() restores the pre-op candidate.
void push_undo(MLP_T& mlp) noexcept { template <typename M>
void push_undo(M& mlp) noexcept {
auto slot = this->undo_slot(undo_head_);
auto w = mlp.get_weights(); auto w = mlp.get_weights();
for (std::size_t i = 0; i < kWeights; ++i) undo_ring_[undo_head_][i] = w[i]; const std::size_t n = this->n_weights();
undo_head_ = (undo_head_ + 1u) % kUndoDepth; for (std::size_t i = 0; i < n; ++i) slot[i] = w[i];
if (undo_count_ < kUndoDepth) ++undo_count_; const std::size_t cap = this->undo_cap();
undo_head_ = (undo_head_ + 1u) % cap;
if (undo_count_ < cap) ++undo_count_;
} }
// Restore the set-aside real net and return to Idle. Shared by exit_explore // Restore the set-aside real net and return to Idle. Shared by exit_explore
// and abort_explore_place. No example stored. // and abort_explore_place. No example stored.
void restore_real_net(MLP_T& mlp) noexcept { template <typename M>
mlp.set_weights(std::span<const float>(snapshot_.data(), kWeights)); void restore_real_net(M& mlp) noexcept {
restore_snapshot(mlp);
learning_paused_ = false; learning_paused_ = false;
ep_state_ = ExploreState::Idle; ep_state_ = ExploreState::Idle;
undo_count_ = 0u; undo_count_ = 0u;
last_placed_valid_ = false; last_placed_valid_ = false;
} }
void abort_explore_place(MLP_T& mlp) noexcept { template <typename M>
void abort_explore_place(M& mlp) noexcept {
if (ep_state_ == ExploreState::Idle) return; if (ep_state_ == ExploreState::Idle) return;
if (reposition_) { if (reposition_) {
// A reposition never set the real net aside, so there is nothing to // A reposition never set the real net aside, so there is nothing to
// restore — clearing snapshot_ into the net here would CLOBBER the // restore — clearing snapshot into the net here would CLOBBER the
// live trained weights. Just drop the hold. // live trained weights. Just drop the hold.
reposition_ = false; reposition_ = false;
learning_paused_ = false; learning_paused_ = false;
@ -516,22 +610,22 @@ class FeedbackController {
FeedbackMode mode_ = FeedbackMode::Avoid; FeedbackMode mode_ = FeedbackMode::Avoid;
bool explore_active_ = false; bool explore_active_ = false;
bool learning_paused_ = false; bool learning_paused_ = false;
std::array<float, kNOut> static_out_{}; std::size_t focus_count_ = 0; // 0 ⇒ all active
std::array<float, kWeights> snapshot_{};
std::array<std::uint8_t, kNOut> focus_{};
std::size_t focus_count_ = 0; // 0 ⇒ all active
// ---- ExploreAndPlace state (all fixed-size, no heap) -------------------- // ---- ExploreAndPlace state ----------------------------------------------
ExploreState ep_state_ = ExploreState::Idle; ExploreState ep_state_ = ExploreState::Idle;
std::array<float, kNOut> placed_out_{}; // frozen audition/carried vector bool last_placed_valid_ = false;
bool last_placed_valid_ = false; bool reposition_ = false; // grab→move→drop hold; net NOT set aside
bool reposition_ = false; // grab→move→drop hold; net NOT set aside std::size_t undo_head_ = 0u; // next write slot
std::array<std::array<float, kWeights>, kUndoDepth> undo_ring_{}; // bounded undo std::size_t undo_count_ = 0u; // valid entries (0..undo_cap)
std::size_t undo_head_ = 0u; // next write slot
std::size_t undo_count_ = 0u; // valid entries (0..kUndoDepth)
std::array<float, kWeights> scratch_buf_{}; // nudge scratch (no heap)
Rng rng_; Rng rng_;
}; };
// The classic fixed-size controller over a compile-time MLP type — the
// firmware model and the default for tests. Sizes derive from the MLP.
template <typename MLP_T, std::size_t UndoDepth = 4u>
using FeedbackController = FeedbackControllerCore<
FixedFeedbackStorage<MLP_T::kOutput, MLP_T::weight_count(), UndoDepth>>;
} // namespace nisps::ml } // namespace nisps::ml

65
nisps/ml/warm_start.hpp Normal file
View file

@ -0,0 +1,65 @@
// nisps/ml/warm_start.hpp — copy overlapping weights between two MLPs of
// (possibly) different shapes.
//
// Used by the runtime-reshape path (one-core-engine-refactor P2): reshape =
// construct a NEW instance at the new dimensions, then warm-start it by
// copying every weight/bias whose (layer, node, input) coordinate exists in
// BOTH shapes. Weights outside the overlap keep the destination's fresh
// initialisation. Deterministic, allocation-free, works across storage
// policies (fixed→dynamic, dynamic→dynamic, fixed→fixed).
//
// Row-major layout per layer: w[node * fan_in + j]. The overlap is the
// top-left submatrix min(fan_out) × min(fan_in) plus the bias prefix
// min(fan_out).
#pragma once
#include <cstddef>
#include <span>
#include "../core/perf.hpp"
namespace nisps::ml {
namespace detail {
template <std::size_t L, typename DstMLP, typename SrcMLP>
NISPS_FORCE_INLINE void warm_start_copy_layer(DstMLP& dst, const SrcMLP& src) noexcept {
const std::size_t src_in = src.template fan_in_l<L>();
const std::size_t src_out = src.template fan_out_l<L>();
const std::size_t dst_in = dst.template fan_in_l<L>();
const std::size_t dst_out = dst.template fan_out_l<L>();
const std::size_t n_in = (src_in < dst_in) ? src_in : dst_in;
const std::size_t n_out = (src_out < dst_out) ? src_out : dst_out;
std::span<const float> sw = src.template weights_l<L>();
std::span<float> dw = dst.template weights_l<L>();
for (std::size_t node = 0; node < n_out; ++node) {
const std::size_t src_row = node * src_in;
const std::size_t dst_row = node * dst_in;
for (std::size_t j = 0; j < n_in; ++j) {
dw[dst_row + j] = sw[src_row + j];
}
}
std::span<const float> sb = src.template biases_l<L>();
std::span<float> db = dst.template biases_l<L>();
for (std::size_t node = 0; node < n_out; ++node) {
db[node] = sb[node];
}
}
} // namespace detail
// Copy the overlapping region of every layer from `src` into `dst`. Both
// must expose the MLP storage surface (fan_in_l/fan_out_l/weights_l/
// biases_l) — i.e. any MLPCore instantiation.
template <typename DstMLP, typename SrcMLP>
inline void warm_start_copy(DstMLP& dst, const SrcMLP& src) noexcept {
detail::warm_start_copy_layer<0u>(dst, src);
detail::warm_start_copy_layer<1u>(dst, src);
detail::warm_start_copy_layer<2u>(dst, src);
detail::warm_start_copy_layer<3u>(dst, src);
}
} // namespace nisps::ml

View file

@ -1,31 +1,26 @@
// nisps/wasm/bindings.cpp — flat C API exported to the SolidJS playground. // nisps/wasm/bindings.cpp — flat C API exported to the Manifold browser app.
// //
// Two consumers per build: // Two consumers per build:
// 1. Main-thread WasmIML (playground/src/ml/wasm-iml.ts) — ML calls. // 1. Main-thread WasmIML (manifold/src/engine/wasm-iml.ts) — ML calls.
// 2. AudioWorklet processor (playground/src/audio/worklet/...) — engine // 2. AudioWorklet processor (manifold/src/engine/worklet/...) — engine
// calls. (Each instance owns its own WASM module instance.) // calls. (Each instance owns its own WASM module instance.)
// //
// ARCHITECTURE (input dim is OVER-PROVISIONED for mix-and-match inputs) // ARCHITECTURE (runtime-shaped since one-core-engine-refactor P2)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// The C++ MLP class is templated on layer sizes (architecture.md §4.1, §6.2). // The browser MLP is `MLPCore<DynamicStorage>`: `nisps_ml_create()` HONOURS
// We instantiate ONE concrete configuration here: // its caller-supplied input_size / output_size / hidden[3] arguments. The
// topology stays fixed at 4 layers (ReLU×3 + Sigmoid); only the dimensions
// are runtime. Non-positive / missing arguments fall back to the historical
// compiled defaults (32 → [10, 14, 18] → 126), which keeps every pre-P2
// caller — including the parity harness — bit-identical (FixedStorage and
// DynamicStorage are bit-parity-tested for equal shapes/seeds).
// //
// using DefaultMLP = nisps::ml::MLP<32, 10, 14, 18, 126>; // Reshape = `nisps_ml_reshape()`: a NEW instance at the new dimensions,
// // warm-started by copying the overlapping weight region from the old net
// The 32-input dimension is the MAX number of composed input axes the manifold // (nisps/ml/warm_start.hpp); weights outside the overlap keep the fresh
// front-end can feed (matches MAX_AXES in manifold/src/inputs/input-layer.ts). // spread-initialised values. The feedback controller is re-created at the
// The mix-and-match input layer (Internal XY pad + Game Controller + MIDI) gives // new dimensions (its exploration state resets — the front-end shows a
// each active axis its OWN dedicated input slot — NO mean-blending — and feeds // reset-on-reshape modal).
// the remaining (unused) slots a constant 0. The "active input dimension count"
// is a front-end concept: a 2-axis pad uses slots 01, a 4-axis pad+stick uses
// 03, etc. Because slot assignment is stable and unused slots are held at 0,
// the net behaves as an N-input net where N = active axes; changing N is a
// reshape, after which the front-end resets the weights (recreate-from-scratch,
// behind a confirm modal). 126 outputs cover C15 + any current schema.
//
// `nisps_ml_create()` accepts caller-supplied input_size/output_size/hidden[]
// but only validates them against these compile-time defaults — extra
// inputs/outputs are clipped at the boundary and hidden overrides are ignored.
// //
// WIRE FORMAT FOR WEIGHTS // WIRE FORMAT FOR WEIGHTS
// ----------------------- // -----------------------
@ -68,9 +63,11 @@
// ML. // ML.
#include "../core/types.hpp" #include "../core/types.hpp"
#include "../ml/dynamic_storage.hpp"
#include "../ml/feedback.hpp" #include "../ml/feedback.hpp"
#include "../ml/mlp.hpp" #include "../ml/mlp.hpp"
#include "../ml/stats.hpp" #include "../ml/stats.hpp"
#include "../ml/warm_start.hpp"
namespace { namespace {
@ -78,45 +75,89 @@ namespace {
// ML side // ML side
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Compile-time default architecture. See header comment. // Historical compiled defaults. Non-positive / missing create() args fall
// // back to these, keeping every pre-P2 caller bit-identical.
// Choice rationale: // * 32 inputs — MAX composed input axes the manifold front-end
// * 2 inputs — playground virtual joystick (X, Y). // feeds (matches MAX_AXES in manifold/src/inputs/input-layer.ts).
// * [10, 14, 18] hidden — covers the largest schema layouts in // * [10, 14, 18] hidden — covers the largest schema layouts in
// `schemas/modes/*.json` (channel_strip variants, verb_fx, breakor, // `schemas/modes/*.json`.
// elysiamorf, memlcelium).
// * 126 outputs — enough for the C15 mode and any current schema. // * 126 outputs — enough for the C15 mode and any current schema.
// constexpr std::size_t kDefaultInputs = 32u;
// The MLP also has dataset slots, loss history etc. — see mlp.hpp. constexpr std::size_t kDefaultHidden[3] = {10u, 14u, 18u};
using DefaultMLP = nisps::ml::MLP<32u, 10u, 14u, 18u, 126u>; constexpr std::size_t kDefaultOutputs = 126u;
// Sanity ceiling per dimension — create/reshape reject anything larger.
constexpr std::size_t kMaxDim = 4096u;
constexpr std::size_t kDefaultInputs = DefaultMLP::kInput; constexpr std::uint64_t kFeedbackSalt = 0xFEEDBACC0DEull;
constexpr std::size_t kDefaultOutputs = DefaultMLP::kOutput;
using BrowserMLP = nisps::ml::MLPCore<nisps::ml::DynamicStorage>;
using BrowserFeedback =
nisps::ml::FeedbackControllerCore<nisps::ml::DynamicFeedbackStorage>;
constexpr std::size_t kFeedbackUndoDepth = 4u;
struct MlDims {
std::size_t n_in;
std::size_t hidden[3];
std::size_t n_out;
bool ok;
};
// Sanitise caller-supplied dims. Non-positive input/output and missing /
// non-3-entry hidden lists fall back to the defaults; out-of-range values
// make the request invalid.
MlDims sanitise_dims(int input_size, int output_size,
const int* hidden, int n_hidden) noexcept {
MlDims d{kDefaultInputs,
{kDefaultHidden[0], kDefaultHidden[1], kDefaultHidden[2]},
kDefaultOutputs,
true};
if (input_size > 0) d.n_in = static_cast<std::size_t>(input_size);
if (output_size > 0) d.n_out = static_cast<std::size_t>(output_size);
if (hidden && n_hidden == 3) {
for (std::size_t i = 0; i < 3u; ++i) {
if (hidden[i] <= 0) { d.ok = false; return d; }
d.hidden[i] = static_cast<std::size_t>(hidden[i]);
}
} else if (hidden && n_hidden != 0) {
d.ok = false; // the 4-layer topology needs exactly 3 hidden sizes
return d;
}
if (d.n_in > kMaxDim || d.n_out > kMaxDim ||
d.hidden[0] > kMaxDim || d.hidden[1] > kMaxDim || d.hidden[2] > kMaxDim) {
d.ok = false;
}
return d;
}
// We allocate the MLP on the heap (one-off — not the audio path) and return // We allocate the MLP on the heap (one-off — not the audio path) and return
// the opaque pointer to JS. // the opaque pointer to JS.
struct MLHandle { struct MLHandle {
DefaultMLP mlp; std::uint64_t seed64;
BrowserMLP mlp;
// "Down Action" negative-feedback controller (Avoid/RandomiseOutputs/ // "Down Action" negative-feedback controller (Avoid/RandomiseOutputs/
// RandomiseMlp). Seeded off the MLP seed XOR a salt so its static-output // RandomiseMlp/ExploreAndPlace). Seeded off the MLP seed XOR a salt so
// RNG stream is independent of the MLP's inference/move RNG. // its static-output RNG stream is independent of the MLP's RNG.
nisps::ml::FeedbackController<DefaultMLP> feedback; BrowserFeedback feedback;
// Buffers used to bridge JS → C++: // Buffers used to bridge JS → C++ (sized to the instance's dims):
std::array<float, kDefaultInputs> input_scratch{}; std::vector<float> output_scratch;
std::array<float, kDefaultOutputs> output_scratch{};
// Stats buffer fed back to JS via get_layer_stats. // Stats buffer fed back to JS via get_layer_stats.
std::array<float, DefaultMLP::kNumLayers * 4u> stats_scratch{}; std::array<float, BrowserMLP::kNumLayers * 4u> stats_scratch{};
// Static-output buffer for the RandomiseOutputs bypass path. // Static-output buffer for the RandomiseOutputs bypass path.
std::array<float, kDefaultOutputs> feedback_static_scratch{}; std::vector<float> feedback_static_scratch;
// Used by infer_batch with arbitrary N — must exceed any reasonable // infer_batch cap; callers must split larger requests.
// request from the heatmap. 256x256 = 65536 max points → too many in
// practice. We cap batch size at 4096 here; callers must split larger
// requests.
static constexpr std::size_t kMaxBatch = 4096u; static constexpr std::size_t kMaxBatch = 4096u;
std::array<float, kMaxBatch * kDefaultOutputs> batch_out_scratch{};
explicit MLHandle(std::uint64_t seed) noexcept MLHandle(std::uint64_t seed, const MlDims& d) noexcept
: mlp(seed), feedback(seed ^ 0xFEEDBACC0DEull) {} : seed64(seed),
mlp(seed, d.n_in, std::span<const std::size_t>(d.hidden, 3u), d.n_out),
feedback(seed ^ kFeedbackSalt, d.n_out, mlp.weight_count(), kFeedbackUndoDepth),
output_scratch(d.n_out, 0.f),
feedback_static_scratch(d.n_out, 0.f) {}
bool valid() const noexcept { return mlp.valid() && feedback.valid(); }
std::size_t n_in() const noexcept { return mlp.n_in(); }
std::size_t n_out() const noexcept { return mlp.n_out(); }
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -290,24 +331,57 @@ extern "C" {
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
void* nisps_ml_create(int input_size, int output_size, void* nisps_ml_create(int input_size, int output_size,
const int* /*hidden*/, int /*n_hidden*/, const int* hidden, int n_hidden,
uint32_t seed) { uint32_t seed) {
// We accept and ignore caller-supplied dimensions if they don't match the // Dimensions are HONOURED (runtime-shaped MLP); non-positive/missing args
// compile-time default. See file header. // fall back to the historical defaults. See file header.
// //
// NOTE: the C++ Rng takes uint64_t; we sign-extend the 32-bit seed into // NOTE: the C++ Rng takes uint64_t; we sign-extend the 32-bit seed into
// the high 32 bits via xor-shift so callers passing zero still get a // the high 32 bits via xor-shift so callers passing zero still get a
// non-degenerate seed. Truly 64-bit seeds are not exposed to JS — the // non-degenerate seed. Truly 64-bit seeds are not exposed to JS — the
// playground doesn't need them, and avoiding BigInt at the boundary // front-end doesn't need them, and avoiding BigInt at the boundary
// simplifies both wasm-iml.ts and wasm-worker.ts. // simplifies both wasm-iml.ts and wasm-worker.ts.
(void)input_size; const MlDims d = sanitise_dims(input_size, output_size, hidden, n_hidden);
(void)output_size; if (!d.ok) return nullptr;
const std::uint64_t s64 = static_cast<std::uint64_t>(seed) ^ const std::uint64_t s64 = static_cast<std::uint64_t>(seed) ^
(static_cast<std::uint64_t>(seed) << 32); (static_cast<std::uint64_t>(seed) << 32);
auto* h = new MLHandle(s64); auto* h = new MLHandle(s64, d);
if (!h->valid()) {
delete h;
return nullptr;
}
return static_cast<void*>(h); return static_cast<void*>(h);
} }
// Reshape: construct a NEW net at the requested dimensions (same seed
// stream restart, fresh spread-init), warm-start it with the overlapping
// weights of the current net, then swap it in. The feedback controller is
// re-created at the new dims (exploration state resets). Returns 1 on
// success; 0 leaves the existing net untouched.
EMSCRIPTEN_KEEPALIVE
int nisps_ml_reshape(void* ml, int input_size, int output_size,
const int* hidden, int n_hidden, float spread) {
if (!ml) return 0;
auto* h = static_cast<MLHandle*>(ml);
const MlDims d = sanitise_dims(input_size, output_size, hidden, n_hidden);
if (!d.ok) return 0;
BrowserMLP fresh(h->seed64, d.n_in, std::span<const std::size_t>(d.hidden, 3u), d.n_out);
if (!fresh.valid()) return 0;
fresh.draw_weights(spread);
nisps::ml::warm_start_copy(fresh, h->mlp);
BrowserFeedback fb(h->seed64 ^ kFeedbackSalt, d.n_out, fresh.weight_count(),
kFeedbackUndoDepth);
if (!fb.valid()) return 0;
h->mlp = static_cast<BrowserMLP&&>(fresh);
h->feedback = static_cast<BrowserFeedback&&>(fb);
h->output_scratch.assign(d.n_out, 0.f);
h->feedback_static_scratch.assign(d.n_out, 0.f);
return 1;
}
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
void nisps_ml_destroy(void* ml) { void nisps_ml_destroy(void* ml) {
if (!ml) return; if (!ml) return;
@ -323,7 +397,7 @@ void nisps_ml_set_input(void* ml, int idx, float v) {
if (!ml) return; if (!ml) return;
auto* h = static_cast<MLHandle*>(ml); auto* h = static_cast<MLHandle*>(ml);
if (idx < 0) return; if (idx < 0) return;
if (static_cast<std::size_t>(idx) >= kDefaultInputs) return; if (static_cast<std::size_t>(idx) >= h->n_in()) return;
h->mlp.set_input(static_cast<std::size_t>(idx), v); h->mlp.set_input(static_cast<std::size_t>(idx), v);
} }
@ -333,7 +407,8 @@ void nisps_ml_process(void* ml) {
auto* h = static_cast<MLHandle*>(ml); auto* h = static_cast<MLHandle*>(ml);
h->mlp.process(); h->mlp.process();
auto outs = h->mlp.outputs(); auto outs = h->mlp.outputs();
for (std::size_t i = 0; i < kDefaultOutputs; ++i) h->output_scratch[i] = outs[i]; const std::size_t n_out = h->n_out();
for (std::size_t i = 0; i < n_out; ++i) h->output_scratch[i] = outs[i];
} }
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
@ -347,18 +422,20 @@ EMSCRIPTEN_KEEPALIVE
void nisps_ml_infer_batch(void* ml, const float* points, int n_points, float* out) { void nisps_ml_infer_batch(void* ml, const float* points, int n_points, float* out) {
if (!ml || !points || !out || n_points <= 0) return; if (!ml || !points || !out || n_points <= 0) return;
auto* h = static_cast<MLHandle*>(ml); auto* h = static_cast<MLHandle*>(ml);
const std::size_t n_in = h->n_in();
const std::size_t n_out = h->n_out();
const std::size_t n = static_cast<std::size_t>(n_points); const std::size_t n = static_cast<std::size_t>(n_points);
if (n > MLHandle::kMaxBatch) { if (n > MLHandle::kMaxBatch) {
// Caller exceeded the scratch buffer. Process what we can. // Caller exceeded the batch cap. Process what we can.
const std::size_t safe_n = MLHandle::kMaxBatch; const std::size_t safe_n = MLHandle::kMaxBatch;
h->mlp.infer_batch( h->mlp.infer_batch(
std::span<const float>(points, safe_n * kDefaultInputs), std::span<const float>(points, safe_n * n_in),
std::span<float>(out, safe_n * kDefaultOutputs)); std::span<float>(out, safe_n * n_out));
return; return;
} }
h->mlp.infer_batch( h->mlp.infer_batch(
std::span<const float>(points, n * kDefaultInputs), std::span<const float>(points, n * n_in),
std::span<float>(out, n * kDefaultOutputs)); std::span<float>(out, n * n_out));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -370,8 +447,8 @@ void nisps_ml_add_example(void* ml, const float* features, const float* labels)
if (!ml || !features || !labels) return; if (!ml || !features || !labels) return;
auto* h = static_cast<MLHandle*>(ml); auto* h = static_cast<MLHandle*>(ml);
h->mlp.add_example( h->mlp.add_example(
std::span<const float>(features, kDefaultInputs), std::span<const float>(features, h->n_in()),
std::span<const float>(labels, kDefaultOutputs)); std::span<const float>(labels, h->n_out()));
} }
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
@ -400,8 +477,15 @@ float nisps_ml_eval_loss(void* ml) {
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
int nisps_ml_weight_count(void* ml) { int nisps_ml_weight_count(void* ml) {
(void)ml; if (!ml) {
return static_cast<int>(DefaultMLP::weight_count()); // Handle-less callers get the default shape's count.
const MlDims d = sanitise_dims(0, 0, nullptr, 0);
return static_cast<int>(
d.n_in * d.hidden[0] + d.hidden[0] * d.hidden[1] +
d.hidden[1] * d.hidden[2] + d.hidden[2] * d.n_out +
d.hidden[0] + d.hidden[1] + d.hidden[2] + d.n_out);
}
return static_cast<int>(static_cast<MLHandle*>(ml)->mlp.weight_count());
} }
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
@ -416,7 +500,7 @@ EMSCRIPTEN_KEEPALIVE
void nisps_ml_set_weights(void* ml, const float* in) { void nisps_ml_set_weights(void* ml, const float* in) {
if (!ml || !in) return; if (!ml || !in) return;
auto* h = static_cast<MLHandle*>(ml); auto* h = static_cast<MLHandle*>(ml);
h->mlp.set_weights(std::span<const float>(in, DefaultMLP::weight_count())); h->mlp.set_weights(std::span<const float>(in, h->mlp.weight_count()));
} }
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
@ -433,7 +517,7 @@ void nisps_ml_move_weights(void* ml, float speed, float spread,
auto* h = static_cast<MLHandle*>(ml); auto* h = static_cast<MLHandle*>(ml);
std::span<const std::uint8_t> mask; std::span<const std::uint8_t> mask;
if (output_pin_mask) { if (output_pin_mask) {
mask = std::span<const std::uint8_t>(output_pin_mask, kDefaultOutputs); mask = std::span<const std::uint8_t>(output_pin_mask, h->n_out());
} }
h->mlp.move_weights(speed, spread, mask); h->mlp.move_weights(speed, spread, mask);
} }
@ -452,7 +536,7 @@ void nisps_ml_move_weights(void* ml, float speed, float spread,
// output (nisps_ml_outputs / nisps_ml_feedback_static_output) BEFORE calling // output (nisps_ml_outputs / nisps_ml_feedback_static_output) BEFORE calling
// nisps_ml_feedback_up / _drag, then store THAT captured vector as the +1 // nisps_ml_feedback_up / _drag, then store THAT captured vector as the +1
// example. Reading the output AFTER the call yields the restored (wrong) net. // example. Reading the output AFTER the call yields the restored (wrong) net.
// nisps_ml_feedback_down with current_out should pass the full kDefaultOutputs // nisps_ml_feedback_down with current_out should pass the full n_out
// live vector (RandomiseOutputs freezes unfocused dims at those values). // live vector (RandomiseOutputs freezes unfocused dims at those values).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -497,7 +581,7 @@ void nisps_ml_feedback_set_focus(void* ml, const uint8_t* mask, int n) {
std::span<const std::uint8_t>(mask, static_cast<std::size_t>(n))); std::span<const std::uint8_t>(mask, static_cast<std::size_t>(n)));
} }
// current_out = kDefaultOutputs floats the user is hearing (may be null). // current_out = n_out floats the user is hearing (may be null).
// pin_mask may be null. Returns the FeedbackAction int. // pin_mask may be null. Returns the FeedbackAction int.
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
int nisps_ml_feedback_down(void* ml, const float* current_out, int nisps_ml_feedback_down(void* ml, const float* current_out,
@ -505,9 +589,9 @@ int nisps_ml_feedback_down(void* ml, const float* current_out,
if (!ml) return 0; if (!ml) return 0;
auto* h = static_cast<MLHandle*>(ml); auto* h = static_cast<MLHandle*>(ml);
std::span<const float> out; std::span<const float> out;
if (current_out) out = std::span<const float>(current_out, kDefaultOutputs); if (current_out) out = std::span<const float>(current_out, h->n_out());
std::span<const std::uint8_t> mask; std::span<const std::uint8_t> mask;
if (pin_mask) mask = std::span<const std::uint8_t>(pin_mask, kDefaultOutputs); if (pin_mask) mask = std::span<const std::uint8_t>(pin_mask, h->n_out());
return static_cast<int>(h->feedback.on_down(h->mlp, out, speed, spread, mask)); return static_cast<int>(h->feedback.on_down(h->mlp, out, speed, spread, mask));
} }
@ -525,17 +609,17 @@ int nisps_ml_feedback_drag(void* ml) {
return static_cast<int>(h->feedback.on_drag(h->mlp)); return static_cast<int>(h->feedback.on_drag(h->mlp));
} }
// If returns 1, `out` (kDefaultOutputs floats) holds the static bypass vector // If returns 1, `out` (n_out floats) holds the static bypass vector
// and the caller should NOT call nisps_ml_process(); if 0, run process(). // and the caller should NOT call nisps_ml_process(); if 0, run process().
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
int nisps_ml_feedback_static_output(void* ml, float* out) { int nisps_ml_feedback_static_output(void* ml, float* out) {
if (!ml || !out) return 0; if (!ml || !out) return 0;
auto* h = static_cast<MLHandle*>(ml); auto* h = static_cast<MLHandle*>(ml);
const bool bypass = const bool bypass = h->feedback.static_output(std::span<float>(
h->feedback.static_output(std::span<float>(h->feedback_static_scratch)); h->feedback_static_scratch.data(), h->feedback_static_scratch.size()));
if (bypass) { if (bypass) {
std::memcpy(out, h->feedback_static_scratch.data(), std::memcpy(out, h->feedback_static_scratch.data(),
kDefaultOutputs * sizeof(float)); h->n_out() * sizeof(float));
} }
return bypass ? 1 : 0; return bypass ? 1 : 0;
} }
@ -637,7 +721,7 @@ int nisps_ml_feedback_undo_depth(void* ml) {
return static_cast<int>(static_cast<MLHandle*>(ml)->feedback.undo_depth()); return static_cast<int>(static_cast<MLHandle*>(ml)->feedback.undo_depth());
} }
// Writes the committed/placed output vector (kDefaultOutputs floats) into `out`. // Writes the committed/placed output vector (n_out floats) into `out`.
// Returns 1 if a vector was written (placing OR a fresh commit), else 0. Reads // Returns 1 if a vector was written (placing OR a fresh commit), else 0. Reads
// committed_output() (valid post-commit) falling back to placed_output() (while // committed_output() (valid post-commit) falling back to placed_output() (while
// placing) so the caller can grab the label either before or after commit. // placing) so the caller can grab the label either before or after commit.
@ -648,7 +732,7 @@ int nisps_ml_feedback_placed_output(void* ml, float* out) {
std::span<const float> v = h->feedback.committed_output(); std::span<const float> v = h->feedback.committed_output();
if (v.empty()) v = h->feedback.placed_output(); if (v.empty()) v = h->feedback.placed_output();
if (v.empty()) return 0; if (v.empty()) return 0;
const std::size_t n = (v.size() < kDefaultOutputs) ? v.size() : kDefaultOutputs; const std::size_t n = (v.size() < h->n_out()) ? v.size() : h->n_out();
std::memcpy(out, v.data(), n * sizeof(float)); std::memcpy(out, v.data(), n * sizeof(float));
return 1; return 1;
} }
@ -657,7 +741,7 @@ EMSCRIPTEN_KEEPALIVE
void nisps_ml_get_layer_stats(void* ml, float* out_stats) { void nisps_ml_get_layer_stats(void* ml, float* out_stats) {
if (!ml || !out_stats) return; if (!ml || !out_stats) return;
auto* h = static_cast<MLHandle*>(ml); auto* h = static_cast<MLHandle*>(ml);
for (std::size_t i = 0; i < DefaultMLP::kNumLayers; ++i) { for (std::size_t i = 0; i < BrowserMLP::kNumLayers; ++i) {
const auto s = h->mlp.layer_stats(i); const auto s = h->mlp.layer_stats(i);
out_stats[i * 4u + 0u] = s.mean_abs; out_stats[i * 4u + 0u] = s.mean_abs;
out_stats[i * 4u + 1u] = s.max_abs; out_stats[i * 4u + 1u] = s.max_abs;
@ -689,17 +773,29 @@ void nisps_ml_reset(void* ml) {
h->mlp.reset(); h->mlp.reset();
} }
// Architecture introspection — returns 4-int packed [in, h1, h2, h3, out, n_layers]. // Architecture introspection — writes [in, h1, h2, h3, out, n_layers] into a
// Kept simple: writes into a caller-supplied int buffer. Always 6 ints. // caller-supplied int buffer. Always 6 ints. With a null handle it reports
// the DEFAULT shape (what create() yields for non-positive args); with a
// handle it reports that instance's actual runtime shape.
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
void nisps_ml_describe(int* out_dims) { void nisps_ml_describe(void* ml, int* out_dims) {
if (!out_dims) return; if (!out_dims) return;
out_dims[0] = static_cast<int>(DefaultMLP::kInput); if (!ml) {
out_dims[1] = static_cast<int>(DefaultMLP::kHidden1); out_dims[0] = static_cast<int>(kDefaultInputs);
out_dims[2] = static_cast<int>(DefaultMLP::kHidden2); out_dims[1] = static_cast<int>(kDefaultHidden[0]);
out_dims[3] = static_cast<int>(DefaultMLP::kHidden3); out_dims[2] = static_cast<int>(kDefaultHidden[1]);
out_dims[4] = static_cast<int>(DefaultMLP::kOutput); out_dims[3] = static_cast<int>(kDefaultHidden[2]);
out_dims[5] = static_cast<int>(DefaultMLP::kNumLayers); out_dims[4] = static_cast<int>(kDefaultOutputs);
out_dims[5] = static_cast<int>(BrowserMLP::kNumLayers);
return;
}
auto* h = static_cast<MLHandle*>(ml);
out_dims[0] = static_cast<int>(h->mlp.n_in());
out_dims[1] = static_cast<int>(h->mlp.fan_out(0u));
out_dims[2] = static_cast<int>(h->mlp.fan_out(1u));
out_dims[3] = static_cast<int>(h->mlp.fan_out(2u));
out_dims[4] = static_cast<int>(h->mlp.n_out());
out_dims[5] = static_cast<int>(BrowserMLP::kNumLayers);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -32,7 +32,7 @@ mkdir -p "$OUT"
# function is missing. # function is missing.
EXPORTED_FUNCS='[ EXPORTED_FUNCS='[
"_malloc","_free", "_malloc","_free",
"_nisps_ml_create","_nisps_ml_destroy","_nisps_ml_reset", "_nisps_ml_create","_nisps_ml_destroy","_nisps_ml_reset","_nisps_ml_reshape",
"_nisps_ml_set_input","_nisps_ml_process","_nisps_ml_outputs","_nisps_ml_infer_batch", "_nisps_ml_set_input","_nisps_ml_process","_nisps_ml_outputs","_nisps_ml_infer_batch",
"_nisps_ml_add_example","_nisps_ml_train","_nisps_ml_eval_loss", "_nisps_ml_add_example","_nisps_ml_train","_nisps_ml_eval_loss",
"_nisps_ml_clear_examples","_nisps_ml_example_count", "_nisps_ml_clear_examples","_nisps_ml_example_count",

View file

@ -112,7 +112,7 @@ function bind(Module) {
feedbackLike: cwrap('nisps_ml_feedback_like', null, ['number']), feedbackLike: cwrap('nisps_ml_feedback_like', null, ['number']),
feedbackCommitPlace: cwrap('nisps_ml_feedback_commit_place', null, ['number']), feedbackCommitPlace: cwrap('nisps_ml_feedback_commit_place', null, ['number']),
feedbackPlacedOutput: cwrap('nisps_ml_feedback_placed_output', 'number', ['number','number']), feedbackPlacedOutput: cwrap('nisps_ml_feedback_placed_output', 'number', ['number','number']),
describe: cwrap('nisps_ml_describe', null, ['number']), describe: cwrap('nisps_ml_describe', null, ['number','number']),
engineCreate: cwrap('nisps_engine_create', 'number', ['string','number']), engineCreate: cwrap('nisps_engine_create', 'number', ['string','number']),
engineDestroy: cwrap('nisps_engine_destroy', null, ['number']), engineDestroy: cwrap('nisps_engine_destroy', null, ['number']),
@ -186,9 +186,10 @@ async function main() {
const Module = await loadWasm(); const Module = await loadWasm();
const api = bind(Module); const api = bind(Module);
// Verify dimensions match the native side. // Verify dimensions match the native side. A null handle reports the
// DEFAULT shape (what create() yields for non-positive args).
const dimsBuf = api.malloc(6 * 4); const dimsBuf = api.malloc(6 * 4);
api.describe(dimsBuf); api.describe(0, dimsBuf);
const dims = new Int32Array(Module.HEAP32.buffer, dimsBuf, 6).slice(); const dims = new Int32Array(Module.HEAP32.buffer, dimsBuf, 6).slice();
api.free(dimsBuf); api.free(dimsBuf);
// Expect: [32, 10, 14, 18, 126, 4] (32-input max for mix-and-match) // Expect: [32, 10, 14, 18, 126, 4] (32-input max for mix-and-match)

View file

@ -12,6 +12,7 @@
#include "../../nisps/ml/dynamic_storage.hpp" #include "../../nisps/ml/dynamic_storage.hpp"
#include "../../nisps/ml/mlp.hpp" #include "../../nisps/ml/mlp.hpp"
#include "../../nisps/ml/warm_start.hpp"
#include "test_helpers.hpp" #include "test_helpers.hpp"
namespace { namespace {
@ -143,6 +144,76 @@ NISPS_TEST(mlp_dynamic_storage_invalid_dims_inert) {
NISPS_EXPECT(bad.eval_loss() == 0.f); NISPS_EXPECT(bad.eval_loss() == 0.f);
} }
// warm_start_copy preserves the overlapping weight region across a reshape
// (grow AND shrink), and leaves the destination's fresh init outside it.
NISPS_TEST(mlp_warm_start_copy_overlap) {
// Source: 3→[10,14,18]→7 with a recognisable weight pattern.
FixedMLP src(kSeed);
{
const auto wf = src.get_weights();
std::vector<float> w(wf.begin(), wf.end());
for (std::size_t i = 0; i < w.size(); ++i) {
w[i] = 0.001f * static_cast<float>(i % 997u);
}
src.set_weights(w);
}
// Grow: 5 inputs, 9 outputs (same hidden). Overlap = src's full matrix
// region per layer.
const std::size_t hidden[3] = {kH1, kH2, kH3};
DynamicMLP grown(kSeed ^ 0x9E3779B9u, 5u, std::span<const std::size_t>(hidden), 9u);
NISPS_ASSERT(grown.valid());
nisps::ml::warm_start_copy(grown, src);
// Layer 0 rows: node < kH1, j < kIn must match; j >= kIn keeps fresh init.
{
auto sw = src.weights_l<0u>();
auto gw = grown.weights_l<0u>();
bool overlap_ok = true;
for (std::size_t node = 0; node < kH1 && overlap_ok; ++node) {
for (std::size_t j = 0; j < kIn; ++j) {
if (sw[node * kIn + j] != gw[node * 5u + j]) { overlap_ok = false; break; }
}
}
NISPS_EXPECT(overlap_ok);
}
// Final layer: node < kOut biases match; nodes kOut..8 keep fresh init.
{
auto sb = src.biases_l<3u>();
auto gb = grown.biases_l<3u>();
bool bias_ok = true;
for (std::size_t node = 0; node < kOut; ++node) {
if (sb[node] != gb[node]) { bias_ok = false; break; }
}
NISPS_EXPECT(bias_ok);
}
// Shrink: 2 inputs, 4 outputs. Every dst weight must come from src.
DynamicMLP shrunk(kSeed ^ 0x51ED270Bu, 2u, std::span<const std::size_t>(hidden), 4u);
NISPS_ASSERT(shrunk.valid());
nisps::ml::warm_start_copy(shrunk, src);
{
auto sw = src.weights_l<0u>();
auto dw = shrunk.weights_l<0u>();
bool ok = true;
for (std::size_t node = 0; node < kH1 && ok; ++node) {
for (std::size_t j = 0; j < 2u; ++j) {
if (sw[node * kIn + j] != dw[node * 2u + j]) { ok = false; break; }
}
}
NISPS_EXPECT(ok);
auto sw3 = src.weights_l<3u>();
auto dw3 = shrunk.weights_l<3u>();
ok = true;
for (std::size_t node = 0; node < 4u && ok; ++node) {
for (std::size_t j = 0; j < kH3; ++j) {
if (sw3[node * kH3 + j] != dw3[node * kH3 + j]) { ok = false; break; }
}
}
NISPS_EXPECT(ok);
}
}
// Moved-from dynamic instances stay inert; moved-to keeps working. // Moved-from dynamic instances stay inert; moved-to keeps working.
NISPS_TEST(mlp_dynamic_storage_move_semantics) { NISPS_TEST(mlp_dynamic_storage_move_semantics) {
DynamicMLP a = make_dynamic(kSeed); DynamicMLP a = make_dynamic(kSeed);