// 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). // // STORAGE POLICY (one-core-engine-refactor P2): like MLPCore, the controller // algorithms are written once in `FeedbackControllerCore` against a // storage surface. Two models: // * `FixedFeedbackStorage` — std::array, zero // heap. The classic `FeedbackController` alias derives // the sizes from the fixed MLP type; firmware + tests compile unchanged. // * `DynamicFeedbackStorage` (nisps/ml/dynamic_storage.hpp) — sizes at // construction for the runtime-shaped browser MLP. Non-embedded only. // // Design: the controller does NOT own the MLP — every mutating method takes // the MLP by reference (method-level template, so fixed and dynamic MLPs both // work). It owns only the exploration state, with its OWN per-instance Rng so // re-rolling outputs is deterministic and never perturbs the MLP's RNG stream. // Honours the RP2350 perf contract in the fixed model: no heap, no virtual // dispatch, deterministic per-instance RNG. // // The C++/JS boundary: the controller decides *what transition happened* // (returns a FeedbackAction); the caller decides *what to persist* (add example, // grow noise, train). All inherently-UI state (pins, pipeline outputs, display) // stays in the caller. #pragma once #include #include #include #include #include "../core/perf.hpp" #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). }; // --------------------------------------------------------------------------- // Fixed feedback storage — std::array, zero heap. Sizes are compile-time. // UndoDepth = number of scratchpad ops (reroll/nudge) that can be undone in // ExploreAndPlace; each undo slot is NWeights floats. WASM historically used // depth 4, firmware 2 (per rl-feedback-design §2.2 — SRAM budget). // --------------------------------------------------------------------------- template class FixedFeedbackStorage { public: static constexpr std::size_t kNOut = NOut; static constexpr std::size_t kWeights = NWeights; static constexpr std::size_t kUndoDepth = UndoDepth; static constexpr std::size_t n_out() noexcept { return NOut; } static constexpr std::size_t n_weights() noexcept { return NWeights; } static constexpr std::size_t undo_cap() noexcept { return UndoDepth; } NISPS_FORCE_INLINE std::span static_out() noexcept { return static_out_; } NISPS_FORCE_INLINE std::span static_out() const noexcept { return static_out_; } NISPS_FORCE_INLINE std::span snapshot() noexcept { return snapshot_; } NISPS_FORCE_INLINE std::span snapshot() const noexcept { return snapshot_; } NISPS_FORCE_INLINE std::span focus() noexcept { return focus_; } NISPS_FORCE_INLINE std::span focus() const noexcept { return focus_; } NISPS_FORCE_INLINE std::span placed_out() noexcept { return placed_out_; } NISPS_FORCE_INLINE std::span placed_out() const noexcept { return placed_out_; } NISPS_FORCE_INLINE std::span scratch_buf() noexcept { return scratch_buf_; } NISPS_FORCE_INLINE std::span undo_slot(std::size_t i) noexcept { return undo_ring_[i]; } NISPS_FORCE_INLINE std::span undo_slot(std::size_t i) const noexcept { return undo_ring_[i]; } private: std::array static_out_{}; std::array snapshot_{}; std::array focus_{}; std::array placed_out_{}; std::array, UndoDepth> undo_ring_{}; std::array scratch_buf_{}; }; // --------------------------------------------------------------------------- // The controller algorithms, written once against the feedback storage // surface: n_out(), n_weights(), undo_cap(), static_out(), snapshot(), // focus(), placed_out(), scratch_buf(), undo_slot(i). // --------------------------------------------------------------------------- template class FeedbackControllerCore : public FbStorage { public: template explicit FeedbackControllerCore(std::uint64_t seed, StorageArgs&&... storage_args) noexcept : FbStorage(static_cast(storage_args)...), rng_(seed) {} // ---- mode --------------------------------------------------------------- // Switching mode mid-exploration cleanly tears down: restores the net (in // RandomiseMlp) and resumes learning, so we never strand a randomised net. template void set_mode(FeedbackMode m, M& mlp) noexcept { if (explore_active_) abort_explore(mlp); if (ep_state_ != ExploreState::Idle) abort_explore_place(mlp); mode_ = m; } 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; } // True while a REPOSITION hold is active (grab→move→drop). Distinguishes a // reposition (real net never set aside) from an Explore→Place (scratchpad + // snapshot). Both sit in ExploreState::Placing and both hold placed_out via // static_output(); only commit/teardown differ (reposition does NOT restore // weights — there is nothing to restore). bool repositioning() const noexcept { return reposition_; } // Depth of the scratchpad undo ring currently available to pop (0..undo_cap). std::size_t undo_depth() const noexcept { return undo_count_; } // The output vector frozen at like()/begin-place time. Valid only while // placing(); empty span otherwise. The caller adds this as the +1 example // label at commit (input → placed_output). std::span placed_output() const noexcept { if (ep_state_ != ExploreState::Placing) return {}; return this->placed_out(); } // ---- focus mask: 1 byte per output; 0 == frozen (unfocused). Copied into a // fixed buffer (no heap, no dangling span). Empty ⇒ all outputs active. void set_focus_mask(std::span mask) noexcept { auto focus = this->focus(); focus_count_ = (mask.size() < focus.size()) ? mask.size() : focus.size(); for (std::size_t i = 0; i < focus_count_; ++i) focus[i] = mask[i]; } void clear_focus_mask() noexcept { focus_count_ = 0; } // ---- press handlers ----------------------------------------------------- // `current_out` is the live (post-pipeline) output the user is hearing // (n_out floats). `pin_mask` may be empty. Returns the FeedbackAction the // caller must act on. template FeedbackAction on_down(M& 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. template FeedbackAction on_up(M& mlp) noexcept { if (mode_ == FeedbackMode::ExploreAndPlace) { // SOFTWARE DEFAULT POLICY (browser): up begins place from // Exploring (freeze the heard output), then commits from Placing // (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. template FeedbackAction on_drag(M& 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 n_out. bool static_output(std::span out) const noexcept { const std::size_t n_out = this->n_out(); // ExploreAndPlace: while PLACING, the audition is the frozen vector the // user liked, held steady as they aim at a location. if (mode_ == FeedbackMode::ExploreAndPlace && ep_state_ == ExploreState::Placing) { const auto placed = this->placed_out(); const std::size_t n = (out.size() < n_out) ? out.size() : n_out; for (std::size_t i = 0; i < n; ++i) out[i] = placed[i]; return true; } if (!(mode_ == FeedbackMode::RandomiseOutputs && explore_active_)) return false; const auto held = this->static_out(); const std::size_t n = (out.size() < n_out) ? out.size() : n_out; for (std::size_t i = 0; i < n; ++i) out[i] = held[i]; return true; } 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. template void enter_explore(M& mlp, float spread) noexcept { if (mode_ != FeedbackMode::ExploreAndPlace) return; if (ep_state_ != ExploreState::Idle) return; take_snapshot(mlp); 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.) template void exit_explore(M& mlp) noexcept { if (mode_ != FeedbackMode::ExploreAndPlace) return; if (ep_state_ == ExploreState::Idle) return; restore_real_net(mlp); } // Exploring scratchpad op: re-randomise the scratchpad. Undoable. template void reroll(M& mlp, float spread) noexcept { if (!can_scratch_op()) return; push_undo(mlp); mlp.draw_weights(spread); } // Exploring scratchpad op: small bounded perturbation of the scratchpad via // the controller's OWN Rng (move_weights uses the MLP's Rng; to keep the // controller's Rng stream out of the MLP stream we draw the perturbation // here and apply it). Undoable. `amount` is the noise stddev (e.g. 0.05). template void nudge(M& mlp, float amount) noexcept { if (!can_scratch_op()) return; push_undo(mlp); auto scratch = this->scratch_buf(); const std::size_t n_weights = this->n_weights(); auto w = mlp.get_weights(); for (std::size_t i = 0; i < n_weights; ++i) { scratch[i] = w[i] + rng_.next_float_gaussian(amount); } mlp.set_weights(std::span(scratch.data(), n_weights)); } // Exploring scratchpad op: undo the last reroll/nudge (bounded ring). template void undo(M& mlp) noexcept { if (!can_scratch_op()) return; if (undo_count_ == 0u) return; const std::size_t cap = this->undo_cap(); undo_head_ = (undo_head_ + cap - 1u) % cap; --undo_count_; const auto slot = this->undo_slot(undo_head_); mlp.set_weights(std::span(slot.data(), this->n_weights())); } // Exploring→Placing. Capture + FREEZE the current scratchpad output the user // is auditioning. The caller MUST have run mlp.process() at the audition // input first; pass that output here. While placing, static_output() holds // this vector and the caller chooses WHERE to place it. template void begin_place(M& mlp, std::span current_out) noexcept { (void)mlp; if (mode_ != FeedbackMode::ExploreAndPlace) return; if (ep_state_ != ExploreState::Exploring) return; capture_placed(current_out); ep_state_ = ExploreState::Placing; } // Convenience: freeze the scratchpad's output at its CURRENT input (runs the // forward pass on the live scratchpad net). Equivalent to process()+capture. template void begin_place(M& mlp) noexcept { if (mode_ != FeedbackMode::ExploreAndPlace) return; if (ep_state_ != ExploreState::Exploring) return; mlp.process(); capture_placed(mlp.outputs()); ep_state_ = ExploreState::Placing; } // Placing→Idle. Restore the real net. The CALLER then adds a +1 example at // (chosen input → placed_output()) and trains. template void commit_place(M& mlp) noexcept { if (mode_ != FeedbackMode::ExploreAndPlace) return; if (ep_state_ != ExploreState::Placing) return; // Restore the real net but KEEP placed_out valid for the caller until // it transitions to Idle; expose via a separate accessor that does not // gate on Placing. restore_snapshot(mlp); 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 this->placed_out(); } // Placing→Exploring. Back out of placing without storing; resume auditioning // the scratchpad (which is still live — begin_place did not touch weights). // A reposition hold has no scratchpad to return to, so it backs out to Idle. void cancel_place() noexcept { if (mode_ != FeedbackMode::ExploreAndPlace) return; if (ep_state_ != ExploreState::Placing) return; if (reposition_) { reposition_ = false; learning_paused_ = false; ep_state_ = ExploreState::Idle; return; } ep_state_ = ExploreState::Exploring; } // ========================================================================= // Reposition (grab → move → drop) — relocate an EXISTING positive example's // output to a new input position. Distinct from Explore→Place: there is NO // scratchpad and NO weight snapshot — the real (trained) net stays live the // whole time. We only FREEZE the currently-heard output and hold it (via // static_output) while the user moves to a new input, then the caller adds // a +1 example (new input → carried output) and trains. This is the new // core's home for the upstream "drag-store / reposition-commit" gesture. // ========================================================================= // Idle→Placing(reposition). Freeze `current_out` — the output the user is // hearing from the TRAINED net — and hold it. No-op unless Idle. void begin_reposition(std::span current_out) noexcept { if (mode_ != FeedbackMode::ExploreAndPlace) return; if (ep_state_ != ExploreState::Idle) return; capture_placed(current_out); reposition_ = true; learning_paused_ = true; last_placed_valid_ = false; ep_state_ = ExploreState::Placing; } // Convenience: capture the trained net's output at its CURRENT input // (process + capture). Equivalent to begin_reposition(mlp.outputs()). template void begin_reposition(M& mlp) noexcept { if (mode_ != FeedbackMode::ExploreAndPlace) return; if (ep_state_ != ExploreState::Idle) return; mlp.process(); capture_placed(mlp.outputs()); reposition_ = true; learning_paused_ = true; last_placed_valid_ = false; ep_state_ = ExploreState::Placing; } // Placing(reposition)→Idle. NO weight restore (the net was never set aside). // committed_output() then holds the carried vector so the caller can add the // +1 example at the new input and train. No-op unless repositioning. void commit_reposition() noexcept { if (!reposition_ || ep_state_ != ExploreState::Placing) return; reposition_ = false; last_placed_valid_ = true; // committed_output() valid for the caller learning_paused_ = false; ep_state_ = ExploreState::Idle; undo_count_ = 0u; } private: void capture_placed(std::span src) noexcept { auto placed = this->placed_out(); const std::size_t n = (src.size() < placed.size()) ? src.size() : placed.size(); for (std::size_t i = 0; i < n; ++i) placed[i] = src[i]; } template void take_snapshot(M& mlp) noexcept { auto snap = this->snapshot(); auto w = mlp.get_weights(); // flat snapshot (size == n_weights) const std::size_t n = this->n_weights(); for (std::size_t i = 0; i < n; ++i) snap[i] = w[i]; } template void restore_snapshot(M& mlp) noexcept { const auto snap = this->snapshot(); mlp.set_weights(std::span(snap.data(), this->n_weights())); } 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 n_out live output. Any dims beyond // a short seed keep their previous static value (we have no live value // to freeze them to); they are only observable if a focus mask freezes // a dim the short seed did not cover — an out-of-contract corner. auto held = this->static_out(); const std::size_t n = (seed_out.size() < held.size()) ? seed_out.size() : held.size(); for (std::size_t i = 0; i < n; ++i) held[i] = seed_out[i]; roll_static_outputs(); } void roll_static_outputs() noexcept { auto held = this->static_out(); const auto focus = this->focus(); for (std::size_t i = 0; i < held.size(); ++i) { const bool active = (focus_count_ == 0u) || (i < focus_count_ && focus[i] != 0u); if (active) held[i] = rng_.next_float_uniform(); // [0, 1) // inactive dims keep their seeded entry value } } template void enter_randomise_mlp(M& mlp, float spread) noexcept { explore_active_ = true; learning_paused_ = true; take_snapshot(mlp); mlp.draw_weights(spread); // randomise the live net } template void restore_after_explore(M& mlp) noexcept { if (mode_ == FeedbackMode::RandomiseMlp) { restore_snapshot(mlp); } learning_paused_ = false; explore_active_ = false; } // Cancel and abort share restore semantics; the caller stores nothing. template void cancel_explore(M& mlp) noexcept { restore_after_explore(mlp); } template void abort_explore(M& 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. template void push_undo(M& mlp) noexcept { auto slot = this->undo_slot(undo_head_); auto w = mlp.get_weights(); const std::size_t n = this->n_weights(); for (std::size_t i = 0; i < n; ++i) slot[i] = w[i]; const std::size_t cap = this->undo_cap(); undo_head_ = (undo_head_ + 1u) % cap; if (undo_count_ < cap) ++undo_count_; } // Restore the set-aside real net and return to Idle. Shared by exit_explore // and abort_explore_place. No example stored. template void restore_real_net(M& mlp) noexcept { restore_snapshot(mlp); learning_paused_ = false; ep_state_ = ExploreState::Idle; undo_count_ = 0u; last_placed_valid_ = false; } template void abort_explore_place(M& mlp) noexcept { if (ep_state_ == ExploreState::Idle) return; if (reposition_) { // A reposition never set the real net aside, so there is nothing to // restore — clearing snapshot into the net here would CLOBBER the // live trained weights. Just drop the hold. reposition_ = false; learning_paused_ = false; ep_state_ = ExploreState::Idle; undo_count_ = 0u; last_placed_valid_ = false; return; } restore_real_net(mlp); } FeedbackMode mode_ = FeedbackMode::Avoid; bool explore_active_ = false; bool learning_paused_ = false; std::size_t focus_count_ = 0; // 0 ⇒ all active // ---- ExploreAndPlace state ---------------------------------------------- ExploreState ep_state_ = ExploreState::Idle; bool last_placed_valid_ = false; bool reposition_ = false; // grab→move→drop hold; net NOT set aside std::size_t undo_head_ = 0u; // next write slot std::size_t undo_count_ = 0u; // valid entries (0..undo_cap) Rng rng_; }; // The classic fixed-size controller over a compile-time MLP type — the // firmware model and the default for tests. Sizes derive from the MLP. template using FeedbackController = FeedbackControllerCore< FixedFeedbackStorage>; } // namespace nisps::ml