From 4f60fc840560befef3512d3c8f04750a4b6eecdb Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 29 Apr 2026 15:21:52 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20nisps/core=20foundation=20=E2=80=94?= =?UTF-8?q?=20perf,=20types,=20concepts,=20buffers,=20rng,=20math?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greenfield C++20 core for the unified firmware+WASM rewrite (architecture.md streams, meml-dn7). Header-only, platform-agnostic, no heap, no virtual dispatch. Components: - perf.hpp memory section + inlining macros, RP2040/RP2350-aware, inert on host/Emscripten - types.hpp stereosample_t (mirrors firmware AudioDriver API), sample_t/param_t aliases, DriverConfig negotiation struct - concepts.hpp MLEngine, AudioEngine, Mode (architecture §4.1-4.3) - fixed_buffer.hpp std::array-backed cursor; replaces std::vector in hot paths - ring_buffer.hpp SPSC lock-free FIFO, power-of-two capacity, atomic head/tail; replaces pico/util/queue in core - rng.hpp xoshiro256+ with splitmix64 seeding, uniform/signed/ gaussian-via-3-uniforms (matches legacy MoveWeights shape) - math.hpp clamp01, fast_sigmoid (tanh-Padé, ~1.2% max err on [-6,6]), exact_sigmoid, fast_exp, named Curve catalog (linear/exp/ log/square/sqrt/sigmoid/cubic) — TypeScript twin lives in playground/src/output/curves.ts (stream 5) Performance discipline (Chris's rules): - No heap, no std::vector, no malloc/new in core - All float literals carry .f suffix - Memory section attrs syntactically present, inert on non-firmware builds --- nisps/.gitignore | 2 + nisps/core/concepts.hpp | 85 ++++++++++++++++++++++++++++ nisps/core/fixed_buffer.hpp | 77 +++++++++++++++++++++++++ nisps/core/math.hpp | 108 ++++++++++++++++++++++++++++++++++++ nisps/core/perf.hpp | 28 ++++++++++ nisps/core/ring_buffer.hpp | 80 ++++++++++++++++++++++++++ nisps/core/rng.hpp | 90 ++++++++++++++++++++++++++++++ nisps/core/types.hpp | 67 ++++++++++++++++++++++ 8 files changed, 537 insertions(+) create mode 100644 nisps/.gitignore create mode 100644 nisps/core/concepts.hpp create mode 100644 nisps/core/fixed_buffer.hpp create mode 100644 nisps/core/math.hpp create mode 100644 nisps/core/perf.hpp create mode 100644 nisps/core/ring_buffer.hpp create mode 100644 nisps/core/rng.hpp create mode 100644 nisps/core/types.hpp diff --git a/nisps/.gitignore b/nisps/.gitignore new file mode 100644 index 0000000..9bd80f2 --- /dev/null +++ b/nisps/.gitignore @@ -0,0 +1,2 @@ +build/ +build-*/ diff --git a/nisps/core/concepts.hpp b/nisps/core/concepts.hpp new file mode 100644 index 0000000..e42a74d --- /dev/null +++ b/nisps/core/concepts.hpp @@ -0,0 +1,85 @@ +// nisps/core/concepts.hpp — C++20 concepts that pin down the shape of the +// three central types in the rewrite: ML engines, audio engines and modes. +// +// These are CONCEPTS, not interfaces — there is no virtual dispatch in the +// hot paths. Concrete types are templated/satisfied at compile time on +// firmware; the WASM bindings layer flattens to a C API for the browser. +// +// See architecture.md §4.1, §4.2, §4.3. + +#pragma once + +#include +#include +#include +#include +#include + +#include "types.hpp" + +namespace nisps { + +// Forward declaration. Concrete `ParamSchema` lives with the codegen output +// (stream 5). Modes only need to expose a constexpr reference to it. +struct ParamSchema; + +// --------------------------------------------------------------------------- +// MLEngine — interactive ML inference + training + RL state perturbation. +// Implementations: nisps::MLP in stream 2. +// --------------------------------------------------------------------------- +template +concept MLEngine = requires(T e, + std::span in, + std::span out, + std::size_t idx, + float fv, + std::uint64_t seed) { + { e.set_input(idx, fv) } -> std::same_as; + { e.process() } -> std::same_as; + { e.outputs() } -> std::same_as>; + { e.add_example(in, in) } -> std::same_as; + { e.train() } -> std::same_as; // returns final loss + { e.move_weights(fv, fv) } -> std::same_as; // (speed, spread) + { e.draw_weights(fv) } -> std::same_as; // (spread) + { e.reset() } -> std::same_as; + { e.seed(seed) } -> std::same_as; +}; + +// --------------------------------------------------------------------------- +// AudioEngine — per-sample stereo processor. Sample-rate is negotiated at +// setup; engines should NOT assume 48 kHz. +// --------------------------------------------------------------------------- +template +concept AudioEngine = requires(T e, + stereosample_t s, + std::span params, + float sr) { + { T::param_count() } -> std::convertible_to; // constexpr + { T::engine_id() } -> std::same_as; // constexpr + { e.setup(sr) } -> std::same_as; + { e.set_params(params) } -> std::same_as; // non-RT + { e.process(s) } -> std::same_as; // RT, per-sample + { e.driver_config() } -> std::convertible_to; +}; + +// --------------------------------------------------------------------------- +// Mode — binds an ML config + audio engine + voice space + I/O channel +// mapping. Hardware bindings live OUTSIDE this concept (firmware/glue, +// playground/src/modes); modes are platform-agnostic. +// --------------------------------------------------------------------------- +template +concept Mode = requires(T m, std::size_t idx, float v, stereosample_t s, float sr) { + { T::mode_id() } -> std::same_as; // constexpr + { T::input_channel_count() } -> std::convertible_to; // constexpr + { T::param_schema() } -> std::same_as; // constexpr ref + { m.setup(sr) } -> std::same_as; + { m.set_input(idx, v) } -> std::same_as; + { m.tick_control() } -> std::same_as; // non-RT + { m.process(s) } -> std::same_as; // RT + // engine() / ml() return references; we only assert they are callable. + // (`auto&` in concept signatures is awkward; require non-void.) + { m.engine() }; + { m.ml() }; +}; + +} // namespace nisps diff --git a/nisps/core/fixed_buffer.hpp b/nisps/core/fixed_buffer.hpp new file mode 100644 index 0000000..e6a0b7e --- /dev/null +++ b/nisps/core/fixed_buffer.hpp @@ -0,0 +1,77 @@ +// nisps/core/fixed_buffer.hpp — heap-free dynamic-length array with +// compile-time capacity. Replaces std::vector in hot paths. +// +// API mirrors a tiny subset of std::vector: push_back / clear / size / data / +// operator[] / iterators / front / back. NO reserve, NO resize-with-default, +// NO insert. If you need those, you're probably reaching for the wrong tool. +// +// Bounds checking: push_back returns bool (false ⇒ full, no-op). operator[] +// is unchecked — match std::vector's behavior. + +#pragma once + +#include +#include +#include +#include + +namespace nisps { + +template +class FixedBuffer { + public: + using value_type = T; + using size_type = std::size_t; + using iterator = T*; + using const_iterator = const T*; + + constexpr FixedBuffer() noexcept = default; + + // Trivially copyable when T is. Move/copy semantics fine — backing store + // is std::array, which has correct value-semantics. + + constexpr size_type size() const noexcept { return n_; } + static constexpr size_type capacity() noexcept { return N; } + constexpr bool empty() const noexcept { return n_ == 0u; } + constexpr bool full() const noexcept { return n_ == N; } + + constexpr T* data() noexcept { return buf_.data(); } + constexpr const T* data() const noexcept { return buf_.data(); } + + constexpr T& operator[](size_type i) noexcept { return buf_[i]; } + constexpr const T& operator[](size_type i) const noexcept { return buf_[i]; } + + constexpr T& front() noexcept { return buf_[0]; } + constexpr const T& front() const noexcept { return buf_[0]; } + constexpr T& back() noexcept { return buf_[n_ - 1u]; } + constexpr const T& back() const noexcept { return buf_[n_ - 1u]; } + + constexpr iterator begin() noexcept { return buf_.data(); } + constexpr const_iterator begin() const noexcept { return buf_.data(); } + constexpr iterator end() noexcept { return buf_.data() + n_; } + constexpr const_iterator end() const noexcept { return buf_.data() + n_; } + + constexpr void clear() noexcept { n_ = 0u; } + + // Returns true on success. Does NOT throw or assert when full — callers + // are expected to size the buffer correctly. + constexpr bool push_back(const T& v) noexcept(std::is_nothrow_copy_assignable_v) { + if (n_ >= N) return false; + buf_[n_++] = v; + return true; + } + constexpr bool push_back(T&& v) noexcept(std::is_nothrow_move_assignable_v) { + if (n_ >= N) return false; + buf_[n_++] = std::move(v); + return true; + } + + // pop_back — trivial decrement; does not destroy (T must be cleanup-free). + constexpr void pop_back() noexcept { if (n_ > 0u) --n_; } + + private: + std::array buf_{}; + size_type n_ = 0u; +}; + +} // namespace nisps diff --git a/nisps/core/math.hpp b/nisps/core/math.hpp new file mode 100644 index 0000000..cc6c4a3 --- /dev/null +++ b/nisps/core/math.hpp @@ -0,0 +1,108 @@ +// nisps/core/math.hpp — small math helpers used by the audio path, the ML +// activations and the parameter curve mapping. +// +// CONTRACT WITH TYPESCRIPT (stream 5) +// The `Curve` enum below is mirrored in `playground/src/output/curves.ts`. The +// numeric meaning of each variant must stay identical so a parameter routed +// through firmware and a parameter routed through the browser produce the +// same value. JSON schemas reference curves by string name, so the source of +// truth for the *names* lives in the schema validator, but the *math* lives +// here. Golden-vector tests in `tests/cpp/parity_check.cpp` (stream 11) +// will pin this down. + +#pragma once + +#include + +namespace nisps { + +inline float clamp01(float x) noexcept { + if (x < 0.f) return 0.f; + if (x > 1.f) return 1.f; + return x; +} + +inline float clamp(float x, float lo, float hi) noexcept { + if (x < lo) return lo; + if (x > hi) return hi; + return x; +} + +// fast_sigmoid: 3rd-order rational approximation via the Padé-approximant of +// tanh. We use the identity sigmoid(x) = 0.5 + 0.5 * tanh(x/2), and approx +// tanh(u) ≈ u * (27 + u²) / (27 + 9u²) +// which is the (3,2)-Padé expansion of tanh around 0. +// +// Max error vs true logistic 1/(1+e^-x) over x ∈ [-6, 6]: ≈ 0.012 (~1.2%). +// Outside that range the approximation drifts, so we clamp to [0, 1]. +// +// For the ML output activation we need monotonic, smooth, bounded — NOT +// log-likelihood-grade accuracy. The MLP can opt into either fast_sigmoid +// or exp-based exact_sigmoid; both are provided. +inline float fast_sigmoid(float x) noexcept { + const float u = x * 0.5f; + const float u2 = u * u; + const float t = u * (27.f + u2) / (27.f + 9.f * u2); + const float y = 0.5f + 0.5f * t; + if (y < 0.f) return 0.f; + if (y > 1.f) return 1.f; + return y; +} + +// True sigmoid via std::exp. Exact, slower; provided so callers that need +// gradient-correct activations have a path that avoids the ~1.8% bias. +inline float exact_sigmoid(float x) noexcept { + // Guard against extreme inputs to avoid expf overflow / underflow noise. + if (x > 40.f) return 1.f; + if (x < -40.f) return 0.f; + return 1.f / (1.f + std::exp(-x)); +} + +// Approximate exp via the limit (1 + x/n)^n with n=256. Good to ~0.5% over +// x∈[-4, 4]; falls apart outside that range. Only use where exp is on the +// hot path AND inputs are bounded — otherwise reach for std::exp. +inline float fast_exp(float x) noexcept { + float r = 1.f + x * (1.f / 256.f); + r *= r; r *= r; r *= r; r *= r; + r *= r; r *= r; r *= r; r *= r; // 8 squarings ⇒ ^256 + return r; +} + +// --------------------------------------------------------------------------- +// Curve catalog. Each curve maps [0,1] → [0,1], monotone increasing, with +// the endpoints fixed at 0 and 1. The caller is responsible for clamping the +// input — apply_curve assumes x is already in range. +// --------------------------------------------------------------------------- +enum class Curve : int { + linear = 0, + exp = 1, + log = 2, + square = 3, + sqrt = 4, + sigmoid = 5, + cubic = 6, +}; + +inline float apply_curve(Curve c, float x) noexcept { + switch (c) { + case Curve::linear: return x; + case Curve::exp: // (e^x - 1) / (e - 1) — concave-up, slow start + return (std::exp(x) - 1.f) * (1.f / 1.71828182845904523536f); + case Curve::log: // log(1 + (e-1) x) — concave-down, fast start + return std::log(1.f + 1.71828182845904523536f * x); + case Curve::square: return x * x; + case Curve::sqrt: return std::sqrt(x); + case Curve::sigmoid: { + // S-curve through (0,0) and (1,1). Stretch logistic and rescale. + const float k = 6.f; // slope at midpoint + const float s = exact_sigmoid(k * (x - 0.5f)); + const float s0 = exact_sigmoid(-k * 0.5f); + const float s1 = exact_sigmoid( k * 0.5f); + return (s - s0) / (s1 - s0); + } + case Curve::cubic: return x * x * x; + } + return x; // unreachable, silences -Wreturn-type +} + +} // namespace nisps diff --git a/nisps/core/perf.hpp b/nisps/core/perf.hpp new file mode 100644 index 0000000..72c4211 --- /dev/null +++ b/nisps/core/perf.hpp @@ -0,0 +1,28 @@ +// nisps/core/perf.hpp — RP2040/RP2350 memory section + inlining attributes. +// +// On firmware builds the macros expand to GCC/Pico-specific section attributes +// so hot code/data lives in SRAM instead of XIP flash. On every other build +// (host tests, Emscripten/WASM) they are inert — the discipline of marking +// audio-critical declarations is preserved syntactically without affecting +// codegen. +// +// See architecture.md §3.4. + +#pragma once + +#if defined(ARDUINO_ARCH_RP2040) || defined(ARDUINO_ARCH_RP2350) + // Pico SDK provides __not_in_flash and __not_in_flash_func. + // __not_in_flash takes a section name string; __not_in_flash_func wraps the + // declaration directly. + #define NISPS_AUDIO_MEM __not_in_flash("audio") + #define NISPS_AUDIO_FUNC __not_in_flash_func + #define NISPS_APP_SRAM __not_in_flash("app") + #define NISPS_FORCE_INLINE __attribute__((always_inline)) inline + #define NISPS_HOT __attribute__((hot)) +#else + #define NISPS_AUDIO_MEM + #define NISPS_AUDIO_FUNC(decl) decl + #define NISPS_APP_SRAM + #define NISPS_FORCE_INLINE inline + #define NISPS_HOT +#endif diff --git a/nisps/core/ring_buffer.hpp b/nisps/core/ring_buffer.hpp new file mode 100644 index 0000000..7853d37 --- /dev/null +++ b/nisps/core/ring_buffer.hpp @@ -0,0 +1,80 @@ +// nisps/core/ring_buffer.hpp — single-producer single-consumer lock-free FIFO. +// +// Replaces pico/util/queue across the platform-agnostic core. On firmware, +// the inter-core hand-off can wrap this OR use queue_t directly — that +// decision lives in stream 6 (firmware glue). Within `nisps/`, this is the +// canonical channel. +// +// Design notes +// - Capacity N must be a power of two. We mask the head/tail indices instead +// of taking modulus; this lets the indices wrap naturally at size_t and we +// compare them with subtraction (i.e. `head - tail == N` ⇒ full). +// - T must be trivially copyable. We do not run T's destructor on pop — +// callers want POD-shaped messages here, not RAII handles. +// - Memory orders follow Vyukov's classic SPSC pattern: +// producer: relaxed load(tail), [write slot], release store(head) +// consumer: relaxed load(head), acquire load(head), [read slot], release store(tail) + +#pragma once + +#include +#include +#include + +namespace nisps { + +template +class RingBuffer { + static_assert(N > 0u, "RingBuffer capacity must be > 0"); + static_assert((N & (N - 1u)) == 0u, "RingBuffer capacity must be power of two"); + static_assert(std::is_trivially_copyable_v, + "RingBuffer element type must be trivially copyable"); + + public: + static constexpr std::size_t capacity() noexcept { return N; } + + RingBuffer() noexcept : head_(0u), tail_(0u) {} + + // No copy / no move — atomics aren't trivially movable and there's no + // good story for "transfer half-full ring under contention". + RingBuffer(const RingBuffer&) = delete; + RingBuffer& operator=(const RingBuffer&) = delete; + + bool try_push(const T& v) noexcept { + const auto head = head_.load(std::memory_order_relaxed); + const auto tail = tail_.load(std::memory_order_acquire); + if (head - tail >= N) return false; // full + buf_[head & kMask] = v; + head_.store(head + 1u, std::memory_order_release); + return true; + } + + bool try_pop(T& out) noexcept { + const auto tail = tail_.load(std::memory_order_relaxed); + const auto head = head_.load(std::memory_order_acquire); + if (head == tail) return false; // empty + out = buf_[tail & kMask]; + tail_.store(tail + 1u, std::memory_order_release); + return true; + } + + // Approximate; relies on head/tail being read in arbitrary order. Use + // for diagnostics, not for synchronization. + std::size_t size_approx() const noexcept { + const auto h = head_.load(std::memory_order_relaxed); + const auto t = tail_.load(std::memory_order_relaxed); + return h - t; + } + bool empty_approx() const noexcept { return size_approx() == 0u; } + bool full_approx() const noexcept { return size_approx() >= N; } + + private: + static constexpr std::size_t kMask = N - 1u; + + // Head/tail use std::size_t and rely on natural unsigned wrap. + std::atomic head_; + std::atomic tail_; + T buf_[N]{}; +}; + +} // namespace nisps diff --git a/nisps/core/rng.hpp b/nisps/core/rng.hpp new file mode 100644 index 0000000..b06473f --- /dev/null +++ b/nisps/core/rng.hpp @@ -0,0 +1,90 @@ +// nisps/core/rng.hpp — deterministic PRNG used everywhere we need +// reproducibility (weight init, RL noise, dataset shuffle, golden vectors). +// +// xoshiro256+ — Blackman & Vigna (2018). 64-bit state, 4× u64 state words, +// passes BigCrush, fast (a handful of ALU ops), no branches in the hot path. +// We use the `+` variant rather than `**` because we only consume the high +// bits for floats; the low-bit linearity that pesters `+` for integer use is +// irrelevant once you mask off the float mantissa. +// +// Seeding: we splitmix64 the user-provided u64 seed to fan it out across the +// four state words, so seed=0 / seed=1 / seed=N all produce uncorrelated +// streams. (Pure xoshiro fails badly for all-zero state.) +// +// Gaussian: sum-of-three-uniforms. Cheaper than Box-Muller (no log/sin) and +// the existing JS engine uses the same shape (`gen_randn = sum of 3 +// uniforms scaled by speed`), so by matching it we keep firmware ↔ browser +// noise statistically equivalent without a parity headache. Box-Muller would +// be more accurate, but we want compatibility with the legacy MoveWeights +// shape — see recon/01-ml-stack.md §3. + +#pragma once + +#include + +namespace nisps { + +class Rng { + public: + explicit Rng(std::uint64_t seed) noexcept { this->seed(seed); } + + void seed(std::uint64_t s) noexcept { + // splitmix64: avalanche the seed into four uncorrelated state words. + for (int i = 0; i < 4; ++i) { + s += 0x9E3779B97F4A7C15ull; + std::uint64_t z = s; + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBull; + z = z ^ (z >> 31); + state_[i] = z; + } + // Guarantee non-zero state. Astronomically unlikely to be all-zero + // post-splitmix anyway, but defense in depth. + if ((state_[0] | state_[1] | state_[2] | state_[3]) == 0ull) { + state_[0] = 1ull; + } + } + + inline std::uint64_t next_u64() noexcept { + const std::uint64_t result = state_[0] + state_[3]; + const std::uint64_t t = state_[1] << 17; + state_[2] ^= state_[0]; + state_[3] ^= state_[1]; + state_[1] ^= state_[2]; + state_[0] ^= state_[3]; + state_[2] ^= t; + state_[3] = rotl_(state_[3], 45); + return result; + } + + // Uniform float in [0, 1). Standard "top 24 bits → mantissa" trick. + inline float next_float_uniform() noexcept { + // 1.f / 2^24 = 5.9604644775390625e-08 + constexpr float kInv = 1.f / 16777216.f; + return static_cast(next_u64() >> 40) * kInv; + } + + // Uniform float in [-1, 1). + inline float next_float_signed() noexcept { + return next_float_uniform() * 2.f - 1.f; + } + + // Approx Gaussian via sum of three [-1, 1) uniforms. + // Variance of one such uniform = 1/3, so summing three gives variance 1 + // and stddev 1. We then scale by the requested stddev. + inline float next_float_gaussian(float stddev = 1.f) noexcept { + const float a = next_float_signed(); + const float b = next_float_signed(); + const float c = next_float_signed(); + return (a + b + c) * stddev; + } + + private: + static inline std::uint64_t rotl_(std::uint64_t x, int k) noexcept { + return (x << k) | (x >> (64 - k)); + } + + std::uint64_t state_[4]{}; +}; + +} // namespace nisps diff --git a/nisps/core/types.hpp b/nisps/core/types.hpp new file mode 100644 index 0000000..5dacc3b --- /dev/null +++ b/nisps/core/types.hpp @@ -0,0 +1,67 @@ +// nisps/core/types.hpp — Foundational scalar/sample types shared by every +// engine, mode and ML component. +// +// `stereosample_t` mirrors the layout/API used in firmware +// (`src/memllib/audio/AudioDriver.hpp`) so engine code can move between +// firmware and WASM unchanged. The `__force_inline` decoration from the +// firmware version is replaced with NISPS_FORCE_INLINE so the same source +// works on host builds. + +#pragma once + +#include +#include + +#include "perf.hpp" + +namespace nisps { + +using sample_t = float; +using param_t = float; + +struct stereosample_t { + float L; + float R; + + NISPS_FORCE_INLINE stereosample_t operator+(const stereosample_t& o) const { + return {L + o.L, R + o.R}; + } + NISPS_FORCE_INLINE stereosample_t& operator+=(const stereosample_t& o) { + L += o.L; R += o.R; return *this; + } + NISPS_FORCE_INLINE stereosample_t operator-() const { return {-L, -R}; } + NISPS_FORCE_INLINE stereosample_t operator-(const stereosample_t& o) const { + return {L - o.L, R - o.R}; + } + NISPS_FORCE_INLINE stereosample_t& operator-=(const stereosample_t& o) { + L -= o.L; R -= o.R; return *this; + } + NISPS_FORCE_INLINE stereosample_t operator*(float s) const { + return {L * s, R * s}; + } + NISPS_FORCE_INLINE stereosample_t& operator*=(float s) { + L *= s; R *= s; return *this; + } + NISPS_FORCE_INLINE float operator[](std::size_t i) const { + return i == 0u ? L : R; + } +}; + +// Audio driver / engine setup contract. Engines advertise the gain staging and +// sample-rate-hint they want; the concrete driver (firmware glue or WASM +// AudioWorklet) honours what it can. Negotiation, not imperative. +// +// `mic_input` true ⇒ codec is configured for mic-level input +// `mic_gain_db` dB of pre-amp gain when `mic_input` is true +// `line_level` 1..15 ish — codec line-input gain step +// `output_volume` 0..1 — analog out master +// `sample_rate` preferred rate (Hz); 0 means "don't care" +struct DriverConfig { + bool mic_input = false; + std::uint8_t mic_gain_db = 0; + std::uint8_t line_level = 0; + float output_volume = 1.f; + float sample_rate = 0.f; +}; + +} // namespace nisps From e5bf2aa055af9675dc82b13baef16b0b0596971e Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 29 Apr 2026 15:22:01 +0300 Subject: [PATCH 2/2] feat: nisps build + host test harness CMakeLists.txt: - Native host build by default; Emscripten-target detection plumbed but WASM emit deferred to stream 7 (playground build script). - Header-only INTERFACE library `nisps_core`. - Host test executable `nisps_core_tests` compiled with -Wall -Wextra -Werror -Wpedantic (Chris's rules: clean build is non-negotiable). tests/cpp/test_helpers.hpp: - Minimal NISPS_TEST / NISPS_EXPECT / NISPS_EXPECT_NEAR macros, no external deps. Rationale documented in-file: Catch2/doctest would add ~10MB and 30s for what is currently <100 LOC of test runtime. 22 unit tests covering FixedBuffer (5), RingBuffer (5), Rng (7), math (5). All green; verified via `cmake --build nisps/build && ./nisps/build/nisps_core_tests`. --- nisps/CMakeLists.txt | 68 ++++++++++++++++++++++ tests/cpp/test_fixed_buffer.cpp | 52 +++++++++++++++++ tests/cpp/test_helpers.hpp | 100 ++++++++++++++++++++++++++++++++ tests/cpp/test_main.cpp | 8 +++ tests/cpp/test_math.cpp | 57 ++++++++++++++++++ tests/cpp/test_ring_buffer.cpp | 64 ++++++++++++++++++++ tests/cpp/test_rng.cpp | 78 +++++++++++++++++++++++++ 7 files changed, 427 insertions(+) create mode 100644 nisps/CMakeLists.txt create mode 100644 tests/cpp/test_fixed_buffer.cpp create mode 100644 tests/cpp/test_helpers.hpp create mode 100644 tests/cpp/test_main.cpp create mode 100644 tests/cpp/test_math.cpp create mode 100644 tests/cpp/test_ring_buffer.cpp create mode 100644 tests/cpp/test_rng.cpp diff --git a/nisps/CMakeLists.txt b/nisps/CMakeLists.txt new file mode 100644 index 0000000..4253e4a --- /dev/null +++ b/nisps/CMakeLists.txt @@ -0,0 +1,68 @@ +cmake_minimum_required(VERSION 3.20) +project(nisps_core CXX) + +# --------------------------------------------------------------------------- +# Toolchain / standard +# --------------------------------------------------------------------------- +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Default to a Release build when invoked without -DCMAKE_BUILD_TYPE so host +# tests get optimized math; flip with `-DCMAKE_BUILD_TYPE=Debug` for stepping. +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +endif() + +# --------------------------------------------------------------------------- +# Core interface library — header-only. Other parts of the build (ml/, dsp/, +# engines/, modes/) will link against this once they exist. +# --------------------------------------------------------------------------- +add_library(nisps_core INTERFACE) +target_include_directories(nisps_core + INTERFACE + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_compile_features(nisps_core INTERFACE cxx_std_20) + +# --------------------------------------------------------------------------- +# Emscripten / WASM target detection +# --------------------------------------------------------------------------- +# When building for WASM via emcmake, ${EMSCRIPTEN} is set automatically. We +# don't actually emit a WASM binary from this CMakeLists yet — the WASM build +# script lives in stream 7 (playground/build) and assembles its own +# Emscripten link command. Here we just gate the host-only test executable so +# `emcmake cmake -S nisps -B build-wasm` configures cleanly. +if(EMSCRIPTEN) + message(STATUS "nisps_core: configuring for Emscripten/WASM target") +endif() + +# --------------------------------------------------------------------------- +# Test scaffold — only built on the host. We use a hand-rolled assertion- +# based harness (see tests/cpp/test_helpers.hpp) rather than Catch2/doctest; +# the rationale is in test_helpers.hpp. +# --------------------------------------------------------------------------- +if(NOT EMSCRIPTEN) + set(NISPS_TEST_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../tests/cpp) + + add_executable(nisps_core_tests + ${NISPS_TEST_DIR}/test_main.cpp + ${NISPS_TEST_DIR}/test_fixed_buffer.cpp + ${NISPS_TEST_DIR}/test_ring_buffer.cpp + ${NISPS_TEST_DIR}/test_rng.cpp + ${NISPS_TEST_DIR}/test_math.cpp + ) + target_link_libraries(nisps_core_tests PRIVATE nisps_core) + + # Chris's rule: the core compiles cleanly under -Wall -Wextra -Werror. + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(nisps_core_tests PRIVATE + -Wall -Wextra -Werror -Wpedantic + ) + elseif(MSVC) + target_compile_options(nisps_core_tests PRIVATE /W4 /WX) + endif() + + enable_testing() + add_test(NAME nisps_core_tests COMMAND nisps_core_tests) +endif() diff --git a/tests/cpp/test_fixed_buffer.cpp b/tests/cpp/test_fixed_buffer.cpp new file mode 100644 index 0000000..7948e67 --- /dev/null +++ b/tests/cpp/test_fixed_buffer.cpp @@ -0,0 +1,52 @@ +// tests/cpp/test_fixed_buffer.cpp — exercises FixedBuffer's cursor semantics +// and capacity guard. + +#include "test_helpers.hpp" +#include "../../nisps/core/fixed_buffer.hpp" + +NISPS_TEST(fixed_buffer_starts_empty) { + nisps::FixedBuffer b; + NISPS_EXPECT(b.size() == 0u); + NISPS_EXPECT(b.empty()); + NISPS_EXPECT(!b.full()); + NISPS_EXPECT(b.capacity() == 8u); +} + +NISPS_TEST(fixed_buffer_push_and_index) { + nisps::FixedBuffer b; + NISPS_EXPECT(b.push_back(10)); + NISPS_EXPECT(b.push_back(20)); + NISPS_EXPECT(b.push_back(30)); + NISPS_EXPECT(b.size() == 3u); + NISPS_EXPECT(b[0] == 10); + NISPS_EXPECT(b[1] == 20); + NISPS_EXPECT(b[2] == 30); + NISPS_EXPECT(b.front() == 10); + NISPS_EXPECT(b.back() == 30); +} + +NISPS_TEST(fixed_buffer_refuses_when_full) { + nisps::FixedBuffer b; + NISPS_EXPECT(b.push_back(1)); + NISPS_EXPECT(b.push_back(2)); + NISPS_EXPECT(b.full()); + NISPS_EXPECT(!b.push_back(3)); // refused + NISPS_EXPECT(b.size() == 2u); // unchanged +} + +NISPS_TEST(fixed_buffer_clear_resets) { + nisps::FixedBuffer b; + b.push_back(1); b.push_back(2); + b.clear(); + NISPS_EXPECT(b.empty()); + NISPS_EXPECT(b.push_back(99)); + NISPS_EXPECT(b[0] == 99); +} + +NISPS_TEST(fixed_buffer_iteration) { + nisps::FixedBuffer b; + for (int i = 0; i < 5; ++i) b.push_back(i * 2); + int sum = 0; + for (int v : b) sum += v; + NISPS_EXPECT(sum == 0 + 2 + 4 + 6 + 8); +} diff --git a/tests/cpp/test_helpers.hpp b/tests/cpp/test_helpers.hpp new file mode 100644 index 0000000..af3cfb5 --- /dev/null +++ b/tests/cpp/test_helpers.hpp @@ -0,0 +1,100 @@ +// tests/cpp/test_helpers.hpp — minimal assertion-based test harness. +// +// Why roll our own: zero external dependencies, builds in <1s, integrates +// trivially with CMake. Catch2/doctest would add a fetch step (≈ 10 MB and +// 30s of compile time) for what is currently a handful of asserts. If the +// suite grows beyond a few hundred lines we can revisit. +// +// API: +// NISPS_TEST(name) — declare a test +// NISPS_EXPECT(cond) — non-fatal check (records, keeps running) +// NISPS_ASSERT(cond) — fatal check (aborts the test) +// NISPS_EXPECT_NEAR(a, b, eps) +// +// Usage: +// NISPS_TEST(my_test) { +// NISPS_EXPECT(1 + 1 == 2); +// } +// int main() { return nisps::test::run_all(); } + +#pragma once + +#include +#include +#include +#include +#include + +namespace nisps::test { + +struct TestFailure { const char* msg; }; + +struct TestCase { + const char* name; + void (*fn)(); +}; + +inline std::vector& registry() { + static std::vector r; + return r; +} + +struct Registrar { + Registrar(const char* name, void (*fn)()) { + registry().push_back({name, fn}); + } +}; + +inline int run_all() { + int passed = 0, failed = 0; + for (const auto& tc : registry()) { + std::printf("[ RUN ] %s\n", tc.name); + try { + tc.fn(); + std::printf("[ OK ] %s\n", tc.name); + ++passed; + } catch (const TestFailure& f) { + std::printf("[ FAILED ] %s — %s\n", tc.name, f.msg); + ++failed; + } + } + std::printf("\n[==========] %d passed, %d failed\n", passed, failed); + return failed == 0 ? 0 : 1; +} + +} // namespace nisps::test + +#define NISPS_TEST_CONCAT_(a, b) a##b +#define NISPS_TEST_CONCAT(a, b) NISPS_TEST_CONCAT_(a, b) + +#define NISPS_TEST(name) \ + static void NISPS_TEST_CONCAT(nisps_test_, name)(); \ + static ::nisps::test::Registrar NISPS_TEST_CONCAT(nisps_test_reg_, name)( \ + #name, &NISPS_TEST_CONCAT(nisps_test_, name)); \ + static void NISPS_TEST_CONCAT(nisps_test_, name)() + +#define NISPS_EXPECT(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, \ + " EXPECT failed at %s:%d: %s\n", \ + __FILE__, __LINE__, #cond); \ + throw ::nisps::test::TestFailure{#cond}; \ + } \ + } while (0) + +#define NISPS_ASSERT(cond) NISPS_EXPECT(cond) + +#define NISPS_EXPECT_NEAR(a, b, eps) \ + do { \ + const double da = static_cast(a); \ + const double db = static_cast(b); \ + if (std::fabs(da - db) > static_cast(eps)) { \ + std::fprintf(stderr, \ + " EXPECT_NEAR failed at %s:%d: %s ≈ %s " \ + "(|%g - %g| = %g > %g)\n", \ + __FILE__, __LINE__, #a, #b, da, db, \ + std::fabs(da - db), static_cast(eps)); \ + throw ::nisps::test::TestFailure{#a " ≈ " #b}; \ + } \ + } while (0) diff --git a/tests/cpp/test_main.cpp b/tests/cpp/test_main.cpp new file mode 100644 index 0000000..be164ee --- /dev/null +++ b/tests/cpp/test_main.cpp @@ -0,0 +1,8 @@ +// tests/cpp/test_main.cpp — entry point that runs every test registered +// across the translation units linked into nisps_core_tests. + +#include "test_helpers.hpp" + +int main() { + return nisps::test::run_all(); +} diff --git a/tests/cpp/test_math.cpp b/tests/cpp/test_math.cpp new file mode 100644 index 0000000..57c8d75 --- /dev/null +++ b/tests/cpp/test_math.cpp @@ -0,0 +1,57 @@ +// tests/cpp/test_math.cpp — sanity check on clamping, sigmoid bounds, and +// curve catalog endpoint pinning. + +#include "test_helpers.hpp" +#include "../../nisps/core/math.hpp" + +NISPS_TEST(clamp01_bounds) { + NISPS_EXPECT(nisps::clamp01(-1.f) == 0.f); + NISPS_EXPECT(nisps::clamp01( 0.f) == 0.f); + NISPS_EXPECT(nisps::clamp01( 0.5f) == 0.5f); + NISPS_EXPECT(nisps::clamp01( 1.f) == 1.f); + NISPS_EXPECT(nisps::clamp01( 2.f) == 1.f); +} + +NISPS_TEST(fast_sigmoid_in_unit_range) { + for (float x = -10.f; x <= 10.f; x += 0.5f) { + const float y = nisps::fast_sigmoid(x); + NISPS_EXPECT(y >= 0.f); + NISPS_EXPECT(y <= 1.f); + } + // Center pinned. + NISPS_EXPECT_NEAR(nisps::fast_sigmoid(0.f), 0.5f, 1e-6); +} + +NISPS_TEST(fast_sigmoid_matches_exact_within_tolerance) { + // Document that fast_sigmoid is within ~2% of exact_sigmoid on [-6, 6]. + for (float x = -6.f; x <= 6.f; x += 0.25f) { + const float fy = nisps::fast_sigmoid(x); + const float ey = nisps::exact_sigmoid(x); + NISPS_EXPECT_NEAR(fy, ey, 0.02); + } +} + +NISPS_TEST(curve_endpoints_pinned) { + // Every curve must map 0→0 and 1→1 exactly. + using nisps::Curve; + Curve curves[] = {Curve::linear, Curve::exp, Curve::log, Curve::square, + Curve::sqrt, Curve::sigmoid, Curve::cubic}; + for (Curve c : curves) { + NISPS_EXPECT_NEAR(nisps::apply_curve(c, 0.f), 0.0, 1e-5); + NISPS_EXPECT_NEAR(nisps::apply_curve(c, 1.f), 1.0, 1e-5); + } +} + +NISPS_TEST(curve_monotone_increasing) { + using nisps::Curve; + Curve curves[] = {Curve::linear, Curve::exp, Curve::log, Curve::square, + Curve::sqrt, Curve::sigmoid, Curve::cubic}; + for (Curve c : curves) { + float prev = nisps::apply_curve(c, 0.f); + for (float x = 0.05f; x <= 1.f + 1e-6f; x += 0.05f) { + const float y = nisps::apply_curve(c, x); + NISPS_EXPECT(y >= prev - 1e-6f); // non-decreasing + prev = y; + } + } +} diff --git a/tests/cpp/test_ring_buffer.cpp b/tests/cpp/test_ring_buffer.cpp new file mode 100644 index 0000000..4d0fd4b --- /dev/null +++ b/tests/cpp/test_ring_buffer.cpp @@ -0,0 +1,64 @@ +// tests/cpp/test_ring_buffer.cpp — single-threaded SPSC behavior. We don't +// stress the memory ordering in unit tests (that would need a multi-thread +// fuzz harness); we just confirm the FIFO invariants and the capacity guard. + +#include "test_helpers.hpp" +#include "../../nisps/core/ring_buffer.hpp" + +NISPS_TEST(ring_buffer_empty_pop_fails) { + nisps::RingBuffer r; + int v = -1; + NISPS_EXPECT(!r.try_pop(v)); + NISPS_EXPECT(r.empty_approx()); +} + +NISPS_TEST(ring_buffer_fifo_order) { + nisps::RingBuffer r; + NISPS_EXPECT(r.try_push(1)); + NISPS_EXPECT(r.try_push(2)); + NISPS_EXPECT(r.try_push(3)); + int v = 0; + NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 1); + NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 2); + NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 3); + NISPS_EXPECT(!r.try_pop(v)); +} + +NISPS_TEST(ring_buffer_full_push_fails) { + nisps::RingBuffer r; + NISPS_EXPECT(r.try_push(1)); + NISPS_EXPECT(r.try_push(2)); + NISPS_EXPECT(r.try_push(3)); + NISPS_EXPECT(r.try_push(4)); + NISPS_EXPECT(!r.try_push(5)); // full + NISPS_EXPECT(r.size_approx() == 4u); +} + +NISPS_TEST(ring_buffer_wraparound) { + nisps::RingBuffer r; + int v = 0; + // Fill, drain, fill again — exercises index wrap. + for (int i = 0; i < 100; ++i) { + NISPS_EXPECT(r.try_push(i)); + NISPS_EXPECT(r.try_pop(v)); + NISPS_EXPECT(v == i); + } + NISPS_EXPECT(r.empty_approx()); +} + +NISPS_TEST(ring_buffer_partial_fill_drain) { + nisps::RingBuffer r; + for (int i = 0; i < 6; ++i) NISPS_EXPECT(r.try_push(i * 10)); + NISPS_EXPECT(r.size_approx() == 6u); + int v = 0; + NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 0); + NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 10); + NISPS_EXPECT(r.try_push(60)); + NISPS_EXPECT(r.try_push(70)); + NISPS_EXPECT(r.size_approx() == 6u); + int expect[] = {20, 30, 40, 50, 60, 70}; + for (int e : expect) { + NISPS_EXPECT(r.try_pop(v)); + NISPS_EXPECT(v == e); + } +} diff --git a/tests/cpp/test_rng.cpp b/tests/cpp/test_rng.cpp new file mode 100644 index 0000000..abe4a4e --- /dev/null +++ b/tests/cpp/test_rng.cpp @@ -0,0 +1,78 @@ +// tests/cpp/test_rng.cpp — verify deterministic seeding, range bounds, and +// reasonable statistical shape on the gaussian helper. + +#include "test_helpers.hpp" +#include "../../nisps/core/rng.hpp" + +NISPS_TEST(rng_deterministic_for_same_seed) { + nisps::Rng a(42ull); + nisps::Rng b(42ull); + for (int i = 0; i < 1000; ++i) { + NISPS_EXPECT(a.next_u64() == b.next_u64()); + } +} + +NISPS_TEST(rng_diverges_for_different_seeds) { + nisps::Rng a(0ull); + nisps::Rng b(1ull); + int distinct = 0; + for (int i = 0; i < 100; ++i) { + if (a.next_u64() != b.next_u64()) ++distinct; + } + // Should differ in nearly every draw (probability of collision ~ 2^-64). + NISPS_EXPECT(distinct >= 99); +} + +NISPS_TEST(rng_zero_seed_does_not_lock_up) { + nisps::Rng r(0ull); + // Pure xoshiro with all-zero state is degenerate; our splitmix64 fan-out + // should prevent that. Confirm we get nonzero output. + bool any_nonzero = false; + for (int i = 0; i < 16; ++i) { + if (r.next_u64() != 0ull) { any_nonzero = true; break; } + } + NISPS_EXPECT(any_nonzero); +} + +NISPS_TEST(rng_uniform_in_unit_interval) { + nisps::Rng r(7ull); + for (int i = 0; i < 10000; ++i) { + const float v = r.next_float_uniform(); + NISPS_EXPECT(v >= 0.f); + NISPS_EXPECT(v < 1.f); + } +} + +NISPS_TEST(rng_signed_in_minus_plus) { + nisps::Rng r(7ull); + for (int i = 0; i < 10000; ++i) { + const float v = r.next_float_signed(); + NISPS_EXPECT(v >= -1.f); + NISPS_EXPECT(v < 1.f); + } +} + +NISPS_TEST(rng_uniform_mean_near_half) { + nisps::Rng r(123ull); + double sum = 0.0; + constexpr int N = 100000; + for (int i = 0; i < N; ++i) sum += r.next_float_uniform(); + const double mean = sum / N; + NISPS_EXPECT_NEAR(mean, 0.5, 0.01); +} + +NISPS_TEST(rng_gaussian_stddev_close_to_one) { + nisps::Rng r(99ull); + constexpr int N = 100000; + double sum = 0.0, sq = 0.0; + for (int i = 0; i < N; ++i) { + const double v = r.next_float_gaussian(1.f); + sum += v; + sq += v * v; + } + const double mean = sum / N; + const double var = sq / N - mean * mean; + NISPS_EXPECT_NEAR(mean, 0.0, 0.05); + // sum-of-three-uniforms gives variance exactly 1.0 in the limit. + NISPS_EXPECT_NEAR(var, 1.0, 0.05); +}