memlnaut-nisps/nisps/core/math.hpp
w1n5t0n 4f60fc8405 feat: nisps/core foundation — perf, types, concepts, buffers, rng, math
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
2026-04-29 15:21:52 +03:00

108 lines
4.1 KiB
C++

// 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 <cmath>
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