memlnaut-nisps/tests/cpp/ml_golden_vectors.cpp
monkey-w1n5t0n f57cddc278 fix(ml): port RMSProp — ported learning rates were landing in SGD
Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit
upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239
ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601,
StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the
difference as an optimiser-choice research question. It was not one.

RMSProp divides each step by the running gradient magnitude, so an upstream
lr is a NORMALISED step; under SGD the same number multiplies the raw
gradient. Every learning rate ported from upstream therefore landed in an
optimiser that reads it differently — most visibly feedback.hpp's
`geo_lr_ = 0.001f  // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted
into a single SGD step.

rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq +
0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The
adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative
lr used by train_targets' "train away from this target" path behaves as it
does upstream. The per-weight squared-gradient average is new persistent
state and lives in the storage policies (FixedStorage arrays /
DynamicStorage arena) so nisps/ stays allocation-free and the firmware's
zero-heap contract holds. It is optimiser state, not model state: excluded
from weight_count()/get_weights()/set_weights(), matching upstream, and
cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState).
draw_weights() deliberately does NOT clear it — upstream's DrawWeights
doesn't either.

Measured with tests/cpp/ml_bench.cpp:
  D1  one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x),
      and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10,
      0.56 at 100) instead of creeping linearly forever.
  A4  geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press.
  U4  the upstream-LR positive path actually trains now (range_util 0.71 at
      100 ticks/gesture, was 0.016 — it was inert under SGD).
Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose
asymmetry. lurch_max is still ~1.08 against a [0,1] output range.

Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training
and did not move, which is the cross-check that only the update rule
changed. manifold/public/nisps.wasm rebuilt so parity-check compares like
with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at
2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is
missing, never when it is stale; noted in MAP.md and filed separately.

ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser
cross-reference updated; new defect 6d for the positive-training dose.

Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp
clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00

285 lines
11 KiB
C++

// tests/cpp/ml_golden_vectors.cpp — fixed-seed regression test for the MLP.
//
// Why this exists
// ---------------
// The MLP class is deterministic given a fixed RNG seed. If anyone refactors
// the forward pass, weight init, or RL noise injection, the *exact* output
// vector for a known sequence of operations will change — and we want CI to
// catch that loudly.
//
// What this captures
// ------------------
// 1. After construction with seed=42 (which calls draw_weights(1.0) inside
// the constructor), we run inference at input (0.5, 0.5) and capture
// every output.
// 2. We call draw_weights(0.5) to re-randomise with mid-spread, infer, and
// capture again.
// 3. We add 4 simple input/label examples, train for 100 iterations at lr=0.5,
// infer, and capture.
// 4. We call move_weights(0.1, 0.3) (RL noise burst), infer, and capture.
//
// Test architecture
// -----------------
// MLP<2, 10, 10, 14, 33> — matches the firmware default for the PAF synth
// mode (33 outputs). Exact dimensions don't matter much; we just need a
// non-trivial output vector that exercises all four layers.
//
// Refreshing the golden file
// --------------------------
// Set NISPS_REGEN_GOLDEN=1 in the environment when running this binary. It
// will print the current outputs in a copy-pasteable C-array literal to stdout
// AND exit 0. Paste them into kExpectedStage[N] below and re-run with the env
// var unset to verify.
//
// Tolerance
// ---------
// 1e-5 absolute. The MLP is float32 throughout; the only nondeterminism is
// floating-point rounding order across optimization levels. We compile -O3 in
// CMake and -O2 + AVX in Emscripten; the parity test catches drift between
// builds. This test catches drift between commits.
#include <array>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include "test_helpers.hpp"
#include "../../nisps/ml/mlp.hpp"
namespace {
using TestMLP = nisps::ml::MLP<2u, 10u, 10u, 14u, 33u>;
constexpr std::uint64_t kSeed = 42u;
constexpr float kInputX = 0.5f;
constexpr float kInputY = 0.5f;
constexpr float kTol = 1.0e-5f;
// Golden vectors captured 2026-04-29 from a clean build of the worktree.
// Stages 2 and 3 RE-CAPTURED 2026-07-25 when the optimiser changed from SGD
// to RMSProp (nisps/ml/training.hpp — the port of upstream memlp Layer.h
// @ ea777502). That is a deliberate behaviour change, not a regression: an
// upstream-tuned learning rate is a normalised step under RMSProp and a raw
// gradient multiplier under SGD, so every ported hyperparameter was landing
// in the wrong optimiser. Stages 0 and 1 are pre-training and did NOT move,
// which is the cross-check that only the update rule changed.
// To regenerate: NISPS_REGEN_GOLDEN=1 ./nisps_golden_tests
//
// The arrays below are the post-process() output vectors at each stage
// described above. Stage 4 in particular is sensitive to RL noise generator
// state — ANY change to xoshiro256+ or the gaussian sum-of-three formula in
// nisps/core/rng.hpp will invalidate it.
//
// Stage 0: post-construction (default Xavier draw with spread=1, called from
// the MLP constructor), before any user-driven draw_weights or training.
constexpr std::array<float, 33u> kExpectedStage0 = {
0.49662992f, 0.49813405f, 0.50554955f, 0.49852926f, 0.49993742f,
0.49980220f, 0.49747804f, 0.50358790f, 0.49999994f, 0.50455135f,
0.49997640f, 0.50042748f, 0.49769798f, 0.50205874f, 0.50156462f,
0.50106966f, 0.49768052f, 0.49796993f, 0.49776515f, 0.50192314f,
0.50345314f, 0.50054342f, 0.50162661f, 0.50061351f, 0.50068146f,
0.50237012f, 0.50153363f, 0.49846420f, 0.49672276f, 0.49584076f,
0.49718165f, 0.49776685f, 0.49800035f,
};
// Stage 1: after explicit draw_weights(0.5) and re-inference.
constexpr std::array<float, 33u> kExpectedStage1 = {
0.51337469f, 0.50231707f, 0.49654010f, 0.50725782f, 0.49775314f,
0.50814718f, 0.50000459f, 0.50158256f, 0.51846194f, 0.51250720f,
0.51246792f, 0.49345547f, 0.50064278f, 0.51497459f, 0.48988324f,
0.50109828f, 0.49480906f, 0.51678216f, 0.50396103f, 0.48962030f,
0.50470036f, 0.50095022f, 0.49689421f, 0.50183755f, 0.50324529f,
0.48351043f, 0.50622481f, 0.50866264f, 0.50432122f, 0.50555164f,
0.50692219f, 0.49826777f, 0.50941539f,
};
// Stage 2: after add_example x4 and train(lr=0.5, max_iter=100).
constexpr std::array<float, 33u> kExpectedStage2 = {
0.66427404f, 0.62863928f, 0.63347638f, 0.68701935f, 0.62282497f,
0.59066784f, 0.57926202f, 0.61092430f, 0.75877625f, 0.71219701f,
0.76448023f, 0.64224732f, 0.67476881f, 0.75132567f, 0.60728127f,
0.69433212f, 0.66885883f, 0.75783861f, 0.70804310f, 0.65609163f,
0.69490194f, 0.71871793f, 0.70532608f, 0.78637725f, 0.78410172f,
0.65154189f, 0.75753516f, 0.78313172f, 0.77768368f, 0.69148761f,
0.74804193f, 0.76963025f, 0.77354264f,
};
// Stage 3: after move_weights(0.1, 0.3) and re-inference.
constexpr std::array<float, 33u> kExpectedStage3 = {
0.66872150f, 0.58160317f, 0.61222225f, 0.60297203f, 0.64177775f,
0.64809793f, 0.54033780f, 0.63073188f, 0.76591563f, 0.64906687f,
0.69643915f, 0.63856536f, 0.67001587f, 0.76458490f, 0.63405144f,
0.61661869f, 0.56021708f, 0.74601728f, 0.64519984f, 0.62616533f,
0.64741832f, 0.68293601f, 0.62627184f, 0.74945569f, 0.75258166f,
0.56667519f, 0.69179827f, 0.80808818f, 0.72934091f, 0.74348420f,
0.75629526f, 0.67320448f, 0.75111967f,
};
// Inference helper: set both inputs, run process(), copy outputs into a
// fixed-size array we can compare against the golden tables.
std::array<float, 33u> capture_outputs(TestMLP& mlp) {
mlp.set_input(0u, kInputX);
mlp.set_input(1u, kInputY);
mlp.process();
const auto outs = mlp.outputs();
std::array<float, 33u> result{};
for (std::size_t i = 0; i < 33u; ++i) result[i] = outs[i];
return result;
}
bool regen_mode() {
const char* env = std::getenv("NISPS_REGEN_GOLDEN");
return env && env[0] == '1';
}
void dump_array(const char* name, const std::array<float, 33u>& v) {
std::printf("constexpr std::array<float, 33u> %s = {\n ", name);
for (std::size_t i = 0; i < v.size(); ++i) {
std::printf("%.8ff%s", v[i], i + 1 == v.size() ? "" : ",");
if ((i + 1) % 5 == 0 && i + 1 != v.size()) std::printf("\n ");
else if (i + 1 != v.size()) std::printf(" ");
}
std::printf(",\n};\n\n");
}
void compare_or_fail(const char* stage,
const std::array<float, 33u>& got,
const std::array<float, 33u>& want) {
bool ok = true;
for (std::size_t i = 0; i < got.size(); ++i) {
if (std::fabs(got[i] - want[i]) > kTol) {
std::fprintf(stderr,
" golden mismatch at %s[%zu]: got %.8f, want %.8f, "
"delta=%.3e (tol=%.3e)\n",
stage, i, got[i], want[i],
std::fabs(got[i] - want[i]), kTol);
ok = false;
}
}
NISPS_EXPECT(ok);
}
} // namespace
NISPS_TEST(ml_golden_vectors_stage0_construction) {
TestMLP mlp(kSeed);
const auto got = capture_outputs(mlp);
if (regen_mode()) {
std::printf("// Regenerated golden vectors (NISPS_REGEN_GOLDEN=1):\n");
dump_array("kExpectedStage0", got);
return;
}
compare_or_fail("stage0_construction", got, kExpectedStage0);
}
NISPS_TEST(ml_golden_vectors_stage1_draw_weights) {
TestMLP mlp(kSeed);
mlp.draw_weights(0.5f);
const auto got = capture_outputs(mlp);
if (regen_mode()) {
dump_array("kExpectedStage1", got);
return;
}
compare_or_fail("stage1_draw_weights", got, kExpectedStage1);
}
NISPS_TEST(ml_golden_vectors_stage2_train) {
TestMLP mlp(kSeed);
mlp.draw_weights(0.5f);
// Four corners of the input space, each mapped to a distinctive constant
// output vector. Tiny dataset → SGD converges to a smooth interpolant.
constexpr std::array<std::array<float, 2u>, 4u> features = {{
{{0.0f, 0.0f}}, {{1.0f, 0.0f}}, {{0.0f, 1.0f}}, {{1.0f, 1.0f}},
}};
auto make_label = [](std::size_t i) {
std::array<float, 33u> out{};
// Three output-space "moods" per corner — a bit of structure rather
// than pure noise so the loss curve actually descends.
const float a = static_cast<float>(i) * 0.25f + 0.1f;
for (std::size_t j = 0; j < 33u; ++j) {
out[j] = a + 0.01f * static_cast<float>(j);
}
return out;
};
for (std::size_t i = 0; i < features.size(); ++i) {
const auto label = make_label(i);
mlp.add_example(std::span<const float>(features[i].data(), 2u),
std::span<const float>(label.data(), 33u));
}
const float final_loss = mlp.train(0.5f, 100u, 0.0f /* never early-out */);
NISPS_EXPECT(std::isfinite(final_loss));
NISPS_EXPECT(final_loss >= 0.0f);
const auto got = capture_outputs(mlp);
if (regen_mode()) {
dump_array("kExpectedStage2", got);
return;
}
compare_or_fail("stage2_train", got, kExpectedStage2);
}
NISPS_TEST(ml_golden_vectors_stage3_move_weights) {
TestMLP mlp(kSeed);
mlp.draw_weights(0.5f);
constexpr std::array<std::array<float, 2u>, 4u> features = {{
{{0.0f, 0.0f}}, {{1.0f, 0.0f}}, {{0.0f, 1.0f}}, {{1.0f, 1.0f}},
}};
auto make_label = [](std::size_t i) {
std::array<float, 33u> out{};
const float a = static_cast<float>(i) * 0.25f + 0.1f;
for (std::size_t j = 0; j < 33u; ++j) out[j] = a + 0.01f * static_cast<float>(j);
return out;
};
for (std::size_t i = 0; i < features.size(); ++i) {
const auto label = make_label(i);
mlp.add_example(std::span<const float>(features[i].data(), 2u),
std::span<const float>(label.data(), 33u));
}
(void)mlp.train(0.5f, 100u, 0.0f);
mlp.move_weights(0.1f, 0.3f);
const auto got = capture_outputs(mlp);
if (regen_mode()) {
dump_array("kExpectedStage3", got);
return;
}
compare_or_fail("stage3_move_weights", got, kExpectedStage3);
}
NISPS_TEST(ml_golden_vectors_seed_isolation) {
// Re-seeding to the same value MUST produce the same draw_weights output.
// This is the contract that makes the parity test possible.
TestMLP a(kSeed);
TestMLP b(kSeed);
a.draw_weights(0.5f);
b.draw_weights(0.5f);
const auto out_a = capture_outputs(a);
const auto out_b = capture_outputs(b);
for (std::size_t i = 0; i < 33u; ++i) {
NISPS_EXPECT_NEAR(out_a[i], out_b[i], 0.0f); // exact bitwise
}
}
NISPS_TEST(ml_golden_vectors_seed_changes_outputs) {
// Different seed → different outputs. Sanity check that the seed is
// actually being applied.
TestMLP a(kSeed);
TestMLP b(kSeed + 1ull);
a.draw_weights(0.5f);
b.draw_weights(0.5f);
const auto out_a = capture_outputs(a);
const auto out_b = capture_outputs(b);
bool any_diff = false;
for (std::size_t i = 0; i < 33u; ++i) {
if (std::fabs(out_a[i] - out_b[i]) > 1.0e-3f) any_diff = true;
}
NISPS_EXPECT(any_diff);
}