diff --git a/CMakeLists.txt b/CMakeLists.txt index b2b65e6..a468e9c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -168,6 +168,48 @@ function(laige_apply_engine_policy target) endif() endfunction() +# --------------------------------------------------------------------------- +# SimMath pinned-math policy (M0-CORE-03; ADR 0002, PRD §10.3) +# --------------------------------------------------------------------------- +# Every target that carries deterministic sim math MUST be passed through +# laige_apply_simmath_policy(). In M0 that is laige-core (the SimMath +# home) and its tests; from M1 on, every sim module (laige-sim, and +# authoritative-sim code in laige-net/laige-server) joins the list. +# +# The flags implement ADR 0002's pinned set for the fp32_pinned backend +# (full rationale in src/laige-core/include/laige/sim_math.h): +# +# GCC / Clang / AppleClang: +# -ffp-contract=off a*b+c is never fused into a single-rounding +# FMA (protects SimMath::lerp / ::length's +# documented two-rounding) +# -fno-associative-math sums are never reassociated (already the +# default; passed explicitly so the pinned set +# is visible on every compile line) +# MSVC 2022: +# /fp:precise the documented precision model; MSVC does not +# FMA-contract C expressions and never +# reassociates at this setting (passed +# explicitly — it is the default — so the +# pinned set is visible on every compile line) +# +# Banned in sim translation units, all compilers: -ffast-math / +# -funsafe-math-optimizations / /fp:fast, floating-point intrinsics, +# rounding-mode changes, and FP exception modes (re-audited at every +# toolchain upgrade, ADR 0002). +function(laige_apply_simmath_policy target) + if(CMAKE_CXX_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$") + target_compile_options(${target} PRIVATE + -ffp-contract=off -fno-associative-math) + elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") + target_compile_options(${target} PRIVATE /fp:precise) + else() + message(FATAL_ERROR + "laige: no simmath compiler policy for '${CMAKE_CXX_COMPILER_ID}'; " + "supported: GNU, Clang/AppleClang, MSVC 2022 (PRD §6).") + endif() +endfunction() + # --------------------------------------------------------------------------- # Modules (PRD §10.1 module map) # --------------------------------------------------------------------------- diff --git a/docs/api/sim_math.md b/docs/api/sim_math.md new file mode 100644 index 0000000..c6cec18 --- /dev/null +++ b/docs/api/sim_math.md @@ -0,0 +1,162 @@ +# SimMath — the deterministic math interface (`laige::sim`) + +The one math interface for deterministic simulation code (M0-CORE-03, +ADR 0002, PRD §10.3). Public header: +`src/laige-core/include/laige/sim_math.h`; pinned instantiation: +`src/laige-core/sim_math.cpp`. Engine math ops are the only +floating-point allowed in deterministic paths — never platform +intrinsics outside the engine (PRD §10.3). The *enforcement* of +"SimMath only" in sim code lands in M1-DET-01 (G-R8); this step ships +the API, the `fp32_pinned` backend, and the pinned flag set. + +## Backends (ADR 0002) + +SimMath has **one op surface** and **two backends**, selected **once per +engine/zone init** from game config (`determinism.math`) and dispatched +at compile time — one template instantiation per backend, no per-call +indirection (PERF-006): + +| Backend | Config id | Storage | Determinism scope (ARCH-010) | +|---|---|---|---| +| `Fp32Pinned` (this step) | `"float_pinned_32"` | IEEE binary32 (`float`) | Bit-exact across runs of the same build on the same platform/ISA. Cross-ISA is **not** promised until the CI detcheck matrix proves it (M1-DET); a failing pair is declared unsupported for this backend. | +| `Fpx16_16` (M0-CORE-04) | `"fixed_point_16_16"` | Q16.16 in `int32_t`, `int64_t` intermediates | Bit-exact across all builds, platforms, ISAs, and compilers by the language standard. The default backend; required for lockstep and authoritative MMO. | + +The **backend id is part of replay identity**: replay = inputs + seed + +math backend + config hash. Cross-backend replays are not bit-exact and +are not supported. The PRD §12.3 bandwidth degradation ladder is +defined on `fpx16_16`; `fp32_pinned` zones do not participate in it. + +## Quick start + +```cpp +#include + +// Once, at engine/zone init (factory-selected, ADR 0002): +const auto math = laige::sim::SimMathFp32::create(); + +// Sim hot path (fixed timestep, ARCH-002): +laige::sim::SimMathFp32::Vec2 pos{1.0f, 2.0f}; +laige::sim::SimMathFp32::Vec2 vel{0.5f, -0.25f}; +pos = math.add(pos, math.mul(vel, 1.0f / 60.0f)); // one tick +pos = math.clamp(pos, laige::sim::SimMathFp32::Vec2{0.0f, 0.0f}, + laige::sim::SimMathFp32::Vec2{64.0f, 64.0f}); +vel = math.normalize(vel); +``` + +Game and system code is written once against the interface — there is no +per-game double code path (ADR 0002). + +## The op surface + +All ops are static and inline on the stateless `SimMath` — +O(1), no allocation, `noexcept`, zero per-call indirection (PERF-006). +`Vec2`/`Vec3` are trivially copyable value types for dense sim state +(PERF-004); default-initialized to the zero vector. + +| Op | Exact expression / policy | +|---|---| +| `add` / `sub` / `mul` / `div` (scalar, `Vec2`) | One IEEE binary32 operation per component, round-to-nearest-even (pinned — see below). `mul(v, v)` is component-wise; `mul(v, s)` / `mul(s, v)` is scaling. | +| `less` / `lessEqual` / `greater` / `greaterEqual` (scalar) | Ordered IEEE comparisons: false when either operand is NaN. | +| `equals` / `notEquals` (scalar, `Vec2`, `Vec3`) | IEEE: `equals` is false when either operand is NaN; `notEquals` is true. Vector forms are component-wise. | +| `isNaN` / `isInf` / `isFinite` / `isOrdered` (scalar) | IEEE classifications; `isOrdered(a,b)` is true iff neither is NaN (the `totalOrder` predicate, not the negation of arithmetic `!=`). | +| `clamp(x, lo, hi)` (scalar, `Vec2`) | Requires finite `lo`/`hi` and `lo <= hi` (debug-assert; release UB — the engine's Result/Status convention). NaN x → NaN; ±inf x → the bound. | +| `lerp(a, b, t)` (scalar, `Vec2`) | Exactly `a + (b - a) * t`: one sub, one mul, one add — **two roundings, never FMA-fused**. Not interchangeable with `a*(1-t) + b*t` (different rounding; replays diverge). t outside [0,1] extrapolates by the same expression (defined). | +| `length(v)` (`Vec2`, `Vec3`) | Exactly `sqrt(x*x + y*y [+ z*z])`: the component squarings, the adds, then one correctly-rounded sqrt (SSE2 vsqrtss / NEON vsqrt), in that order. Always ≥ 0; `length((0,0)) = +0`. | +| `normalize(v)` (`Vec2`, `Vec3`) | `v / length(v)`, component-wise IEEE division. The zero vector is defined to normalize to the zero vector — SimMath never injects NaN from a zero-length input (IEEE 0/0 would give NaN). NaN/inf components propagate per IEEE (an infinite vector can normalize to NaN components). | +| `create()` | The factory form of backend selection (stateless; holding the handle is free). | + +## fp32_pinned NaN/Inf policy + +Defined, not "whatever the CPU does" (full text in the header): + +- **Arithmetic** (`add`/`sub`/`mul`/`div`, `sqrt`): IEEE-754 binary32, + round-to-nearest-even. A NaN operand yields NaN; 0/0 = NaN; x/0 = ±inf + (no trap); inf − inf = NaN; inf × 0 = NaN; x/inf = ±0. +- **Comparisons:** ordered — false when either operand is NaN; + `notEquals` is true when either is NaN; NaN is not equal to itself. +- **`clamp`:** NaN in → NaN out; ±inf in → the corresponding bound. +- **`lerp` / `length`:** NaN in → NaN out; the exact pinned expressions + above. +- **`normalize`:** zero vector → zero vector (policy); NaN/inf propagate + per IEEE. +- **Signed zero** follows IEEE: `-0.0f` is representable, + `-0.0f == +0.0f` is true, `0 + -0 = +0`, `-1 * 0 = -0`. +- **No op may signal an FP exception or trap** on a P0 target; no flush + to zero (denormals are first-class: e.g. `sqrt(denorm_min)` is a + finite denormal). + +## fp32_pinned pinned flag set (ADR 0002) + +The bit-exact promise holds only if every compiler emits the same +operation sequence. The pinned set is applied by +`laige_apply_simmath_policy()` (root `CMakeLists.txt`) to every target +that carries deterministic sim math — today `laige-core` and its tests; +from M1 on, every sim module (`laige-sim`, and authoritative-sim code in +`laige-net`/`laige-server`): + +| Compiler | Flags | Why | +|---|---|---| +| GCC / Clang / AppleClang | `-ffp-contract=off -fno-associative-math` | Never fuse `a*b+c` into a single-rounding FMA (protects `lerp`'s and `length`'s documented two-rounding); never reassociate sums (already the default; passed explicitly so the pinned set is visible on every compile line). | +| MSVC 2022 | `/fp:precise` | MSVC does not FMA-contract C expressions and never reassociates at this setting (passed explicitly — it is the default — so the pinned set is visible on every compile line). | + +Banned in sim translation units, all compilers (re-audited at every +toolchain upgrade, ADR 0002): + +- `-ffast-math` / `-funsafe-math-optimizations` / `/fp:fast` — they + enable reassociation, reciprocal math, and FMA contraction, and remove + the NaN/Inf guarantees above. +- Floating-point intrinsics (`__builtin_*`, `_mm_*`, `_Float*`, FPU + intrinsics) outside `sim_math.h`. +- Rounding-mode changes (`fesetround`) and FP exception modes (`FE_*`, + `SetErrorMode`): the pinned mode is round-to-nearest-even, the default + on every P0 target. + +The runtime FMA canaries in `tests/laige-core/math_float_tests.cpp` +(`DotProductCanaryDetectsFmaContraction`, `LerpIsNotFmaFused`) fail +loudly if the flags are ever missing — verified by a negative build with +`-ffp-contract=fast -mfma`. + +## Determinism scope (ARCH-010) + +`fp32_pinned` is bit-exact across **runs of the same build on the same +platform/ISA**. Cross-ISA bit-exactness (x86-64 vs arm64) is not +promised: it holds only if the compilers emit the same operation +sequence, which is defended by the pinned set and re-audited at every +toolchain upgrade. The M1-DET detcheck matrix generates the +per-platform support list; any desyncing pair is declared unsupported +for this backend. `fpx16_16` (M0-CORE-04) is the cross-ISA default and +the required backend for lockstep (AC-10.3) and authoritative MMO. + +## Performance (DOC-004) + +- **Complexity:** every op is O(1); no loops, no recursion. +- **Allocation:** none (value types, inline calls). +- **Indirection:** none — `SimMath` is a stateless template; + ops are static inline with one instantiation per backend (PERF-006: + no virtual dispatch, no `std::function`, no `std::map`, no locks). +- **Budget:** both backends must meet the §8.1 sim budget (10k entities + @ 60 Hz ≤ 3 ms); measured in M1 (CORE-001). +- **Trap:** computing the same math two different ways (e.g. + `lerp(a,b,t)` vs `a*(1-t)+b*t`, or reordering a sum) produces + different bits and breaks replays. Use one pinned expression per + quantity and keep it that way. + +## Misuse warnings + +- Raw `float` operators in deterministic sim code bypass SimMath and + break the determinism contract (PRD §10.3). Enforcement: M1-DET-01. +- Replays across backends are not bit-exact: the backend id is part of + replay identity (ADR 0002). +- `clamp`'s `lo`/`hi` must be finite and ordered; NaN/Inf bounds are + undefined behavior in release builds (debug builds assert). +- A translation unit that instantiates `SimMath` must carry + the pinned flag set (`laige_apply_simmath_policy`); otherwise the + bit-exact promise for that TU is void. + +## Verification + +`ctest -R math_float` — suites `SimMathBasics` (bit-exact known values, +±0, commutativity, vector ops), `SimMathNanInf` (the full NaN/Inf +policy), `SimMathProperties` (idempotence, round-trip tolerances, the +pinned-flag runtime canaries), `SimMathDispatch` (stateless +compile-time dispatch, `noexcept` contract, backend contract). diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index b70bb5d..8546902 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -117,6 +117,26 @@ Every engine target is passed through `laige_apply_engine_policy()` flags do not (CPP-010 — a game linking the engine keeps its own compiler policy). +## SimMath pinned-math policy (M0-CORE-03, ADR 0002) + +Every target that carries deterministic sim math is passed through +`laige_apply_simmath_policy()` (root `CMakeLists.txt`) — in M0 that is +`laige-core` and the `laige-core_tests` executable; from M1 on, every +sim module joins the list (e.g. `laige-sim`). The flags pin the +IEEE float semantics of the `fp32_pinned` backend +(`src/laige-core/include/laige/sim_math.h` is the source of truth for the +pinned set and the NaN/Inf policy): + +- GCC/Clang/AppleClang: `-ffp-contract=off -fno-associative-math` + (no FMA contraction of `a*b+c`, no reassociation — the pinned set is + visible on every compile line). +- MSVC 2022: `/fp:precise` (MSVC does not FMA-contract C expressions and + never reassociates at this setting). +- Banned in sim translation units: `-ffast-math` / + `-funsafe-math-optimizations` / `/fp:fast`, floating-point + intrinsics, rounding-mode changes, FP exception modes (re-audited at + every toolchain upgrade, ADR 0002). + ## Current status (M0) - `laige-core` builds as a static library (default) or a shared library @@ -125,10 +145,15 @@ Every engine target is passed through `laige_apply_engine_policy()` engine code from M0-CORE-01: `laige::Result` / `laige::Status` and the error-code registry (`include/laige/result.h`, `include/laige/errors.h`, `errors.cpp`; error text follows the NFR-13.3 - 5-field grammar — see [docs/api/errors.md](../api/errors.md)), and the + 5-field grammar — see [docs/api/errors.md](../api/errors.md)), the structured logging facade from M0-CORE-02 (`include/laige/logging.h`, `logging.cpp`; API contract in - [docs/api/logging.md](../api/logging.md)). + [docs/api/logging.md](../api/logging.md)), and the SimMath + deterministic-math interface with the `fp32_pinned` backend from + M0-CORE-03 (`include/laige/sim_math.h`, `sim_math.cpp`; API contract + and NaN/Inf policy in + [docs/api/sim_math.md](../api/sim_math.md), pinned flags via + `laige_apply_simmath_policy()`). - `tests/laige-core/laige-core_tests` is a CTest link smoke test (a GoogleTest suite since M0-DEP-01) that runs in every build tree above: it verifies the static/shared link and checks the NFR-8.10 policy flags with @@ -142,6 +167,10 @@ Every engine target is passed through `laige_apply_engine_policy()` `LogRateLimit`, `LogFatal`, `LogCrash`, `LogConcurrency`, and `LogPerformance` suites — the step's Verify command is `ctest -R logging`. +- `math_float` is the M0-CORE-03 CTest entry: a filtered view of the same + executable covering the `SimMathBasics`, `SimMathNanInf`, + `SimMathProperties`, and `SimMathDispatch` suites — the step's Verify + command is `ctest -R math_float`. - Every configure verifies the vendored dependency lock (`cmake/laige-deps-lock.cmake` against `deps.lock`); a tampered or unlisted file under `deps/` fails the configure loudly. GoogleTest is the diff --git a/roadmap/M0-foundations.md b/roadmap/M0-foundations.md index e78ba08..1a1a96b 100644 --- a/roadmap/M0-foundations.md +++ b/roadmap/M0-foundations.md @@ -357,7 +357,7 @@ No rendering, no physics, no networking yet — `laige-core` only. tests prove the step's Verify clauses — zero-alloc, rate-limit summary, Fatal termination — cohesive, not split) -- [ ] **M0-CORE-03 · SimMath interface + `fp32_pinned` backend** +- [x] **M0-CORE-03 · SimMath interface + `fp32_pinned` backend** - **Refs:** PRD §10.3, S-7; ADR 0002 (`fp32_pinned` backend); AGENTS CORE-005 - **Depends:** M0-DEC-02, M0-CORE-01 - **Scope:** diff --git a/src/laige-core/CMakeLists.txt b/src/laige-core/CMakeLists.txt index 51cdc78..f19c985 100644 --- a/src/laige-core/CMakeLists.txt +++ b/src/laige-core/CMakeLists.txt @@ -14,13 +14,20 @@ # smoke test in tests/laige-core/ verifies the library in both variants. if(LAIGE_BUILD_SHARED) - add_library(laige-core SHARED version.cpp errors.cpp logging.cpp) + add_library(laige-core SHARED version.cpp errors.cpp logging.cpp + sim_math.cpp) else() - add_library(laige-core STATIC version.cpp errors.cpp logging.cpp) + add_library(laige-core STATIC version.cpp errors.cpp logging.cpp + sim_math.cpp) endif() laige_apply_engine_policy(laige-core) +# M0-CORE-03: laige-core carries the SimMath deterministic-math interface +# (include/laige/sim_math.h); pin its IEEE float semantics (ADR 0002, +# PRD §10.3). Every later sim module passes through this same policy. +laige_apply_simmath_policy(laige-core) + # Public header root. Only this directory is exposed to consumers # (CPP-010: no transitive leakage). target_include_directories(laige-core PUBLIC diff --git a/src/laige-core/README.md b/src/laige-core/README.md index 8deb609..32fabb7 100644 --- a/src/laige-core/README.md +++ b/src/laige-core/README.md @@ -30,6 +30,21 @@ Status: M0 — Foundations. value) and `ErrorCode::IoError` (`5`) were added as small additive extensions of M0-CORE-01 to support the FileSink→facade hand-off. API contract: `docs/api/logging.md`; unit suite: `ctest -R logging`. +- **M0-CORE-03 (done):** SimMath — the single deterministic-math + interface (ADR 0002, PRD §10.3) with the `fp32_pinned` backend: + `laige::sim::SimMath` (add/sub/mul/div, ordered comparison + + IEEE NaN/Inf predicates, clamp, lerp, normalize, length over + scalar/`Vec2`/`Vec3`) and `laige::sim::Fp32Pinned` + (`include/laige/sim_math.h`, pinned instantiation in `sim_math.cpp`). + The pinned flag set of ADR 0002 (`-ffp-contract=off + -fno-associative-math` GCC/Clang/AppleClang, `/fp:precise` MSVC) is + applied by `laige_apply_simmath_policy()` to `laige-core` and the + test targets; the NaN/Inf policy is documented in the header and + tested (incl. runtime FMA canaries). API contract: + `docs/api/sim_math.md`; unit suite: `ctest -R math_float`. This step + also fixed a latent CTest filter bug (quoted `--gtest_filter` args + silently under-ran the `result_status`/`logging` Verify suites — + see `tests/laige-core/CMakeLists.txt`). - Further public headers land with each M0-CORE-xx step. Canonical build commands: diff --git a/src/laige-core/include/laige/sim_math.h b/src/laige-core/include/laige/sim_math.h new file mode 100644 index 0000000..30cdf67 --- /dev/null +++ b/src/laige-core/include/laige/sim_math.h @@ -0,0 +1,353 @@ +// laige-core SimMath — the single deterministic-math interface +// (M0-CORE-03; roadmap S-7 / G-R8). +// +// ADR 0002 (Deterministic math strategy): all deterministic simulation +// code uses exactly one math interface, SimMath, with two backends, +// selected once per engine/zone init from game config +// (`determinism.math`): +// +// Backend Config id Storage Determinism scope (ARCH-010) +// ------------- ------------------ ------------------------- ---------------------------------------- +// Fp32Pinned "float_pinned_32" IEEE binary32 (`float`) Bit-exact across runs of the same +// (this file) build on the same platform/ISA. +// Cross-ISA is NOT promised until the CI +// detcheck matrix proves it (M1-DET); a +// failing pair is declared unsupported +// for this backend. +// Fpx16_16 "fixed_point_16_16" Q16.16 in `int32_t`, Bit-exact across all builds, +// (M0-CORE-04) `int64_t` intermediates platforms, ISAs, and compilers +// by the language standard. The default +// backend; required for lockstep and +// authoritative MMO. +// +// PRD §10.3: in deterministic paths, engine math ops are the only +// floating-point allowed — never platform intrinsics outside the +// engine. This step ships the API, the `fp32_pinned` backend, and the +// pinned flag set; the *enforcement* of "SimMath only" in sim code +// lands in M1-DET-01 (G-R8). +// +// --------------------------------------------------------------------------- +// fp32_pinned: the pinned flag set (ADR 0002) +// --------------------------------------------------------------------------- +// The `fp32_pinned` promise is "bit-exact across runs of the same build +// on the same platform/ISA". It holds only if every compiler emits the +// same operation sequence for the pinned expressions below, which is +// defended by the pinned flag set — applied by +// `laige_apply_simmath_policy()` (root CMakeLists.txt) to every target +// that carries deterministic sim math (today: `laige-core` and its +// tests; from M1 on: every sim module, e.g. `laige-sim`): +// +// GCC / Clang / AppleClang: +// -ffp-contract=off a*b+c is never fused into a single-rounding +// FMA. This is what keeps `lerp()` and +// `length()` at the documented two-rounding +// below: under -ffp-contract=fast, lerp(a,b,t) +// could round as FMA(a, b-a, t) and length() +// as FMA(x, x, y*y) — each a different bit +// result. +// -fno-associative-math Sums are never reassociated (already the +// default; passed explicitly so the pinned +// set is visible on every compile line). +// MSVC 2022: +// /fp:precise The documented precision model; MSVC does +// not FMA-contract C expressions and never +// reassociates at this setting. Passed +// explicitly (it is the default) so the +// pinned set is visible on every compile +// line. +// +// Banned in sim translation units, all compilers (re-audited at every +// toolchain upgrade, ADR 0002): +// - -ffast-math / -funsafe-math-optimizations / /fp:fast — they +// enable reassociation, reciprocal math, and FMA contraction and +// remove the NaN/Inf guarantees below. +// - Floating-point intrinsics (`__builtin_*`, `_mm_*`, `_Float*`, +// FPU intrinsics) outside this header. +// - Rounding-mode changes (`fesetround`) and FP exception modes +// (`FE_*`, `SetErrorMode`): the pinned mode is +// round-to-nearest-even — the default on every P0 target — and no +// op may trap on a P0 target. +// +// --------------------------------------------------------------------------- +// fp32_pinned: NaN/Inf policy — defined, not "whatever the CPU does" +// --------------------------------------------------------------------------- +// All ops are IEEE-754 binary32, round-to-nearest-even: +// +// Arithmetic (`add`/`sub`/`mul`/`div`, `sqrt`): a NaN operand yields a +// NaN result (quiet NaN propagates). The exceptional values are +// IEEE-defined: 0/0 = NaN, x/0 = ±inf (no trap), inf - inf = NaN, +// inf * 0 = NaN, x/inf = ±0. +// Comparisons: `less`/`lessEqual`/`greater`/`greaterEqual`/`equals` +// are *ordered* — false when either operand is NaN. `notEquals` is +// true when either operand is NaN. `isOrdered(a,b)` is true iff +// neither is NaN. `isNaN`, `isInf`, `isFinite` are the IEEE +// classifications. +// `clamp(x, lo, hi)`: requires `lo` and `hi` finite and `lo <= hi` +// (debug builds assert; release builds treat a violation as +// undefined behavior — the engine's Result/Status convention). +// NaN in → NaN out; ±inf in → the corresponding bound. +// `lerp(a, b, t)` is exactly a + (b - a) * t: one sub, one mul, one +// add — two roundings, never fused (pinned -ffp-contract=off). It is +// NOT interchangeable with a*(1-t) + b*t (different rounding; +// replays diverge). t outside [0,1] extrapolates by the same +// expression (defined behavior). NaN in any operand → NaN out. +// `length(v)` is exactly sqrt(x*x + y*y): two muls, one add, one +// correctly-rounded sqrt (SSE2 vsqrtss / NEON vsqrt), in that order; +// the pinned flags keep x*x + y*y from FMA-contraction. `length` is +// always >= 0; length((0,0)) = +0. +// `normalize(v)` = v / length(v), component-wise IEEE division. The +// zero vector is defined to normalize to the zero vector — SimMath +// never injects NaN from a zero-length input (IEEE 0/0 would give +// NaN). NaN/inf components propagate per IEEE (an infinite vector +// can normalize to NaN components: inf/inf = NaN). +// Signed zero follows IEEE: -0.0f is representable, -0.0f == +0.0f is +// true, 0 + -0 = +0, and -1 * 0 = -0. +// +// No op may signal an FP exception or trap on a P0 target +// (documented policy, enforced by the pinned build). +// +// --------------------------------------------------------------------------- +// Performance (PERF-006, PERF-003) +// --------------------------------------------------------------------------- +// SimMath is a stateless template: all ops are static and +// inline, one template instantiation per backend, zero per-call +// indirection (no virtual dispatch, no std::function), O(1), no +// allocation, noexcept. Vec2/Vec3 are trivially copyable value types +// (PERF-004) for dense sim state. +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// - Raw `float` operators in deterministic sim code bypass SimMath +// and break the determinism contract (PRD §10.3). Enforcement: +// M1-DET-01. +// - The backend id is part of replay identity (ADR 0002): replays +// across backends are not bit-exact and are not supported. +// - The `fp32_pinned` backend is single-ISA by definition (ADR 0002); +// it does not participate in the PRD §12.3 bandwidth degradation +// ladder, which is defined on `fpx16_16`. + +#pragma once + +#include +#include +#include + +namespace laige::sim { + +// --------------------------------------------------------------------------- +// Backends +// --------------------------------------------------------------------------- + +// A backend is a stateless type providing the arithmetic primitives that +// the SimMath op surface is built from (ADR 0002: one op surface, two +// implementations). Contract: +// +// - `Scalar`: the backend's storage/operation type. +// - `add` / `sub` / `mul` / `div` / `sqrt`: the five pinned arithmetic +// primitives. Each must be one correctly-rounded operation of the +// backend's format (no fused or reassociated sequence), +// deterministic by the scope documented in the backend. +// +// Adding a backend is an additive change (ADR 0002 review conditions); +// `Fpx16_16` lands in M0-CORE-04. + +// IEEE-754 binary32 with pinned semantics (ADR 0002). See the header +// preamble for the pinned flag set and the full NaN/Inf policy. +struct Fp32Pinned { + using Scalar = float; + + // a + b: one IEEE binary32 addition, round-to-nearest-even. The + // build's -ffp-contract=off guarantees this does not fuse with an + // adjacent multiply (e.g. inside lerp()). + static Scalar add(Scalar a, Scalar b) noexcept { return a + b; } + static Scalar sub(Scalar a, Scalar b) noexcept { return a - b; } + static Scalar mul(Scalar a, Scalar b) noexcept { return a * b; } + // IEEE binary32 division: x/0 = ±inf (no trap on P0 targets), + // 0/0 = NaN. + static Scalar div(Scalar a, Scalar b) noexcept { return a / b; } + // Correctly-rounded binary32 square root (SSE2 vsqrtss / NEON vsqrt). + // sqrt of a negative value is NaN (IEEE); no trap. + static Scalar sqrt(Scalar a) noexcept { return std::sqrt(a); } +}; + +// --------------------------------------------------------------------------- +// The SimMath op surface (ADR 0002): one interface, one template +// instantiation per backend +// --------------------------------------------------------------------------- + +template +struct SimMath { + using Scalar = typename Backend::Scalar; + + // 2D world/vector: positions, velocities, deltas. Default-initialized + // to the zero vector (the additive identity; all components +0). The + // coordinate system (axes, handedness, units) is defined in + // docs/concepts/coordinates.md (M0-DOC-02). + struct Vec2 { + Scalar x{}; + Scalar y{}; + }; + + // 2.5D world space: (x, y) is the ground plane, z is depth/height. + struct Vec3 { + Scalar x{}; + Scalar y{}; + Scalar z{}; + }; + + // Factory (ADR 0002): backend selection is compile-time dispatch, + // factory-selected once per engine/zone init. The returned handle is + // stateless — holding one for a sim session costs nothing. + [[nodiscard]] static SimMath create() noexcept { return SimMath{}; } + + // ------------------------------------------------------------------ + // Arithmetic (IEEE per the backend's policy; NaN/Inf per the header + // preamble; division by zero does not trap) + // ------------------------------------------------------------------ + [[nodiscard]] static Scalar add(Scalar a, Scalar b) noexcept { + return Backend::add(a, b); + } + [[nodiscard]] static Scalar sub(Scalar a, Scalar b) noexcept { + return Backend::sub(a, b); + } + [[nodiscard]] static Scalar mul(Scalar a, Scalar b) noexcept { + return Backend::mul(a, b); + } + [[nodiscard]] static Scalar div(Scalar a, Scalar b) noexcept { + return Backend::div(a, b); + } + + // Component-wise vector arithmetic (same IEEE policy per component). + [[nodiscard]] static Vec2 add(Vec2 a, Vec2 b) noexcept { + return Vec2{add(a.x, b.x), add(a.y, b.y)}; + } + [[nodiscard]] static Vec2 sub(Vec2 a, Vec2 b) noexcept { + return Vec2{sub(a.x, b.x), sub(a.y, b.y)}; + } + [[nodiscard]] static Vec2 mul(Vec2 a, Vec2 b) noexcept { + return Vec2{mul(a.x, b.x), mul(a.y, b.y)}; + } + [[nodiscard]] static Vec2 mul(Vec2 a, Scalar s) noexcept { + return Vec2{mul(a.x, s), mul(a.y, s)}; + } + [[nodiscard]] static Vec2 mul(Scalar s, Vec2 a) noexcept { + return mul(a, s); + } + + // ------------------------------------------------------------------ + // Length / normalize + // ------------------------------------------------------------------ + // length is exactly sqrt(x*x + y*y): two muls, one add, one + // correctly-rounded sqrt, in that order (pinned -ffp-contract=off + // keeps x*x + y*y from FMA-contraction, which would round once and + // change the result). + [[nodiscard]] static Scalar length(Vec2 v) noexcept { + return Backend::sqrt( + Backend::add(Backend::mul(v.x, v.x), Backend::mul(v.y, v.y))); + } + [[nodiscard]] static Scalar length(Vec3 v) noexcept { + return Backend::sqrt(Backend::add( + Backend::add(Backend::mul(v.x, v.x), Backend::mul(v.y, v.y)), + Backend::mul(v.z, v.z))); + } + + // Unit vector: v / length(v), component-wise IEEE division. The zero + // vector is defined to normalize to the zero vector (SimMath never + // injects NaN from a zero-length input). NaN/inf components propagate + // per IEEE (an infinite vector can normalize to NaN components). + [[nodiscard]] static Vec2 normalize(Vec2 v) noexcept { + const Scalar len = length(v); + if (equals(len, Scalar{0.0})) return Vec2{}; + return Vec2{div(v.x, len), div(v.y, len)}; + } + [[nodiscard]] static Vec3 normalize(Vec3 v) noexcept { + const Scalar len = length(v); + if (equals(len, Scalar{0.0})) return Vec3{}; + return Vec3{div(v.x, len), div(v.y, len), div(v.z, len)}; + } + + // ------------------------------------------------------------------ + // Interpolation / bounding + // ------------------------------------------------------------------ + // lerp is exactly a + (b - a) * t (see the header preamble for the + // rounding contract and the non-interchangeability with a*(1-t)+b*t). + // t outside [0,1] extrapolates by the same expression (defined). + [[nodiscard]] static Scalar lerp(Scalar a, Scalar b, Scalar t) noexcept { + return Backend::add(a, Backend::mul(Backend::sub(b, a), t)); + } + [[nodiscard]] static Vec2 lerp(Vec2 a, Vec2 b, Scalar t) noexcept { + return Vec2{lerp(a.x, b.x, t), lerp(a.y, b.y, t)}; + } + + // Bounding: requires `lo` and `hi` finite and `lo <= hi` (debug builds + // assert; release builds treat a violation as undefined behavior — the + // engine's Result/Status convention). NaN x → NaN; ±inf x → the + // corresponding bound. + [[nodiscard]] static Scalar clamp(Scalar x, Scalar lo, Scalar hi) noexcept { + assert(isFinite(lo) && isFinite(hi) && lessEqual(lo, hi) && + "SimMath::clamp requires finite, ordered lo/hi (ADR 0002 policy)"); + if (greater(x, hi)) return hi; + if (less(x, lo)) return lo; + return x; + } + [[nodiscard]] static Vec2 clamp(Vec2 v, Vec2 lo, Vec2 hi) noexcept { + return Vec2{clamp(v.x, lo.x, hi.x), clamp(v.y, lo.y, hi.y)}; + } + + // ------------------------------------------------------------------ + // Comparison (IEEE, ordered) + // ------------------------------------------------------------------ + // NaN policy: less/lessEqual/greater/greaterEqual/equals are false + // when either operand is NaN; notEquals is true. The eq/ne primitives + // below are the only place in the engine where a raw floating-point + // equality comparison appears — deliberate bit-level comparisons that + // implement the documented policy — so the -Wfloat-equal heuristic (a + // -Wall member, which does not understand the pinned NaN/Inf + // contract) is scoped away there (CORE-010: no global suppression). +#if defined(__GNUC__) || defined(__clang__) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wfloat-equal" +#endif + static bool eq(Scalar a, Scalar b) noexcept { return a == b; } + static bool ne(Scalar a, Scalar b) noexcept { return a != b; } +#if defined(__GNUC__) || defined(__clang__) +# pragma GCC diagnostic pop +#endif + + static bool less(Scalar a, Scalar b) noexcept { return a < b; } + static bool lessEqual(Scalar a, Scalar b) noexcept { return a <= b; } + static bool greater(Scalar a, Scalar b) noexcept { return a > b; } + static bool greaterEqual(Scalar a, Scalar b) noexcept { return a >= b; } + static bool equals(Scalar a, Scalar b) noexcept { return eq(a, b); } + static bool notEquals(Scalar a, Scalar b) noexcept { return ne(a, b); } + // True iff neither operand is NaN (IEEE 754-2008 `totalOrder` + // predicate, i.e. the negation of `isunordered` — not the negation of + // the arithmetic != operator). + static bool isOrdered(Scalar a, Scalar b) noexcept { + return !isNaN(a) && !isNaN(b); + } + // IEEE classifications. + static bool isNaN(Scalar x) noexcept { return ne(x, x); } + static bool isInf(Scalar x) noexcept { + const Scalar inf = std::numeric_limits::infinity(); + return eq(x, inf) || eq(x, -inf); + } + static bool isFinite(Scalar x) noexcept { return !isNaN(x) && !isInf(x); } + + // Vector equality (component-wise; treats ±0 as equal, per IEEE). + static bool equals(Vec2 a, Vec2 b) noexcept { + return equals(a.x, b.x) && equals(a.y, b.y); + } + static bool notEquals(Vec2 a, Vec2 b) noexcept { return !equals(a, b); } + static bool equals(Vec3 a, Vec3 b) noexcept { + return equals(a.x, b.x) && equals(a.y, b.y) && equals(a.z, b.z); + } + static bool notEquals(Vec3 a, Vec3 b) noexcept { return !equals(a, b); } +}; + +// The `fp32_pinned` SimMath (this step; `determinism.math` config id +// "float_pinned_32"). `SimMathFpx16_16` follows in M0-CORE-04. +using SimMathFp32 = SimMath; + +} // namespace laige::sim diff --git a/src/laige-core/sim_math.cpp b/src/laige-core/sim_math.cpp new file mode 100644 index 0000000..2756db8 --- /dev/null +++ b/src/laige-core/sim_math.cpp @@ -0,0 +1,28 @@ +// laige-core SimMath — fp32_pinned backend instantiation (M0-CORE-03). +// +// This translation unit pins the fp32_pinned backend in two ways: +// +// 1. It carries the pinned flag set of ADR 0002 (applied by +// `laige_apply_simmath_policy()`: -ffp-contract=off +// -fno-associative-math for GCC/Clang/AppleClang, /fp:precise for +// MSVC), so the ops below compile exactly as written — never +// FMA-contracted, never reassociated. +// 2. It gives the library a real, linkable instantiation of the +// fp32_pinned backend (M0-BUILD-01 pattern: each functional step +// carries a symbol in both the static and shared variants), which +// the link smoke test in tests/laige-core/ exercises in both +// variants (NFR-8.9). +// +// Consumers instantiate SimMath in their own translation +// units; those translation units must carry the same pinned flag set +// (PRD §10.3, ADR 0002 — see the header preamble). + +#include "laige/sim_math.h" + +namespace laige::sim { + +// One template instantiation per backend (ADR 0002, PERF-006): emits +// every SimMath op into this pinned translation unit. +template struct SimMath; + +} // namespace laige::sim diff --git a/tests/laige-core/CMakeLists.txt b/tests/laige-core/CMakeLists.txt index 98d0724..e264659 100644 --- a/tests/laige-core/CMakeLists.txt +++ b/tests/laige-core/CMakeLists.txt @@ -11,7 +11,7 @@ # their own CTest entries (M0-CORE-01 adds `result_status`; later # M0-CORE-xx steps follow the same pattern). set(LAIGE_CORE_TEST_SOURCES laige-core_tests.cpp result_status_tests.cpp - logging_tests.cpp) + logging_tests.cpp math_float_tests.cpp) # M0-CORE-02: the test-only allocation counter overrides the global # operator new/new[]; the sanitizer runtimes define their own new/delete # (strong symbols in the Clang/GCC TSan runtime archives, interposed by @@ -28,6 +28,11 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN) target_compile_definitions(laige-core_tests PRIVATE LAIGE_ALLOC_COUNTER=1) endif() laige_apply_engine_policy(laige-core_tests) +# M0-CORE-03: the fp32_pinned SimMath backend is instantiated in this test +# executable, so it must carry the same pinned flag set as laige-core +# (ADR 0002, PRD §10.3) — the runtime FMA canaries in +# math_float_tests.cpp depend on it. +laige_apply_simmath_policy(laige-core_tests) # Links the library under test (the link itself is the static/shared check) # plus the dev-only test framework; gtest_main provides main(). @@ -46,6 +51,14 @@ endif() add_test(NAME laige-core_tests COMMAND laige-core_tests) +# The gtest filter is always one UNQUOTED argument. CTest passes quoted +# arguments through with the literal quote characters still attached, and +# gtest's colon-split of the filter then drops the quote-attached +# patterns — silently under-running the step's Verify scope (CORE-008). +# (Fixed in M0-CORE-03, where this defect was found under +# `ctest -R math_float`; it affected the result_status and logging +# entries inherited from M0-CORE-01/02 as well.) + # M0-CORE-01: Result/Status + error registry. The step's Verify # command is `ctest -R result_status`; this entry selects exactly the # ResultStatus/Status/ErrorCodeRegistry suites from the shared @@ -53,20 +66,26 @@ add_test(NAME laige-core_tests COMMAND laige-core_tests) # as part of the full module suite). add_test(NAME result_status COMMAND laige-core_tests - --gtest_filter="ResultStatus.*:Status.*:ErrorCodeRegistry.*") + --gtest_filter=ResultStatus.*:Status.*:ErrorCodeRegistry.*) # M0-CORE-02: structured logging facade. The step's Verify command is # `ctest -R logging`; this entry selects exactly the logging suites # from the shared laige-core_tests executable. add_test(NAME logging COMMAND laige-core_tests - --gtest_filter="LogGate.*:LogRecord.*:LogSinks.*:LogRateLimit.*:" - "LogFatal.*:LogCrash.*:LogConcurrency.*:" - "LogPerformance.*") + --gtest_filter=LogGate.*:LogRecord.*:LogSinks.*:LogRateLimit.*:LogFatal.*:LogCrash.*:LogConcurrency.*:LogPerformance.*) + +# M0-CORE-03: SimMath interface + fp32_pinned backend. The step's Verify +# command is `ctest -R math_float`; this entry selects exactly the +# SimMath* suites (SimMathBasics, SimMathNanInf, SimMathProperties, +# SimMathDispatch) from the shared laige-core_tests executable. +add_test(NAME math_float + COMMAND laige-core_tests + --gtest_filter=SimMathBasics.*:SimMathNanInf.*:SimMathProperties.*:SimMathDispatch.*) if(LAIGE_TSAN) # Make the first data race report fatal to the test process (NFR-8.2), # so ctest fails loudly on any TSan report. - set_tests_properties(laige-core_tests result_status logging + set_tests_properties(laige-core_tests result_status logging math_float PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-core/math_float_tests.cpp b/tests/laige-core/math_float_tests.cpp new file mode 100644 index 0000000..4d5d1c7 --- /dev/null +++ b/tests/laige-core/math_float_tests.cpp @@ -0,0 +1,513 @@ +// laige-core SimMath fp32_pinned backend suite (M0-CORE-03). +// +// Step Verify scope (roadmap/M0-foundations.md): +// - `ctest -R math_float` green (suites: SimMathBasics, SimMathNanInf, +// SimMathProperties, SimMathDispatch) +// - NaN/Inf handling is *defined* and tested — the documented policy of +// the header, not "whatever the CPU does" (SimMathNanInf) +// - Associativity guard: the pinned flag set (-ffp-contract=off) is +// verified at runtime — DotProductCanary and LerpIsNotFmaFused fail +// if the compiler FMA-contracts or reassociates the pinned +// expressions +// - Compile-time dispatch: SimMath is stateless, one +// instantiation per backend (ADR 0002, PERF-006) +// +// This translation unit is a sim target: it is built with the pinned +// flag set (laige_apply_simmath_policy). std::sqrt / std::numeric_limits +// appear only as oracles for known values; every value under test comes +// from a SimMath op (PRD §10.3, ADR 0002). +// +// House convention (also enforced by -Wall -Werror): raw `float` +// equality comparisons trigger -Wfloat-equal, so all float equality +// checks here go through bits() (bit-exact uint32 comparison) or same() +// (the SimMath NaN-aware equality), never EXPECT_EQ on floats. + +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/sim_math.h" + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "math_float_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "math_float_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "math_float_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +namespace { + +using Sim = laige::sim::SimMathFp32; +using Vec2 = Sim::Vec2; +using Vec3 = Sim::Vec3; +using Scalar = Sim::Scalar; + +const Scalar kNaN = std::numeric_limits::quiet_NaN(); +const Scalar kInf = std::numeric_limits::infinity(); +const Scalar kNegInf = -kInf; + +// Bit-exact comparison helpers (memcpy, not pointer casts — CPP-004). +std::uint32_t bits(Scalar f) { + std::uint32_t u; + std::memcpy(&u, &f, sizeof u); + return u; +} + +// NaN-aware equality for EXPECT_*: a raw == is false for (NaN, NaN), +// which is the IEEE classification SimMath documents. +bool same(Scalar a, Scalar b) { + return Sim::equals(a, b) || (Sim::isNaN(a) && Sim::isNaN(b)); +} + +} // namespace + +// --------------------------------------------------------------------------- +// SimMathBasics — exact IEEE values, ±0, bit-exact known results +// --------------------------------------------------------------------------- + +TEST(SimMathBasics, KnownValuesBitExact) { + // 0.1f + 0.2f == 0.3f: the exact sum 0.30000000447034836 rounds to the + // same binary32 as the 0.3 literal. + EXPECT_EQ(bits(Sim::add(0.1f, 0.2f)), bits(0.3f)); + // 1/3 in binary32: the repeating 1.01010101... significand rounds up + // at bit 24 to 0x3EAAAAAB. + EXPECT_EQ(bits(Sim::div(1.0f, 3.0f)), 0x3EAAAAABu); + // (1/3) * 3 rounds back to exactly 1.0 (the product + // 1.0000000298... is within half an ulp of 1.0). + EXPECT_EQ(Sim::mul(Sim::div(1.0f, 3.0f), 3.0f), 1.0f); + // 1e-8 < half ulp(1.0) ≈ 5.96e-8 → 1.0 + 1e-8f rounds to exactly 1.0. + EXPECT_EQ(Sim::add(1.0f, 1e-8f), 1.0f); + // 3-4-5 right triangle, exact in binary32. + EXPECT_EQ(Sim::length(Vec2{3.0f, 4.0f}), 5.0f); + // length((1,1)) is the correctly-rounded sqrt(2) (oracle: std::sqrt). + EXPECT_EQ(bits(Sim::length(Vec2{1.0f, 1.0f})), bits(std::sqrt(2.0f))); + // 8 * 0.25 is exact dyadic scaling → lerp from 0 at t = 0.25 is exact. + EXPECT_EQ(Sim::lerp(0.0f, 8.0f, 0.25f), 2.0f); + // lerp(a, b, 1) is exact for dyadic a, b (a + fl(b-a) rounds back to b). + EXPECT_EQ(Sim::lerp(1.0f, 2.0f, 1.0f), 2.0f); +} + +TEST(SimMathBasics, SignedZero) { + // IEEE signed zero: 0 + -0 = +0, and the sign survives multiplication. + EXPECT_EQ(bits(Sim::add(0.0f, -0.0f)), 0u); + EXPECT_EQ(bits(Sim::mul(-1.0f, 0.0f)), 0x80000000u); + // ±0 compare equal and are not ordered against each other. + EXPECT_TRUE(Sim::equals(0.0f, -0.0f)); + EXPECT_FALSE(Sim::less(0.0f, -0.0f)); + EXPECT_FALSE(Sim::greater(0.0f, -0.0f)); + // Vector add normalizes (-0, -0) + (0, 0) to (+0, +0). + EXPECT_TRUE(Sim::equals(Sim::add(Vec2{-0.0f, -0.0f}, Vec2{0.0f, 0.0f}), + Vec2{0.0f, 0.0f})); + // Vec2 equality is component-wise and treats ±0 as equal. + EXPECT_TRUE(Sim::equals(Vec2{-0.0f, 0.0f}, Vec2{0.0f, 0.0f})); + EXPECT_FALSE(Sim::notEquals(Vec2{-0.0f, 0.0f}, Vec2{0.0f, 0.0f})); +} + +TEST(SimMathBasics, CommutativityBitExact) { + // IEEE addition and multiplication are commutative (including ±0 and + // denormals); the guard pins that no reassociation or sign quirk + // breaks it in this build. + const Scalar xs[] = {0.0f, -0.0f, 1.0f, -1.0f, 0.1f, -0.1f, 3.25f, -3.25f, + std::numeric_limits::max(), + std::numeric_limits::denorm_min()}; + for (const Scalar x : xs) { + for (const Scalar y : xs) { + EXPECT_EQ(bits(Sim::add(x, y)), bits(Sim::add(y, x))) << "x=" << x; + EXPECT_EQ(bits(Sim::mul(x, y)), bits(Sim::mul(y, x))) << "x=" << x; + } + } +} + +TEST(SimMathBasics, Division) { + // x / x == 1 for every finite nonzero x. + const Scalar xs[] = {1.0f, -1.0f, 0.1f, 123456.75f, 6.5536e-5f}; + for (const Scalar x : xs) EXPECT_EQ(Sim::div(x, x), 1.0f); + // inf / inf is NaN (IEEE), never 1. + EXPECT_TRUE(Sim::isNaN(Sim::div(kInf, kInf))); + EXPECT_TRUE(Sim::isNaN(Sim::div(kNegInf, kNegInf))); + // Exact dyadic divisions. + EXPECT_EQ(Sim::div(8.0f, 2.0f), 4.0f); + EXPECT_EQ(Sim::div(-8.0f, 2.0f), -4.0f); + EXPECT_EQ(bits(Sim::div(1.0f, -2.0f)), bits(-0.5f)); +} + +TEST(SimMathBasics, VectorOps) { + const Vec2 p{1.5f, -2.25f}; + const Vec2 q{0.25f, 4.0f}; + EXPECT_TRUE(Sim::equals(Sim::add(p, q), Vec2{1.75f, 1.75f})); + EXPECT_TRUE(Sim::equals(Sim::sub(p, q), Vec2{1.25f, -6.25f})); + EXPECT_TRUE(Sim::equals(Sim::mul(p, q), Vec2{0.375f, -9.0f})); + EXPECT_TRUE(Sim::equals(Sim::mul(p, 2.0f), Vec2{3.0f, -4.5f})); + EXPECT_TRUE(Sim::equals(Sim::mul(2.0f, p), Sim::mul(p, 2.0f))); + // 0.5 scaling is exact dyadic scaling. + EXPECT_TRUE(Sim::equals(Sim::mul(p, 0.5f), Vec2{0.75f, -1.125f})); + // The zero vector is the additive identity (bit-exact). + EXPECT_TRUE(Sim::equals(Sim::add(p, Vec2{}), p)); + EXPECT_TRUE(Sim::equals(Sim::sub(p, p), Vec2{})); + // Vec3: 3-4-12 → 13 exact. + EXPECT_EQ(Sim::length(Vec3{3.0f, 4.0f, 12.0f}), 13.0f); + // normalize is exact for dyadic components. + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec2{0.0f, 2.0f}), Vec2{0.0f, 1.0f})); + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec2{2.0f, 0.0f}), Vec2{1.0f, 0.0f})); + EXPECT_TRUE( + Sim::equals(Sim::normalize(Vec2{-2.0f, 0.0f}), Vec2{-1.0f, 0.0f})); + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec3{0.0f, 0.0f, 3.0f}), + Vec3{0.0f, 0.0f, 1.0f})); +} + +// --------------------------------------------------------------------------- +// SimMathNanInf — the documented NaN/Inf policy, defined and tested +// --------------------------------------------------------------------------- + +TEST(SimMathNanInf, ArithmeticPropagatesNaN) { + EXPECT_TRUE(Sim::isNaN(Sim::add(kNaN, 1.0f))); + EXPECT_TRUE(Sim::isNaN(Sim::add(1.0f, kNaN))); + EXPECT_TRUE(Sim::isNaN(Sim::sub(kNaN, 1.0f))); + EXPECT_TRUE(Sim::isNaN(Sim::mul(kNaN, 1.0f))); + EXPECT_TRUE(Sim::isNaN(Sim::div(kNaN, 1.0f))); + EXPECT_TRUE(Sim::isNaN(Sim::div(1.0f, kNaN))); + // IEEE exceptional values. + EXPECT_TRUE(Sim::isNaN(Sim::div(0.0f, 0.0f))); + EXPECT_TRUE(Sim::isNaN(Sim::add(kInf, kNegInf))); + EXPECT_TRUE(Sim::isNaN(Sim::sub(kInf, kInf))); + EXPECT_TRUE(Sim::isNaN(Sim::mul(kInf, 0.0f))); + EXPECT_TRUE(Sim::isNaN(Sim::div(kInf, kInf))); + EXPECT_TRUE(Sim::isNaN(laige::sim::Fp32Pinned::sqrt(-1.0f))); + // NaN propagates through vectors component-wise. + EXPECT_TRUE(Sim::isNaN(Sim::add(Vec2{kNaN, 1.0f}, Vec2{0.0f, 0.0f}).x)); + EXPECT_TRUE(Sim::isNaN(Sim::length(Vec2{kNaN, 0.0f}))); +} + +TEST(SimMathNanInf, DivisionByZeroPolicy) { + // No trap on P0 targets: x/0 = ±inf with the sign of x * sign(0). + EXPECT_EQ(bits(Sim::div(1.0f, 0.0f)), bits(kInf)); + EXPECT_EQ(bits(Sim::div(-1.0f, 0.0f)), bits(kNegInf)); + EXPECT_EQ(bits(Sim::div(1.0f, -0.0f)), bits(kNegInf)); + EXPECT_TRUE(Sim::isNaN(Sim::div(0.0f, 0.0f))); + // x/inf = ±0 (signed), 0/inf = +0. + EXPECT_EQ(Sim::div(1.0f, kInf), 0.0f); + EXPECT_EQ(bits(Sim::div(-1.0f, kInf)), 0x80000000u); + EXPECT_EQ(Sim::div(0.0f, kInf), 0.0f); +} + +TEST(SimMathNanInf, Classification) { + EXPECT_TRUE(Sim::isNaN(kNaN)); + EXPECT_FALSE(Sim::isNaN(0.0f)); + EXPECT_TRUE(Sim::isInf(kInf)); + EXPECT_TRUE(Sim::isInf(kNegInf)); + EXPECT_FALSE(Sim::isInf(kNaN)); + EXPECT_TRUE(Sim::isFinite(1234.5f)); + EXPECT_FALSE(Sim::isFinite(kNaN)); + EXPECT_FALSE(Sim::isFinite(kInf)); + // No flush-to-zero: sqrt of the smallest denormal is a finite + // (denormal) value, not 0 (pinned build, ADR 0002). + const Scalar tiny = + laige::sim::Fp32Pinned::sqrt(std::numeric_limits::denorm_min()); + EXPECT_TRUE(Sim::isFinite(tiny)); + EXPECT_TRUE(Sim::greater(tiny, 0.0f)); +} + +TEST(SimMathNanInf, OrderedComparisons) { + // NaN: every ordered comparison is false, notEquals is true. + EXPECT_FALSE(Sim::less(kNaN, 1.0f)); + EXPECT_FALSE(Sim::less(1.0f, kNaN)); + EXPECT_FALSE(Sim::lessEqual(kNaN, 1.0f)); + EXPECT_FALSE(Sim::greater(kNaN, 1.0f)); + EXPECT_FALSE(Sim::greaterEqual(1.0f, kNaN)); + EXPECT_FALSE(Sim::equals(kNaN, kNaN)); // NaN is not equal to itself + EXPECT_FALSE(Sim::equals(1.0f, kNaN)); + EXPECT_TRUE(Sim::notEquals(kNaN, 1.0f)); + EXPECT_FALSE(Sim::isOrdered(kNaN, 1.0f)); + EXPECT_TRUE(Sim::isOrdered(-1.0f, 1.0f)); + // Normal ordering still works, and ±inf sit at the ends. + EXPECT_TRUE(Sim::less(-1.0f, 0.0f)); + EXPECT_TRUE(Sim::lessEqual(0.0f, 0.0f)); + EXPECT_TRUE(Sim::greater(1.0f, 0.0f)); + EXPECT_TRUE(Sim::greaterEqual(1.0f, 1.0f)); + EXPECT_TRUE(Sim::less(kNegInf, 1.0f)); + EXPECT_TRUE(Sim::greater(kInf, 1.0f)); +} + +TEST(SimMathNanInf, ClampPolicy) { + EXPECT_EQ(Sim::clamp(5.0f, 0.0f, 10.0f), 5.0f); + EXPECT_EQ(Sim::clamp(15.0f, 0.0f, 10.0f), 10.0f); + EXPECT_EQ(Sim::clamp(-5.0f, 0.0f, 10.0f), 0.0f); + EXPECT_EQ(Sim::clamp(10.0f, 0.0f, 10.0f), 10.0f); + // Defined NaN/Inf policy: NaN x propagates; ±inf x clamps to the bound. + EXPECT_TRUE(Sim::isNaN(Sim::clamp(kNaN, 0.0f, 10.0f))); + EXPECT_EQ(Sim::clamp(kInf, 0.0f, 10.0f), 10.0f); + EXPECT_EQ(Sim::clamp(kNegInf, 0.0f, 10.0f), 0.0f); + // Vec2 clamp is component-wise. + EXPECT_TRUE(Sim::equals( + Sim::clamp(Vec2{15.0f, -5.0f}, Vec2{0.0f, 0.0f}, Vec2{10.0f, 10.0f}), + Vec2{10.0f, 0.0f})); +} + +TEST(SimMathNanInf, LerpNanInfPolicy) { + // NaN in any operand → NaN out. + EXPECT_TRUE(Sim::isNaN(Sim::lerp(kNaN, 1.0f, 0.5f))); + EXPECT_TRUE(Sim::isNaN(Sim::lerp(1.0f, kNaN, 0.5f))); + EXPECT_TRUE(Sim::isNaN(Sim::lerp(1.0f, 2.0f, kNaN))); + // IEEE: (inf - inf) = NaN, so lerp over an infinite span is NaN; + // lerp(inf, -inf, t) hits inf + (-inf) = NaN. + EXPECT_TRUE(Sim::isNaN(Sim::lerp(kInf, kInf, 0.5f))); + EXPECT_TRUE(Sim::isNaN(Sim::lerp(kInf, kNegInf, 0.5f))); +} + +TEST(SimMathNanInf, LengthPolicy) { + // length((0,0)) = +0 (exact bits). + EXPECT_EQ(bits(Sim::length(Vec2{})), 0u); + // Infinite components give an infinite length. + EXPECT_TRUE(Sim::isInf(Sim::length(Vec2{kInf, 0.0f}))); + EXPECT_TRUE(Sim::isInf(Sim::length(Vec2{kNegInf, 0.0f}))); + EXPECT_TRUE(Sim::isInf(Sim::length(Vec2{kInf, kNegInf}))); + // length is always >= 0 (ordered comparison with +0). + EXPECT_TRUE(Sim::greaterEqual(Sim::length(Vec2{0.1f, -2.5f}), 0.0f)); + EXPECT_TRUE(Sim::greaterEqual(Sim::length(Vec2{}), 0.0f)); +} + +TEST(SimMathNanInf, NormalizePolicy) { + // Policy: the zero vector normalizes to the zero vector — SimMath + // never injects NaN from a zero-length input (IEEE 0/0 would give + // NaN). + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec2{}), Vec2{})); + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec3{}), Vec3{})); + // NaN components propagate. + EXPECT_TRUE(Sim::isNaN(Sim::normalize(Vec2{kNaN, 1.0f}).x)); + // 3-4-5 normalizes exactly to (0.6, 0.8) (3/5 and 4/5 are exact + // binary32 roundings of the literals). + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec2{3.0f, 4.0f}), + Vec2{0.6f, 0.8f})); + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec2{-3.0f, -4.0f}), + Vec2{-0.6f, -0.8f})); +} + +// --------------------------------------------------------------------------- +// SimMathProperties — property tests + the pinned-flag runtime guards +// --------------------------------------------------------------------------- + +TEST(SimMathProperties, ClampIdempotent) { + // clamp(clamp(x)) == clamp(x) for every x in the policy domain, + // including NaN and ±inf. + const Scalar xs[] = {-1e30f, kNegInf, -1.0f, -0.0f, 0.0f, 1.0f, 7.5f, + std::numeric_limits::denorm_min(), kInf, + std::numeric_limits::max(), kNaN}; + for (const Scalar x : xs) { + const Scalar once = Sim::clamp(x, -10.0f, 10.0f); + EXPECT_TRUE(same(Sim::clamp(once, -10.0f, 10.0f), once)) << "x=" << x; + } +} + +TEST(SimMathProperties, LerpExactMidpointsAndExtrapolation) { + // lerp(a, b, 0) is exact for every finite a, b: (b-a)*0 = ±0 and + // a ± 0 = a. + const Scalar aVals[] = {0.0f, -0.0f, 1.0f, -1.0f, 0.1f, 3.25f}; + const Scalar bVals[] = {0.0f, 2.0f, -0.5f, 0.2f, 12345.25f}; + for (const Scalar a : aVals) + for (const Scalar b : bVals) + EXPECT_TRUE(same(Sim::lerp(a, b, 0.0f), a)) << "a=" << a << " b=" << b; + // Midpoint with t = 0.5 is exact for dyadic a, b (*0.5 is exact + // scaling). + EXPECT_EQ(Sim::lerp(1.0f, 2.0f, 0.5f), 1.5f); + EXPECT_EQ(Sim::lerp(-3.0f, 3.0f, 0.5f), 0.0f); + EXPECT_TRUE( + Sim::equals(Sim::lerp(Vec2{0.0f, 4.0f}, Vec2{2.0f, 0.0f}, 0.5f), + Vec2{1.0f, 2.0f})); + // t outside [0,1] extrapolates by the same expression (defined). + EXPECT_EQ(Sim::lerp(0.0f, 2.0f, 2.0f), 4.0f); + EXPECT_EQ(Sim::lerp(0.0f, 2.0f, -1.0f), -2.0f); +} + +TEST(SimMathProperties, LengthInvariantUnderSign) { + // (-x)*(-x) == x*x bit-exactly (IEEE sign rule), so length is + // invariant under per-component sign flips — even for NaN/inf + // inputs. + const Vec2 vs[] = {Vec2{0.1f, -2.5f}, Vec2{0.0f, 0.0f}, + Vec2{123456.75f, 6.5536e-5f}}; + for (const Vec2 v : vs) { + EXPECT_TRUE(same(Sim::length(v), Sim::length(Vec2{-v.x, v.y}))); + EXPECT_TRUE(same(Sim::length(v), Sim::length(Vec2{v.x, -v.y}))); + EXPECT_TRUE(same(Sim::length(v), Sim::length(Vec2{-v.x, -v.y}))); + } + EXPECT_TRUE(same(Sim::length(Vec2{kNaN, 0.0f}), + Sim::length(Vec2{kNaN, -0.0f}))); +} + +TEST(SimMathProperties, NormalizeRoundTripWithinTolerance) { + // length(normalize(v)) is within 4 ulp of 1.0: the error budget of the + // two component divisions, two squarings, the sum, and the sqrt, each + // at most 2^-24 relative (documented tolerance, CORE-005). + const Scalar tol = 4.0f * std::numeric_limits::epsilon(); + const Vec2 vs[] = {Vec2{0.1f, 0.2f}, Vec2{3.0f, 4.0f}, + Vec2{-7.25f, 0.03125f}, Vec2{123456.75f, -9.765625e-4f}, + Vec2{1.0f, 1.0f}, Vec2{6.5536e-5f, 0.0f}}; + for (const Vec2 v : vs) { + const Scalar len = Sim::length(Sim::normalize(v)); + EXPECT_TRUE(Sim::isFinite(len)); + EXPECT_LE(len, 1.0f + tol); + EXPECT_GE(len, 1.0f - tol); + } +} + +TEST(SimMathProperties, AddSubInverseOnExactValues) { + // On dyadic values in range, (a + b) - b == a is bit-exact (no + // rounding occurs, so the inverse property holds). + const Scalar xs[] = {0.0f, 1.0f, -1.0f, 0.25f, -0.25f, 4096.0f, -4096.0f}; + const Scalar ys[] = {1.0f, -1.0f, 2.5f, -2.5f, 0.5f, 8192.0f}; + for (const Scalar x : xs) + for (const Scalar y : ys) + EXPECT_EQ(Sim::sub(Sim::add(x, y), y), x) << "x=" << x << " y=" << y; +} + +// The pinned flag set (ADR 0002: -ffp-contract=off) is verified at +// runtime, not only on the compile line. This loop is exactly the +// pattern FMA contraction targets: s = s + x[i]*y[i]. The reference path +// uses a volatile accumulator, so the optimizer must not transform it — +// it is the exact IEEE operation sequence of the expression. If the +// flags were missing (e.g. the target built with -ffp-contract=fast, +// -ffast-math, or MSVC /fp:fast), the engine path could fuse the +// multiply-add into one single-rounding FMA and the results diverge +// bit-wise; EXPECT_EQ then fails loudly (CORE-008: no silent failure). +TEST(SimMathProperties, DotProductCanaryDetectsFmaContraction) { + constexpr int kN = 256; + Scalar x[kN], y[kN]; + for (int i = 0; i < kN; ++i) { + // Deterministic, bit-exact input generation: integer → float, then + // exact literal scaling (no transcendental functions, no + // platform-dependent libm). + x[i] = static_cast((i * 7919) % 1000) * 0.001f - 0.5f; + y[i] = static_cast((i * 104729) % 1000) * 0.001f - 0.5f; + } + volatile float ref = 0.0f; + for (int i = 0; i < kN; ++i) ref = Sim::add(ref, Sim::mul(x[i], y[i])); + float acc = 0.0f; + for (int i = 0; i < kN; ++i) acc = Sim::add(acc, Sim::mul(x[i], y[i])); + EXPECT_EQ(bits(acc), bits(static_cast(ref))); +} + +// SimMath::lerp is pinned to the two-rounding form a + (b - a) * t. If +// -ffp-contract=off were missing, the inlined lerp could be +// FMA-contracted to the single-rounding FMA(a, b-a, t), changing the +// result wherever the two roundings differ. The fused result is emulated +// exactly in double — for the magnitudes searched here every float +// intermediate stays within double's 53-bit significand — so the +// comparison is bit-exact. If the flags were missing, even the +// two-rounding reference would fuse, and the search would find no +// differing case; EXPECT_GE then fails loudly either way. +TEST(SimMathProperties, LerpIsNotFmaFused) { + const Scalar aVals[] = {-4.0f, -1.0f, -0.5f, 0.0f, 0.5f, 1.0f, 2.0f, 4.0f, + 0.1f, 0.2f, 0.3f, -0.3f, 1.5f, -2.5f}; + const Scalar bVals[] = {-4.0f, -2.0f, -1.0f, 0.0f, 1.0f, 2.0f, 4.0f, + 0.2f, 0.3f, -0.2f, -1.5f, 2.5f, 0.7f, -0.7f}; + const Scalar tVals[] = {0.1f, 0.2f, 0.3f, 0.25f, 0.5f, 0.75f, 1.0f, + -0.25f}; + int fusedDiffers = 0; + for (const Scalar a : aVals) + for (const Scalar b : bVals) + for (const Scalar t : tVals) { + volatile float refA = a, refB = b, refT = t; + const float twoStep = refA + (refB - refA) * refT; // pinned form + const float fused = static_cast( + static_cast(refA) + + (static_cast(refB) - static_cast(refA)) * + static_cast(refT)); // exact FMA emulation + if (bits(twoStep) != bits(fused)) { + ++fusedDiffers; + EXPECT_EQ(bits(Sim::lerp(a, b, t)), bits(twoStep)) + << "a=" << a << " b=" << b << " t=" << t; + } + } + // The search must actually find cases where the two roundings differ; + // otherwise this test proves nothing. + EXPECT_GE(fusedDiffers, 1); +} + +// --------------------------------------------------------------------------- +// SimMathDispatch — compile-time dispatch mechanics (ADR 0002, PERF-006) +// --------------------------------------------------------------------------- + +TEST(SimMathDispatch, StatelessCompileTimeDispatch) { + // One template instantiation per backend, no per-call indirection: the + // type is stateless and trivially copyable, so SimMath ops inline to + // the backend's primitive operations. + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(Sim) == 1); + static_assert(std::is_same_v); + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(Vec2) == sizeof(float) * 2); + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(Vec3) == sizeof(float) * 3); + + // A cast to a `noexcept` function pointer type is ill-formed unless the + // pointee is itself noexcept, so each line fails the build if a SimMath + // op ever stops being noexcept (PERF-006 hot-path contract). + using AddFn = + decltype(static_cast(Sim::add)); + using SubFn = + decltype(static_cast(Sim::sub)); + using MulFn = + decltype(static_cast(Sim::mul)); + using DivFn = + decltype(static_cast(Sim::div)); + using LenFn = decltype(static_cast(Sim::length)); + using NormFn = decltype(static_cast(Sim::normalize)); + using LerpFn = decltype(static_cast(Sim::lerp)); + using ClampFn = decltype(static_cast(Sim::clamp)); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + + // The factory form (ADR 0002: factory-selected at init): a held handle + // dispatches to the same pinned ops. + const Sim math = Sim::create(); + EXPECT_EQ(math.add(1.0f, 1.0f), 2.0f); + EXPECT_EQ(math.sub(3.0f, 1.0f), 2.0f); + EXPECT_EQ(math.mul(2.0f, 3.0f), 6.0f); + EXPECT_EQ(math.div(8.0f, 2.0f), 4.0f); + EXPECT_TRUE(Sim::equals(math.normalize(Vec2{0.0f, 5.0f}), + Vec2{0.0f, 1.0f})); + EXPECT_EQ(math.clamp(15.0f, 0.0f, 10.0f), 10.0f); + EXPECT_EQ(math.lerp(0.0f, 8.0f, 0.25f), 2.0f); + EXPECT_EQ(math.length(Vec2{3.0f, 4.0f}), 5.0f); +} + +TEST(SimMathDispatch, Fp32PinnedBackendContract) { + // The backend contract (ADR 0002): one correctly-rounded operation per + // primitive, deterministic by the header's pinned scope. + using B = laige::sim::Fp32Pinned; + static_assert(std::is_same_v); + static_assert(std::is_trivially_copyable_v); + EXPECT_EQ(B::add(1.0f, 1.0f), 2.0f); + EXPECT_EQ(B::sub(3.0f, 1.0f), 2.0f); + EXPECT_EQ(B::mul(2.0f, 3.0f), 6.0f); + EXPECT_TRUE(Sim::isInf(B::div(1.0f, 0.0f))); + EXPECT_TRUE(Sim::isNaN(B::div(0.0f, 0.0f))); + EXPECT_EQ(B::sqrt(4.0f), 2.0f); + EXPECT_EQ(B::sqrt(0.0f), 0.0f); + EXPECT_TRUE(Sim::isNaN(B::sqrt(-1.0f))); + // inf*inf = inf, sqrt(inf) = inf. + EXPECT_TRUE(Sim::isInf(B::sqrt(B::mul(kInf, kInf)))); +}