memlnaut-nisps/nisps/ml/ou_noise.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

101 lines
3.9 KiB
C++

// nisps/ml/ou_noise.hpp — Ornstein-Uhlenbeck exploration noise.
//
// Ported from upstream memllib InterfaceRL (commit d0d8a72 "noise",
// e291192), which adds a per-output Ornstein-Uhlenbeck random walk to the
// network's action vector. Unlike i.i.d. per-frame noise (which jitters),
// an OU process is temporally correlated: each output drifts in long, smooth
// sweeps and is gently pulled back toward the mapping output (mean reversion).
// Because learning stays active while the noise roams, "likes" registered
// during the wander steer the network toward sounds the player wants.
//
// Discrete Euler-Maruyama update, per output channel x:
// x += theta * (mu - x) * dt + noise_scale * N(0,1)
// out = clamp(out + x, 0, 1)
// where, to make the process's stationary standard deviation equal a
// requested `std`:
// sigma = std * sqrt(2 * theta) (continuous OU relation)
// noise_scale = sigma * sqrt(dt) = std * sqrt(2 * theta * dt)
//
// The exploration knob [0,1] maps to the stationary std via
// `set_intensity(level)` → std = level * kMaxAmplitude (0.65, upstream).
//
// DEFAULT STATE IS INERT: intensity defaults to 0, `enabled()` is false, and
// `apply()` neither advances the RNG nor touches the output — so a mode that
// never sets an intensity behaves bit-identically to one without OU at all
// (parity-safe). Owns a per-instance deterministic `Rng`.
#pragma once
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>
#include "../core/perf.hpp"
#include "../core/rng.hpp"
namespace nisps::ml {
// Upstream kMaxAmplitude: the exploration knob's full-scale stationary std in
// parameter space.
inline constexpr float kOUMaxAmplitude = 0.65f;
template <std::size_t N>
class OUNoise {
public:
explicit OUNoise(std::uint64_t seed) noexcept : rng_(seed) {
recompute_scale_();
}
// Exploration amount in [0,1]; 0 disables (inert). Maps to the OU
// stationary std = level * kMaxAmplitude.
void set_intensity(float level) noexcept {
if (level < 0.f) level = 0.f;
else if (level > 1.f) level = 1.f;
stationary_std_ = level * kOUMaxAmplitude;
recompute_scale_();
}
float intensity() const noexcept { return stationary_std_ / kOUMaxAmplitude; }
bool enabled() const noexcept { return stationary_std_ > 0.f; }
// OU smoothness controls (upstream defaults theta=0.02, dt=0.001).
void set_theta(float theta) noexcept { theta_ = theta; recompute_scale_(); }
void set_dt(float dt) noexcept { dt_ = dt; recompute_scale_(); }
// Advance the per-channel OU state and add it (clamped) to `out`. No-op
// when disabled. `out` is the post-inference parameter vector.
NISPS_HOT void apply(std::span<float> out) noexcept {
if (!enabled()) return;
const std::size_t n = out.size() < N ? out.size() : N;
for (std::size_t i = 0u; i < n; ++i) {
// mu = 0: the walk is an offset that mean-reverts to zero, so the
// network's own mapping output stays the anchor.
state_[i] += theta_ * (-state_[i]) * dt_ +
noise_scale_ * rng_.next_float_gaussian(1.f);
float v = out[i] + state_[i];
if (v < 0.f) v = 0.f;
else if (v > 1.f) v = 1.f;
out[i] = v;
}
}
void reset() noexcept { state_.fill(0.f); }
void seed(std::uint64_t s) noexcept { rng_.seed(s); }
private:
NISPS_FORCE_INLINE void recompute_scale_() noexcept {
float k = 2.f * theta_ * dt_;
if (k < 0.f) k = 0.f;
noise_scale_ = stationary_std_ * std::sqrt(k);
}
Rng rng_;
std::array<float, N> state_{};
float theta_ = 0.02f;
float dt_ = 0.001f;
float stationary_std_ = 0.f;
float noise_scale_ = 0.f;
};
} // namespace nisps::ml