diff --git a/nisps/CMakeLists.txt b/nisps/CMakeLists.txt index 4bb4e14..8ff80d2 100644 --- a/nisps/CMakeLists.txt +++ b/nisps/CMakeLists.txt @@ -56,6 +56,7 @@ if(NOT EMSCRIPTEN) ${NISPS_TEST_DIR}/test_mlp_training.cpp ${NISPS_TEST_DIR}/test_mlp_loss.cpp ${NISPS_TEST_DIR}/test_mlp_rl.cpp + ${NISPS_TEST_DIR}/test_mlp_feedback.cpp ${NISPS_TEST_DIR}/test_mlp_serialize.cpp ) target_link_libraries(nisps_core_tests PRIVATE nisps_core) diff --git a/nisps/ml/feedback.hpp b/nisps/ml/feedback.hpp new file mode 100644 index 0000000..259d792 --- /dev/null +++ b/nisps/ml/feedback.hpp @@ -0,0 +1,461 @@ +// nisps/ml/feedback.hpp — the "Down Action" negative-feedback controller. +// +// Ported from the firmware InterfaceRL FEEDBACK_MODE state machine (upstream +// branch feat/feedback-explore-modes) into the shared nisps/ core so the SAME +// logic compiles to both WASM (browser) and RP2350 firmware. +// +// Three selectable behaviours for the "down" (thumbs-down) gesture: +// * Avoid — delegate to MLP::move_weights (Gaussian perturb). This +// is the new core's "avoid"; the old firmware's k-NN +// geometric centroid push depended on a firmware-only +// ReplayMemory and is intentionally NOT ported (see +// ALIGNMENT.md). The controller owns no Avoid state. +// * RandomiseOutputs — bypass the MLP and hold a static random output vector; +// each subsequent down re-rolls it (focus-aware). Up +// commits the held output as a +1 example at the current +// input, then resumes. +// * RandomiseMlp — snapshot the live weights and randomise the net +// (draw_weights) so the user auditions a random mapping +// by moving the joystick. Down again cancels (restore). +// Up/drag commits the auditioned output as a +1 example +// 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 +// 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. +// +// The C++/JS boundary: the controller decides *what transition happened* +// (returns a FeedbackAction); the caller decides *what to persist* (add example, +// grow noise, train). All inherently-UI state (pins, pipeline outputs, display) +// stays in the caller. + +#pragma once + +#include +#include +#include +#include + +#include "../core/rng.hpp" + +namespace nisps::ml { + +enum class FeedbackMode : std::uint8_t { + Avoid = 0, // down → move_weights (Gaussian perturb). No internal state. + RandomiseOutputs = 1, // down → bypass MLP, hold static random vector; re-roll each down. + RandomiseMlp = 2, // down → snapshot + draw_weights live net; down-again cancels. + ExploreAndPlace = 3, // Idle→Exploring→Placing→Idle scratchpad lifecycle (default product mode). +}; + +// The explicit lifecycle state for FeedbackMode::ExploreAndPlace. The whole +// mode is a three-state machine; granular methods drive the transitions +// (firmware maps buttons to them directly), while on_down/on_up implement the +// SOFTWARE default policy (browser) on top of the same machine. +// Idle — the real (trained) net is live; no scratchpad. +// 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 +// place it. The caller drives inference at the chosen input but +// the audition stays the frozen vector. +enum class ExploreState : std::uint8_t { + Idle = 0, + Exploring = 1, + Placing = 2, +}; + +// What a press resolved to. The caller (JS runtime / firmware glue) performs the +// replay-memory / training side effect; the controller owns the in-core state +// machine and the weight snapshot/restore. +enum class FeedbackAction : std::uint8_t { + None = 0, + AvoidPerturb = 1, // move_weights already applied; caller grows exploration noise. + LikeStore = 2, // caller: add +1 example at (input, output) + train. + EnterExplore = 3, // entered a RANDOMISE_* exploration (UI: show "exploring"). + Reroll = 4, // re-rolled within a RandomiseOutputs exploration. + CommitStore = 5, // caller: add +1 example at (input, captured output); explore ended. + Cancel = 6, // exploration discarded; net restored (RandomiseMlp). + Restore = 7, // exploration kept via drag; net restored (caller already stored). + // ---- ExploreAndPlace (append-only; never renumber the TS↔C++ contract) ---- + 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). + CommitPlace = 12, // Placing→Idle; real net restored. CALLER adds +1 (input→placed_output) + trains. + CancelPlace = 13, // Placing→Exploring; backed out of placing (no store). +}; + +// 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 +class FeedbackController { + 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 kUndoDepth = UndoDepth; + + explicit FeedbackController(std::uint64_t seed) noexcept : 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 { + if (explore_active_) abort_explore(mlp); + if (ep_state_ != ExploreState::Idle) abort_explore_place(mlp); + mode_ = m; + } + FeedbackMode mode() const noexcept { return mode_; } + + // `exploring()` is true whenever a scratchpad net is live and learning is + // paused — for the legacy RANDOMISE_* modes, AND for ExploreAndPlace in + // either Exploring or Placing (the real net stays snapshotted aside the + // whole time). The TS/firmware UI uses it to show the "exploring" state. + bool exploring() const noexcept { + return explore_active_ || ep_state_ != ExploreState::Idle; + } + bool learning_paused() const noexcept { return learning_paused_; } + + // ---- ExploreAndPlace state introspection -------------------------------- + ExploreState explore_state() const noexcept { return ep_state_; } + bool placing() const noexcept { return ep_state_ == ExploreState::Placing; } + // Depth of the scratchpad undo ring currently available to pop (0..UndoDepth). + 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 placed_output() const noexcept { + if (ep_state_ != ExploreState::Placing) return {}; + return std::span(placed_out_.data(), kNOut); + } + + // ---- 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 mask) noexcept { + focus_count_ = (mask.size() < kNOut) ? mask.size() : kNOut; + 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 + // caller must act on. + FeedbackAction on_down(MLP_T& mlp, std::span current_out, + float speed, float spread, + std::span pin_mask) noexcept { + switch (mode_) { + case FeedbackMode::Avoid: + mlp.move_weights(speed, spread, pin_mask); + return FeedbackAction::AvoidPerturb; + case FeedbackMode::RandomiseOutputs: + if (!explore_active_) { + enter_randomise_outputs(current_out); + return FeedbackAction::EnterExplore; + } + roll_static_outputs(); + return FeedbackAction::Reroll; + case FeedbackMode::RandomiseMlp: + if (!explore_active_) { + enter_randomise_mlp(mlp, spread); + return FeedbackAction::EnterExplore; + } + cancel_explore(mlp); + return FeedbackAction::Cancel; + case FeedbackMode::ExploreAndPlace: + // SOFTWARE DEFAULT POLICY (browser): down enters explore from + // Idle, else re-rolls the scratchpad. (Firmware maps its own + // buttons to the granular methods instead.) + switch (ep_state_) { + case ExploreState::Idle: + enter_explore(mlp, spread); + return FeedbackAction::EnterExplore; + case ExploreState::Exploring: + reroll(mlp, spread); + return FeedbackAction::ScratchReroll; + case ExploreState::Placing: + // Down while placing backs out to Exploring. + cancel_place(); + return FeedbackAction::CancelPlace; + } + return FeedbackAction::None; + } + return FeedbackAction::None; + } + + // 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 { + if (mode_ == FeedbackMode::ExploreAndPlace) { + // SOFTWARE DEFAULT POLICY (browser): up begins place from + // Exploring (freeze the heard output), then commits from Placing + // (restore the real net; caller stores +1 (input→placed_output)). + switch (ep_state_) { + case ExploreState::Idle: + return FeedbackAction::LikeStore; // not exploring → plain like + case ExploreState::Exploring: + begin_place(mlp); + return FeedbackAction::BeginPlace; + case ExploreState::Placing: + commit_place(mlp); + return FeedbackAction::CommitPlace; + } + return FeedbackAction::None; + } + if (explore_active_ && (mode_ == FeedbackMode::RandomiseOutputs || + mode_ == FeedbackMode::RandomiseMlp)) { + restore_after_explore(mlp); + return FeedbackAction::CommitStore; + } + return FeedbackAction::LikeStore; + } + + // 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 { + if (explore_active_ && mode_ == FeedbackMode::RandomiseMlp) { + restore_after_explore(mlp); + return FeedbackAction::Restore; + } + return FeedbackAction::LikeStore; + } + + // 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. + bool static_output(std::span out) const noexcept { + // 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]; + 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]; + return true; + } + + void seed(std::uint64_t s) noexcept { rng_.seed(s); } + + // ========================================================================= + // ExploreAndPlace — granular lifecycle methods (firmware maps buttons to + // these directly; on_down/on_up call them for the browser default policy). + // + // CONTRACT: the controller owns the WEIGHT snapshot/restore and all scratch + // state; the CALLER owns example-storage + training. On commit_place the + // controller restores the real net and the caller does add_example(current + // input → placed_output()) + train (warm-start to interpolate all anchors). + // ========================================================================= + + // 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 { + 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]; + learning_paused_ = true; + ep_state_ = ExploreState::Exploring; + undo_count_ = 0u; + undo_head_ = 0u; + mlp.draw_weights(spread); // first scratchpad candidate + } + + // 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 { + 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 { + 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 { + if (!can_scratch_op()) return; + push_undo(mlp); + auto w = mlp.get_weights(); + for (std::size_t i = 0; i < kWeights; ++i) { + scratch_buf_[i] = w[i] + rng_.next_float_gaussian(amount); + } + mlp.set_weights(std::span(scratch_buf_.data(), kWeights)); + } + + // Exploring scratchpad op: undo the last reroll/nudge (bounded ring). + void undo(MLP_T& mlp) noexcept { + if (!can_scratch_op()) return; + if (undo_count_ == 0u) return; + undo_head_ = (undo_head_ + kUndoDepth - 1u) % kUndoDepth; + --undo_count_; + mlp.set_weights(std::span(undo_ring_[undo_head_].data(), kWeights)); + } + + // 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 current_out) noexcept { + 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]; + 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 { + 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]; + 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 { + if (mode_ != FeedbackMode::ExploreAndPlace) return; + if (ep_state_ != ExploreState::Placing) return; + // 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(snapshot_.data(), kWeights)); + last_placed_valid_ = true; // placed_out_ holds the just-committed vector + learning_paused_ = false; + ep_state_ = ExploreState::Idle; + undo_count_ = 0u; + } + + // The output vector committed by the most recent commit_place, valid until + // the next enter_explore/begin_place. Lets the caller add the +1 example + // AFTER commit_place has restored the real net. + std::span committed_output() const noexcept { + if (!last_placed_valid_) return {}; + return std::span(placed_out_.data(), kNOut); + } + + // Placing→Exploring. Back out of placing without storing; resume auditioning + // the scratchpad (which is still live — begin_place did not touch weights). + void cancel_place() noexcept { + if (mode_ != FeedbackMode::ExploreAndPlace) return; + if (ep_state_ != ExploreState::Placing) return; + ep_state_ = ExploreState::Exploring; + } + + private: + void enter_randomise_outputs(std::span 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 + // 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]; + 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) + // inactive dims keep their seeded entry value + } + } + + void enter_randomise_mlp(MLP_T& 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]; + mlp.draw_weights(spread); // randomise the live net + } + + void restore_after_explore(MLP_T& mlp) noexcept { + if (mode_ == FeedbackMode::RandomiseMlp) { + mlp.set_weights(std::span(snapshot_.data(), kWeights)); + } + 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); } + + // ---- ExploreAndPlace helpers -------------------------------------------- + bool can_scratch_op() const noexcept { + return mode_ == FeedbackMode::ExploreAndPlace && + ep_state_ == ExploreState::Exploring; + } + + // 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 { + 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_; + } + + // 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(snapshot_.data(), kWeights)); + learning_paused_ = false; + ep_state_ = ExploreState::Idle; + undo_count_ = 0u; + last_placed_valid_ = false; + } + + void abort_explore_place(MLP_T& mlp) noexcept { + if (ep_state_ != ExploreState::Idle) restore_real_net(mlp); + } + + FeedbackMode mode_ = FeedbackMode::Avoid; + bool explore_active_ = false; + bool learning_paused_ = false; + std::array static_out_{}; + std::array snapshot_{}; + std::array focus_{}; + std::size_t focus_count_ = 0; // 0 ⇒ all active + + // ---- ExploreAndPlace state (all fixed-size, no heap) -------------------- + ExploreState ep_state_ = ExploreState::Idle; + std::array placed_out_{}; // frozen audition vector + bool last_placed_valid_ = false; + std::array, 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 scratch_buf_{}; // nudge scratch (no heap) + + Rng rng_; +}; + +} // namespace nisps::ml diff --git a/nisps/wasm/bindings.cpp b/nisps/wasm/bindings.cpp index 85897ea..0404e68 100644 --- a/nisps/wasm/bindings.cpp +++ b/nisps/wasm/bindings.cpp @@ -65,6 +65,7 @@ // ML. #include "../core/types.hpp" +#include "../ml/feedback.hpp" #include "../ml/mlp.hpp" #include "../ml/stats.hpp" @@ -93,11 +94,17 @@ constexpr std::size_t kDefaultOutputs = DefaultMLP::kOutput; // the opaque pointer to JS. struct MLHandle { DefaultMLP 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 feedback; // Buffers used to bridge JS → C++: std::array input_scratch{}; std::array output_scratch{}; // Stats buffer fed back to JS via get_layer_stats. std::array stats_scratch{}; + // Static-output buffer for the RandomiseOutputs bypass path. + std::array 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 @@ -105,7 +112,8 @@ struct MLHandle { static constexpr std::size_t kMaxBatch = 4096u; std::array batch_out_scratch{}; - explicit MLHandle(std::uint64_t seed) noexcept : mlp(seed) {} + explicit MLHandle(std::uint64_t seed) noexcept + : mlp(seed), feedback(seed ^ 0xFEEDBACC0DEull) {} }; // --------------------------------------------------------------------------- @@ -427,6 +435,221 @@ void nisps_ml_move_weights(void* ml, float speed, float spread, h->mlp.move_weights(speed, spread, mask); } +// --------------------------------------------------------------------------- +// ML feedback — the "Down Action" state machine (Avoid / RandomiseOutputs / +// RandomiseMlp). The controller decides WHAT transition happened (returns a +// FeedbackAction int); JS performs the side effect (store example, grow noise, +// train). See nisps/ml/feedback.hpp. Mode ints: 0=Avoid 1=RandOut 2=RandMlp. +// Action ints mirror nisps::ml::FeedbackAction. +// +// CALLER CONTRACT (commit ordering — important): +// On a "keep" (up) or drag-commit while exploring RandomiseMlp, the controller +// RESTORES the original net before returning. The output the user is hearing +// comes from the *temporary* (randomised) net, so you MUST capture the current +// 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 +// live vector (RandomiseOutputs freezes unfocused dims at those values). +// --------------------------------------------------------------------------- + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_set_mode(void* ml, int mode) { + if (!ml) return; + auto* h = static_cast(ml); + nisps::ml::FeedbackMode m = nisps::ml::FeedbackMode::Avoid; + if (mode == 1) m = nisps::ml::FeedbackMode::RandomiseOutputs; + else if (mode == 2) m = nisps::ml::FeedbackMode::RandomiseMlp; + else if (mode == 3) m = nisps::ml::FeedbackMode::ExploreAndPlace; + h->feedback.set_mode(m, h->mlp); +} + +EMSCRIPTEN_KEEPALIVE +int nisps_ml_feedback_get_mode(void* ml) { + if (!ml) return 0; + return static_cast(static_cast(ml)->feedback.mode()); +} + +EMSCRIPTEN_KEEPALIVE +int nisps_ml_feedback_exploring(void* ml) { + if (!ml) return 0; + return static_cast(ml)->feedback.exploring() ? 1 : 0; +} + +EMSCRIPTEN_KEEPALIVE +int nisps_ml_feedback_learning_paused(void* ml) { + if (!ml) return 0; + return static_cast(ml)->feedback.learning_paused() ? 1 : 0; +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_set_focus(void* ml, const uint8_t* mask, int n) { + if (!ml) return; + auto* h = static_cast(ml); + if (!mask || n <= 0) { + h->feedback.clear_focus_mask(); + return; + } + h->feedback.set_focus_mask( + std::span(mask, static_cast(n))); +} + +// current_out = kDefaultOutputs 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, + float speed, float spread, const uint8_t* pin_mask) { + if (!ml) return 0; + auto* h = static_cast(ml); + std::span out; + if (current_out) out = std::span(current_out, kDefaultOutputs); + std::span mask; + if (pin_mask) mask = std::span(pin_mask, kDefaultOutputs); + return static_cast(h->feedback.on_down(h->mlp, out, speed, spread, mask)); +} + +EMSCRIPTEN_KEEPALIVE +int nisps_ml_feedback_up(void* ml) { + if (!ml) return 0; + auto* h = static_cast(ml); + return static_cast(h->feedback.on_up(h->mlp)); +} + +EMSCRIPTEN_KEEPALIVE +int nisps_ml_feedback_drag(void* ml) { + if (!ml) return 0; + auto* h = static_cast(ml); + return static_cast(h->feedback.on_drag(h->mlp)); +} + +// If returns 1, `out` (kDefaultOutputs 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(ml); + const bool bypass = + h->feedback.static_output(std::span(h->feedback_static_scratch)); + if (bypass) { + std::memcpy(out, h->feedback_static_scratch.data(), + kDefaultOutputs * sizeof(float)); + } + return bypass ? 1 : 0; +} + +// --------------------------------------------------------------------------- +// ML feedback — ExploreAndPlace lifecycle (Idle → Exploring → Placing → Idle). +// Granular transitions so the SAME shared core drives both the browser (which +// also uses on_down/on_up via _down/_up) and firmware (which maps buttons to +// these directly). Set mode 3 (ExploreAndPlace) via nisps_ml_feedback_set_mode. +// +// CALLER CONTRACT (commit ordering): on _commit_place the controller restores +// the REAL net; the caller then reads nisps_ml_feedback_committed_output and +// adds it as the +1 example label at the chosen input, then trains. +// --------------------------------------------------------------------------- + +// Idle→Exploring: snapshot the real net, randomise a scratchpad. +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_enter_explore(void* ml, float spread) { + if (!ml) return; + auto* h = static_cast(ml); + h->feedback.enter_explore(h->mlp, spread); +} + +// Exploring→Idle: restore the real net, discard scratchpad. +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_exit_explore(void* ml) { + if (!ml) return; + auto* h = static_cast(ml); + h->feedback.exit_explore(h->mlp); +} + +// Exploring scratchpad op: re-randomise. +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_reroll(void* ml, float spread) { + if (!ml) return; + auto* h = static_cast(ml); + h->feedback.reroll(h->mlp, spread); +} + +// Exploring scratchpad op: bounded nudge (amount = noise stddev, e.g. 0.05). +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_nudge(void* ml, float amount) { + if (!ml) return; + auto* h = static_cast(ml); + h->feedback.nudge(h->mlp, amount); +} + +// Exploring scratchpad op: undo last reroll/nudge. +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_undo(void* ml) { + if (!ml) return; + auto* h = static_cast(ml); + h->feedback.undo(h->mlp); +} + +// Exploring→Placing: freeze the scratchpad output at its CURRENT input. +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_like(void* ml) { + if (!ml) return; + auto* h = static_cast(ml); + h->feedback.begin_place(h->mlp); +} + +// Placing→Idle: restore the real net. Caller then reads committed_output and +// stores the +1 example at the chosen input. +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_commit_place(void* ml) { + if (!ml) return; + auto* h = static_cast(ml); + h->feedback.commit_place(h->mlp); +} + +// Placing→Exploring: back out of placing (no store). +EMSCRIPTEN_KEEPALIVE +void nisps_ml_feedback_cancel_place(void* ml) { + if (!ml) return; + auto* h = static_cast(ml); + h->feedback.cancel_place(); +} + +// 1 if currently Placing (audition is the frozen vector), else 0. +EMSCRIPTEN_KEEPALIVE +int nisps_ml_feedback_placing(void* ml) { + if (!ml) return 0; + return static_cast(ml)->feedback.placing() ? 1 : 0; +} + +// ExploreState int: 0=Idle 1=Exploring 2=Placing. +EMSCRIPTEN_KEEPALIVE +int nisps_ml_feedback_state(void* ml) { + if (!ml) return 0; + return static_cast(static_cast(ml)->feedback.explore_state()); +} + +// Scratchpad undo-ring depth currently available to pop. +EMSCRIPTEN_KEEPALIVE +int nisps_ml_feedback_undo_depth(void* ml) { + if (!ml) return 0; + return static_cast(static_cast(ml)->feedback.undo_depth()); +} + +// Writes the committed/placed output vector (kDefaultOutputs 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. +EMSCRIPTEN_KEEPALIVE +int nisps_ml_feedback_placed_output(void* ml, float* out) { + if (!ml || !out) return 0; + auto* h = static_cast(ml); + std::span 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; + std::memcpy(out, v.data(), n * sizeof(float)); + return 1; +} + EMSCRIPTEN_KEEPALIVE void nisps_ml_get_layer_stats(void* ml, float* out_stats) { if (!ml || !out_stats) return; diff --git a/playground/public/nisps.js b/playground/public/nisps.js index 3efe02b..9c76fe9 100644 --- a/playground/public/nisps.js +++ b/playground/public/nisps.js @@ -1,2 +1,19 @@ -var createNispsModule=(()=>{var _scriptName=globalThis.document?.currentScript?.src;return async function(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";var arguments_=[];var thisProgram="./this.program";if(ENVIRONMENT_IS_WORKER){_scriptName=self.location.href}var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var readyPromiseResolve,readyPromiseReject;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["d"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("nisps.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var __abort_js=()=>abort("");var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var _nisps_ml_create,_nisps_ml_destroy,_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_weight_count,_nisps_ml_get_weights,_nisps_ml_set_weights,_nisps_ml_draw_weights,_nisps_ml_move_weights,_nisps_ml_get_layer_stats,_nisps_ml_example_count,_nisps_ml_clear_examples,_nisps_ml_reset,_nisps_ml_describe,_nisps_engine_create,_nisps_engine_destroy,_nisps_engine_set_params,_nisps_engine_process_block,_malloc,_free,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_nisps_ml_create=Module["_nisps_ml_create"]=wasmExports["e"];_nisps_ml_destroy=Module["_nisps_ml_destroy"]=wasmExports["f"];_nisps_ml_set_input=Module["_nisps_ml_set_input"]=wasmExports["g"];_nisps_ml_process=Module["_nisps_ml_process"]=wasmExports["h"];_nisps_ml_outputs=Module["_nisps_ml_outputs"]=wasmExports["i"];_nisps_ml_infer_batch=Module["_nisps_ml_infer_batch"]=wasmExports["j"];_nisps_ml_add_example=Module["_nisps_ml_add_example"]=wasmExports["k"];_nisps_ml_train=Module["_nisps_ml_train"]=wasmExports["l"];_nisps_ml_eval_loss=Module["_nisps_ml_eval_loss"]=wasmExports["m"];_nisps_ml_weight_count=Module["_nisps_ml_weight_count"]=wasmExports["n"];_nisps_ml_get_weights=Module["_nisps_ml_get_weights"]=wasmExports["o"];_nisps_ml_set_weights=Module["_nisps_ml_set_weights"]=wasmExports["p"];_nisps_ml_draw_weights=Module["_nisps_ml_draw_weights"]=wasmExports["q"];_nisps_ml_move_weights=Module["_nisps_ml_move_weights"]=wasmExports["r"];_nisps_ml_get_layer_stats=Module["_nisps_ml_get_layer_stats"]=wasmExports["s"];_nisps_ml_example_count=Module["_nisps_ml_example_count"]=wasmExports["t"];_nisps_ml_clear_examples=Module["_nisps_ml_clear_examples"]=wasmExports["u"];_nisps_ml_reset=Module["_nisps_ml_reset"]=wasmExports["v"];_nisps_ml_describe=Module["_nisps_ml_describe"]=wasmExports["w"];_nisps_engine_create=Module["_nisps_engine_create"]=wasmExports["x"];_nisps_engine_destroy=Module["_nisps_engine_destroy"]=wasmExports["y"];_nisps_engine_set_params=Module["_nisps_engine_set_params"]=wasmExports["z"];_nisps_engine_process_block=Module["_nisps_engine_process_block"]=wasmExports["A"];_malloc=Module["_malloc"]=wasmExports["B"];_free=Module["_free"]=wasmExports["C"];__emscripten_stack_restore=wasmExports["D"];__emscripten_stack_alloc=wasmExports["E"];_emscripten_stack_get_current=wasmExports["F"];memory=wasmMemory=wasmExports["c"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:__abort_js,b:_emscripten_resize_heap};function run(){preRun();function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} -;return moduleRtn}})();if(typeof exports==="object"&&typeof module==="object"){module.exports=createNispsModule;module.exports.default=createNispsModule}else if(typeof define==="function"&&define["amd"])define([],()=>createNispsModule); + +var createNispsModule = (() => { + var _scriptName = typeof document != 'undefined' ? document.currentScript?.src : undefined; + + return ( +function(moduleArg = {}) { + var moduleRtn; + +var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);function findWasmBinary(){var f="nisps.wasm";if(!isDataURI(f)){return locateFile(f)}return f}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["d"];updateMemoryViews();addOnInit(wasmExports["e"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var __abort_js=()=>{abort("")};var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};var wasmImports={a:__abort_js,c:__emscripten_memcpy_js,b:_emscripten_resize_heap};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["e"])();var _nisps_ml_create=Module["_nisps_ml_create"]=(a0,a1,a2,a3,a4)=>(_nisps_ml_create=Module["_nisps_ml_create"]=wasmExports["f"])(a0,a1,a2,a3,a4);var _nisps_ml_destroy=Module["_nisps_ml_destroy"]=a0=>(_nisps_ml_destroy=Module["_nisps_ml_destroy"]=wasmExports["g"])(a0);var _nisps_ml_set_input=Module["_nisps_ml_set_input"]=(a0,a1,a2)=>(_nisps_ml_set_input=Module["_nisps_ml_set_input"]=wasmExports["h"])(a0,a1,a2);var _nisps_ml_process=Module["_nisps_ml_process"]=a0=>(_nisps_ml_process=Module["_nisps_ml_process"]=wasmExports["i"])(a0);var _nisps_ml_outputs=Module["_nisps_ml_outputs"]=a0=>(_nisps_ml_outputs=Module["_nisps_ml_outputs"]=wasmExports["j"])(a0);var _nisps_ml_infer_batch=Module["_nisps_ml_infer_batch"]=(a0,a1,a2,a3)=>(_nisps_ml_infer_batch=Module["_nisps_ml_infer_batch"]=wasmExports["k"])(a0,a1,a2,a3);var _nisps_ml_add_example=Module["_nisps_ml_add_example"]=(a0,a1,a2)=>(_nisps_ml_add_example=Module["_nisps_ml_add_example"]=wasmExports["l"])(a0,a1,a2);var _nisps_ml_train=Module["_nisps_ml_train"]=(a0,a1,a2,a3,a4)=>(_nisps_ml_train=Module["_nisps_ml_train"]=wasmExports["m"])(a0,a1,a2,a3,a4);var _nisps_ml_eval_loss=Module["_nisps_ml_eval_loss"]=a0=>(_nisps_ml_eval_loss=Module["_nisps_ml_eval_loss"]=wasmExports["n"])(a0);var _nisps_ml_weight_count=Module["_nisps_ml_weight_count"]=a0=>(_nisps_ml_weight_count=Module["_nisps_ml_weight_count"]=wasmExports["o"])(a0);var _nisps_ml_get_weights=Module["_nisps_ml_get_weights"]=(a0,a1)=>(_nisps_ml_get_weights=Module["_nisps_ml_get_weights"]=wasmExports["p"])(a0,a1);var _nisps_ml_set_weights=Module["_nisps_ml_set_weights"]=(a0,a1)=>(_nisps_ml_set_weights=Module["_nisps_ml_set_weights"]=wasmExports["q"])(a0,a1);var _nisps_ml_draw_weights=Module["_nisps_ml_draw_weights"]=(a0,a1)=>(_nisps_ml_draw_weights=Module["_nisps_ml_draw_weights"]=wasmExports["r"])(a0,a1);var _nisps_ml_move_weights=Module["_nisps_ml_move_weights"]=(a0,a1,a2,a3)=>(_nisps_ml_move_weights=Module["_nisps_ml_move_weights"]=wasmExports["s"])(a0,a1,a2,a3);var _nisps_ml_feedback_set_mode=Module["_nisps_ml_feedback_set_mode"]=(a0,a1)=>(_nisps_ml_feedback_set_mode=Module["_nisps_ml_feedback_set_mode"]=wasmExports["t"])(a0,a1);var _nisps_ml_feedback_get_mode=Module["_nisps_ml_feedback_get_mode"]=a0=>(_nisps_ml_feedback_get_mode=Module["_nisps_ml_feedback_get_mode"]=wasmExports["u"])(a0);var _nisps_ml_feedback_exploring=Module["_nisps_ml_feedback_exploring"]=a0=>(_nisps_ml_feedback_exploring=Module["_nisps_ml_feedback_exploring"]=wasmExports["v"])(a0);var _nisps_ml_feedback_learning_paused=Module["_nisps_ml_feedback_learning_paused"]=a0=>(_nisps_ml_feedback_learning_paused=Module["_nisps_ml_feedback_learning_paused"]=wasmExports["w"])(a0);var _nisps_ml_feedback_set_focus=Module["_nisps_ml_feedback_set_focus"]=(a0,a1,a2)=>(_nisps_ml_feedback_set_focus=Module["_nisps_ml_feedback_set_focus"]=wasmExports["x"])(a0,a1,a2);var _nisps_ml_feedback_down=Module["_nisps_ml_feedback_down"]=(a0,a1,a2,a3,a4)=>(_nisps_ml_feedback_down=Module["_nisps_ml_feedback_down"]=wasmExports["y"])(a0,a1,a2,a3,a4);var _nisps_ml_feedback_up=Module["_nisps_ml_feedback_up"]=a0=>(_nisps_ml_feedback_up=Module["_nisps_ml_feedback_up"]=wasmExports["z"])(a0);var _nisps_ml_feedback_drag=Module["_nisps_ml_feedback_drag"]=a0=>(_nisps_ml_feedback_drag=Module["_nisps_ml_feedback_drag"]=wasmExports["A"])(a0);var _nisps_ml_feedback_static_output=Module["_nisps_ml_feedback_static_output"]=(a0,a1)=>(_nisps_ml_feedback_static_output=Module["_nisps_ml_feedback_static_output"]=wasmExports["B"])(a0,a1);var _nisps_ml_feedback_enter_explore=Module["_nisps_ml_feedback_enter_explore"]=(a0,a1)=>(_nisps_ml_feedback_enter_explore=Module["_nisps_ml_feedback_enter_explore"]=wasmExports["C"])(a0,a1);var _nisps_ml_feedback_exit_explore=Module["_nisps_ml_feedback_exit_explore"]=a0=>(_nisps_ml_feedback_exit_explore=Module["_nisps_ml_feedback_exit_explore"]=wasmExports["D"])(a0);var _nisps_ml_feedback_reroll=Module["_nisps_ml_feedback_reroll"]=(a0,a1)=>(_nisps_ml_feedback_reroll=Module["_nisps_ml_feedback_reroll"]=wasmExports["E"])(a0,a1);var _nisps_ml_feedback_nudge=Module["_nisps_ml_feedback_nudge"]=(a0,a1)=>(_nisps_ml_feedback_nudge=Module["_nisps_ml_feedback_nudge"]=wasmExports["F"])(a0,a1);var _nisps_ml_feedback_undo=Module["_nisps_ml_feedback_undo"]=a0=>(_nisps_ml_feedback_undo=Module["_nisps_ml_feedback_undo"]=wasmExports["G"])(a0);var _nisps_ml_feedback_like=Module["_nisps_ml_feedback_like"]=a0=>(_nisps_ml_feedback_like=Module["_nisps_ml_feedback_like"]=wasmExports["H"])(a0);var _nisps_ml_feedback_commit_place=Module["_nisps_ml_feedback_commit_place"]=a0=>(_nisps_ml_feedback_commit_place=Module["_nisps_ml_feedback_commit_place"]=wasmExports["I"])(a0);var _nisps_ml_feedback_cancel_place=Module["_nisps_ml_feedback_cancel_place"]=a0=>(_nisps_ml_feedback_cancel_place=Module["_nisps_ml_feedback_cancel_place"]=wasmExports["J"])(a0);var _nisps_ml_feedback_placing=Module["_nisps_ml_feedback_placing"]=a0=>(_nisps_ml_feedback_placing=Module["_nisps_ml_feedback_placing"]=wasmExports["K"])(a0);var _nisps_ml_feedback_state=Module["_nisps_ml_feedback_state"]=a0=>(_nisps_ml_feedback_state=Module["_nisps_ml_feedback_state"]=wasmExports["L"])(a0);var _nisps_ml_feedback_undo_depth=Module["_nisps_ml_feedback_undo_depth"]=a0=>(_nisps_ml_feedback_undo_depth=Module["_nisps_ml_feedback_undo_depth"]=wasmExports["M"])(a0);var _nisps_ml_feedback_placed_output=Module["_nisps_ml_feedback_placed_output"]=(a0,a1)=>(_nisps_ml_feedback_placed_output=Module["_nisps_ml_feedback_placed_output"]=wasmExports["N"])(a0,a1);var _nisps_ml_get_layer_stats=Module["_nisps_ml_get_layer_stats"]=(a0,a1)=>(_nisps_ml_get_layer_stats=Module["_nisps_ml_get_layer_stats"]=wasmExports["O"])(a0,a1);var _nisps_ml_example_count=Module["_nisps_ml_example_count"]=a0=>(_nisps_ml_example_count=Module["_nisps_ml_example_count"]=wasmExports["P"])(a0);var _nisps_ml_clear_examples=Module["_nisps_ml_clear_examples"]=a0=>(_nisps_ml_clear_examples=Module["_nisps_ml_clear_examples"]=wasmExports["Q"])(a0);var _nisps_ml_reset=Module["_nisps_ml_reset"]=a0=>(_nisps_ml_reset=Module["_nisps_ml_reset"]=wasmExports["R"])(a0);var _nisps_ml_describe=Module["_nisps_ml_describe"]=a0=>(_nisps_ml_describe=Module["_nisps_ml_describe"]=wasmExports["S"])(a0);var _nisps_engine_create=Module["_nisps_engine_create"]=(a0,a1)=>(_nisps_engine_create=Module["_nisps_engine_create"]=wasmExports["T"])(a0,a1);var _nisps_engine_destroy=Module["_nisps_engine_destroy"]=a0=>(_nisps_engine_destroy=Module["_nisps_engine_destroy"]=wasmExports["U"])(a0);var _nisps_engine_set_params=Module["_nisps_engine_set_params"]=(a0,a1,a2)=>(_nisps_engine_set_params=Module["_nisps_engine_set_params"]=wasmExports["V"])(a0,a1,a2);var _nisps_engine_process_block=Module["_nisps_engine_process_block"]=(a0,a1,a2,a3,a4,a5)=>(_nisps_engine_process_block=Module["_nisps_engine_process_block"]=wasmExports["W"])(a0,a1,a2,a3,a4,a5);var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["Y"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["Z"])(a0);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["_"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["$"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["aa"])();Module["ccall"]=ccall;Module["cwrap"]=cwrap;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise; + + + return moduleRtn; +} +); +})(); +if (typeof exports === 'object' && typeof module === 'object') + module.exports = createNispsModule; +else if (typeof define === 'function' && define['amd']) + define([], () => createNispsModule); diff --git a/playground/public/nisps.wasm b/playground/public/nisps.wasm index 1eecd69..d2226c2 100755 Binary files a/playground/public/nisps.wasm and b/playground/public/nisps.wasm differ diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 0087e91..28db680 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -13,7 +13,7 @@ set -euo pipefail -EMCC="${EMCC:-/usr/lib/emscripten/emcc}" +EMCC="${EMCC:-$(command -v emcc || echo /usr/lib/emscripten/emcc)}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" OUT="$ROOT/playground/public" SRC="$ROOT/nisps/wasm/bindings.cpp" @@ -38,6 +38,15 @@ EXPORTED_FUNCS='[ "_nisps_ml_clear_examples","_nisps_ml_example_count", "_nisps_ml_weight_count","_nisps_ml_get_weights","_nisps_ml_set_weights", "_nisps_ml_draw_weights","_nisps_ml_move_weights", + "_nisps_ml_feedback_set_mode","_nisps_ml_feedback_get_mode", + "_nisps_ml_feedback_exploring","_nisps_ml_feedback_learning_paused", + "_nisps_ml_feedback_set_focus","_nisps_ml_feedback_down", + "_nisps_ml_feedback_up","_nisps_ml_feedback_drag","_nisps_ml_feedback_static_output", + "_nisps_ml_feedback_enter_explore","_nisps_ml_feedback_exit_explore", + "_nisps_ml_feedback_reroll","_nisps_ml_feedback_nudge","_nisps_ml_feedback_undo", + "_nisps_ml_feedback_like","_nisps_ml_feedback_commit_place","_nisps_ml_feedback_cancel_place", + "_nisps_ml_feedback_placing","_nisps_ml_feedback_state","_nisps_ml_feedback_undo_depth", + "_nisps_ml_feedback_placed_output", "_nisps_ml_get_layer_stats","_nisps_ml_describe", "_nisps_engine_create","_nisps_engine_destroy", "_nisps_engine_set_params","_nisps_engine_process_block" diff --git a/tests/cpp/parity_check.cpp b/tests/cpp/parity_check.cpp index 4aaaca8..99bf291 100644 --- a/tests/cpp/parity_check.cpp +++ b/tests/cpp/parity_check.cpp @@ -57,6 +57,7 @@ #include "../../nisps/engines/channel_strip.hpp" #include "../../nisps/engines/paf_synth.hpp" +#include "../../nisps/ml/feedback.hpp" #include "../../nisps/ml/mlp.hpp" namespace { @@ -82,7 +83,11 @@ constexpr std::array kProbeIdx = { }; constexpr std::uint32_t kMagic = 0x5450524Eu; // 'NPRT' -constexpr std::uint32_t kVersion = 1u; +constexpr std::uint32_t kVersion = 3u; // v3 adds stage 5d (ExploreAndPlace lifecycle) + +// Must match the salt in nisps/wasm/bindings.cpp MLHandle so the controller's +// static-output RNG stream is identical native ↔ WASM. +constexpr std::uint64_t kFeedbackSalt = 0xFEEDBACC0DEull; void push_floats(std::vector& v, std::span add) { for (float f : add) v.push_back(f); @@ -106,7 +111,7 @@ int main(int argc, char** argv) { const std::string out_path = (argc > 1) ? argv[1] : "parity_native.bin"; std::vector payload; - payload.reserve(126u + 12u + 126u + 1u + 2u + 2u); + payload.reserve(126u + 12u + 126u + 1u + 2u + 2u + 276u); // ---- Stage 1: ML inference at fixed input ---- ParityMLP mlp(kSeed); @@ -190,6 +195,78 @@ int main(int argc, char** argv) { payload.push_back(r_acc / static_cast(kSynthFrames)); } + // ---- Stage 5: feedback ("Down Action": RandomiseOutputs + RandomiseMlp) ---- + // Seeded exactly as the WASM MLHandle (kSeed XOR kFeedbackSalt) so the + // controller's static-output RNG stream is bit-reproducible native ↔ WASM. + // RandomiseOutputs proves the controller's own RNG; RandomiseMlp proves the + // weight snapshot/restore round-trips identically across platforms. `mlp` is + // untouched by stages 3-4, so its RNG state here equals post-stage-2. + { + nisps::ml::FeedbackController fb(kSeed ^ kFeedbackSalt); + std::array sbuf{}; + const std::span no_out{}; + const std::span no_mask{}; + + fb.set_mode(nisps::ml::FeedbackMode::RandomiseOutputs, mlp); + fb.on_down(mlp, no_out, 0.1f, 0.5f, no_mask); // enter + fb.static_output(std::span(sbuf)); + push_floats(payload, std::span(sbuf.data(), 126u)); + fb.on_down(mlp, no_out, 0.1f, 0.5f, no_mask); // re-roll + fb.static_output(std::span(sbuf)); + push_floats(payload, std::span(sbuf.data(), 126u)); + fb.on_up(mlp); // commit (no weight change) + + fb.set_mode(nisps::ml::FeedbackMode::RandomiseMlp, mlp); + fb.on_down(mlp, no_out, 0.1f, 0.5f, no_mask); // enter → randomise temp net + { + const auto w = mlp.get_weights(); + for (std::size_t idx : kProbeIdx) payload.push_back(idx < w.size() ? w[idx] : 0.f); + } + fb.on_up(mlp); // commit → restore original net + { + const auto w = mlp.get_weights(); + for (std::size_t idx : kProbeIdx) payload.push_back(idx < w.size() ? w[idx] : 0.f); + } + + // ---- Stage 5d: ExploreAndPlace lifecycle ---- + // Proves the shared explore→reroll→nudge→undo→place→commit core is bit- + // reproducible native↔WASM: the scratchpad nudge uses the controller's + // own per-instance Rng (no libc rand), and the snapshot/restore round- + // trips identically. We REUSE the same `fb` controller (not a fresh + // one) so its RNG state matches the WASM MLHandle.feedback, which by + // this point has drained identical RandomiseOutputs draws on both + // platforms (enter + reroll = 2*kNOut uniform draws each side). + fb.set_mode(nisps::ml::FeedbackMode::ExploreAndPlace, mlp); + fb.enter_explore(mlp, 0.5f); // snapshot + randomise scratchpad + fb.reroll(mlp, 0.5f); // scratchpad op (undoable) + fb.nudge(mlp, 0.05f); // controller-Rng perturb + // Probe the scratchpad net (12 weights) — exercises the new RNG stream. + { + const auto w = mlp.get_weights(); + for (std::size_t idx : kProbeIdx) payload.push_back(idx < w.size() ? w[idx] : 0.f); + } + fb.undo(mlp); // pop nudge + // Audition + place at a fixed input; freeze the scratchpad output. + mlp.set_input(0u, kInputX); + mlp.set_input(1u, kInputY); + mlp.process(); + fb.begin_place(mlp); + // Push the frozen placed output (126 floats) — must match across plats. + { + const auto v = fb.placed_output(); + for (std::size_t i = 0; i < 126u; ++i) payload.push_back(i < v.size() ? v[i] : 0.f); + } + fb.commit_place(mlp); // restore real net + // After restore, the probed weights must equal the pre-explore real net, + // and the committed output is the +1 label the caller would store. + { + const auto w = mlp.get_weights(); + for (std::size_t idx : kProbeIdx) payload.push_back(idx < w.size() ? w[idx] : 0.f); + const auto v = fb.committed_output(); + for (std::size_t i = 0; i < 126u; ++i) payload.push_back(i < v.size() ? v[i] : 0.f); + } + } + // ---- Sanity: every value finite ---- for (std::size_t i = 0; i < payload.size(); ++i) { if (!std::isfinite(payload[i])) { diff --git a/tests/cpp/parity_diff.mjs b/tests/cpp/parity_diff.mjs index e8bd40f..c2cc6e6 100644 --- a/tests/cpp/parity_diff.mjs +++ b/tests/cpp/parity_diff.mjs @@ -16,7 +16,7 @@ import { readFile } from 'node:fs/promises'; const MAGIC = 0x5450524e; -const VERSION = 1; +const VERSION = 3; const DEFAULT_TOL = 1e-5; // Layout for context in error messages — must match parity_check.cpp / parity_wasm.mjs. @@ -27,6 +27,19 @@ const SECTIONS = [ { name: 'final_loss', count: 1 }, { name: 'paf_synth_means', count: 2 }, { name: 'channel_strip_means', count: 2 }, + // Stage 5 (feedback): 126 enter-static + 126 reroll-static + 12 temp-net + // weight probes + 12 restored-net weight probes. + { name: 'feedback_randout_enter', count: 126 }, + { name: 'feedback_randout_reroll', count: 126 }, + { name: 'feedback_randmlp_temp', count: 12 }, + { name: 'feedback_randmlp_restored', count: 12 }, + // Stage 5d (ExploreAndPlace): 12 scratchpad-net probes (post enter/reroll/ + // nudge) + 126 frozen placed output + 12 restored-net probes + 126 committed + // output. + { name: 'feedback_ep_scratch', count: 12 }, + { name: 'feedback_ep_placed', count: 126 }, + { name: 'feedback_ep_restored', count: 12 }, + { name: 'feedback_ep_committed', count: 126 }, ]; async function readBlob(path) { diff --git a/tests/cpp/parity_wasm.mjs b/tests/cpp/parity_wasm.mjs index b49f6ad..840242b 100644 --- a/tests/cpp/parity_wasm.mjs +++ b/tests/cpp/parity_wasm.mjs @@ -38,7 +38,7 @@ const __dirname = dirname(__filename); const repoRoot = resolve(__dirname, '..', '..'); const MAGIC = 0x5450524e; // 'NPRT' -const VERSION = 1; +const VERSION = 3; // v3 adds stage 5d (ExploreAndPlace lifecycle) const SEED = 42 >>> 0; const INPUT_X = 0.25; @@ -101,6 +101,17 @@ function bind(Module) { getWeights: cwrap('nisps_ml_get_weights', null, ['number','number']), drawWeights: cwrap('nisps_ml_draw_weights', null, ['number','number']), moveWeights: cwrap('nisps_ml_move_weights', null, ['number','number','number','number']), + feedbackSetMode: cwrap('nisps_ml_feedback_set_mode', null, ['number','number']), + feedbackDown: cwrap('nisps_ml_feedback_down', 'number', ['number','number','number','number','number']), + feedbackUp: cwrap('nisps_ml_feedback_up', 'number', ['number']), + feedbackStaticOutput: cwrap('nisps_ml_feedback_static_output', 'number', ['number','number']), + feedbackEnterExplore: cwrap('nisps_ml_feedback_enter_explore', null, ['number','number']), + feedbackReroll: cwrap('nisps_ml_feedback_reroll', null, ['number','number']), + feedbackNudge: cwrap('nisps_ml_feedback_nudge', null, ['number','number']), + feedbackUndo: cwrap('nisps_ml_feedback_undo', null, ['number']), + 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']), engineCreate: cwrap('nisps_engine_create', 'number', ['string','number']), @@ -235,7 +246,7 @@ async function main() { api.process(ml); const outsStage2 = getOutputsCopy(api, ml, N_OUT); - api.destroy(ml); + // (ml stays alive through stage 5 below; destroyed after the feedback stage.) // --- Stage 3: PAFSynth --- // PAFSynth has 33 params per param_count() in nisps/engines/paf_synth.hpp. @@ -244,6 +255,72 @@ async function main() { // --- Stage 4: ChannelStrip (24 params) --- const [csL, csR] = runEngine(api, 'channel_strip', 24, 0.25, SYNTH_FRAMES); + // --- Stage 5: feedback ("Down Action": RandomiseOutputs + RandomiseMlp) --- + // Mirrors parity_check.cpp stage 5. The controller is seeded inside the WASM + // MLHandle as (seed XOR salt), matching the native side. ml is untouched by + // stages 3-4, so its RNG state here equals post-stage-2. + const FB_RANDOUT = 1; + const FB_RANDMLP = 2; + const feedbackFloats = []; + const fbBuf = api.malloc(N_OUT * 4); + api.feedbackSetMode(ml, FB_RANDOUT); + api.feedbackDown(ml, 0, 0.1, 0.5, 0); // enter + api.feedbackStaticOutput(ml, fbBuf); + for (const v of new Float32Array(api.HEAPF32.buffer, fbBuf, N_OUT)) feedbackFloats.push(v); + api.feedbackDown(ml, 0, 0.1, 0.5, 0); // re-roll + api.feedbackStaticOutput(ml, fbBuf); + for (const v of new Float32Array(api.HEAPF32.buffer, fbBuf, N_OUT)) feedbackFloats.push(v); + api.free(fbBuf); + api.feedbackUp(ml); // commit (no weight change) + + api.feedbackSetMode(ml, FB_RANDMLP); + api.feedbackDown(ml, 0, 0.1, 0.5, 0); // enter → randomise temp net + { + const tempW = getWeightsCopy(api, ml); + for (const idx of PROBE_IDX) feedbackFloats.push(idx < tempW.length ? tempW[idx] : 0); + } + api.feedbackUp(ml); // commit → restore original net + { + const restoredW = getWeightsCopy(api, ml); + for (const idx of PROBE_IDX) feedbackFloats.push(idx < restoredW.length ? restoredW[idx] : 0); + } + + // --- Stage 5d: ExploreAndPlace lifecycle --- + // Reuses the single MLHandle.feedback controller (mode → ExploreAndPlace) so + // its RNG state matches native `fb` (both drained identical RandomiseOutputs + // draws). enter → reroll → nudge → undo → place → commit. + const FB_EXPLORE_PLACE = 3; + api.feedbackSetMode(ml, FB_EXPLORE_PLACE); + api.feedbackEnterExplore(ml, 0.5); // snapshot + randomise scratchpad + api.feedbackReroll(ml, 0.5); // scratchpad op + api.feedbackNudge(ml, 0.05); // controller-Rng perturb + { + const scratchW = getWeightsCopy(api, ml); + for (const idx of PROBE_IDX) feedbackFloats.push(idx < scratchW.length ? scratchW[idx] : 0); + } + api.feedbackUndo(ml); // pop nudge + api.setInput(ml, 0, INPUT_X); + api.setInput(ml, 1, INPUT_Y); + api.process(ml); + api.feedbackLike(ml); // begin place: freeze scratchpad output + { + const placedBuf = api.malloc(N_OUT * 4); + api.feedbackPlacedOutput(ml, placedBuf); + for (const v of new Float32Array(api.HEAPF32.buffer, placedBuf, N_OUT)) feedbackFloats.push(v); + api.free(placedBuf); + } + api.feedbackCommitPlace(ml); // restore real net + { + const restoredW = getWeightsCopy(api, ml); + for (const idx of PROBE_IDX) feedbackFloats.push(idx < restoredW.length ? restoredW[idx] : 0); + const committedBuf = api.malloc(N_OUT * 4); + api.feedbackPlacedOutput(ml, committedBuf); + for (const v of new Float32Array(api.HEAPF32.buffer, committedBuf, N_OUT)) feedbackFloats.push(v); + api.free(committedBuf); + } + + api.destroy(ml); + // --- Build payload, write blob --- const payload = []; for (const v of outsStage1) payload.push(v); @@ -252,6 +329,7 @@ async function main() { payload.push(finalLoss); payload.push(pafL, pafR); payload.push(csL, csR); + for (const v of feedbackFloats) payload.push(v); // Sanity: all finite. for (let i = 0; i < payload.length; ++i) { diff --git a/tests/cpp/test_mlp_feedback.cpp b/tests/cpp/test_mlp_feedback.cpp new file mode 100644 index 0000000..2a64195 --- /dev/null +++ b/tests/cpp/test_mlp_feedback.cpp @@ -0,0 +1,685 @@ +// tests/cpp/test_mlp_feedback.cpp — verify the FeedbackController "Down Action" +// state machine: Avoid / RandomiseOutputs / RandomiseMlp. +// +// Mirrors test_mlp_rl.cpp conventions (SmallMLP, snapshot-and-compare). The +// controller owns no MLP; every mutating call passes the MLP by reference. + +#include +#include +#include +#include + +#include "test_helpers.hpp" + +#include "../../nisps/ml/feedback.hpp" +#include "../../nisps/ml/mlp.hpp" + +namespace { + +using SmallMLP = nisps::ml::MLP<2, 4, 4, 4, 6, 8, 32>; +using FB = nisps::ml::FeedbackController; +using nisps::ml::FeedbackAction; +using nisps::ml::FeedbackMode; + +constexpr std::size_t kNOut = SmallMLP::kOutput; // 6 +constexpr std::size_t kW = SmallMLP::weight_count(); + +// Empty spans for the "don't care" args. +const std::span kNoOut{}; +const std::span kNoMask{}; + +std::array snapshot_weights(SmallMLP& m) { + std::array out{}; + auto w = m.get_weights(); + for (std::size_t i = 0; i < w.size(); ++i) out[i] = w[i]; + return out; +} + +int distinct(const std::array& a, const std::array& b) { + int d = 0; + for (std::size_t i = 0; i < kW; ++i) { + if (a[i] != b[i]) ++d; + } + return d; +} + +bool weights_equal(SmallMLP& m, const std::array& ref) { + auto w = m.get_weights(); + for (std::size_t i = 0; i < w.size(); ++i) { + if (w[i] != ref[i]) return false; + } + return true; +} + +// -- Avoid ------------------------------------------------------------------ + +NISPS_TEST(feedback_avoid_routes_to_move_weights) { + SmallMLP m(99ull); + m.draw_weights(0.5f); + FB fb(7ull); // default mode is Avoid + + const auto before = snapshot_weights(m); + const FeedbackAction a = fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); + const auto after = snapshot_weights(m); + + NISPS_EXPECT(a == FeedbackAction::AvoidPerturb); + NISPS_EXPECT(distinct(before, after) > static_cast(kW) / 2); // perturbed + NISPS_EXPECT(!fb.exploring()); + NISPS_EXPECT(!fb.learning_paused()); +} + +// -- RandomiseOutputs ------------------------------------------------------- + +NISPS_TEST(feedback_randout_enter_roll_state) { + SmallMLP m(0ull); + FB fb(123ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); + + std::array cur{}; + for (auto& v : cur) v = 0.5f; + + const auto before_w = snapshot_weights(m); + const FeedbackAction a = + fb.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); + + NISPS_EXPECT(a == FeedbackAction::EnterExplore); + NISPS_EXPECT(fb.exploring()); + NISPS_EXPECT(fb.learning_paused()); + + std::array buf{}; + const bool bypass = fb.static_output(std::span(buf)); + NISPS_EXPECT(bypass); // MLP bypassed while exploring + + int changed = 0; + for (std::size_t i = 0; i < kNOut; ++i) { + if (buf[i] != 0.5f) ++changed; + } + NISPS_EXPECT(changed == static_cast(kNOut)); // every dim rolled (no mask) + + // RandomiseOutputs must NOT touch the network weights. + NISPS_EXPECT(weights_equal(m, before_w)); +} + +NISPS_TEST(feedback_randout_reroll_changes_focused_only) { + SmallMLP m(0ull); + FB fb(55ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); + + // Focus mask: roll dims 0,2,4; freeze dims 1,3,5. + std::array mask{1, 0, 1, 0, 1, 0}; + fb.set_focus_mask(std::span(mask)); + + const std::array seed{0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f}; + fb.on_down(m, std::span(seed), 0.1f, 0.5f, kNoMask); // enter + first roll + + std::array a{}; + fb.static_output(std::span(a)); + + fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); // re-roll + std::array b{}; + fb.static_output(std::span(b)); + + // Unfocused dims frozen at their seed value across both rolls. + for (std::size_t i : {1u, 3u, 5u}) { + NISPS_EXPECT(a[i] == seed[i]); + NISPS_EXPECT(b[i] == seed[i]); + } + // Focused dims re-rolled → at least 2 of 3 differ between the two rolls. + int focused_changed = 0; + for (std::size_t i : {0u, 2u, 4u}) { + if (a[i] != b[i]) ++focused_changed; + } + NISPS_EXPECT(focused_changed == 3); // all focused dims re-rolled +} + +NISPS_TEST(feedback_randout_commit_clears_state) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(9ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); + + const auto w0 = snapshot_weights(m); + std::array cur{}; + for (auto& v : cur) v = 0.5f; + fb.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); // enter + + const FeedbackAction a = fb.on_up(m); // keep + NISPS_EXPECT(a == FeedbackAction::CommitStore); + NISPS_EXPECT(!fb.exploring()); + NISPS_EXPECT(!fb.learning_paused()); + + std::array buf{}; + NISPS_EXPECT(!fb.static_output(std::span(buf))); // no longer bypassing + NISPS_EXPECT(weights_equal(m, w0)); // net untouched +} + +// -- RandomiseMlp ----------------------------------------------------------- + +NISPS_TEST(feedback_randmlp_snapshot_and_cancel_restores) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseMlp, m); + + const auto w0 = snapshot_weights(m); + const FeedbackAction enter = fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); + NISPS_EXPECT(enter == FeedbackAction::EnterExplore); + NISPS_EXPECT(fb.exploring()); + NISPS_EXPECT(!weights_equal(m, w0)); // live net randomised + + const FeedbackAction cancel = fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); + NISPS_EXPECT(cancel == FeedbackAction::Cancel); + NISPS_EXPECT(!fb.exploring()); + NISPS_EXPECT(weights_equal(m, w0)); // original net byte-restored +} + +NISPS_TEST(feedback_randmlp_commit_restores_then_caller_trains) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseMlp, m); + + const auto w0 = snapshot_weights(m); + fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); // enter (randomise) + + const FeedbackAction a = fb.on_up(m); // keep + NISPS_EXPECT(a == FeedbackAction::CommitStore); + NISPS_EXPECT(!fb.exploring()); + NISPS_EXPECT(weights_equal(m, w0)); // net restored; kept example trains it later +} + +NISPS_TEST(feedback_randmlp_drag_repositions) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseMlp, m); + + const auto w0 = snapshot_weights(m); + fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); // enter + + const FeedbackAction a = fb.on_drag(m); + NISPS_EXPECT(a == FeedbackAction::Restore); + NISPS_EXPECT(!fb.exploring()); + NISPS_EXPECT(weights_equal(m, w0)); +} + +NISPS_TEST(feedback_mode_switch_aborts_explore) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseMlp, m); + + const auto w0 = snapshot_weights(m); + fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); // enter (randomise) + NISPS_EXPECT(!weights_equal(m, w0)); + + fb.set_mode(FeedbackMode::Avoid, m); // switching aborts → restore + resume + NISPS_EXPECT(fb.mode() == FeedbackMode::Avoid); + NISPS_EXPECT(!fb.exploring()); + NISPS_EXPECT(!fb.learning_paused()); + NISPS_EXPECT(weights_equal(m, w0)); +} + +// -- determinism + bypass invariants --------------------------------------- + +NISPS_TEST(feedback_determinism_fixed_seed) { + // Same controller seed + same press sequence → byte-identical static output, + // proving the per-instance Rng (no libc rand()). + SmallMLP m1(0ull), m2(0ull); + FB a(42ull), b(42ull); + a.set_mode(FeedbackMode::RandomiseOutputs, m1); + b.set_mode(FeedbackMode::RandomiseOutputs, m2); + + std::array cur{}; + for (auto& v : cur) v = 0.25f; + + a.on_down(m1, std::span(cur), 0.1f, 0.5f, kNoMask); // enter + b.on_down(m2, std::span(cur), 0.1f, 0.5f, kNoMask); + a.on_down(m1, kNoOut, 0.1f, 0.5f, kNoMask); // reroll + b.on_down(m2, kNoOut, 0.1f, 0.5f, kNoMask); + + std::array ba{}, bb{}; + a.static_output(std::span(ba)); + b.static_output(std::span(bb)); + for (std::size_t i = 0; i < kNOut; ++i) NISPS_EXPECT(ba[i] == bb[i]); +} + +NISPS_TEST(feedback_focus_empty_means_all_active) { + SmallMLP m(0ull); + FB fb(1ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); // no focus mask set + + std::array cur{}; + for (auto& v : cur) v = 0.5f; + fb.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); + + std::array buf{}; + fb.static_output(std::span(buf)); + int changed = 0; + for (std::size_t i = 0; i < kNOut; ++i) { + if (buf[i] != 0.5f) ++changed; + } + NISPS_EXPECT(changed == static_cast(kNOut)); // all dims rolled +} + +NISPS_TEST(feedback_static_output_bypass_only_in_randout) { + SmallMLP m(0ull); + std::array buf{}; + + FB avoid(0ull); // Avoid, not exploring + NISPS_EXPECT(!avoid.static_output(std::span(buf))); + + FB ro(0ull); + ro.set_mode(FeedbackMode::RandomiseOutputs, m); // mode set but NOT entered + NISPS_EXPECT(!ro.static_output(std::span(buf))); + + std::array cur{}; + ro.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); // now exploring + NISPS_EXPECT(ro.static_output(std::span(buf))); +} + +// -- LikeStore / non-exploring fallbacks ------------------------------------ + +NISPS_TEST(feedback_avoid_up_is_like_store) { + SmallMLP m(0ull); + FB fb(0ull); // Avoid + NISPS_EXPECT(fb.on_up(m) == FeedbackAction::LikeStore); + NISPS_EXPECT(!fb.exploring()); +} + +NISPS_TEST(feedback_idle_up_is_like_store) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); // mode set, never entered + const auto w0 = snapshot_weights(m); + NISPS_EXPECT(fb.on_up(m) == FeedbackAction::LikeStore); + NISPS_EXPECT(weights_equal(m, w0)); // idle up touches nothing +} + +NISPS_TEST(feedback_drag_non_explore_is_like_store) { + SmallMLP m(0ull); + FB fb(0ull); // Avoid, not exploring + NISPS_EXPECT(fb.on_drag(m) == FeedbackAction::LikeStore); +} + +NISPS_TEST(feedback_commit_without_enter_is_like_store) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseMlp, m); // never entered + const auto w0 = snapshot_weights(m); + NISPS_EXPECT(fb.on_up(m) == FeedbackAction::LikeStore); + NISPS_EXPECT(fb.on_drag(m) == FeedbackAction::LikeStore); + NISPS_EXPECT(weights_equal(m, w0)); // no enter → nothing restored/mutated +} + +// -- drag must NOT cancel a RandomiseOutputs exploration (firmware semantics) - + +NISPS_TEST(feedback_randout_drag_stays_in_explore) { + SmallMLP m(0ull); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); + std::array cur{}; + for (auto& v : cur) v = 0.5f; + fb.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); // enter + NISPS_EXPECT(fb.on_drag(m) == FeedbackAction::LikeStore); // plain store + NISPS_EXPECT(fb.exploring()); // exploration CONTINUES + NISPS_EXPECT(fb.learning_paused()); + std::array buf{}; + NISPS_EXPECT(fb.static_output(std::span(buf))); // still bypassing +} + +// -- fidelity: unfocused dims freeze at the live (current_out) value --------- + +NISPS_TEST(feedback_randout_unfocused_freezes_at_current_out) { + SmallMLP m(0ull); + FB fb(3ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); + std::array mask{1, 0, 1, 0, 1, 0}; // freeze 1,3,5 + fb.set_focus_mask(std::span(mask)); + const std::array cur{0.11f, 0.22f, 0.33f, 0.44f, 0.55f, 0.66f}; + fb.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); // enter + std::array buf{}; + fb.static_output(std::span(buf)); + NISPS_EXPECT(buf[1] == cur[1]); // frozen at the live value, not 0 or random + NISPS_EXPECT(buf[3] == cur[3]); + NISPS_EXPECT(buf[5] == cur[5]); + int focused_changed = 0; + for (std::size_t i : {0u, 2u, 4u}) { + if (buf[i] != cur[i]) ++focused_changed; + } + NISPS_EXPECT(focused_changed == 3); +} + +// -- golden RNG stream: catches an RNG/seed-mix regression even if symmetric -- + +NISPS_TEST(feedback_randout_golden_stream) { + SmallMLP m(0ull); + FB fb(42ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); + std::array cur{}; // no mask → all dims rolled from the FB RNG + fb.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); // enter + std::array buf{}; + fb.static_output(std::span(buf)); + // GOLDEN (seed 42, first roll) — regenerate ONLY on an intentional RNG change. + NISPS_EXPECT_NEAR(buf[0], 0.0857555866f, 1e-6); + NISPS_EXPECT_NEAR(buf[1], 0.310411394f, 1e-6); + NISPS_EXPECT_NEAR(buf[2], 0.0625697374f, 1e-6); + NISPS_EXPECT_NEAR(buf[5], 0.3030653f, 1e-6); +} + +// -- RandomiseMlp: repeated enter/commit re-snapshots each cycle ------------- + +NISPS_TEST(feedback_randmlp_repeated_enter_commit_restores_each_time) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseMlp, m); + for (int cycle = 0; cycle < 3; ++cycle) { + const auto w0 = snapshot_weights(m); + fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); // enter → randomise temp net + NISPS_EXPECT(!weights_equal(m, w0)); + fb.on_up(m); // commit → restore + NISPS_EXPECT(weights_equal(m, w0)); // a stale snapshot would fail here + m.move_weights(0.05f, 0.5f); // mutate the "original" net for next cycle + } +} + +// -- focus mask edge cases --------------------------------------------------- + +NISPS_TEST(feedback_clear_focus_restores_all_active) { + SmallMLP m(0ull); + FB fb(8ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); + std::array mask{1, 0, 0, 0, 0, 0}; + fb.set_focus_mask(std::span(mask)); + fb.clear_focus_mask(); // back to all-active + std::array cur{}; + for (auto& v : cur) v = 0.5f; + fb.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); + std::array buf{}; + fb.static_output(std::span(buf)); + int changed = 0; + for (std::size_t i = 0; i < kNOut; ++i) { + if (buf[i] != cur[i]) ++changed; + } + NISPS_EXPECT(changed == static_cast(kNOut)); // all dims roll again +} + +NISPS_TEST(feedback_focus_mask_truncates_when_oversized) { + SmallMLP m(0ull); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); + // Oversized mask (kNOut+4): set_focus_mask must clamp to kNOut, no overflow. + std::array mask{}; + for (auto& v : mask) v = 1; + fb.set_focus_mask(std::span(mask)); + std::array cur{}; + for (auto& v : cur) v = 0.5f; + fb.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); // must not overflow + std::array buf{}; + NISPS_EXPECT(fb.static_output(std::span(buf))); +} + +NISPS_TEST(feedback_short_static_buffer_clamps) { + SmallMLP m(0ull); + FB fb(0ull); + fb.set_mode(FeedbackMode::RandomiseOutputs, m); + std::array cur{}; + fb.on_down(m, std::span(cur), 0.1f, 0.5f, kNoMask); // enter + // Caller passes a buffer SHORTER than kNOut — must fill only that many. + std::array small{-1.f, -1.f, -1.f}; + NISPS_EXPECT(fb.static_output(std::span(small))); + for (float v : small) NISPS_EXPECT(v != -1.f); // all 3 written, no overflow +} + +// =========================================================================== +// ExploreAndPlace — the Idle → Exploring → Placing → Idle lifecycle. +// =========================================================================== + +using nisps::ml::ExploreState; + +NISPS_TEST(ep_enter_explore_snapshots_and_randomises) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + + const auto real = snapshot_weights(m); + NISPS_EXPECT(fb.explore_state() == ExploreState::Idle); + NISPS_EXPECT(!fb.exploring()); + + fb.enter_explore(m, 0.5f); + NISPS_EXPECT(fb.explore_state() == ExploreState::Exploring); + NISPS_EXPECT(fb.exploring()); + NISPS_EXPECT(fb.learning_paused()); + NISPS_EXPECT(!weights_equal(m, real)); // scratchpad net is live + + fb.exit_explore(m); // back out → real net restored + NISPS_EXPECT(fb.explore_state() == ExploreState::Idle); + NISPS_EXPECT(!fb.learning_paused()); + NISPS_EXPECT(weights_equal(m, real)); +} + +NISPS_TEST(ep_reroll_changes_scratchpad_undo_restores) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(7ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + fb.enter_explore(m, 0.5f); + + const auto cand0 = snapshot_weights(m); + fb.reroll(m, 0.5f); + const auto cand1 = snapshot_weights(m); + NISPS_EXPECT(distinct(cand0, cand1) > 0); // re-rolled + NISPS_EXPECT(fb.undo_depth() == 1u); + + fb.undo(m); // back to cand0 + NISPS_EXPECT(weights_equal(m, cand0)); + NISPS_EXPECT(fb.undo_depth() == 0u); +} + +NISPS_TEST(ep_nudge_is_bounded_and_undoable) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(3ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + fb.enter_explore(m, 0.5f); + + const auto before = snapshot_weights(m); + fb.nudge(m, 0.05f); + const auto after = snapshot_weights(m); + NISPS_EXPECT(distinct(before, after) > 0); // perturbed + // Bounded: small stddev → deltas stay modest. + float max_delta = 0.f; + for (std::size_t i = 0; i < kW; ++i) { + const float d = after[i] - before[i]; + const float ad = d < 0.f ? -d : d; + if (ad > max_delta) max_delta = ad; + } + NISPS_EXPECT(max_delta < 1.0f); // nudge, not a re-roll + + fb.undo(m); + NISPS_EXPECT(weights_equal(m, before)); +} + +NISPS_TEST(ep_undo_ring_bounded_to_depth) { + // Default UndoDepth = 4. After 6 rerolls, only 4 undos are available. + SmallMLP m(0ull); + FB fb(11ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + fb.enter_explore(m, 0.5f); + for (int i = 0; i < 6; ++i) fb.reroll(m, 0.5f); + NISPS_EXPECT(fb.undo_depth() == FB::kUndoDepth); // saturated at depth + for (std::size_t i = 0; i < FB::kUndoDepth; ++i) fb.undo(m); + NISPS_EXPECT(fb.undo_depth() == 0u); + fb.undo(m); // extra undo is a no-op + NISPS_EXPECT(fb.undo_depth() == 0u); +} + +NISPS_TEST(ep_place_freezes_output_and_holds_via_static) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + fb.enter_explore(m, 0.5f); + + // Audition at a fixed input, then begin_place freezes that output. + m.set_input(0u, 0.3f); + m.set_input(1u, 0.7f); + m.process(); + std::array auditioned{}; + { + auto o = m.outputs(); + for (std::size_t i = 0; i < kNOut; ++i) auditioned[i] = o[i]; + } + + fb.begin_place(m); // convenience overload: process + capture + NISPS_EXPECT(fb.placing()); + NISPS_EXPECT(fb.explore_state() == ExploreState::Placing); + + auto placed = fb.placed_output(); + NISPS_EXPECT(placed.size() == kNOut); + for (std::size_t i = 0; i < kNOut; ++i) NISPS_EXPECT(placed[i] == auditioned[i]); + + // While placing, static_output holds the frozen vector regardless of input. + std::array buf{}; + NISPS_EXPECT(fb.static_output(std::span(buf))); + for (std::size_t i = 0; i < kNOut; ++i) NISPS_EXPECT(buf[i] == auditioned[i]); +} + +NISPS_TEST(ep_commit_place_restores_real_net_exposes_committed) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + + const auto real = snapshot_weights(m); + fb.enter_explore(m, 0.5f); + fb.begin_place(m); + std::array frozen{}; + { + auto p = fb.placed_output(); + for (std::size_t i = 0; i < kNOut; ++i) frozen[i] = p[i]; + } + + fb.commit_place(m); + NISPS_EXPECT(fb.explore_state() == ExploreState::Idle); + NISPS_EXPECT(!fb.learning_paused()); + NISPS_EXPECT(weights_equal(m, real)); // real net restored + + // The caller reads the committed vector AFTER restore to add the +1 example. + auto committed = fb.committed_output(); + NISPS_EXPECT(committed.size() == kNOut); + for (std::size_t i = 0; i < kNOut; ++i) NISPS_EXPECT(committed[i] == frozen[i]); +} + +NISPS_TEST(ep_cancel_place_returns_to_exploring) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + fb.enter_explore(m, 0.5f); + const auto scratch = snapshot_weights(m); + fb.begin_place(m); + NISPS_EXPECT(fb.placing()); + + fb.cancel_place(); + NISPS_EXPECT(fb.explore_state() == ExploreState::Exploring); + NISPS_EXPECT(fb.exploring()); + NISPS_EXPECT(weights_equal(m, scratch)); // scratchpad untouched +} + +NISPS_TEST(ep_software_policy_down_up_drives_machine) { + // on_down / on_up are the BROWSER default policy over the same machine. + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + const auto real = snapshot_weights(m); + + NISPS_EXPECT(fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask) == FeedbackAction::EnterExplore); + NISPS_EXPECT(fb.explore_state() == ExploreState::Exploring); + + NISPS_EXPECT(fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask) == FeedbackAction::ScratchReroll); + NISPS_EXPECT(fb.explore_state() == ExploreState::Exploring); + + NISPS_EXPECT(fb.on_up(m) == FeedbackAction::BeginPlace); + NISPS_EXPECT(fb.placing()); + + NISPS_EXPECT(fb.on_up(m) == FeedbackAction::CommitPlace); + NISPS_EXPECT(fb.explore_state() == ExploreState::Idle); + NISPS_EXPECT(weights_equal(m, real)); + + // Down while placing backs out to exploring. + fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); // enter + fb.on_up(m); // begin place + NISPS_EXPECT(fb.placing()); + NISPS_EXPECT(fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask) == FeedbackAction::CancelPlace); + NISPS_EXPECT(fb.explore_state() == ExploreState::Exploring); +} + +NISPS_TEST(ep_mode_switch_aborts_session_restores) { + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(0ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + const auto real = snapshot_weights(m); + fb.enter_explore(m, 0.5f); + fb.begin_place(m); + NISPS_EXPECT(fb.placing()); + + fb.set_mode(FeedbackMode::Avoid, m); // switching aborts + NISPS_EXPECT(fb.mode() == FeedbackMode::Avoid); + NISPS_EXPECT(fb.explore_state() == ExploreState::Idle); + NISPS_EXPECT(!fb.learning_paused()); + NISPS_EXPECT(weights_equal(m, real)); +} + +NISPS_TEST(ep_determinism_fixed_seed) { + // Same seed + same op sequence → byte-identical scratchpad nudges, proving + // the per-instance Rng (the nudge draws from the controller's own stream). + SmallMLP m1(0ull), m2(0ull); + m1.draw_weights(0.5f); m2.draw_weights(0.5f); + FB a(42ull), b(42ull); + a.set_mode(FeedbackMode::ExploreAndPlace, m1); + b.set_mode(FeedbackMode::ExploreAndPlace, m2); + a.enter_explore(m1, 0.5f); + b.enter_explore(m2, 0.5f); + a.nudge(m1, 0.05f); + b.nudge(m2, 0.05f); + auto wa = snapshot_weights(m1); + auto wb = snapshot_weights(m2); + for (std::size_t i = 0; i < kW; ++i) NISPS_EXPECT(wa[i] == wb[i]); +} + +NISPS_TEST(ep_full_flow_two_anchors_caller_trains) { + // End-to-end: explore→place→commit twice, with the CALLER doing the + // add_example + train (the contract). After warm-start the net should bend + // toward both placed anchors. + SmallMLP m(0ull); + m.draw_weights(0.5f); + FB fb(5ull); + fb.set_mode(FeedbackMode::ExploreAndPlace, m); + + const std::array, 2> inputs{{{{0.1f, 0.1f}}, {{0.9f, 0.9f}}}}; + for (int anchor = 0; anchor < 2; ++anchor) { + fb.enter_explore(m, 0.5f); + // audition at the chosen input + m.set_input(0u, inputs[anchor][0]); + m.set_input(1u, inputs[anchor][1]); + m.process(); + fb.begin_place(m); + fb.commit_place(m); // restores real net + // CALLER stores the +1 example (input → committed output) and trains. + auto out = fb.committed_output(); + NISPS_EXPECT(out.size() == kNOut); + m.add_example(std::span(inputs[anchor].data(), 2u), out); + } + const float loss = m.train(0.3f, 200u, 0.0f); + NISPS_EXPECT(loss >= 0.f); // trained, finite + NISPS_EXPECT(m.example_count() == 2u); +} + +} // namespace