Skip to content

Stage 3b: fixed-point real FFT, Q15 and Q31 profiles of the same contract - #27

Draft
tap wants to merge 5 commits into
mainfrom
claude/wave2-stage3b-kernel
Draft

tap wants to merge 5 commits into
mainfrom
claude/wave2-stage3b-kernel

Conversation

@tap

@tap tap commented Sep 18, 2026

Copy link
Copy Markdown
Owner

What this changes

Stage 3b of docs/audit-fft-and-code-smells.md (Part 7 design, Decisions D2/D3): the fixed-point real FFT. basic_real_fft gains a Scaling policy parameter (default scaling::fixed); std::int16_t (Q15) and std::int32_t (Q31) route through a constrained partial specialization to detail::fixed_point_rdft in fft/fixed_point.h: one int32 radix-4 DIF kernel of length N/2 with Q1.30 twiddles, Ooura's real post-pass formulas and DC/Nyquist glue transcribed into the fft_arith trait's operations, written entirely against the trait 3a landed (no arithmetic of its own). Aliases real_fft_q15, real_fft_q31, real_fft_q15_bfp, real_fft_q31_bfp. fft/tables.h carries the bit-reversal, twiddle and real-post-pass generators (each coefficient one rounding from its double) and the FNV-1a-64 checksum the battery pins. docs/fft-fixed-point.md is the design note; README gains the four-profile table; CLAUDE.md the ladder line and the checklist line.

Status: DRAFT until the battery PR #29 (claude/wave2-stage3b-battery, stacked on this branch) is merged into this branch. The kernel and its docs are complete; the battery passes 397/397 against this head (merged locally on g++ 13 and clang++ 18, see Verification).

Why

Fixed-point projects (M4/M33-class, no affordable float) need an FFT with the same packing and exp(+i) convention as the floating profiles, with the Q ladder visible and pinned rather than hidden in a wrapper class.

The exponent contract (as implemented)

Every fixed-point transform returns e. Read the buffer as fractions of full scale (Q0.15 / Q0.31); let G be basic_real_fft<double> on the same input read as fractions. Forward: G.forward_inplace result == data * 2^e, same packing. Inverse: G's unnormalized inverse_inplace result == data * 2^e. The fixed-point inverse() applies no 2/N. Round trip: x == out * 2^(e_fwd + e_inv + 1 - log2 N) under both policies.

  • scaling::fixed: e == fixed_scaling_exponent(N) == log2 N + fft_arith<Sample>::k_fixed_scaling_input_pre_shift in both directions (Q15: log2 N, output exactly X / N; Q31: log2 N + 1, X / 2N). Shift-before-butterfly: 2 bits per radix-4 stage, 1 per radix-2, 1 before the real post-pass / pre-pass, the Q31 pre-shift folded into the first shift as one shr_round.
  • scaling::block_floating: 0 <= e <= fixed_scaling_exponent(N), data-dependent. Before each stage (and before the Q15 narrow) the shift is clamp(growth + 1 - headroom_bits, 0, fixed_cumulative - e): one bit of headroom is never consumed (the trait's INT32_MIN rule), and the upper clamp makes the bound a contract; it binds only when the block is already at the fixed schedule's data, where the result is the fixed schedule's bit for bit (pinned by BfpAtTheFullExponentIsBitIdenticalToFixed). An all-zero block returns e == 0.

CMSIS-DSP convention (D3, decided and written in fft.h and the design note)

CMSIS documents arm_rfft_q15/q31 as "downscaled by 2 for every stage" (forward output X / N, log2 N bits to upscale). The Q15 fixed forward here is that same X / N; the Q31 fixed forward is X / 2N (the pre-shift, one bit below CMSIS); the inverse is Ooura's unnormalized inverse over 2^e ((1/N) Σ X W^-jk divided by 2 for Q15, 4 for Q31), not CMSIS's; block floating point has no CMSIS analogue. Only CMSIS's documentation was read.

Measured floors vs Welch (output-referred, against the double golden model on the same quantized input; white noise; the level does not move the fixed floors)

profile, policy N rms error, LSB Welch variance-only prediction per-bin SNR, 0 dBFS −40 dBFS
Q15 fixed 256 / 512 / 2048 0.29 / 0.30 / 0.29 0.289 (the narrow's rounding; kernel noise 0.55 LSB32 = 2^-14 LSB16) 68.7 / 65.8 / 60.1 dB 28.6 / 25.9 / 20.3 dB
Q31 fixed 256 / 512 / 2048 0.72 / 0.69 / 0.71 (inverse 0.76–0.83) 0.527 / 0.545 / 0.545 151.0 / 148.9 / 142.7 dB 110.6 / 108.9 / 102.8 dB
Q15 block floating 256 / 512 / 2048 0.29 (e = 5…0) 86.9 / 90.4 / 84.1 dB 77.3 / 80.5 / 86.5 dB
Q31 block floating 256 / 512 / 2048 0.95…6.6 (e = 9…0) 160.7 / 159.6 / 156.8 dB 155.6 / 155.3 / 153.8 dB

The Q31 fixed gap to the variance-only model is the round-half-up bias of shr_round (+2^-(s+1) LSB per s-bit shift): +0.22–0.28 LSB mean over the interior bins (the final one-bit shift) and ~20% extra variance from earlier stages' biases arriving with rotated phases. Honest limit, documented in the header: the bias is coherent along the unrotated DC path; +1.0–2.1 LSB at DC under Q31 fixed, ~15 LSB (N = 2048) to ~140 LSB (N = 65536, −143 dBFS) under Q31 BFP. Convergent rounding would remove it and is a different trait contract, not this kernel's decision. Part 7's "about 25 dB at −40 dBFS for N = 512" for Q15 fixed measures 25.9 dB.

Verification

  • Battery (Stage 3b battery: the fixed-point contract, pinned #29, 8b86d48) against this kernel: merged locally into a scratch worktree (git merge --no-commit, not pushed); full ctest 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 the fixed-point suites (fft_fixed_point_test, fft_fixed_scaling_test, fft_fixed_bfp_test, fft_fixed_tables, fft_fixed_q31, fft_fixed_profiles), the widened real_fft_test / fft_oracle_test / fft_rt_test over all four sample types, and Q15TracksDouble / Q31TracksDouble. No kernel change was needed. The QEMU legs run it once Stage 3b battery: the fixed-point contract, pinned #29 is merged into this branch.
  • Hosts on this branch alone: configure/build/ctest green on g++ 13 and clang++ 18 at every push; scripts/tidy.sh clean on the TUs that include fft.h; pre-commit clang-format. CI on the head: all check runs green (3 hosts, 4 QEMU legs, style, drift, icount).
  • Kernel (scratch programs, not committed; the numbers are in the design note and the header): against basic_real_fft<double> scaled by 2^-e, forward, unnormalized inverse and round trip for N = 4 … 65536 on full-scale noise, impulse, DC (both signs, INT16_MIN included), the alternating pattern, on-bin cosine and sine, and the packed-pair patterns at full scale on the 0° and 45° twiddle bins in both directions; no saturating operation under either policy (max error 0.5 LSB Q15, 2.3 / 5.6 LSB Q31 forward / inverse, same as on noise). Level sweep 0 / −20 / −40 / −60 dBFS at N = 256 / 512 / 2048, white noise and on-bin tone, all four profiles.

Notes for the reviewer

  • Contract change. New profiles only; the float/double paths' code and numerics are untouched (the primary template changes only in its template head and static_asserts; the CMSIS/vDSP backends are untouched). No consumer is on fixed point yet.
  • The battery PR Stage 3b battery: the fixed-point contract, pinned #29 (stacked on this branch) is merged into this branch before this PR merges; this PR is un-drafted then.
  • Wave 3 items, deliberately not in this PR: the rfft_q15_512 / rfft_q31_512 / rfft_q31_2048 ratchet scenarios (bench/ is the port agent's this wave) and folding docs/fft-fixed-point.md into docs/fft-design.md. Stage 3c: instruments, capi, notebook.

🤖 Generated with Claude Code

https://claude.ai/code/session_019ZPTzNxo5Fe4EtpXXKf7Sy

tap and others added 4 commits September 18, 2026 01:19
…gine surface

basic_real_fft gains a Scaling policy parameter (default scaling::fixed);
the float/double primary template is unchanged apart from the template
head and the static_asserts, and a constrained partial specialization
routes std::int16_t / std::int32_t to detail::fixed_point_rdft in
fft/fixed_point.h. Aliases real_fft_q15 / q31 / q15_bfp / q31_bfp.

fft/tables.h is real: bit-reversal table, Q1.30 kernel twiddles, the
real post-pass coefficient table, and the FNV-1a-64 checksum the battery
pins. The transforms in fixed_point.h are TODO(3b) stubs that leave the
data unchanged and return fixed_scaling_exponent(n), so the battery can
be written against the final surface while the kernel lands.

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

Replaces the TODO(3b) stubs in fft/fixed_point.h with the engine: a
radix-4 decimation-in-frequency complex kernel of length N/2 (radix-2
final stage for odd log2, radix-2^2 output placement so the permutation
is a plain bit reversal), Ooura's rftfsub/rftbsub formulas and the rdft
DC/Nyquist glue transcribed into fft_arith's operations, and one shift
schedule serving both policies: scaling::fixed shifts each stage's
growth before the butterfly (plus the profile's input pre-shift, folded
into the first stage's shr_round); scaling::block_floating shifts
min(max(0, growth + 1 - headroom), fixed_cumulative - e), so one bit of
headroom is never consumed and e <= fixed_scaling_exponent(N) is a
contract. Q15 widens into the work buffer on entry and narrows on exit,
with one more growth-1 stage before the narrow under block floating
point so the result fits Q0.15.

Verified against basic_real_fft<double> scaled by 2^-e (scratch, not
committed): forward, unnormalized inverse and round trip for N = 4 ..
65536 on full-scale noise, impulse, DC (both signs), the alternating
pattern, on-bin cosine and sine, and the packed-pair patterns at full
scale under 0/45-degree twiddles in both directions; no saturation
under either policy. Floors: Q15 fixed 0.29 LSB rms (the narrow's
rounding), Q31 fixed 0.69-0.73 LSB rms at every N and level (Welch
variance model 0.55 plus the round-half-up bias); numbers go into the
header and docs/fft-fixed-point.md next.

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

fft/fixed_point.h and fft.h carry the measured floors as contract
numbers (Q15 fixed 0.29 LSB rms; Q31 fixed 0.65-0.83 LSB rms,
level-independent; block floating point per level), the round-half-up
bias as an honest limit (coherent at DC, ~140 LSB at N = 65536 under
Q31 BFP), and the CMSIS-DSP convention decision (Q15 fixed forward
matches CMSIS's documented X / N; Q31 is one bit below; the inverse is
Ooura's over 2^e; BFP has no analogue).

docs/fft-fixed-point.md is the Stage 3b design note: kernel structure,
the halving count and exponent derivation, the headroom proof for both
policies, twiddle quantization, the Welch variance model beside the
measured table, and the D3 decision. It folds into docs/fft-design.md at
the wave-3 merge.

README: the FFT profiles table (double / float / Q15 / Q31 with scale,
floor and target) per Part 8 item 3; the fft_arith paragraph now points
at the kernel and tables. CLAUDE.md: the four-profile ladder line and
the checklist line per Part 8 item 5.

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

The Stage 3b battery is up as #29, stacked on this branch (base claude/wave2-stage3b-kernel, measured against a55a14f). Every test passes against the kernel as it is: 397 / 397 on g++ 13.3 and clang++ 18.1 with -DTAP_DSP_WERROR=ON. No kernel defect found. Two things this branch should pick up:

1. The header docstrings cite test names that are not the battery's. fft.h, fixed_point.h and tables.h name ForwardScaleIsExactlyXOverN, FixedExponentIsTheConstant, BlockFloatingExponentIsBounded, WorstCaseGrowthDoesNotSaturate, NoiseFloorTracksWelch, TransformsDoNotAllocate, CopiesAreBitIdentical, TwiddleQuantizationIsHalfAnLsb. The tests that exist (Part 9's names, in tests/test_fft_fixed.cpp unless noted):

  • FixedExponentIsTheStatedConstant, BfpExponentIsWithinRange
  • FixedForwardScaleIsExactlyXOverN, fft_fixed_q31.FixedForwardScaleIsExactlyXOverTwoN, FixedInverseCarriesTheSameExponent
  • SaturationFreeWorstCaseDoesNotWrap, SilenceIsSilence
  • RoundTripReconstructsInputPerPolicy, OutOfPlaceIsCopyThenInPlace
  • BfpMatchesFixedAfterShift, BfpAtTheFullExponentIsBitIdenticalToFixed
  • RoundingBiasOnNegatedInputIsBounded, fft_fixed_profiles.Q15AndQ31AgreeToTheQ15Floor
  • NoiseFloorTracksWelchModel
  • fft_fixed_tables.TwiddleTableIsWithinHalfLsb, fft_fixed_tables.TwiddleTableChecksumIsPinned
  • in test_fft.cpp: RoundTripReproducesInput, ImpulseHasFlatSpectrum, DcAndNyquistPacking, SignConventionIsPlusI, ParsevalEnergyConservation (all four profiles), Q15TracksDouble, Q31TracksDouble
  • in test_fft_rt.cpp (six FFT classes): TransformsAreNoexcept, ForwardInplaceAllocatesNothing, InverseInplaceAllocatesNothing, ForwardOutOfPlaceAllocatesNothing, InverseOutOfPlaceAllocatesNothing, CopyProducesBitIdenticalOutput, CopyOfUnwarmedSourceProducesBitIdenticalOutput, CopyAssignmentProducesBitIdenticalOutput

2. Worst-case numbers in docs/fft-fixed-point.md §3. The note quotes the saturation sweep at 0.5 (Q15) and 2.3 / 5.6 LSB (Q31 forward / inverse). The battery's sweep (24 full-scale patterns, 7 sizes, both directions, both policies) measures 4.25 LSB for Q31 fixed (inverse of a full-scale constant, N = 2048, index 0) and 16.0 LSB for Q31 block floating (inverse of full-scale binary noise, N = 1024, index 0), plus 62 LSB of F(x) + F(-x) asymmetry at index 1 under Q31 block floating (inverse, -20 dBFS noise, N = 1024). All of it is on the DC/Nyquist glue path the note calls the honest limit, so the mechanism is already documented; only the numbers are larger than quoted. Pinned as measured (at 2x) in #29.

Table checksums on this host (x86-64 Linux, glibc 2.39; g++ and clang++ agree), for the record: N = 256 twiddles 95f5c68afe494835 / post-pass 66c84a75861eafb6; N = 512 6df6ff99a3ed3c85 / 4b1374200abed27c; N = 2048 e42c528f3ae88b45 / a0f40e80bbf4efb9. The macOS, Windows and newlib legs on #29 are the first cross-libm check of them.


Generated by Claude Code

The contract bullets named the tests before the battery existed; the
names now match tests/test_fft_fixed.cpp, test_fft.cpp and
test_fft_rt.cpp on claude/wave2-stage3b-battery, which passes 397/397
against this kernel on g++ 13 (merged locally, not pushed here).

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