feat(nisps/ml): crystallise Explore-and-place into shared FeedbackController

Add FeedbackMode::ExploreAndPlace + Idle/Exploring/Placing state machine
(no-heap, deterministic nisps::Rng): enter/exit_explore, reroll, nudge, undo,
begin_place, commit_place, cancel_place + on_down/on_up browser policy. Wire
the nisps_ml_feedback_* C API + EXPORTED_FUNCTIONS, extend parity Stage 5d.
Fix set_mode(3) falling back to Avoid. Native ctest 4/4; parity native==WASM
within 1e-5 (max delta 2.4e-7). Rebuilt nisps.{js,wasm}.
This commit is contained in:
monkey-w1n5t0n 2026-06-28 04:14:12 +02:00
parent 24057e7b5f
commit 22efb1c411
10 changed files with 1573 additions and 9 deletions

View file

@ -56,6 +56,7 @@ if(NOT EMSCRIPTEN)
${NISPS_TEST_DIR}/test_mlp_training.cpp ${NISPS_TEST_DIR}/test_mlp_training.cpp
${NISPS_TEST_DIR}/test_mlp_loss.cpp ${NISPS_TEST_DIR}/test_mlp_loss.cpp
${NISPS_TEST_DIR}/test_mlp_rl.cpp ${NISPS_TEST_DIR}/test_mlp_rl.cpp
${NISPS_TEST_DIR}/test_mlp_feedback.cpp
${NISPS_TEST_DIR}/test_mlp_serialize.cpp ${NISPS_TEST_DIR}/test_mlp_serialize.cpp
) )
target_link_libraries(nisps_core_tests PRIVATE nisps_core) target_link_libraries(nisps_core_tests PRIVATE nisps_core)

461
nisps/ml/feedback.hpp Normal file
View file

@ -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 <array>
#include <cstddef>
#include <cstdint>
#include <span>
#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 <typename MLP_T, std::size_t UndoDepth = 4u>
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<const float> placed_output() const noexcept {
if (ep_state_ != ExploreState::Placing) return {};
return std::span<const float>(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<const std::uint8_t> mask) noexcept {
focus_count_ = (mask.size() < kNOut) ? mask.size() : kNOut;
for (std::size_t i = 0; i < focus_count_; ++i) focus_[i] = mask[i];
}
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<const float> current_out,
float speed, float spread,
std::span<const std::uint8_t> 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<float> 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<const float>(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<const float>(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<const float> 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<const float>(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<const float> committed_output() const noexcept {
if (!last_placed_valid_) return {};
return std::span<const float>(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<const float> seed_out) noexcept {
explore_active_ = true;
learning_paused_ = true;
// Seed every dim with the live output the user is hearing, so unfocused
// (frozen) dims hold that value through the exploration — matching the
// firmware `staticRandomOut_ = action; _roll_static_outputs();`. The
// CALLER CONTRACT is to pass the full kNOut live output. Any dims beyond
// 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<const float>(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<const float>(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<float, kNOut> static_out_{};
std::array<float, kWeights> snapshot_{};
std::array<std::uint8_t, kNOut> focus_{};
std::size_t focus_count_ = 0; // 0 ⇒ all active
// ---- ExploreAndPlace state (all fixed-size, no heap) --------------------
ExploreState ep_state_ = ExploreState::Idle;
std::array<float, kNOut> placed_out_{}; // frozen audition vector
bool last_placed_valid_ = false;
std::array<std::array<float, kWeights>, kUndoDepth> undo_ring_{}; // bounded undo
std::size_t undo_head_ = 0u; // next write slot
std::size_t undo_count_ = 0u; // valid entries (0..kUndoDepth)
std::array<float, kWeights> scratch_buf_{}; // nudge scratch (no heap)
Rng rng_;
};
} // namespace nisps::ml

View file

@ -65,6 +65,7 @@
// ML. // ML.
#include "../core/types.hpp" #include "../core/types.hpp"
#include "../ml/feedback.hpp"
#include "../ml/mlp.hpp" #include "../ml/mlp.hpp"
#include "../ml/stats.hpp" #include "../ml/stats.hpp"
@ -93,11 +94,17 @@ constexpr std::size_t kDefaultOutputs = DefaultMLP::kOutput;
// the opaque pointer to JS. // the opaque pointer to JS.
struct MLHandle { struct MLHandle {
DefaultMLP mlp; 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<DefaultMLP> feedback;
// Buffers used to bridge JS → C++: // Buffers used to bridge JS → C++:
std::array<float, kDefaultInputs> input_scratch{}; std::array<float, kDefaultInputs> input_scratch{};
std::array<float, kDefaultOutputs> output_scratch{}; std::array<float, kDefaultOutputs> output_scratch{};
// Stats buffer fed back to JS via get_layer_stats. // Stats buffer fed back to JS via get_layer_stats.
std::array<float, DefaultMLP::kNumLayers * 4u> stats_scratch{}; std::array<float, DefaultMLP::kNumLayers * 4u> stats_scratch{};
// Static-output buffer for the RandomiseOutputs bypass path.
std::array<float, kDefaultOutputs> feedback_static_scratch{};
// Used by infer_batch with arbitrary N — must exceed any reasonable // Used by infer_batch with arbitrary N — must exceed any reasonable
// request from the heatmap. 256x256 = 65536 max points → too many in // request from the heatmap. 256x256 = 65536 max points → too many in
// practice. We cap batch size at 4096 here; callers must split larger // 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; static constexpr std::size_t kMaxBatch = 4096u;
std::array<float, kMaxBatch * kDefaultOutputs> batch_out_scratch{}; std::array<float, kMaxBatch * kDefaultOutputs> 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); 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<MLHandle*>(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<int>(static_cast<MLHandle*>(ml)->feedback.mode());
}
EMSCRIPTEN_KEEPALIVE
int nisps_ml_feedback_exploring(void* ml) {
if (!ml) return 0;
return static_cast<MLHandle*>(ml)->feedback.exploring() ? 1 : 0;
}
EMSCRIPTEN_KEEPALIVE
int nisps_ml_feedback_learning_paused(void* ml) {
if (!ml) return 0;
return static_cast<MLHandle*>(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<MLHandle*>(ml);
if (!mask || n <= 0) {
h->feedback.clear_focus_mask();
return;
}
h->feedback.set_focus_mask(
std::span<const std::uint8_t>(mask, static_cast<std::size_t>(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<MLHandle*>(ml);
std::span<const float> out;
if (current_out) out = std::span<const float>(current_out, kDefaultOutputs);
std::span<const std::uint8_t> mask;
if (pin_mask) mask = std::span<const std::uint8_t>(pin_mask, kDefaultOutputs);
return static_cast<int>(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<MLHandle*>(ml);
return static_cast<int>(h->feedback.on_up(h->mlp));
}
EMSCRIPTEN_KEEPALIVE
int nisps_ml_feedback_drag(void* ml) {
if (!ml) return 0;
auto* h = static_cast<MLHandle*>(ml);
return static_cast<int>(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<MLHandle*>(ml);
const bool bypass =
h->feedback.static_output(std::span<float>(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<MLHandle*>(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<MLHandle*>(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<MLHandle*>(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<MLHandle*>(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<MLHandle*>(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<MLHandle*>(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<MLHandle*>(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<MLHandle*>(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<MLHandle*>(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<int>(static_cast<MLHandle*>(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<int>(static_cast<MLHandle*>(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<MLHandle*>(ml);
std::span<const float> v = h->feedback.committed_output();
if (v.empty()) v = h->feedback.placed_output();
if (v.empty()) return 0;
const std::size_t n = (v.size() < kDefaultOutputs) ? v.size() : kDefaultOutputs;
std::memcpy(out, v.data(), n * sizeof(float));
return 1;
}
EMSCRIPTEN_KEEPALIVE EMSCRIPTEN_KEEPALIVE
void nisps_ml_get_layer_stats(void* ml, float* out_stats) { void nisps_ml_get_layer_stats(void* ml, float* out_stats) {
if (!ml || !out_stats) return; if (!ml || !out_stats) return;

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -13,7 +13,7 @@
set -euo pipefail set -euo pipefail
EMCC="${EMCC:-/usr/lib/emscripten/emcc}" EMCC="${EMCC:-$(command -v emcc || echo /usr/lib/emscripten/emcc)}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/playground/public" OUT="$ROOT/playground/public"
SRC="$ROOT/nisps/wasm/bindings.cpp" SRC="$ROOT/nisps/wasm/bindings.cpp"
@ -38,6 +38,15 @@ EXPORTED_FUNCS='[
"_nisps_ml_clear_examples","_nisps_ml_example_count", "_nisps_ml_clear_examples","_nisps_ml_example_count",
"_nisps_ml_weight_count","_nisps_ml_get_weights","_nisps_ml_set_weights", "_nisps_ml_weight_count","_nisps_ml_get_weights","_nisps_ml_set_weights",
"_nisps_ml_draw_weights","_nisps_ml_move_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_ml_get_layer_stats","_nisps_ml_describe",
"_nisps_engine_create","_nisps_engine_destroy", "_nisps_engine_create","_nisps_engine_destroy",
"_nisps_engine_set_params","_nisps_engine_process_block" "_nisps_engine_set_params","_nisps_engine_process_block"

View file

@ -57,6 +57,7 @@
#include "../../nisps/engines/channel_strip.hpp" #include "../../nisps/engines/channel_strip.hpp"
#include "../../nisps/engines/paf_synth.hpp" #include "../../nisps/engines/paf_synth.hpp"
#include "../../nisps/ml/feedback.hpp"
#include "../../nisps/ml/mlp.hpp" #include "../../nisps/ml/mlp.hpp"
namespace { namespace {
@ -82,7 +83,11 @@ constexpr std::array<std::size_t, 12u> kProbeIdx = {
}; };
constexpr std::uint32_t kMagic = 0x5450524Eu; // 'NPRT' 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<float>& v, std::span<const float> add) { void push_floats(std::vector<float>& v, std::span<const float> add) {
for (float f : add) v.push_back(f); 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"; const std::string out_path = (argc > 1) ? argv[1] : "parity_native.bin";
std::vector<float> payload; std::vector<float> 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 ---- // ---- Stage 1: ML inference at fixed input ----
ParityMLP mlp(kSeed); ParityMLP mlp(kSeed);
@ -190,6 +195,78 @@ int main(int argc, char** argv) {
payload.push_back(r_acc / static_cast<float>(kSynthFrames)); payload.push_back(r_acc / static_cast<float>(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<ParityMLP> fb(kSeed ^ kFeedbackSalt);
std::array<float, 126u> sbuf{};
const std::span<const float> no_out{};
const std::span<const std::uint8_t> 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<float>(sbuf));
push_floats(payload, std::span<const float>(sbuf.data(), 126u));
fb.on_down(mlp, no_out, 0.1f, 0.5f, no_mask); // re-roll
fb.static_output(std::span<float>(sbuf));
push_floats(payload, std::span<const float>(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 ---- // ---- Sanity: every value finite ----
for (std::size_t i = 0; i < payload.size(); ++i) { for (std::size_t i = 0; i < payload.size(); ++i) {
if (!std::isfinite(payload[i])) { if (!std::isfinite(payload[i])) {

View file

@ -16,7 +16,7 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
const MAGIC = 0x5450524e; const MAGIC = 0x5450524e;
const VERSION = 1; const VERSION = 3;
const DEFAULT_TOL = 1e-5; const DEFAULT_TOL = 1e-5;
// Layout for context in error messages — must match parity_check.cpp / parity_wasm.mjs. // 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: 'final_loss', count: 1 },
{ name: 'paf_synth_means', count: 2 }, { name: 'paf_synth_means', count: 2 },
{ name: 'channel_strip_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) { async function readBlob(path) {

View file

@ -38,7 +38,7 @@ const __dirname = dirname(__filename);
const repoRoot = resolve(__dirname, '..', '..'); const repoRoot = resolve(__dirname, '..', '..');
const MAGIC = 0x5450524e; // 'NPRT' const MAGIC = 0x5450524e; // 'NPRT'
const VERSION = 1; const VERSION = 3; // v3 adds stage 5d (ExploreAndPlace lifecycle)
const SEED = 42 >>> 0; const SEED = 42 >>> 0;
const INPUT_X = 0.25; const INPUT_X = 0.25;
@ -101,6 +101,17 @@ function bind(Module) {
getWeights: cwrap('nisps_ml_get_weights', null, ['number','number']), getWeights: cwrap('nisps_ml_get_weights', null, ['number','number']),
drawWeights: cwrap('nisps_ml_draw_weights', null, ['number','number']), drawWeights: cwrap('nisps_ml_draw_weights', null, ['number','number']),
moveWeights: cwrap('nisps_ml_move_weights', null, ['number','number','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']), describe: cwrap('nisps_ml_describe', null, ['number']),
engineCreate: cwrap('nisps_engine_create', 'number', ['string','number']), engineCreate: cwrap('nisps_engine_create', 'number', ['string','number']),
@ -235,7 +246,7 @@ async function main() {
api.process(ml); api.process(ml);
const outsStage2 = getOutputsCopy(api, ml, N_OUT); 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 --- // --- Stage 3: PAFSynth ---
// PAFSynth has 33 params per param_count() in nisps/engines/paf_synth.hpp. // 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) --- // --- Stage 4: ChannelStrip (24 params) ---
const [csL, csR] = runEngine(api, 'channel_strip', 24, 0.25, SYNTH_FRAMES); 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 --- // --- Build payload, write blob ---
const payload = []; const payload = [];
for (const v of outsStage1) payload.push(v); for (const v of outsStage1) payload.push(v);
@ -252,6 +329,7 @@ async function main() {
payload.push(finalLoss); payload.push(finalLoss);
payload.push(pafL, pafR); payload.push(pafL, pafR);
payload.push(csL, csR); payload.push(csL, csR);
for (const v of feedbackFloats) payload.push(v);
// Sanity: all finite. // Sanity: all finite.
for (let i = 0; i < payload.length; ++i) { for (let i = 0; i < payload.length; ++i) {

View file

@ -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 <array>
#include <cstddef>
#include <cstdint>
#include <span>
#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<SmallMLP>;
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<const float> kNoOut{};
const std::span<const std::uint8_t> kNoMask{};
std::array<float, kW> snapshot_weights(SmallMLP& m) {
std::array<float, kW> 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<float, kW>& a, const std::array<float, kW>& 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<float, kW>& 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<int>(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<float, kNOut> cur{};
for (auto& v : cur) v = 0.5f;
const auto before_w = snapshot_weights(m);
const FeedbackAction a =
fb.on_down(m, std::span<const float>(cur), 0.1f, 0.5f, kNoMask);
NISPS_EXPECT(a == FeedbackAction::EnterExplore);
NISPS_EXPECT(fb.exploring());
NISPS_EXPECT(fb.learning_paused());
std::array<float, kNOut> buf{};
const bool bypass = fb.static_output(std::span<float>(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<int>(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<std::uint8_t, kNOut> mask{1, 0, 1, 0, 1, 0};
fb.set_focus_mask(std::span<const std::uint8_t>(mask));
const std::array<float, kNOut> seed{0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f};
fb.on_down(m, std::span<const float>(seed), 0.1f, 0.5f, kNoMask); // enter + first roll
std::array<float, kNOut> a{};
fb.static_output(std::span<float>(a));
fb.on_down(m, kNoOut, 0.1f, 0.5f, kNoMask); // re-roll
std::array<float, kNOut> b{};
fb.static_output(std::span<float>(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<float, kNOut> cur{};
for (auto& v : cur) v = 0.5f;
fb.on_down(m, std::span<const float>(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<float, kNOut> buf{};
NISPS_EXPECT(!fb.static_output(std::span<float>(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<float, kNOut> cur{};
for (auto& v : cur) v = 0.25f;
a.on_down(m1, std::span<const float>(cur), 0.1f, 0.5f, kNoMask); // enter
b.on_down(m2, std::span<const float>(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<float, kNOut> ba{}, bb{};
a.static_output(std::span<float>(ba));
b.static_output(std::span<float>(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<float, kNOut> cur{};
for (auto& v : cur) v = 0.5f;
fb.on_down(m, std::span<const float>(cur), 0.1f, 0.5f, kNoMask);
std::array<float, kNOut> buf{};
fb.static_output(std::span<float>(buf));
int changed = 0;
for (std::size_t i = 0; i < kNOut; ++i) {
if (buf[i] != 0.5f) ++changed;
}
NISPS_EXPECT(changed == static_cast<int>(kNOut)); // all dims rolled
}
NISPS_TEST(feedback_static_output_bypass_only_in_randout) {
SmallMLP m(0ull);
std::array<float, kNOut> buf{};
FB avoid(0ull); // Avoid, not exploring
NISPS_EXPECT(!avoid.static_output(std::span<float>(buf)));
FB ro(0ull);
ro.set_mode(FeedbackMode::RandomiseOutputs, m); // mode set but NOT entered
NISPS_EXPECT(!ro.static_output(std::span<float>(buf)));
std::array<float, kNOut> cur{};
ro.on_down(m, std::span<const float>(cur), 0.1f, 0.5f, kNoMask); // now exploring
NISPS_EXPECT(ro.static_output(std::span<float>(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<float, kNOut> cur{};
for (auto& v : cur) v = 0.5f;
fb.on_down(m, std::span<const float>(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<float, kNOut> buf{};
NISPS_EXPECT(fb.static_output(std::span<float>(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<std::uint8_t, kNOut> mask{1, 0, 1, 0, 1, 0}; // freeze 1,3,5
fb.set_focus_mask(std::span<const std::uint8_t>(mask));
const std::array<float, kNOut> cur{0.11f, 0.22f, 0.33f, 0.44f, 0.55f, 0.66f};
fb.on_down(m, std::span<const float>(cur), 0.1f, 0.5f, kNoMask); // enter
std::array<float, kNOut> buf{};
fb.static_output(std::span<float>(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<float, kNOut> cur{}; // no mask → all dims rolled from the FB RNG
fb.on_down(m, std::span<const float>(cur), 0.1f, 0.5f, kNoMask); // enter
std::array<float, kNOut> buf{};
fb.static_output(std::span<float>(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<std::uint8_t, kNOut> mask{1, 0, 0, 0, 0, 0};
fb.set_focus_mask(std::span<const std::uint8_t>(mask));
fb.clear_focus_mask(); // back to all-active
std::array<float, kNOut> cur{};
for (auto& v : cur) v = 0.5f;
fb.on_down(m, std::span<const float>(cur), 0.1f, 0.5f, kNoMask);
std::array<float, kNOut> buf{};
fb.static_output(std::span<float>(buf));
int changed = 0;
for (std::size_t i = 0; i < kNOut; ++i) {
if (buf[i] != cur[i]) ++changed;
}
NISPS_EXPECT(changed == static_cast<int>(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<std::uint8_t, kNOut + 4> mask{};
for (auto& v : mask) v = 1;
fb.set_focus_mask(std::span<const std::uint8_t>(mask));
std::array<float, kNOut> cur{};
for (auto& v : cur) v = 0.5f;
fb.on_down(m, std::span<const float>(cur), 0.1f, 0.5f, kNoMask); // must not overflow
std::array<float, kNOut> buf{};
NISPS_EXPECT(fb.static_output(std::span<float>(buf)));
}
NISPS_TEST(feedback_short_static_buffer_clamps) {
SmallMLP m(0ull);
FB fb(0ull);
fb.set_mode(FeedbackMode::RandomiseOutputs, m);
std::array<float, kNOut> cur{};
fb.on_down(m, std::span<const float>(cur), 0.1f, 0.5f, kNoMask); // enter
// Caller passes a buffer SHORTER than kNOut — must fill only that many.
std::array<float, 3> small{-1.f, -1.f, -1.f};
NISPS_EXPECT(fb.static_output(std::span<float>(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<float, kNOut> 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<float, kNOut> buf{};
NISPS_EXPECT(fb.static_output(std::span<float>(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<float, kNOut> 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<std::array<float, 2>, 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<const float>(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