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:
parent
24057e7b5f
commit
22efb1c411
10 changed files with 1573 additions and 9 deletions
|
|
@ -56,6 +56,7 @@ if(NOT EMSCRIPTEN)
|
|||
${NISPS_TEST_DIR}/test_mlp_training.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_loss.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_rl.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_feedback.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_serialize.cpp
|
||||
)
|
||||
target_link_libraries(nisps_core_tests PRIVATE nisps_core)
|
||||
|
|
|
|||
461
nisps/ml/feedback.hpp
Normal file
461
nisps/ml/feedback.hpp
Normal 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
|
||||
|
|
@ -65,6 +65,7 @@
|
|||
|
||||
// ML.
|
||||
#include "../core/types.hpp"
|
||||
#include "../ml/feedback.hpp"
|
||||
#include "../ml/mlp.hpp"
|
||||
#include "../ml/stats.hpp"
|
||||
|
||||
|
|
@ -93,11 +94,17 @@ constexpr std::size_t kDefaultOutputs = DefaultMLP::kOutput;
|
|||
// the opaque pointer to JS.
|
||||
struct MLHandle {
|
||||
DefaultMLP mlp;
|
||||
// "Down Action" negative-feedback controller (Avoid/RandomiseOutputs/
|
||||
// RandomiseMlp). Seeded off the MLP seed XOR a salt so its static-output
|
||||
// RNG stream is independent of the MLP's inference/move RNG.
|
||||
nisps::ml::FeedbackController<DefaultMLP> feedback;
|
||||
// Buffers used to bridge JS → C++:
|
||||
std::array<float, kDefaultInputs> input_scratch{};
|
||||
std::array<float, kDefaultOutputs> output_scratch{};
|
||||
// Stats buffer fed back to JS via get_layer_stats.
|
||||
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
|
||||
// request from the heatmap. 256x256 = 65536 max points → too many in
|
||||
// practice. We cap batch size at 4096 here; callers must split larger
|
||||
|
|
@ -105,7 +112,8 @@ struct MLHandle {
|
|||
static constexpr std::size_t kMaxBatch = 4096u;
|
||||
std::array<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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
void nisps_ml_get_layer_stats(void* ml, float* out_stats) {
|
||||
if (!ml || !out_stats) return;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
set -euo pipefail
|
||||
|
||||
EMCC="${EMCC:-/usr/lib/emscripten/emcc}"
|
||||
EMCC="${EMCC:-$(command -v emcc || echo /usr/lib/emscripten/emcc)}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT="$ROOT/playground/public"
|
||||
SRC="$ROOT/nisps/wasm/bindings.cpp"
|
||||
|
|
@ -38,6 +38,15 @@ EXPORTED_FUNCS='[
|
|||
"_nisps_ml_clear_examples","_nisps_ml_example_count",
|
||||
"_nisps_ml_weight_count","_nisps_ml_get_weights","_nisps_ml_set_weights",
|
||||
"_nisps_ml_draw_weights","_nisps_ml_move_weights",
|
||||
"_nisps_ml_feedback_set_mode","_nisps_ml_feedback_get_mode",
|
||||
"_nisps_ml_feedback_exploring","_nisps_ml_feedback_learning_paused",
|
||||
"_nisps_ml_feedback_set_focus","_nisps_ml_feedback_down",
|
||||
"_nisps_ml_feedback_up","_nisps_ml_feedback_drag","_nisps_ml_feedback_static_output",
|
||||
"_nisps_ml_feedback_enter_explore","_nisps_ml_feedback_exit_explore",
|
||||
"_nisps_ml_feedback_reroll","_nisps_ml_feedback_nudge","_nisps_ml_feedback_undo",
|
||||
"_nisps_ml_feedback_like","_nisps_ml_feedback_commit_place","_nisps_ml_feedback_cancel_place",
|
||||
"_nisps_ml_feedback_placing","_nisps_ml_feedback_state","_nisps_ml_feedback_undo_depth",
|
||||
"_nisps_ml_feedback_placed_output",
|
||||
"_nisps_ml_get_layer_stats","_nisps_ml_describe",
|
||||
"_nisps_engine_create","_nisps_engine_destroy",
|
||||
"_nisps_engine_set_params","_nisps_engine_process_block"
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
|
||||
#include "../../nisps/engines/channel_strip.hpp"
|
||||
#include "../../nisps/engines/paf_synth.hpp"
|
||||
#include "../../nisps/ml/feedback.hpp"
|
||||
#include "../../nisps/ml/mlp.hpp"
|
||||
|
||||
namespace {
|
||||
|
|
@ -82,7 +83,11 @@ constexpr std::array<std::size_t, 12u> kProbeIdx = {
|
|||
};
|
||||
|
||||
constexpr std::uint32_t kMagic = 0x5450524Eu; // 'NPRT'
|
||||
constexpr std::uint32_t kVersion = 1u;
|
||||
constexpr std::uint32_t kVersion = 3u; // v3 adds stage 5d (ExploreAndPlace lifecycle)
|
||||
|
||||
// Must match the salt in nisps/wasm/bindings.cpp MLHandle so the controller's
|
||||
// static-output RNG stream is identical native ↔ WASM.
|
||||
constexpr std::uint64_t kFeedbackSalt = 0xFEEDBACC0DEull;
|
||||
|
||||
void push_floats(std::vector<float>& v, std::span<const float> add) {
|
||||
for (float f : add) v.push_back(f);
|
||||
|
|
@ -106,7 +111,7 @@ int main(int argc, char** argv) {
|
|||
const std::string out_path = (argc > 1) ? argv[1] : "parity_native.bin";
|
||||
|
||||
std::vector<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 ----
|
||||
ParityMLP mlp(kSeed);
|
||||
|
|
@ -190,6 +195,78 @@ int main(int argc, char** argv) {
|
|||
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 ----
|
||||
for (std::size_t i = 0; i < payload.size(); ++i) {
|
||||
if (!std::isfinite(payload[i])) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const MAGIC = 0x5450524e;
|
||||
const VERSION = 1;
|
||||
const VERSION = 3;
|
||||
const DEFAULT_TOL = 1e-5;
|
||||
|
||||
// Layout for context in error messages — must match parity_check.cpp / parity_wasm.mjs.
|
||||
|
|
@ -27,6 +27,19 @@ const SECTIONS = [
|
|||
{ name: 'final_loss', count: 1 },
|
||||
{ name: 'paf_synth_means', count: 2 },
|
||||
{ name: 'channel_strip_means', count: 2 },
|
||||
// Stage 5 (feedback): 126 enter-static + 126 reroll-static + 12 temp-net
|
||||
// weight probes + 12 restored-net weight probes.
|
||||
{ name: 'feedback_randout_enter', count: 126 },
|
||||
{ name: 'feedback_randout_reroll', count: 126 },
|
||||
{ name: 'feedback_randmlp_temp', count: 12 },
|
||||
{ name: 'feedback_randmlp_restored', count: 12 },
|
||||
// Stage 5d (ExploreAndPlace): 12 scratchpad-net probes (post enter/reroll/
|
||||
// nudge) + 126 frozen placed output + 12 restored-net probes + 126 committed
|
||||
// output.
|
||||
{ name: 'feedback_ep_scratch', count: 12 },
|
||||
{ name: 'feedback_ep_placed', count: 126 },
|
||||
{ name: 'feedback_ep_restored', count: 12 },
|
||||
{ name: 'feedback_ep_committed', count: 126 },
|
||||
];
|
||||
|
||||
async function readBlob(path) {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const __dirname = dirname(__filename);
|
|||
const repoRoot = resolve(__dirname, '..', '..');
|
||||
|
||||
const MAGIC = 0x5450524e; // 'NPRT'
|
||||
const VERSION = 1;
|
||||
const VERSION = 3; // v3 adds stage 5d (ExploreAndPlace lifecycle)
|
||||
|
||||
const SEED = 42 >>> 0;
|
||||
const INPUT_X = 0.25;
|
||||
|
|
@ -101,6 +101,17 @@ function bind(Module) {
|
|||
getWeights: cwrap('nisps_ml_get_weights', null, ['number','number']),
|
||||
drawWeights: cwrap('nisps_ml_draw_weights', null, ['number','number']),
|
||||
moveWeights: cwrap('nisps_ml_move_weights', null, ['number','number','number','number']),
|
||||
feedbackSetMode: cwrap('nisps_ml_feedback_set_mode', null, ['number','number']),
|
||||
feedbackDown: cwrap('nisps_ml_feedback_down', 'number', ['number','number','number','number','number']),
|
||||
feedbackUp: cwrap('nisps_ml_feedback_up', 'number', ['number']),
|
||||
feedbackStaticOutput: cwrap('nisps_ml_feedback_static_output', 'number', ['number','number']),
|
||||
feedbackEnterExplore: cwrap('nisps_ml_feedback_enter_explore', null, ['number','number']),
|
||||
feedbackReroll: cwrap('nisps_ml_feedback_reroll', null, ['number','number']),
|
||||
feedbackNudge: cwrap('nisps_ml_feedback_nudge', null, ['number','number']),
|
||||
feedbackUndo: cwrap('nisps_ml_feedback_undo', null, ['number']),
|
||||
feedbackLike: cwrap('nisps_ml_feedback_like', null, ['number']),
|
||||
feedbackCommitPlace: cwrap('nisps_ml_feedback_commit_place', null, ['number']),
|
||||
feedbackPlacedOutput: cwrap('nisps_ml_feedback_placed_output', 'number', ['number','number']),
|
||||
describe: cwrap('nisps_ml_describe', null, ['number']),
|
||||
|
||||
engineCreate: cwrap('nisps_engine_create', 'number', ['string','number']),
|
||||
|
|
@ -235,7 +246,7 @@ async function main() {
|
|||
api.process(ml);
|
||||
const outsStage2 = getOutputsCopy(api, ml, N_OUT);
|
||||
|
||||
api.destroy(ml);
|
||||
// (ml stays alive through stage 5 below; destroyed after the feedback stage.)
|
||||
|
||||
// --- Stage 3: PAFSynth ---
|
||||
// PAFSynth has 33 params per param_count() in nisps/engines/paf_synth.hpp.
|
||||
|
|
@ -244,6 +255,72 @@ async function main() {
|
|||
// --- Stage 4: ChannelStrip (24 params) ---
|
||||
const [csL, csR] = runEngine(api, 'channel_strip', 24, 0.25, SYNTH_FRAMES);
|
||||
|
||||
// --- Stage 5: feedback ("Down Action": RandomiseOutputs + RandomiseMlp) ---
|
||||
// Mirrors parity_check.cpp stage 5. The controller is seeded inside the WASM
|
||||
// MLHandle as (seed XOR salt), matching the native side. ml is untouched by
|
||||
// stages 3-4, so its RNG state here equals post-stage-2.
|
||||
const FB_RANDOUT = 1;
|
||||
const FB_RANDMLP = 2;
|
||||
const feedbackFloats = [];
|
||||
const fbBuf = api.malloc(N_OUT * 4);
|
||||
api.feedbackSetMode(ml, FB_RANDOUT);
|
||||
api.feedbackDown(ml, 0, 0.1, 0.5, 0); // enter
|
||||
api.feedbackStaticOutput(ml, fbBuf);
|
||||
for (const v of new Float32Array(api.HEAPF32.buffer, fbBuf, N_OUT)) feedbackFloats.push(v);
|
||||
api.feedbackDown(ml, 0, 0.1, 0.5, 0); // re-roll
|
||||
api.feedbackStaticOutput(ml, fbBuf);
|
||||
for (const v of new Float32Array(api.HEAPF32.buffer, fbBuf, N_OUT)) feedbackFloats.push(v);
|
||||
api.free(fbBuf);
|
||||
api.feedbackUp(ml); // commit (no weight change)
|
||||
|
||||
api.feedbackSetMode(ml, FB_RANDMLP);
|
||||
api.feedbackDown(ml, 0, 0.1, 0.5, 0); // enter → randomise temp net
|
||||
{
|
||||
const tempW = getWeightsCopy(api, ml);
|
||||
for (const idx of PROBE_IDX) feedbackFloats.push(idx < tempW.length ? tempW[idx] : 0);
|
||||
}
|
||||
api.feedbackUp(ml); // commit → restore original net
|
||||
{
|
||||
const restoredW = getWeightsCopy(api, ml);
|
||||
for (const idx of PROBE_IDX) feedbackFloats.push(idx < restoredW.length ? restoredW[idx] : 0);
|
||||
}
|
||||
|
||||
// --- Stage 5d: ExploreAndPlace lifecycle ---
|
||||
// Reuses the single MLHandle.feedback controller (mode → ExploreAndPlace) so
|
||||
// its RNG state matches native `fb` (both drained identical RandomiseOutputs
|
||||
// draws). enter → reroll → nudge → undo → place → commit.
|
||||
const FB_EXPLORE_PLACE = 3;
|
||||
api.feedbackSetMode(ml, FB_EXPLORE_PLACE);
|
||||
api.feedbackEnterExplore(ml, 0.5); // snapshot + randomise scratchpad
|
||||
api.feedbackReroll(ml, 0.5); // scratchpad op
|
||||
api.feedbackNudge(ml, 0.05); // controller-Rng perturb
|
||||
{
|
||||
const scratchW = getWeightsCopy(api, ml);
|
||||
for (const idx of PROBE_IDX) feedbackFloats.push(idx < scratchW.length ? scratchW[idx] : 0);
|
||||
}
|
||||
api.feedbackUndo(ml); // pop nudge
|
||||
api.setInput(ml, 0, INPUT_X);
|
||||
api.setInput(ml, 1, INPUT_Y);
|
||||
api.process(ml);
|
||||
api.feedbackLike(ml); // begin place: freeze scratchpad output
|
||||
{
|
||||
const placedBuf = api.malloc(N_OUT * 4);
|
||||
api.feedbackPlacedOutput(ml, placedBuf);
|
||||
for (const v of new Float32Array(api.HEAPF32.buffer, placedBuf, N_OUT)) feedbackFloats.push(v);
|
||||
api.free(placedBuf);
|
||||
}
|
||||
api.feedbackCommitPlace(ml); // restore real net
|
||||
{
|
||||
const restoredW = getWeightsCopy(api, ml);
|
||||
for (const idx of PROBE_IDX) feedbackFloats.push(idx < restoredW.length ? restoredW[idx] : 0);
|
||||
const committedBuf = api.malloc(N_OUT * 4);
|
||||
api.feedbackPlacedOutput(ml, committedBuf);
|
||||
for (const v of new Float32Array(api.HEAPF32.buffer, committedBuf, N_OUT)) feedbackFloats.push(v);
|
||||
api.free(committedBuf);
|
||||
}
|
||||
|
||||
api.destroy(ml);
|
||||
|
||||
// --- Build payload, write blob ---
|
||||
const payload = [];
|
||||
for (const v of outsStage1) payload.push(v);
|
||||
|
|
@ -252,6 +329,7 @@ async function main() {
|
|||
payload.push(finalLoss);
|
||||
payload.push(pafL, pafR);
|
||||
payload.push(csL, csR);
|
||||
for (const v of feedbackFloats) payload.push(v);
|
||||
|
||||
// Sanity: all finite.
|
||||
for (let i = 0; i < payload.length; ++i) {
|
||||
|
|
|
|||
685
tests/cpp/test_mlp_feedback.cpp
Normal file
685
tests/cpp/test_mlp_feedback.cpp
Normal 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
|
||||
Loading…
Reference in a new issue