Skip to content

Stage 3b battery: the fixed-point contract, pinned - #29

Open
tap wants to merge 4 commits into
claude/wave2-stage3b-kernelfrom
claude/wave2-stage3b-battery
Open

tap wants to merge 4 commits into
claude/wave2-stage3b-kernelfrom
claude/wave2-stage3b-battery

Conversation

@tap

@tap tap commented Sep 18, 2026

Copy link
Copy Markdown
Owner

What this changes

The Part 9 test battery for the Q15 / Q31 real FFT profiles under both scaling policies (Stage 3b of docs/audit-fft-and-code-smells.md), written test-first against the API contract and stacked on the kernel PR (#27). New tests/test_fft_fixed.cpp; tests/test_fft.cpp widened to four profiles; tests/test_fft_oracle.cpp and tests/test_fft_rt.cpp extended to the fixed profiles; one shared helper (tap::dsp::test::sample_scale) in tests/support/signals.h. No include/, docs, bench or workflow files are touched.

Why

The kernel's header cites contract numbers; this is the battery that pins them, so a wrong kernel cannot slip through: every exponent, scale, saturation bound, noise floor, rounding property and table checksum is asserted, and every tolerance is a measured number pinned at 2x, printed by the test that carries it so a -V run records the current value beside the pin.

The tests and what each pins

tests/test_fft_fixed.cpp (typed over {Q15, Q31} x {fixed, block_floating} unless noted):

test promise
FixedExponentIsTheStatedConstant scaling::fixed: e == fixed_scaling_exponent(n) == log2 n + pre-shift (Q15: log2 n, Q31: log2 n + 1) from all four entry points, both directions, 4 <= n <= 65536, also on silence; static_asserted constexpr/noexcept
BfpExponentIsWithinRange scaling::block_floating: 0 <= e <= the constant, both directions, on silence, one LSB, noise at three levels, full-scale DC/INT_MIN and every adversarial pattern
FixedForwardScaleIsExactlyXOverN bit-exact X * 2^-e on an integer impulse (A = m * 2^e) and a constant (multiple of 2^(pre+2)), n = 4 .. 2048
fft_fixed_q31.FixedForwardScaleIsExactlyXOverTwoN the Q31 extra bit spelled out (0.5 constant -> 0.25; 2^20 impulse -> 2^10 at n = 512), Q15 contrasted
FixedInverseCarriesTheSameExponent inverse e is the same constant; DC-only and flat spectra are bit-exact (constant carries pre + 3 zero bits: the pre-pass eats pre + 1, then the unrotated path shifts 2 per stage)
SaturationFreeWorstCaseDoesNotWrap 24 full-scale patterns (DC +full / INT_MIN, Nyquist both phases, impulses, the +/-90-degree rotated packed pair incl. from INT_MIN, square-wave complex exponentials at M/8, M/16, 3M/8 (the 45-degree twiddle), real square waves, four binary-noise seeds; plus one-full-bin and DC+Nyquist spectra for the inverse), 7 sizes, both directions: max deviation from G * 2^-e <= pin and no output on a rail the golden model does not reach
SilenceIsSilence zero in, zero out, both directions
RoundTripReconstructsInputPerPolicy x == out * 2^(e_fwd + e_inv + 1 - log2 n), error in reconstructed LSB, n = 16 .. 1024, 0 / -12 / -40 dBFS
OutOfPlaceIsCopyThenInPlace forward()/inverse() bit-identical to the in-place calls (aliased too), same e; no 2/N on the fixed-point inverse
BfpMatchesFixedAfterShift BFP output shr_round'ed by e_fixed - e_bfp agrees with the fixed output within the pin, both directions
BfpAtTheFullExponentIsBitIdenticalToFixed on every pattern where BFP reports the constant it is bit-identical to fixed (78 / 126 patterns Q15, 98 / 126 Q31 reach it)
RoundingBiasOnNegatedInputIsBounded max and mean of F(x) + F(-x), both directions
fft_fixed_profiles.Q15AndQ31AgreeToTheQ15Floor same signal widened exactly, both policies, both directions, in Q15 output LSB at a common exponent
NoiseFloorTracksWelchModel per-bin floor vs the double golden model at 0 / -20 / -40 / -60 dBFS, N = 256 / 512 / 2048, white noise and an on-bin tone; the Welch model is derived in the file (shift-before-butterfly, two-rounding complex multiply, exact shift variances, twiddle quantisation, early/late BFP schedule brackets); ratio pinned per configuration and material; the 96-row table is printed
fft_fixed_tables.TwiddleTableIsWithinHalfLsb kernel twiddles (M = N/2) and post-pass pairs within 0.5 LSB (+2^-20) of libm on the exact turn fraction, exact 1 / 0 / i entries, k <-> M-k symmetry, n = 4 .. 65536
fft_fixed_tables.TwiddleTableChecksumIsPinned FNV-1a-64 (independent fold, cross-checked against detail::table_checksum) of both tables at N = 256 / 512 / 2048

tests/test_fft.cpp: real_fft_test now runs SizeAndBinCount, RoundTripReproducesInput, RoundTripInPlaceAndAliased, ImpulseHasFlatSpectrum, DcAndNyquistPacking, SignConventionIsPlusI, ParsevalEnergyConservation on float, double, int16_t, int32_t through a per-type profile (input conversion, forward scale from the returned exponent, tolerance); Q15TracksDouble / Q31TracksDouble beside FloatTracksDouble. The float/double comparisons are numerically what they were (same signals, same tolerances).

tests/test_fft_oracle.cpp: profile<int16_t> / profile<int32_t> (full scale 1 - 2^-15 / 1 - 2^-31, scales 2^-e, tolerance derived from fft_arith.h's rounding count: 4q per kernel stage + 4q post-pass + q/2 pre-shift + half an I/O LSB each way, doubled), appended to oracle_types: the closed forms to 65536 and the double-double DFT to 256 pass on all four profiles.

tests/test_fft_rt.cpp: typed over the six FFT classes (real_fft32, real_fft, real_fft_q15, real_fft_q31, real_fft_q15_bfp, real_fft_q31_bfp): static_assert(noexcept) on every transform, the operator-new guard on all four transforms (first call and steady state), copy / unwarmed copy / copy-assignment bit-identity including the returned exponents.

Measured numbers (x86-64 Linux, GCC 13.3.0 and clang 18.1.3 -O3 agree; kernel a55a14f; 2026-09-18; pins at 2x)

configuration saturation max dev (LSB) round trip (recon. LSB) BFP vs fixed (LSB) F(x)+F(-x) max / bias (LSB) noise/model: noise / tone Q15 vs Q31 (Q15 LSB)
Q15 fixed 0.500 0.562 - 1.00 / 0.0005 1.055 / 0.022 0.500
Q31 fixed 4.250 3.382 - 6.00 / 0.8125 1.744 / 0.383 -
Q15 bfp 0.750 5.000 1.0 1.00 / 0.0156 1.041 / 0.535 0.500
Q31 bfp 15.988 41.50 4.0 62.0 / 1.0469 2.321 / 0.644 -

Floors (from the printed table): Q15 fixed -101.0 dBFS/component at every N and level (the narrow's 1/sqrt(12) LSB; per-bin SNR on full-scale noise 69.1 / 66.5 / 60.2 dB at N = 256 / 512 / 2048, 29.7 / 26.6 / 20.3 dB at -40 dBFS); Q31 fixed -189.7 .. -190.2 dBFS (model -191.3 .. -191.5: the ratio 1.3-1.7 is the round-half-up bias the kernel documents; SNR 152 / 149 / 143 dB); Q15 bfp holds 84-91 dB SNR at 0 dBFS and 77-86 dB at -40 dBFS; Q31 bfp 157-161 dB at 0 dBFS, 135-136 dB at -60 dBFS (e = 0). The on-bin tone sits far below the white model (ratio 0.001-0.6) because most butterflies then run on exact zeros, which round to nothing.

test_fft.cpp pins: Q15 / Q31 contract tolerance 2 LSB (measured 1.0 / 1.0), round trip 1.02 / 6.7 reconstructed LSB (0.51 / 3.34), Parseval 1.43e-5 / 3.7e-9 (7.15e-6 / 1.83e-9), Q15TracksDouble 1.4e-3 (6.949e-4, the predicted narrowing 6.9e-4), Q31TracksDouble 1.1e-7 (5.458e-8).

Table checksums (this host; the other CI hosts are the first cross-libm check): N = 256 twiddles 95f5c68afe494835 post-pass 66c84a75861eafb6; N = 512 6df6ff99a3ed3c85 / 4b1374200abed27c; N = 2048 e42c528f3ae88b45 / a0f40e80bbf4efb9. Max |w_q - w| 0.4974 LSB.

Verification

Built and ran cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DTAP_DSP_WERROR=ON -DTAP_DSP_BUILD_CAPI=ON -DTAP_DSP_BUILD_BENCH=ON with g++ 13.3 and clang++ 18.1 on this branch (kernel a55a14f underneath): 397 / 397 ctest passes on both. clang-tidy-18 as style.yml runs it: clean on the five changed files. clang-format via the pre-commit hook. The fixed-profile selection (82 tests) runs in 0.29 s on the host; nothing is added to MAIN_FILTER, so the whole battery runs on the four QEMU legs, which are the first real gate for the on-target run and for the macOS / Windows libm checksums (not runnable here). Fixed seeds, no wall clock, no filesystem, no <random> in new code (test_fft.cpp keeps its existing mt19937 for the unchanged float/double signals).

Kernel findings

No kernel defect: every test passes against a55a14f as measured. Two things for the kernel PR (#27), posted there as well:

  • The header docstrings (fft.h, fixed_point.h, tables.h) cite test names that do not exist in this battery (ForwardScaleIsExactlyXOverN, FixedExponentIsTheConstant, BlockFloatingExponentIsBounded, WorstCaseGrowthDoesNotSaturate, NoiseFloorTracksWelch, TransformsDoNotAllocate, CopiesAreBitIdentical, TwiddleQuantizationIsHalfAnLsb); the names in the table above are the ones to cite.
  • docs/fft-fixed-point.md §3 quotes the worst-case sweep at 2.3 / 5.6 LSB (Q31 forward / inverse); this sweep, with more patterns and sizes and both policies, measures 4.25 LSB (Q31 fixed, inverse of a full-scale constant at N = 2048, index 0) and 16.0 LSB (Q31 bfp, inverse of binary noise at N = 1024, index 0), and 62 LSB of F(x)+F(-x) asymmetry at index 1 under Q31 bfp. All on the DC/Nyquist path the note calls the honest limit; pinned as measured, and worth quoting in the note.

Nothing in the API contract proved wrong in practice. One nuance: "e == the constant implies bit-identical to fixed" is not implied by the per-stage clamp alone, but it holds on every one of the 176 patterns that reach the constant here.

Notes for the reviewer

  • Stacked PR: base is claude/wave2-stage3b-kernel, not main; rebased onto its head each time it moves. Only tests/ changes.
  • The test_fft_oracle.cpp tolerance for the fixed profiles is derived (a worst-case rounding count), not fitted; the measured pins live in test_fft.cpp / test_fft_fixed.cpp.

🤖 Generated with Claude Code

https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy


Generated by Claude Code

tap and others added 3 commits September 18, 2026 01:35
… contract

Adds tests/test_fft_fixed.cpp (Part 9's fixed-point battery: exponents,
exact fixed scale, saturation sweep, round trip per policy, BFP against
fixed, rounding symmetry, Q15 vs Q31, the Welch noise-floor model and the
twiddle-table pins) typed over {Q15, Q31} x {fixed, block_floating}, and
tests/support/sample_scale so the shared generators land in the fixed
profiles. Every measured pin is 0.0 (red) until the real kernel supplies it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
…rofiles

test_fft.cpp's typed contract suite runs on float, double, Q15 and Q31
through a per-profile trait (input conversion, forward scale from the
returned exponent, tolerance), with Q15TracksDouble / Q31TracksDouble
beside FloatTracksDouble; the float/double comparisons are unchanged.
test_fft_oracle.cpp gains profile<int16_t> / profile<int32_t> with a
tolerance derived from fft_arith.h's rounding count. test_fft_rt.cpp is
typed over the six FFT classes (both scaling policies) for noexcept, the
allocation guard and copy bit-identity including the exponent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
Measured 2026-09-18 on x86-64 Linux (GCC 13.3.0 and clang 18.1.3 agree)
against claude/wave2-stage3b-kernel a55a14f and pinned at 2x: the
saturation-sweep deviation, round-trip error, BFP-vs-fixed shift
agreement, negation asymmetry and bias, Welch-model ratios for white
noise and an on-bin tone, Q15-vs-Q31 agreement, the Q15/Q31 contract
tolerances, Parseval and TracksDouble numbers, and the Q1.30 table
checksums for N = 256 / 512 / 2048. The Welch model is refined to the
kernel's stated structure (no rotation on the L = 4 and radix-2 stages,
q = 0 skips, post-pass propagation gain 1, folded Q31 pre-shift, exact
shift variances); every pinned test prints its measurement before it
asserts; the inverse-exactness premise carries pre + 3 zero bits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy

tap commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Kernel side: merged 8b86d48 onto claude/wave2-stage3b-kernel locally (git merge --no-commit, not pushed) and ran the full ctest battery against the kernel head. 397/397 pass on g++ 13 and on clang++ 18 with -DTAP_DSP_WERROR=ON -DTAP_DSP_BUILD_CAPI=ON -DTAP_DSP_BUILD_BENCH=ON, including BfpAtTheFullExponentIsBitIdenticalToFixed, BfpMatchesFixedAfterShift, NoiseFloorTracksWelchModel and TwiddleTableChecksumIsPinned. No test looks wrong from the kernel's side. The kernel branch's head is now e32b0ef (docstring-only change: the contract bullets cite these test names as landed), so a rebase of this branch onto it is a no-op for the tests.


Generated by Claude Code

newlib's printf on the QEMU legs has no %zu: it printed "zu", left the
varargs desynchronised, and the next %s HardFaulted the M55 run inside
RoundingBiasOnNegatedInputIsBounded. Sizes now go through %lu as
unsigned long and the 64-bit table checksums print as two %08lx halves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant