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:
parent
8a19e5b52c
commit
b6819fd26f
13 changed files with 640 additions and 218 deletions
|
|
@ -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,
|
||||
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.
|
||||
- `nisps_ml_create(input, output, hidden[])` honours its arguments. Reshape = new instance + warm-start
|
||||
copy of overlapping weights (the BUILD-PLAN warm-start idea, now runtime).
|
||||
- ✅ (landed 2026-07-14, operator-approved) `nisps_ml_create(input, output, hidden[])` honours its
|
||||
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
|
||||
browser-viable.
|
||||
- **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.
|
|
@ -28,9 +28,14 @@ export interface NispsModule {
|
|||
_free(ptr: number): void;
|
||||
|
||||
// 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_destroy(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.
|
||||
_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_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_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 /
|
||||
// RandomiseMlp). Mode ints: 0=Avoid 1=RandomiseOutputs 2=RandomiseMlp.
|
||||
|
|
|
|||
|
|
@ -164,8 +164,20 @@ export class WasmIML {
|
|||
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.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);
|
||||
this.arch_ = {
|
||||
inputSize: dims[0],
|
||||
|
|
@ -174,25 +186,6 @@ export class WasmIML {
|
|||
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.featuresBuf = new HeapBuffer(this.module, this.arch_.inputSize);
|
||||
|
|
|
|||
|
|
@ -194,6 +194,11 @@ if (isWorker) {
|
|||
mod = await factory({
|
||||
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);
|
||||
weightCount = mod._nisps_ml_weight_count(mlHandle);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
#endif
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <new>
|
||||
#include <span>
|
||||
|
||||
|
|
@ -191,4 +192,88 @@ class DynamicStorage {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -21,12 +21,21 @@
|
|||
// then restores the original net (the kept example then
|
||||
// trains the original net toward the audition).
|
||||
//
|
||||
// Design: header-only class template over the concrete MLP type. The controller
|
||||
// does NOT own the MLP — every mutating method takes `MLP_T&`. It owns only the
|
||||
// exploration state, all fixed-size (no heap), with its OWN per-instance Rng so
|
||||
// STORAGE POLICY (one-core-engine-refactor P2): like MLPCore, the controller
|
||||
// algorithms are written once in `FeedbackControllerCore<FbStorage>` against a
|
||||
// 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.
|
||||
// Honours the RP2350 perf contract: no heap, no virtual dispatch, deterministic
|
||||
// per-instance RNG.
|
||||
// Honours the RP2350 perf contract in the fixed model: no heap, no virtual
|
||||
// dispatch, deterministic per-instance RNG.
|
||||
//
|
||||
// The C++/JS boundary: the controller decides *what transition happened*
|
||||
// (returns a FeedbackAction); the caller decides *what to persist* (add example,
|
||||
|
|
@ -40,6 +49,7 @@
|
|||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include "../core/perf.hpp"
|
||||
#include "../core/rng.hpp"
|
||||
|
||||
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
|
||||
// the user auditions it (reroll / nudge / undo). NEVER trained.
|
||||
// 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
|
||||
// the audition stays the frozen vector.
|
||||
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.
|
||||
ScratchNudge = 9, // scratchpad nudged (bounded perturb, Exploring); undoable.
|
||||
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.
|
||||
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
|
||||
// ExploreAndPlace. The undo ring is a fixed std::array of weight snapshots
|
||||
// (no heap); each slot is kWeights floats. WASM uses depth 4, firmware 2 (per
|
||||
// rl-feedback-design §2.2 — SRAM budget). Default 4 (the WASM depth).
|
||||
template <typename MLP_T, std::size_t UndoDepth = 4u>
|
||||
class FeedbackController {
|
||||
// ExploreAndPlace; each undo slot is NWeights floats. WASM historically used
|
||||
// depth 4, firmware 2 (per rl-feedback-design §2.2 — SRAM budget).
|
||||
// ---------------------------------------------------------------------------
|
||||
template <std::size_t NOut, std::size_t NWeights, std::size_t UndoDepth = 4u>
|
||||
class FixedFeedbackStorage {
|
||||
public:
|
||||
static constexpr std::size_t kNOut = MLP_T::kOutput;
|
||||
static constexpr std::size_t kWeights = MLP_T::weight_count();
|
||||
static constexpr std::size_t kNOut = NOut;
|
||||
static constexpr std::size_t kWeights = NWeights;
|
||||
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 ---------------------------------------------------------------
|
||||
// Switching mode mid-exploration cleanly tears down: restores the net (in
|
||||
// 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 (ep_state_ != ExploreState::Idle) abort_explore_place(mlp);
|
||||
mode_ = m;
|
||||
|
|
@ -126,33 +176,35 @@ class FeedbackController {
|
|||
bool placing() const noexcept { return ep_state_ == ExploreState::Placing; }
|
||||
// True while a REPOSITION hold is active (grab→move→drop). Distinguishes a
|
||||
// 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
|
||||
// weights — there is nothing to restore).
|
||||
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_; }
|
||||
// The output vector frozen at like()/begin-place time. Valid only while
|
||||
// placing(); empty span otherwise. The caller adds this as the +1 example
|
||||
// label at commit (input → placed_output).
|
||||
std::span<const float> placed_output() const noexcept {
|
||||
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
|
||||
// fixed buffer (no heap, no dangling span). Empty ⇒ all outputs active.
|
||||
void set_focus_mask(std::span<const std::uint8_t> mask) noexcept {
|
||||
focus_count_ = (mask.size() < kNOut) ? mask.size() : kNOut;
|
||||
for (std::size_t i = 0; i < focus_count_; ++i) focus_[i] = mask[i];
|
||||
auto focus = this->focus();
|
||||
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; }
|
||||
|
||||
// ---- press handlers -----------------------------------------------------
|
||||
// `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.
|
||||
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,
|
||||
std::span<const std::uint8_t> pin_mask) noexcept {
|
||||
switch (mode_) {
|
||||
|
|
@ -197,7 +249,8 @@ class FeedbackController {
|
|||
// Up = thumbs-up / "keep". While exploring it commits: the CALLER must have
|
||||
// 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.
|
||||
FeedbackAction on_up(MLP_T& mlp) noexcept {
|
||||
template <typename M>
|
||||
FeedbackAction on_up(M& mlp) noexcept {
|
||||
if (mode_ == FeedbackMode::ExploreAndPlace) {
|
||||
// SOFTWARE DEFAULT POLICY (browser): up begins place from
|
||||
// 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
|
||||
// the "reposition-commit": the caller has already stored the +1 at the new
|
||||
// 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) {
|
||||
restore_after_explore(mlp);
|
||||
return FeedbackAction::Restore;
|
||||
|
|
@ -235,18 +289,21 @@ class FeedbackController {
|
|||
|
||||
// Inference hook: fills `out` with the held static vector and returns true
|
||||
// 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 {
|
||||
const std::size_t n_out = this->n_out();
|
||||
// ExploreAndPlace: while PLACING, the audition is the frozen vector the
|
||||
// user liked, held steady as they aim at a location.
|
||||
if (mode_ == FeedbackMode::ExploreAndPlace && ep_state_ == ExploreState::Placing) {
|
||||
const std::size_t n = (out.size() < kNOut) ? out.size() : kNOut;
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = placed_out_[i];
|
||||
const auto placed = this->placed_out();
|
||||
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;
|
||||
}
|
||||
if (!(mode_ == FeedbackMode::RandomiseOutputs && explore_active_)) return false;
|
||||
const std::size_t n = (out.size() < kNOut) ? out.size() : kNOut;
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = static_out_[i];
|
||||
const auto held = this->static_out();
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -264,11 +321,11 @@ class FeedbackController {
|
|||
|
||||
// Idle→Exploring. Snapshot the real (trained) net aside, randomise a
|
||||
// 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 (ep_state_ != ExploreState::Idle) return;
|
||||
auto w = mlp.get_weights(); // flat snapshot (size == kWeights)
|
||||
for (std::size_t i = 0; i < kWeights; ++i) snapshot_[i] = w[i];
|
||||
take_snapshot(mlp);
|
||||
learning_paused_ = true;
|
||||
ep_state_ = ExploreState::Exploring;
|
||||
undo_count_ = 0u;
|
||||
|
|
@ -278,78 +335,85 @@ class FeedbackController {
|
|||
|
||||
// Exploring→Idle. Restore the real net, discard the scratchpad. No example
|
||||
// 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 (ep_state_ == ExploreState::Idle) return;
|
||||
restore_real_net(mlp);
|
||||
}
|
||||
|
||||
// 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;
|
||||
push_undo(mlp);
|
||||
mlp.draw_weights(spread);
|
||||
}
|
||||
|
||||
// Exploring scratchpad op: small bounded perturbation of the scratchpad via
|
||||
// move_weights on the controller's OWN Rng-free path — move_weights uses the
|
||||
// MLP's Rng, so to keep the controller's Rng stream out of the MLP stream we
|
||||
// draw the perturbation here and apply it. Undoable. `amount` is the noise
|
||||
// stddev (small, e.g. 0.05).
|
||||
void nudge(MLP_T& mlp, float amount) noexcept {
|
||||
// the controller's OWN Rng (move_weights uses the MLP's Rng; to keep the
|
||||
// controller's Rng stream out of the MLP stream we draw the perturbation
|
||||
// here and apply it). Undoable. `amount` is the noise stddev (e.g. 0.05).
|
||||
template <typename M>
|
||||
void nudge(M& mlp, float amount) noexcept {
|
||||
if (!can_scratch_op()) return;
|
||||
push_undo(mlp);
|
||||
auto scratch = this->scratch_buf();
|
||||
const std::size_t n_weights = this->n_weights();
|
||||
auto w = mlp.get_weights();
|
||||
for (std::size_t i = 0; i < kWeights; ++i) {
|
||||
scratch_buf_[i] = w[i] + rng_.next_float_gaussian(amount);
|
||||
for (std::size_t i = 0; i < n_weights; ++i) {
|
||||
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).
|
||||
void undo(MLP_T& mlp) noexcept {
|
||||
template <typename M>
|
||||
void undo(M& mlp) noexcept {
|
||||
if (!can_scratch_op()) 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_;
|
||||
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
|
||||
// is auditioning. The caller MUST have run mlp.process() at the audition
|
||||
// input first; pass that output here. While placing, static_output() holds
|
||||
// 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 (ep_state_ != ExploreState::Exploring) return;
|
||||
const std::size_t n = (current_out.size() < kNOut) ? current_out.size() : kNOut;
|
||||
for (std::size_t i = 0; i < n; ++i) placed_out_[i] = current_out[i];
|
||||
capture_placed(current_out);
|
||||
ep_state_ = ExploreState::Placing;
|
||||
}
|
||||
|
||||
// Convenience: freeze the scratchpad's output at its CURRENT input (runs the
|
||||
// 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 (ep_state_ != ExploreState::Exploring) return;
|
||||
mlp.process();
|
||||
const auto outs = 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];
|
||||
capture_placed(mlp.outputs());
|
||||
ep_state_ = ExploreState::Placing;
|
||||
}
|
||||
|
||||
// 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
|
||||
// the caller can read it after the restore (it survives the restore).
|
||||
void commit_place(MLP_T& mlp) noexcept {
|
||||
// (chosen input → placed_output()) and trains.
|
||||
template <typename M>
|
||||
void commit_place(M& mlp) noexcept {
|
||||
if (mode_ != FeedbackMode::ExploreAndPlace) 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
|
||||
// gate on Placing.
|
||||
mlp.set_weights(std::span<const float>(snapshot_.data(), kWeights));
|
||||
last_placed_valid_ = true; // placed_out_ holds the just-committed vector
|
||||
restore_snapshot(mlp);
|
||||
last_placed_valid_ = true; // placed_out holds the just-committed vector
|
||||
learning_paused_ = false;
|
||||
ep_state_ = ExploreState::Idle;
|
||||
undo_count_ = 0u;
|
||||
|
|
@ -360,7 +424,7 @@ class FeedbackController {
|
|||
// AFTER commit_place has restored the real net.
|
||||
std::span<const float> committed_output() const noexcept {
|
||||
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
|
||||
|
|
@ -393,8 +457,7 @@ class FeedbackController {
|
|||
void begin_reposition(std::span<const float> current_out) noexcept {
|
||||
if (mode_ != FeedbackMode::ExploreAndPlace) return;
|
||||
if (ep_state_ != ExploreState::Idle) return;
|
||||
const std::size_t n = (current_out.size() < kNOut) ? current_out.size() : kNOut;
|
||||
for (std::size_t i = 0; i < n; ++i) placed_out_[i] = current_out[i];
|
||||
capture_placed(current_out);
|
||||
reposition_ = true;
|
||||
learning_paused_ = true;
|
||||
last_placed_valid_ = false;
|
||||
|
|
@ -403,13 +466,12 @@ class FeedbackController {
|
|||
|
||||
// Convenience: capture the trained net's output at its CURRENT input
|
||||
// (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 (ep_state_ != ExploreState::Idle) return;
|
||||
mlp.process();
|
||||
const auto outs = 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];
|
||||
capture_placed(mlp.outputs());
|
||||
reposition_ = true;
|
||||
learning_paused_ = true;
|
||||
last_placed_valid_ = false;
|
||||
|
|
@ -429,48 +491,74 @@ class FeedbackController {
|
|||
}
|
||||
|
||||
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 {
|
||||
explore_active_ = true;
|
||||
learning_paused_ = true;
|
||||
// Seed every dim with the live output the user is hearing, so unfocused
|
||||
// (frozen) dims hold that value through the exploration — matching 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
|
||||
// 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.
|
||||
const std::size_t n = (seed_out.size() < kNOut) ? seed_out.size() : kNOut;
|
||||
for (std::size_t i = 0; i < n; ++i) static_out_[i] = seed_out[i];
|
||||
auto held = this->static_out();
|
||||
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();
|
||||
}
|
||||
|
||||
void roll_static_outputs() noexcept {
|
||||
for (std::size_t i = 0; i < kNOut; ++i) {
|
||||
const bool active = (focus_count_ == 0u) || (i < focus_count_ && focus_[i] != 0u);
|
||||
if (active) static_out_[i] = rng_.next_float_uniform(); // [0, 1)
|
||||
auto held = this->static_out();
|
||||
const auto focus = this->focus();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
learning_paused_ = true;
|
||||
auto w = mlp.get_weights(); // flat snapshot (size == kWeights)
|
||||
for (std::size_t i = 0; i < kWeights; ++i) snapshot_[i] = w[i];
|
||||
take_snapshot(mlp);
|
||||
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) {
|
||||
mlp.set_weights(std::span<const float>(snapshot_.data(), kWeights));
|
||||
restore_snapshot(mlp);
|
||||
}
|
||||
learning_paused_ = false;
|
||||
explore_active_ = false;
|
||||
}
|
||||
|
||||
// Cancel and abort share restore semantics; the caller stores nothing.
|
||||
void cancel_explore(MLP_T& mlp) noexcept { restore_after_explore(mlp); }
|
||||
void abort_explore(MLP_T& mlp) noexcept { restore_after_explore(mlp); }
|
||||
template <typename M>
|
||||
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 --------------------------------------------
|
||||
bool can_scratch_op() const noexcept {
|
||||
|
|
@ -480,28 +568,34 @@ class FeedbackController {
|
|||
|
||||
// Push the CURRENT scratchpad weights onto the bounded undo ring before a
|
||||
// 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();
|
||||
for (std::size_t i = 0; i < kWeights; ++i) undo_ring_[undo_head_][i] = w[i];
|
||||
undo_head_ = (undo_head_ + 1u) % kUndoDepth;
|
||||
if (undo_count_ < kUndoDepth) ++undo_count_;
|
||||
const std::size_t n = this->n_weights();
|
||||
for (std::size_t i = 0; i < n; ++i) slot[i] = w[i];
|
||||
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
|
||||
// and abort_explore_place. No example stored.
|
||||
void restore_real_net(MLP_T& mlp) noexcept {
|
||||
mlp.set_weights(std::span<const float>(snapshot_.data(), kWeights));
|
||||
template <typename M>
|
||||
void restore_real_net(M& mlp) noexcept {
|
||||
restore_snapshot(mlp);
|
||||
learning_paused_ = false;
|
||||
ep_state_ = ExploreState::Idle;
|
||||
undo_count_ = 0u;
|
||||
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 (reposition_) {
|
||||
// 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.
|
||||
reposition_ = false;
|
||||
learning_paused_ = false;
|
||||
|
|
@ -516,22 +610,22 @@ class FeedbackController {
|
|||
FeedbackMode mode_ = FeedbackMode::Avoid;
|
||||
bool explore_active_ = false;
|
||||
bool learning_paused_ = false;
|
||||
std::array<float, kNOut> static_out_{};
|
||||
std::array<float, kWeights> snapshot_{};
|
||||
std::array<std::uint8_t, kNOut> focus_{};
|
||||
std::size_t focus_count_ = 0; // 0 ⇒ all active
|
||||
std::size_t focus_count_ = 0; // 0 ⇒ all active
|
||||
|
||||
// ---- ExploreAndPlace state (all fixed-size, no heap) --------------------
|
||||
ExploreState ep_state_ = ExploreState::Idle;
|
||||
std::array<float, kNOut> placed_out_{}; // frozen audition/carried vector
|
||||
bool last_placed_valid_ = false;
|
||||
bool reposition_ = false; // grab→move→drop hold; net NOT set aside
|
||||
std::array<std::array<float, kWeights>, kUndoDepth> undo_ring_{}; // bounded undo
|
||||
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)
|
||||
// ---- ExploreAndPlace state ----------------------------------------------
|
||||
ExploreState ep_state_ = ExploreState::Idle;
|
||||
bool last_placed_valid_ = false;
|
||||
bool reposition_ = false; // grab→move→drop hold; net NOT set aside
|
||||
std::size_t undo_head_ = 0u; // next write slot
|
||||
std::size_t undo_count_ = 0u; // valid entries (0..undo_cap)
|
||||
|
||||
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
|
||||
|
|
|
|||
65
nisps/ml/warm_start.hpp
Normal file
65
nisps/ml/warm_start.hpp
Normal 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
|
||||
|
|
@ -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:
|
||||
// 1. Main-thread WasmIML (playground/src/ml/wasm-iml.ts) — ML calls.
|
||||
// 2. AudioWorklet processor (playground/src/audio/worklet/...) — engine
|
||||
// 1. Main-thread WasmIML (manifold/src/engine/wasm-iml.ts) — ML calls.
|
||||
// 2. AudioWorklet processor (manifold/src/engine/worklet/...) — engine
|
||||
// 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).
|
||||
// We instantiate ONE concrete configuration here:
|
||||
// The browser MLP is `MLPCore<DynamicStorage>`: `nisps_ml_create()` HONOURS
|
||||
// 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>;
|
||||
//
|
||||
// The 32-input dimension is the MAX number of composed input axes the manifold
|
||||
// front-end can feed (matches MAX_AXES in manifold/src/inputs/input-layer.ts).
|
||||
// The mix-and-match input layer (Internal XY pad + Game Controller + MIDI) gives
|
||||
// each active axis its OWN dedicated input slot — NO mean-blending — and feeds
|
||||
// the remaining (unused) slots a constant 0. The "active input dimension count"
|
||||
// is a front-end concept: a 2-axis pad uses slots 0–1, a 4-axis pad+stick uses
|
||||
// 0–3, 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.
|
||||
// Reshape = `nisps_ml_reshape()`: a NEW instance at the new dimensions,
|
||||
// warm-started by copying the overlapping weight region from the old net
|
||||
// (nisps/ml/warm_start.hpp); weights outside the overlap keep the fresh
|
||||
// spread-initialised values. The feedback controller is re-created at the
|
||||
// new dimensions (its exploration state resets — the front-end shows a
|
||||
// reset-on-reshape modal).
|
||||
//
|
||||
// WIRE FORMAT FOR WEIGHTS
|
||||
// -----------------------
|
||||
|
|
@ -68,9 +63,11 @@
|
|||
|
||||
// ML.
|
||||
#include "../core/types.hpp"
|
||||
#include "../ml/dynamic_storage.hpp"
|
||||
#include "../ml/feedback.hpp"
|
||||
#include "../ml/mlp.hpp"
|
||||
#include "../ml/stats.hpp"
|
||||
#include "../ml/warm_start.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
@ -78,45 +75,89 @@ namespace {
|
|||
// ML side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Compile-time default architecture. See header comment.
|
||||
//
|
||||
// Choice rationale:
|
||||
// * 2 inputs — playground virtual joystick (X, Y).
|
||||
// Historical compiled defaults. Non-positive / missing create() args fall
|
||||
// back to these, keeping every pre-P2 caller bit-identical.
|
||||
// * 32 inputs — MAX composed input axes the manifold front-end
|
||||
// feeds (matches MAX_AXES in manifold/src/inputs/input-layer.ts).
|
||||
// * [10, 14, 18] hidden — covers the largest schema layouts in
|
||||
// `schemas/modes/*.json` (channel_strip variants, verb_fx, breakor,
|
||||
// elysiamorf, memlcelium).
|
||||
// `schemas/modes/*.json`.
|
||||
// * 126 outputs — enough for the C15 mode and any current schema.
|
||||
//
|
||||
// The MLP also has dataset slots, loss history etc. — see mlp.hpp.
|
||||
using DefaultMLP = nisps::ml::MLP<32u, 10u, 14u, 18u, 126u>;
|
||||
constexpr std::size_t kDefaultInputs = 32u;
|
||||
constexpr std::size_t kDefaultHidden[3] = {10u, 14u, 18u};
|
||||
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::size_t kDefaultOutputs = DefaultMLP::kOutput;
|
||||
constexpr std::uint64_t kFeedbackSalt = 0xFEEDBACC0DEull;
|
||||
|
||||
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
|
||||
// the opaque pointer to JS.
|
||||
struct MLHandle {
|
||||
DefaultMLP mlp;
|
||||
std::uint64_t seed64;
|
||||
BrowserMLP mlp;
|
||||
// "Down Action" negative-feedback controller (Avoid/RandomiseOutputs/
|
||||
// RandomiseMlp). Seeded off the MLP seed XOR a salt so its static-output
|
||||
// RNG stream is independent of the MLP's inference/move RNG.
|
||||
nisps::ml::FeedbackController<DefaultMLP> feedback;
|
||||
// Buffers used to bridge JS → C++:
|
||||
std::array<float, kDefaultInputs> input_scratch{};
|
||||
std::array<float, kDefaultOutputs> output_scratch{};
|
||||
// RandomiseMlp/ExploreAndPlace). Seeded off the MLP seed XOR a salt so
|
||||
// its static-output RNG stream is independent of the MLP's RNG.
|
||||
BrowserFeedback feedback;
|
||||
// Buffers used to bridge JS → C++ (sized to the instance's dims):
|
||||
std::vector<float> output_scratch;
|
||||
// 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.
|
||||
std::array<float, kDefaultOutputs> feedback_static_scratch{};
|
||||
// Used by infer_batch with arbitrary N — must exceed any reasonable
|
||||
// request from the heatmap. 256x256 = 65536 max points → too many in
|
||||
// practice. We cap batch size at 4096 here; callers must split larger
|
||||
// requests.
|
||||
std::vector<float> feedback_static_scratch;
|
||||
// infer_batch cap; callers must split larger requests.
|
||||
static constexpr std::size_t kMaxBatch = 4096u;
|
||||
std::array<float, kMaxBatch * kDefaultOutputs> batch_out_scratch{};
|
||||
|
||||
explicit MLHandle(std::uint64_t seed) noexcept
|
||||
: mlp(seed), feedback(seed ^ 0xFEEDBACC0DEull) {}
|
||||
MLHandle(std::uint64_t seed, const MlDims& d) noexcept
|
||||
: 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
|
||||
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) {
|
||||
// We accept and ignore caller-supplied dimensions if they don't match the
|
||||
// compile-time default. See file header.
|
||||
// Dimensions are HONOURED (runtime-shaped MLP); non-positive/missing args
|
||||
// fall back to the historical defaults. See file header.
|
||||
//
|
||||
// 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
|
||||
// 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.
|
||||
(void)input_size;
|
||||
(void)output_size;
|
||||
const MlDims d = sanitise_dims(input_size, output_size, hidden, n_hidden);
|
||||
if (!d.ok) return nullptr;
|
||||
const std::uint64_t s64 = static_cast<std::uint64_t>(seed) ^
|
||||
(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);
|
||||
}
|
||||
|
||||
// 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
|
||||
void nisps_ml_destroy(void* ml) {
|
||||
if (!ml) return;
|
||||
|
|
@ -323,7 +397,7 @@ void nisps_ml_set_input(void* ml, int idx, float v) {
|
|||
if (!ml) return;
|
||||
auto* h = static_cast<MLHandle*>(ml);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -333,7 +407,8 @@ void nisps_ml_process(void* ml) {
|
|||
auto* h = static_cast<MLHandle*>(ml);
|
||||
h->mlp.process();
|
||||
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
|
||||
|
|
@ -347,18 +422,20 @@ EMSCRIPTEN_KEEPALIVE
|
|||
void nisps_ml_infer_batch(void* ml, const float* points, int n_points, float* out) {
|
||||
if (!ml || !points || !out || n_points <= 0) return;
|
||||
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);
|
||||
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;
|
||||
h->mlp.infer_batch(
|
||||
std::span<const float>(points, safe_n * kDefaultInputs),
|
||||
std::span<float>(out, safe_n * kDefaultOutputs));
|
||||
std::span<const float>(points, safe_n * n_in),
|
||||
std::span<float>(out, safe_n * n_out));
|
||||
return;
|
||||
}
|
||||
h->mlp.infer_batch(
|
||||
std::span<const float>(points, n * kDefaultInputs),
|
||||
std::span<float>(out, n * kDefaultOutputs));
|
||||
std::span<const float>(points, n * n_in),
|
||||
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;
|
||||
auto* h = static_cast<MLHandle*>(ml);
|
||||
h->mlp.add_example(
|
||||
std::span<const float>(features, kDefaultInputs),
|
||||
std::span<const float>(labels, kDefaultOutputs));
|
||||
std::span<const float>(features, h->n_in()),
|
||||
std::span<const float>(labels, h->n_out()));
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
|
|
@ -400,8 +477,15 @@ float nisps_ml_eval_loss(void* ml) {
|
|||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int nisps_ml_weight_count(void* ml) {
|
||||
(void)ml;
|
||||
return static_cast<int>(DefaultMLP::weight_count());
|
||||
if (!ml) {
|
||||
// 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
|
||||
|
|
@ -416,7 +500,7 @@ EMSCRIPTEN_KEEPALIVE
|
|||
void nisps_ml_set_weights(void* ml, const float* in) {
|
||||
if (!ml || !in) return;
|
||||
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
|
||||
|
|
@ -433,7 +517,7 @@ void nisps_ml_move_weights(void* ml, float speed, float spread,
|
|||
auto* h = static_cast<MLHandle*>(ml);
|
||||
std::span<const std::uint8_t> 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
// 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.
|
||||
// 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).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
|
@ -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)));
|
||||
}
|
||||
|
||||
// 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.
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
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;
|
||||
auto* h = static_cast<MLHandle*>(ml);
|
||||
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;
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -525,17 +609,17 @@ int nisps_ml_feedback_drag(void* ml) {
|
|||
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().
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int nisps_ml_feedback_static_output(void* ml, float* out) {
|
||||
if (!ml || !out) return 0;
|
||||
auto* h = static_cast<MLHandle*>(ml);
|
||||
const bool bypass =
|
||||
h->feedback.static_output(std::span<float>(h->feedback_static_scratch));
|
||||
const bool bypass = h->feedback.static_output(std::span<float>(
|
||||
h->feedback_static_scratch.data(), h->feedback_static_scratch.size()));
|
||||
if (bypass) {
|
||||
std::memcpy(out, h->feedback_static_scratch.data(),
|
||||
kDefaultOutputs * sizeof(float));
|
||||
h->n_out() * sizeof(float));
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
// 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
|
||||
// committed_output() (valid post-commit) falling back to placed_output() (while
|
||||
// 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();
|
||||
if (v.empty()) v = h->feedback.placed_output();
|
||||
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));
|
||||
return 1;
|
||||
}
|
||||
|
|
@ -657,7 +741,7 @@ EMSCRIPTEN_KEEPALIVE
|
|||
void nisps_ml_get_layer_stats(void* ml, float* out_stats) {
|
||||
if (!ml || !out_stats) return;
|
||||
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);
|
||||
out_stats[i * 4u + 0u] = s.mean_abs;
|
||||
out_stats[i * 4u + 1u] = s.max_abs;
|
||||
|
|
@ -689,17 +773,29 @@ void nisps_ml_reset(void* ml) {
|
|||
h->mlp.reset();
|
||||
}
|
||||
|
||||
// Architecture introspection — returns 4-int packed [in, h1, h2, h3, out, n_layers].
|
||||
// Kept simple: writes into a caller-supplied int buffer. Always 6 ints.
|
||||
// Architecture introspection — writes [in, h1, h2, h3, out, n_layers] into a
|
||||
// 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
|
||||
void nisps_ml_describe(int* out_dims) {
|
||||
void nisps_ml_describe(void* ml, int* out_dims) {
|
||||
if (!out_dims) return;
|
||||
out_dims[0] = static_cast<int>(DefaultMLP::kInput);
|
||||
out_dims[1] = static_cast<int>(DefaultMLP::kHidden1);
|
||||
out_dims[2] = static_cast<int>(DefaultMLP::kHidden2);
|
||||
out_dims[3] = static_cast<int>(DefaultMLP::kHidden3);
|
||||
out_dims[4] = static_cast<int>(DefaultMLP::kOutput);
|
||||
out_dims[5] = static_cast<int>(DefaultMLP::kNumLayers);
|
||||
if (!ml) {
|
||||
out_dims[0] = static_cast<int>(kDefaultInputs);
|
||||
out_dims[1] = static_cast<int>(kDefaultHidden[0]);
|
||||
out_dims[2] = static_cast<int>(kDefaultHidden[1]);
|
||||
out_dims[3] = static_cast<int>(kDefaultHidden[2]);
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ mkdir -p "$OUT"
|
|||
# function is missing.
|
||||
EXPORTED_FUNCS='[
|
||||
"_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_add_example","_nisps_ml_train","_nisps_ml_eval_loss",
|
||||
"_nisps_ml_clear_examples","_nisps_ml_example_count",
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ function bind(Module) {
|
|||
feedbackLike: cwrap('nisps_ml_feedback_like', null, ['number']),
|
||||
feedbackCommitPlace: cwrap('nisps_ml_feedback_commit_place', null, ['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']),
|
||||
engineDestroy: cwrap('nisps_engine_destroy', null, ['number']),
|
||||
|
|
@ -186,9 +186,10 @@ async function main() {
|
|||
const Module = await loadWasm();
|
||||
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);
|
||||
api.describe(dimsBuf);
|
||||
api.describe(0, dimsBuf);
|
||||
const dims = new Int32Array(Module.HEAP32.buffer, dimsBuf, 6).slice();
|
||||
api.free(dimsBuf);
|
||||
// Expect: [32, 10, 14, 18, 126, 4] (32-input max for mix-and-match)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
#include "../../nisps/ml/dynamic_storage.hpp"
|
||||
#include "../../nisps/ml/mlp.hpp"
|
||||
#include "../../nisps/ml/warm_start.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
namespace {
|
||||
|
|
@ -143,6 +144,76 @@ NISPS_TEST(mlp_dynamic_storage_invalid_dims_inert) {
|
|||
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.
|
||||
NISPS_TEST(mlp_dynamic_storage_move_semantics) {
|
||||
DynamicMLP a = make_dynamic(kSeed);
|
||||
|
|
|
|||
Loading…
Reference in a new issue