From e5bf2aa055af9675dc82b13baef16b0b0596971e Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 29 Apr 2026 15:22:01 +0300 Subject: [PATCH] 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); +}