memlnaut-nisps/tests/cpp/test_mlp_init.cpp
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

98 lines
2.8 KiB
C++

// tests/cpp/test_mlp_init.cpp — exercises MLP construction, deterministic
// seeding, the spread parameter, and the static_assert that the class
// satisfies the MLEngine concept.
#include "test_helpers.hpp"
#include "../../nisps/core/concepts.hpp"
#include "../../nisps/ml/mlp.hpp"
namespace {
// Compact alias used across the test suite.
using SmallMLP = nisps::ml::MLP<2, 8, 8, 8, 4, 16, 64>;
// Hard ground-truth: the class satisfies MLEngine. Compile-time check.
static_assert(nisps::MLEngine<SmallMLP>,
"MLP<...> must satisfy nisps::MLEngine concept");
NISPS_TEST(mlp_same_seed_same_init_weights) {
SmallMLP a(123ull);
SmallMLP b(123ull);
auto wa = a.get_weights();
auto wb = b.get_weights();
NISPS_EXPECT(wa.size() == wb.size());
for (std::size_t i = 0; i < wa.size(); ++i) {
NISPS_EXPECT(wa[i] == wb[i]);
}
}
NISPS_TEST(mlp_diff_seed_diff_init_weights) {
SmallMLP a(1ull);
SmallMLP b(2ull);
auto wa = a.get_weights();
auto wb = b.get_weights();
int distinct = 0;
for (std::size_t i = 0; i < wa.size(); ++i) {
if (wa[i] != wb[i]) ++distinct;
}
// With ~200+ weights, almost all should differ.
NISPS_EXPECT(distinct > static_cast<int>(wa.size()) / 2);
}
NISPS_TEST(mlp_seed_method_resets_rng) {
SmallMLP a(5ull);
a.seed(42ull);
a.draw_weights(0.5f);
SmallMLP b(99ull);
b.seed(42ull);
b.draw_weights(0.5f);
auto wa = a.get_weights();
auto wb = b.get_weights();
for (std::size_t i = 0; i < wa.size(); ++i) {
NISPS_EXPECT(wa[i] == wb[i]);
}
}
NISPS_TEST(mlp_weight_count_matches_topology) {
using M = nisps::ml::MLP<3, 10, 10, 14, 126>;
// Layer fan_in*fan_out: 3*10 + 10*10 + 10*14 + 14*126 = 30+100+140+1764 = 2034
// Biases: 10+10+14+126 = 160
// Total: 2194
NISPS_EXPECT(M::weight_count() == 2194u);
}
NISPS_TEST(mlp_spread_zero_uniform_in_minus_one_one_range) {
// spread=0 → weights drawn from U[-1,1]. The max |w| should be ≤ 1.
SmallMLP m(7ull);
m.draw_weights(0.f);
auto w = m.get_weights();
float maxabs = 0.f;
for (float v : w) {
const float a = v >= 0.f ? v : -v;
if (a > maxabs) maxabs = a;
}
NISPS_EXPECT(maxabs <= 1.f);
}
NISPS_TEST(mlp_spread_one_xavier_smaller_range) {
// spread=1 → weights scaled by 1/sqrt(fan_in). For fan_in≥2, weights
// should be strictly smaller in magnitude than the spread=0 case.
SmallMLP m(7ull);
m.draw_weights(1.f);
auto w = m.get_weights();
float maxabs = 0.f;
for (float v : w) {
const float a = v >= 0.f ? v : -v;
if (a > maxabs) maxabs = a;
}
// 1/sqrt(2) ≈ 0.707 — the smallest fan_in is 2 (input). So max possible
// is ≈ 0.707 (drawn from rng_signed, which is < 1).
NISPS_EXPECT(maxabs < 1.f);
}
} // namespace