memlnaut-nisps/nisps/ml/jolt.hpp
monkey-w1n5t0n 4e60d0192a feat(slp-workshop): new MEMLCelium-based mode + port Jolt & OU-noise RL learning
New SLP-Workshop firmware variant (Synth Library Portland), built on the
MEMLCelium engine + MLP shape. Ports the two post-fork learning-algorithm
changes from upstream memllib InterfaceRL into the shared nisps/ml core,
runtime-configurable (no compile-time switch), inert by default:

- nisps/ml/jolt.hpp: Jolt — held continuous weight morph over the flat
  weight buffer + post-release LR ramp (kJolt* constants verbatim).
- nisps/ml/ou_noise.hpp: OUNoise<N> — Ornstein-Uhlenbeck exploration walk
  on the output vector (theta=0.02, dt=0.001, kMaxAmplitude=0.65).

Both wired into ModeBase so every mode gains jolt_press/jolt_release/
jolt_lr_scale + set_explore_intensity; gated so existing modes stay
bit-identical (parity + golden tests green). Firmware surfaces them on
TogB1 (Jolt) and RVX1 (explore). New SLPWorkshopMode mode + schema +
codegen; firmware alias + .ino variant; playground mode registration.

Tests: jolt + OU unit tests, ModeBase learning integration incl. an
inert-parity test proving SLP-Workshop == MEMLCelium with features off.
Verified: cpp tests, wasm build, native↔wasm parity, lint, codegen
golden, playground typecheck. Firmware compile/e2e/hardware are
environment-bound (no arduino-cli/submodules/browser here).

Refs ergo 019f0fca.
2026-06-28 22:15:36 +02:00

131 lines
5.1 KiB
C++

// nisps/ml/jolt.hpp — "Jolt": held-button continuous weight morph.
//
// Ported from upstream memllib InterfaceRL (commit 9fcd459 "jolts",
// e291192). While a button/pedal is held, pick a handful of random weights
// scattered across the whole network and EMA-glide each toward a bounded
// random target; on arrival, re-roll the target so the motion never stops.
// Releasing freezes the weights where they landed (the change is permanent),
// then ramps the effective learning rate back from 0 → 1 over ~5 s so any
// subsequent training eases in instead of yanking the net off the jolted
// sound.
//
// This operates on the MLP's FLAT weight buffer (mlp.hpp `get_weights()` /
// `set_weights()` / `weight_count()`), so it is architecture-agnostic — it
// does not care how many layers or how wide. It owns a per-instance
// deterministic `Rng` (seeded by the caller) so firmware ↔ browser parity
// holds.
//
// DEFAULT STATE IS INERT: a freshly constructed Jolt is inactive, `step()`
// is a no-op, and `lr_scale()` returns 1.0 (full LR). Nothing perturbs the
// network until `press()` is called.
//
// Upstream constants (InterfaceRL.hpp kJolt*) are reproduced verbatim in
// JoltParams defaults.
#pragma once
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>
#include "../core/perf.hpp"
#include "../core/rng.hpp"
namespace nisps::ml {
// Upper bound on simultaneously-morphed weights; sizes the index/target
// arrays so the class stays heap-free. Upstream uses 40.
inline constexpr std::size_t kJoltMaxWeights = 64u;
struct JoltParams {
std::size_t num_weights = 40u; // kJoltNumWeights
float morph_rate = 0.017f; // kJoltMorphRate (EMA per tick, ~1s @200Hz)
float target_min = -1.2f; // kJoltWeightMin (== weight-init range)
float target_max = 0.9f; // kJoltWeightMax
float target_epsilon = 0.05f; // kJoltTargetEpsilon (re-roll within this)
float lr_ramp_step = 0.001f; // kJoltLRRampStep (1/(5s*200Hz))
};
class Jolt {
public:
explicit Jolt(std::uint64_t seed) noexcept : rng_(seed) {}
void set_params(const JoltParams& p) noexcept { params_ = p; }
const JoltParams& params() const noexcept { return params_; }
bool active() const noexcept { return active_; }
// Effective-learning-rate multiplier for the caller's training step:
// 0 while the jolt is held, then ramps 0 → 1 after release. Callers that
// train (firmware optimise / playground continuous trainer) should
// multiply their LR by this. Pure-example modes that only train on an
// explicit gesture may ignore it.
float lr_scale() const noexcept { return active_ ? 0.f : lr_ramp_; }
// Begin a jolt over a flat weight buffer of `weight_count` entries: pick
// `num_weights` random global indices and a bounded random target each.
void press(std::size_t weight_count) noexcept {
active_ = true;
lr_ramp_ = 0.f;
n_ = params_.num_weights;
if (n_ > kJoltMaxWeights) n_ = kJoltMaxWeights;
if (weight_count == 0u) { n_ = 0u; return; }
for (std::size_t i = 0u; i < n_; ++i) {
idx_[i] = static_cast<std::size_t>(rng_.next_u64() % weight_count);
target_[i] = roll_target_();
}
}
// Per control tick while held: EMA-glide each selected weight toward its
// target, re-rolling targets that have been reached. No-op when inactive.
NISPS_HOT void step(std::span<float> weights) noexcept {
if (!active_) return;
const std::size_t wc = weights.size();
for (std::size_t i = 0u; i < n_; ++i) {
const std::size_t k = idx_[i];
if (k >= wc) continue;
float w = weights[k];
w += params_.morph_rate * (target_[i] - w);
weights[k] = w;
if (std::fabs(target_[i] - w) < params_.target_epsilon) {
target_[i] = roll_target_();
}
}
}
// Release: freeze weights where they are (permanent) and re-arm the LR
// ramp from 0.
void release() noexcept {
active_ = false;
lr_ramp_ = 0.f;
}
// Advance the post-release LR ramp toward full. Call once per control
// tick; a no-op while active or already ramped.
void tick_lr_ramp() noexcept {
if (active_ || lr_ramp_ >= 1.f) return;
lr_ramp_ += params_.lr_ramp_step;
if (lr_ramp_ > 1.f) lr_ramp_ = 1.f;
}
// Re-seed the internal RNG (parity / reset).
void seed(std::uint64_t s) noexcept { rng_.seed(s); }
private:
NISPS_FORCE_INLINE float roll_target_() noexcept {
return params_.target_min +
rng_.next_float_uniform() * (params_.target_max - params_.target_min);
}
JoltParams params_{};
Rng rng_;
bool active_ = false;
float lr_ramp_ = 1.f;
std::size_t n_ = 0u;
std::array<std::size_t, kJoltMaxWeights> idx_{};
std::array<float, kJoltMaxWeights> target_{};
};
} // namespace nisps::ml