memlnaut-nisps/tests/cpp/test_mlp_serialize.cpp

147 lines
4.4 KiB
C++
Raw Permalink Normal View History

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 14:55:43 +02:00
// tests/cpp/test_mlp_serialize.cpp — round-trip get_weights → set_weights.
//
// Serializing weights, restoring them in a fresh MLP, and running inference
// must produce bit-identical outputs. This is the gate for cross-platform
// state transfer (firmware ↔ browser).
#include <array>
#include <vector>
#include "test_helpers.hpp"
#include "../../nisps/ml/mlp.hpp"
namespace {
using TestMLP = nisps::ml::MLP<3, 8, 8, 10, 5, 16, 32>;
NISPS_TEST(mlp_get_set_weights_roundtrip_preserves_inference) {
TestMLP a(123ull);
a.draw_weights(0.4f);
// Snapshot weights.
auto w = a.get_weights();
std::vector<float> snap(w.begin(), w.end());
TestMLP b(0ull); // different seed → different starting weights
b.set_weights(std::span<const float>(snap.data(), snap.size()));
// Inference on identical input should produce identical outputs.
const float in[3] = {0.2f, -0.3f, 0.5f};
for (std::size_t i = 0; i < 3u; ++i) {
a.set_input(i, in[i]);
b.set_input(i, in[i]);
}
a.process();
b.process();
auto oa = a.outputs();
auto ob = b.outputs();
NISPS_EXPECT(oa.size() == ob.size());
for (std::size_t i = 0; i < oa.size(); ++i) {
NISPS_EXPECT(oa[i] == ob[i]);
}
}
NISPS_TEST(mlp_weight_count_matches_get_weights_size) {
TestMLP m(0ull);
auto w = m.get_weights();
NISPS_EXPECT(w.size() == TestMLP::weight_count());
}
NISPS_TEST(mlp_get_weights_contains_layer_concatenation) {
// Verify the documented flat layout: weights all layers, then biases
// all layers. We do this by setting a known pattern via set_weights
// and reading it back.
TestMLP m(0ull);
constexpr std::size_t WC = TestMLP::weight_count();
std::vector<float> pattern(WC);
for (std::size_t i = 0; i < WC; ++i) {
pattern[i] = static_cast<float>(i) * 0.001f - 0.5f;
}
m.set_weights(std::span<const float>(pattern.data(), pattern.size()));
auto w = m.get_weights();
NISPS_EXPECT(w.size() == WC);
for (std::size_t i = 0; i < WC; ++i) {
NISPS_EXPECT_NEAR(w[i], pattern[i], 1e-7);
}
}
NISPS_TEST(mlp_set_weights_too_short_is_ignored) {
TestMLP m(7ull);
auto before = m.get_weights();
std::vector<float> snap(before.begin(), before.end());
// Pass a too-short buffer.
std::array<float, 2> tiny{1.f, 2.f};
m.set_weights(std::span<const float>(tiny));
auto after = m.get_weights();
// Weights should be unchanged.
for (std::size_t i = 0; i < snap.size(); ++i) {
NISPS_EXPECT(snap[i] == after[i]);
}
}
NISPS_TEST(mlp_eval_loss_does_not_modify_state) {
TestMLP m(5ull);
m.draw_weights(0.5f);
std::array<float, 3> f{0.1f, 0.2f, 0.3f};
std::array<float, 5> l{0.5f, 0.5f, 0.5f, 0.5f, 0.5f};
m.add_example(std::span<const float>(f), std::span<const float>(l));
// Snapshot weights.
auto w_before = m.get_weights();
std::vector<float> snap(w_before.begin(), w_before.end());
const float L = m.eval_loss();
NISPS_EXPECT(L >= 0.f);
auto w_after = m.get_weights();
for (std::size_t i = 0; i < snap.size(); ++i) {
NISPS_EXPECT(snap[i] == w_after[i]);
}
}
NISPS_TEST(mlp_infer_batch_matches_individual_inference) {
TestMLP m(42ull);
m.draw_weights(0.5f);
constexpr std::size_t N = 5;
constexpr std::size_t NI = 3;
constexpr std::size_t NO = 5;
std::array<float, N * NI> points{};
for (std::size_t i = 0; i < N * NI; ++i) {
points[i] = static_cast<float>(i) * 0.07f - 0.3f;
}
std::array<float, N * NO> outs{};
m.infer_batch(std::span<const float>(points), std::span<float>(outs));
// Compare against individual inference.
for (std::size_t i = 0; i < N; ++i) {
for (std::size_t j = 0; j < NI; ++j) {
m.set_input(j, points[i * NI + j]);
}
m.process();
auto o = m.outputs();
for (std::size_t j = 0; j < NO; ++j) {
NISPS_EXPECT(o[j] == outs[i * NO + j]);
}
}
}
NISPS_TEST(mlp_layer_stats_reasonable_after_init) {
TestMLP m(0ull);
m.draw_weights(0.5f);
for (std::size_t L = 0; L < 4; ++L) {
const auto s = m.layer_stats(L);
NISPS_EXPECT(s.mean_abs > 0.f);
NISPS_EXPECT(s.max_abs >= s.mean_abs);
NISPS_EXPECT(s.dead_frac >= 0.f && s.dead_frac <= 1.f);
NISPS_EXPECT(s.saturating_frac >= 0.f && s.saturating_frac <= 1.f);
}
}
} // namespace