Commit graph

3 commits

Author SHA1 Message Date
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
monkey-w1n5t0n
c936bf75c9 docs(ergo): update task guidance 2026-07-13 22:58:26 +02:00
w1n5t0n
825ed6ad33 feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh)
Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a
header-only, heap-free MLP that satisfies nisps::core::MLEngine.

Files (nisps/ml/):
- activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh
- loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the
  sample's MSE without an extra 1/N multiplication; the training loop
  averages explicitly)
- init.hpp — uniform/Xavier/spread-aware weight init
- training.hpp — gradient clip helper (±10.0 matches legacy)
- rl.hpp — move_weights with per-layer Xavier scaling, weight decay
  (10% * spread), gaussian noise via the deterministic Rng (matches the
  legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware
- stats.hpp — per-layer mean/max/dead/saturating diagnostics
- mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with
  std::array-backed weights, biases, gradient accumulators, dataset
  ring buffer (default 128 examples), loss history (default 4096 iters).
  Bias is a separate per-layer parameter — no input-vector mutation.
  Flat get_weights/set_weights layout: weights all layers (row-major,
  layer order), then biases all layers.

Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic):
- test_mlp_init.cpp — deterministic seeding, spread regimes,
  static_assert MLEngine concept satisfied
- test_mlp_inference.cpp — golden hand-computed forward pass match,
  sigmoid output range, set_input bounds
- test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters),
  ring-buffer eviction
- test_mlp_loss.cpp — meml-ues regression test: reported loss equals
  hand-computed average MSE without extra 1/N scaling; sample weights
  honoured
- test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer
  rows + biases preserved); spread regimes; grad clear after draw_weights
- test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves
  inference exactly; eval_loss is non-mutating; infer_batch matches
  individual inference

Verification:
- Clean build, no warnings
- 50 tests pass (22 prior + 28 new)
- No std::vector / new / malloc in nisps/ml/
- All float literals .f-suffixed in code (comments excepted)
2026-04-29 15:55:43 +03:00