From 2c58a93f7d494e62fe8309c67820cae59ea0f34b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 17:02:36 +0000 Subject: [PATCH] rational M5: the fixed-point profiles, measured per stage The stage has been format-generic since M3 (tap::dsp::sample_traits); M5 is the battery that states each format's contract as numbers, for every ratio of the vocabulary at every named profile (tests/test_fixed_point.cpp): - every phase row, and every decimator's whole filter, sums to exact unity in Q15 and Q31, so full-scale DC of either sign is exactly full scale from every phase; - the Q15 and Q31 tables are bit-pinned (FNV-1a-64, 112 pins); - no structural zero enters the dot of an interpolator, a decimator or a mixed ratio going up; float and Q31 count the design's nonzero taps, Q15 its quantized span (pinned per stage; the 120 dB designs' outer taps round to zero in Q1.14 and are trimmed); - Q31 tracks double within 3.4e-9 of full scale, a decade under float (5.1e-8; bridge's floor is 5e-8); - Q15's RMS deviation from double (-85.6 to -95.8 dBFS) and the stopband its quantized table attains are pinned per stage; - +-full-scale noise saturates and never wraps (within 8.8e-4 / 1.6e-8 of the clamped double model); chains stack the floors stage by stage. Two honest limits, stated in PLAN.md section 6 and pinned: a Q15 decimator's table is h / M on the same Q1.14 LSB, so its attained stopband loses about 20 log10 M dB (by 6 at economy -64.7 dB, by 8 -62.9 dB; Q15's pairing is economy, transparent buys nothing there); and a mixed ratio going down (2/3, 3/4, 3/8) runs L-phase rows over a band-M design and multiplies the band's zeros its rows cross (20 of 65 MACs per superblock for 2/3). Both levers are deferred with the codegen levers after M6. The quantized-stopband test is a host suite (a 16384-point DFT over 112 tables), excluded on the QEMU legs and Hexagon. Plan v0.8; the stage header, READMEs and CLAUDE files follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015VR1VC4SDGxHZQQsQvPBaA --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 6 +- PLAN.md | 8 +- README.md | 2 +- rational/CLAUDE.md | 9 +- rational/PLAN.md | 61 ++- rational/README.md | 30 +- rational/include/tap/sr/rational/stage.h | 13 +- rational/tests/CMakeLists.txt | 1 + rational/tests/bare_metal_main.cpp | 12 +- rational/tests/test_fixed_point.cpp | 590 +++++++++++++++++++++++ 11 files changed, 699 insertions(+), 35 deletions(-) create mode 100644 rational/tests/test_fixed_point.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb4f197..b3c65d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,7 +197,7 @@ jobs: jobs: 4 build_type: Release toolchain: cmake/hexagon-linux-musl.cmake - exclude: Throw|Matrix\.EveryRowMeets|RelaxationTablesAreTheSearch + exclude: Throw|Matrix\.EveryRowMeets|RelaxationTablesAreTheSearch|QuantizedTablesAttainTheirStatedStopbands - target: m55 title: Cortex-M55 cross engine: rational diff --git a/CLAUDE.md b/CLAUDE.md index 72b0e5d..004ad88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,9 +8,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co over one substrate. `async/` (`tap::sr::async`) is the asynchronous near-unity converter that *absorbs the clock*; `bridge/` (`tap::sr::bridge`) is the synchronous 44.1 ↔ 48 kHz converter that *converts the number*; `rational/` (`tap::sr::rational`) is the synchronous small-factor L/M -converter within a rate family (L, M ∈ {2^a·3^b}), chains of Nyquist stages, at M4 of its plan -(ratio types, pinned designs, the single stages, the named chains and the 182-row coverage matrix; -fixed point and the C ABI follow). All build on DspTap (`submodules/dsptap`, +converter within a rate family (L, M ∈ {2^a·3^b}), chains of Nyquist stages, at M5 of its plan +(ratio types, pinned designs, the single stages, the named chains, the 182-row coverage matrix and +the fixed-point profiles measured per stage; the C ABI follows). All build on DspTap (`submodules/dsptap`, `tap::dsp`), pinned once at the root. Each engine has its own `README.md`, `PLAN.md` and `CLAUDE.md`; read the engine's before touching its code. diff --git a/PLAN.md b/PLAN.md index a6b225d..b77a5fa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1020,8 +1020,12 @@ G13 and G14, plus G9 from 3.7. one of the 182 rows measured and pinned, the cross rows through `bridge` at every k — landed (`rational/PLAN.md` v0.7; the matrix was regenerated from the measured lengths, 35 rows changed chain, the - ledger in its section 8). Next: the fixed-point profiles (M5), then the - C ABI, notebook and icount baselines with the family version 0.5.0 (M6). + ledger in its section 8). M5 — the fixed-point profiles, measured per + stage: bit-pinned Q15 / Q31 tables, exact unity, saturation, Q31 within + 3.4e−9 of double, Q15's floor and attained stopband stated (a Q15 + decimator by 6 or 8 attains −65 / −63 dB, not 70) — landed + (`rational/PLAN.md` v0.8). Next: the C ABI, notebook and icount + baselines with the family version 0.5.0 (M6). --- diff --git a/README.md b/README.md index 261df15..d3ca503 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ quantization, measurement instruments). |---|---|---|---| | **`async`** | `tap::sr::async` | Asynchronous, near-unity (±`max_deviation_ppm`, default 1000 ppm): two clock domains at nominally the same rate, one thread pushing at the input clock and one pulling at the output clock. **Absorbs the clock.** | [`async/`](async/README.md) | | **`bridge`** | `tap::sr::bridge` | Synchronous 44.1 ↔ 48 kHz (160/147 up, 147/160 down) and the pair at 2× and 4× (88.2 ↔ 96, 176.4 ↔ 192), direction and rate scale fixed at compile time, speed-first with Q15/Q31 profiles for M33/M55-class targets. **Converts the number.** | [`bridge/`](bridge/README.md) | -| **`rational`** | `tap::sr::rational` | Synchronous small-factor L/M *within* a rate family (L, M ∈ {2^a·3^b}: ↑2, ↓3, 2/3, …), as chains of Nyquist (L-th-band) stages, the ratio a compile-time type. **Converts the number.** M1–M4 landed (ratio types, pinned designs, the single stages for every ratio of the vocabulary, the named chains and the 182-row coverage matrix generated from the measured lengths); fixed point and the C ABI follow its plan. | [`rational/`](rational/README.md) | +| **`rational`** | `tap::sr::rational` | Synchronous small-factor L/M *within* a rate family (L, M ∈ {2^a·3^b}: ↑2, ↓3, 2/3, …), as chains of Nyquist (L-th-band) stages, the ratio a compile-time type. **Converts the number.** M1–M5 landed (ratio types, pinned designs, the single stages for every ratio of the vocabulary, the named chains and the 182-row coverage matrix generated from the measured lengths, the fixed-point profiles measured per stage); the C ABI follows its plan. | [`rational/`](rational/README.md) | The engines never route by rate. The caller declares the clock topology by choosing a type: `async` when the clocks are independent, `bridge` when the diff --git a/rational/CLAUDE.md b/rational/CLAUDE.md index 923b55c..1cc2635 100644 --- a/rational/CLAUDE.md +++ b/rational/CLAUDE.md @@ -19,7 +19,7 @@ anything; do not re-derive what it settles (the compile-time ratio, one Nyquist stage factoring by MACs then by stage count, `bridge` at the lowest k, the profile vocabulary, latency as an exact rational). -Current state: **M4** — the tree, `ratio` with its charter `static_assert`s and +Current state: **M5** — the tree, `ratio` with its charter `static_assert`s and `ratio_traits`, `profile` carrying the pinned taps per branch for every band of the vocabulary (the M2 design spike, `notebooks/design_spike.ipynb`, executed; the pins found on a 16384-point grid and enforced by `tests/test_design.cpp`) and, since M4, the relaxation tables @@ -32,8 +32,11 @@ the vocabulary with bridge's call shapes, `converter` / `converter_q15` / divisor (PLAN.md 3.1), the 20 named multi-stage chains, and the 182-row coverage matrix generated from the pinned lengths by `tools/coverage/matrix.py` (section 3 of the plan; `tests/coverage/matrix_rows.h`) and pinned row by row by `tests/test_matrix.cpp` (the -cross-family rows through `tests/support/bridge_stage.h`). M5 (fixed point) and M6 (C ABI, -notebook, icount baselines; family version 0.5.0) follow in PLAN.md section 6. +cross-family rows through `tests/support/bridge_stage.h`), and, since M5, the fixed-point +profiles measured per stage by `tests/test_fixed_point.cpp` (bit-pinned Q15 / Q31 tables, exact +unity, saturation, the Q15 floors and attained stopbands stated in PLAN.md section 6 — a +change that moves a table pin is a numeric change to the fixed-point datapath). M6 (C ABI, +notebook, icount baselines; family version 0.5.0) follows in PLAN.md section 6. ## The charter constraints (load-bearing) diff --git a/rational/PLAN.md b/rational/PLAN.md index a6b9a44..df9d9ec 100644 --- a/rational/PLAN.md +++ b/rational/PLAN.md @@ -15,6 +15,7 @@ matrix, layout, test strategy, milestones, non-goals and risks. | v0.3 | 2026-10-01 | M0 landed (DspTap `nyquist.h` and `chain.h`, tap/DspTap#48); 2.3's half- and third-band counts are measured by the shipped designer, the harris estimates kept beside them | | v0.4 | 2026-10-01 | M1 landed: the `rational/` tree, `tap::sr::rational`, `ratio` and `ratio_traits` (R1), `profile` and `design_stage` (R5, R4), the family's dependency-rule tests over three engines, the compile-fail charter test; the M2 table's half- and third-band columns are re-measured through the engine's own design | | v0.6 | 2026-10-02 | M3 landed: `stage.h` (`basic_stage`: the L-phase schedule machine for interpolators and mixed ratios with trimmed phase rows, the M-branch commutator for decimators summing its nonzero branches under one finalize, bridge's call shapes, the exact-rational latency), `converter.h` (the aliases), the committed scipy vectors for every ratio of the vocabulary, the M3 battery; the symmetry-halved table of section 4's layout is deferred to the codegen levers after M6's baselines, as bridge did (M7 lever 3) | +| v0.8 | 2026-10-02 | M5 landed: the fixed-point profiles measured and pinned for every ratio at every profile (`tests/test_fixed_point.cpp`): exact-unity rows, bit-pinned Q15 / Q31 tables, the structural zeros out of the dot, Q31 a decade under float's floor, Q15's floor and attained stopband stated per stage, saturation without wrap, full-scale DC exact; two honest limits recorded (a Q15 decimator's stopband loses about 20 log10 M; a mixed ratio going down multiplies its band's zeros), section 6 | | v0.7 | 2026-10-02 | M4 landed: `chain.h` (`basic_chain` over DspTap's `chain<>`, each stage designed at its **design divisor** — its lower rate over the chain's lowest, 3.1 — and the 20 named multi-stage chains), `tools/coverage/matrix.py` committed and re-run on the pinned lengths with the per-stage relaxation pins (`design.h`'s relaxation tables, verified by `test_design.cpp`), section 3 regenerated from measured counts (35 of 182 rows changed chain, the ledger in section 8 S1), `test_matrix.cpp` over all 182 rows (every row measured, none skipped: 2.2 had landed), DspTap's `chain<>::flush` (tap/DspTap#52) | | v0.5 | 2026-10-02 | M2 landed: the design spike (`notebooks/design_spike.ipynb`, executed) pins N for every band ∈ {2, 3, 4, 6, 8} × profile on a 16384-point grid, the mixed ratios' taps per phase, measured worst stopband and ripple; `profile` carries the pins and `test_design.cpp` enforces them; the M2 table below is filled from measurements. Finding: the designer's 1024-point default grid under-pins two rows (tap/DspTap#50 made the search grid a parameter) | @@ -30,9 +31,10 @@ with `bridge` at k > 0 is marked as waiting for it. M0 is implemented (v0.3: DspTap's `nyquist.h` and `chain.h`), M1 (v0.4: the tree, the ratio types, the profiles and the stage design), M2 (v0.5: the pinned lengths per band and profile, measured), M3 (v0.6: the single -stages, every ratio of the vocabulary, pinned against scipy) and M4 (v0.7: +stages, every ratio of the vocabulary, pinned against scipy), M4 (v0.7: the chains and the matrix, every row of section 3 regenerated from the -pinned lengths and pinned by `test_matrix.cpp`). The numbers still marked +pinned lengths and pinned by `test_matrix.cpp`) and M5 (v0.8: the +fixed-point profiles, measured per stage). The numbers still marked *est.* — 2.1's arithmetic and 3.2's worked cases — are Kaiser/harris length estimates (`tap::dsp::estimate_taps`, `kaiser.h:77-85`) kept as the derivation of the rules; the measured chains and counts are 3.3–3.6, and @@ -815,7 +817,7 @@ Plus: | M2 | **Design spike**: `notebooks/design_spike.ipynb`, executed, pins N per (single-stage ratio ∈ {2, 3, 6, 8, 3/2, 2/3, 4/3, 3/4, 8/3, 3/8} × profile; the 4th-band row is measured for the record only, decision 6, and serves 4/3 and 3/4) by the ≥ 1 dB margin criterion on a fine grid, with measured worst stopband and ripple; `test_design.cpp` enforces the pins. **Done** (2026-10-02, v0.5: the notebook is the independent numpy leg from the same published math, `profile::taps_per_branch` carries the pins per band, `design_stage` reads them, a custom profile without pins is searched on the same grid) | The table below filled ✓; every pinned N of the form 2mL − 1 ✓ (mixed: T = ⌈N / L⌉ per phase); measured A ≥ spec + 1 dB on 16384 points ✓ (−71.1 … −72.8 dB at 70 dB, −121.1 … −121.7 dB at 120 dB) with m − 1 missing it ✓; ripple ≤ 2.3's candidate ✓ (≤ 0.0025 / 0.00001 dB); the scipy `upfirdn` end-to-end check of the economy half-band decimator: a tone just past the stopband edge aliases at −72.1 dB, passband tones within 0.001 dB ✓ | no | | M3 | **Single stages, float golden leg**: `basic_stage` for ↑2 / ↓2 (half-band) and ↑3 / ↓3 (third-band) first, then the rest of the vocabulary; both call shapes; `flush`; scipy vectors committed. **Done** (2026-10-02, v0.6: the whole vocabulary at once, since the two machines are generic — the L-phase schedule machine with trimmed phase rows for interpolators and mixed ratios, the M-branch commutator for decimators, its nonzero branches summed under one finalize through DspTap's `accumulate_row` (tap/DspTap#51, the substrate addition M3 needed); MACs per output are the design's nonzero counts for every interpolator and decimator, the half-band decimator 23 of its 43 taps) | Scipy vectors sample-for-sample ✓ (16 vectors, every ratio at `economy` and the by-2 pair at `transparent`; float within 6e-8 of the float64 reference, the 3e-5 tolerance is bridge's); exhaustive phase sweeps ✓ (the impulse reproduces the table bit for bit from every input phase, every format; accounting exact from every position of every superblock); `PullMatchesProcessBitExact` ✓; chunking invariance ✓ (every format); `decimate.h` second-golden agreement at ↓2 / ↓3 / ↓6 within the documented design difference ✓; latency equals (N − 1)/2 at the higher rate by impulse ✓ (every ratio, the exact rational (N − 1)/(2M) at the output rate); DC gain exactly 1 in every format ✓; flush equals zero padding bit for bit ✓ | no | | M4 | **Chains and the coverage matrix**: `chain.h`'s named chains, `tools/coverage/matrix.py` committed, `test_matrix.cpp` pinning (f_pass, A, δ, MACs/out, latency) for all 182 rows. **Done** (2026-10-02, v0.7: `basic_chain` constructs each stage at its design divisor (3.1) over DspTap's `chain<>`, whose `flush` landed for it (tap/DspTap#52); the relaxation tables in `design.h`; the generator re-run on the pinned lengths, section 3 regenerated — 35 rows changed chain, the ledger in S1 — and the 20 named multi-stage chains follow it) | Every row measured and pinned, the k > 0 rows included (2.2 had landed, so nothing was skipped) ✓; chain vectors against sequenced upfirdn ✓ (seven chains, float within 3e-5 and double within 1e-7 of the float64 reference); a chain equals its stages run in sequence bit for bit in every format ✓; `frames_needed` / `outputs_for` exact from every position of every named chain ✓; flush equals zero padding bit for bit ✓; the tone battery's numbers in section 5 | no | -| M5 | **Fixed-point profiles**: Q15 and Q31 datapaths through the traits; per-branch quantization; bit-pinned tables; cross-precision numbers | Q31 within the format floor of float on the reference noise (`bridge`: 5e−8, `../bridge/PLAN.md` §8); Q15 format-limited numbers stated per stage; exact-unity row sums; wrap safety; the Q15 half-band's zero taps absent from the dot (counted MACs equal the nonzero count) | no | +| M5 | **Fixed-point profiles**: Q15 and Q31 datapaths through the traits; per-branch quantization; bit-pinned tables; cross-precision numbers. **Done** (2026-10-02, v0.8: the datapaths were M3's — the stage is format-generic over `tap::dsp::sample_traits` — so M5 is the battery that states each format's contract as numbers, `tests/test_fixed_point.cpp`, every ratio at every profile; the measured table and the two limits below) | Q31 within the format floor of float on the reference noise ✓ (worst \|Q31 − double\| 3.4e−9 against float's 5.1e−8, bridge's 5e−8); Q15 format-limited numbers stated per stage ✓ (the table below: RMS −85.6 … −95.8 dBFS, attained stopband per stage); exact-unity row sums ✓ (every phase row, every decimator's whole filter, both formats); wrap safety ✓ (±full-scale noise lands within 8.8e−4 / 1.6e−8 of the clamped double model; full-scale DC of either sign is exactly full scale from every phase); the Q15 half-band's zero taps absent from the dot ✓ (23 MACs of 43 taps at `economy`; no structural zero inside any interpolator's, decimator's or mixed-up stage's dot, Q15 MACs pinned per stage); bit-pinned tables ✓ (FNV-1a-64 per ratio, profile and format, 112 pins) | no | | M6 | **C ABI, notebook, icount baselines**: `libtap_sr_rational_capi`, `tap_sr_rational_py.py`, `matrix.ipynb` executed through the C ABIs (bridge's for cross rows), `bench/icount/` with baselines on M33 / M55 / Hexagon, README icount table, `CApi.VersionIsBitPacked` | Notebook measures the shipping C++ and reproduces the pinned matrix numbers; ratchet green two-sided on three targets; `nm -D` symbol set recorded; **family version 0.5.0** (decision 10, R11: all three umbrella headers and the root `project()` bump together, D13) | notebook's k > 0 cells wait | **The M2 table, measured** (N = 2mB − 1 per band × profile, the worst @@ -845,8 +847,59 @@ their trimmed rows' lengths over L — a 2/3 stage at `economy` costs 32.5, not the 26 the v0.2 arithmetic in 2.1 estimated — and the matrix's figures were regenerated from these counts at M4 (v0.7, section 3; risk S1). +**The fixed-point profiles, measured** (M5, `test_fixed_point.cpp`; the +reference noise, 480 frames at peak 0.9; Q15's attained stopband is the +quantized Q1.14 table's worst response on the 16384-point design grid; +Q31's equals the design's within 0.1 dB at every stage and profile, and its +worst deviation from double is 3.4e−9; Q15 / float MACs are the counted +MACs per superblock): + +| Stage | Q15 stopband eco (dB) | Q15 RMS vs double eco (dBFS) | Q15 / float MACs eco | Q15 stopband tr (dB) | Q15 RMS tr (dBFS) | Q15 / float MACs tr | +|---|---|---|---|---|---|---| +| ↑2 | -72.3 | -95.8 | 23 / 23 | -77.3 | -93.4 | 55 / 63 | +| ↓2 | -71.6 | -92.8 | 23 / 23 | -69.2 | -89.3 | 53 / 63 | +| ↑3 | -70.2 | -93.5 | 45 / 45 | -76.1 | -91.2 | 89 / 101 | +| ↓3 | -72.4 | -90.8 | 45 / 45 | -63.8 | -86.4 | 82 / 101 | +| ↑6 | -71.5 | -92.4 | 121 / 121 | -78.5 | -90.7 | 219 / 251 | +| ↓6 | -64.7 | -86.5 | 119 / 121 | -62.9 | -85.6 | 193 / 251 | +| ↑8 | -71.7 | -92.3 | 169 / 169 | -78.2 | -90.2 | 303 / 351 | +| ↓8 | -62.9 | -86.8 | 165 / 169 | -61.1 | -85.6 | 259 / 351 | +| 3/2 | -70.2 | -93.4 | 45 / 45 | -76.1 | -91.1 | 89 / 101 | +| 2/3 | -69.7 | -91.0 | 65 / 65 | -73.9 | -90.6 | 125 / 149 | +| 4/3 | -71.7 | -93.6 | 67 / 67 | -77.2 | -90.6 | 131 / 151 | +| 3/4 | -72.8 | -91.0 | 87 / 87 | -75.8 | -89.2 | 167 / 199 | +| 8/3 | -71.7 | -92.2 | 169 / 169 | -78.2 | -90.4 | 303 / 351 | +| 3/8 | -71.2 | -87.9 | 189 / 191 | -69.6 | -87.1 | 325 / 399 | + +Two limits, stated rather than papered over (both pinned by the battery): + +- **A Q15 decimator's stopband is format-limited.** Its table is h / M, + the whole filter quantized as one row summing to 2^14 so that DC stays + exact, so each coefficient is M times smaller than an interpolator's on + the same Q1.14 LSB: the quantization noise floor of the response rises by + about 20 log10 M. ↓2 and ↓3 hold 70 dB at `economy`; ↓6 reaches −64.7 dB + and ↓8 −62.9 dB. Interpolators and mixed ratios hold within about a dB of + 70 at the 70 dB tiers. `transparent` buys nothing in Q15 (−61 … −78 dB, + and the outer taps of its long designs round to zero and are trimmed: + Q15 MACs below float's), so at Q15 `economy` is the pairing, as `bridge` + found (`../bridge/tests/test_converter_fixed_point.cpp`). The 70 / 120 dB + promises of 2.3 and section 3 are float's and Q31's. A lever exists for + the decimators — quantize each branch at unity and fold the 1 / M into a + power-of-two finalize where M is one (↓2, ↓8), or a Q1.14 multiply-back + where it is not — and is deferred with the codegen levers after M6: it + changes the bit-pinned tables and the rounding point, and no consumer has + asked. +- **A mixed ratio going down multiplies its band's structural zeros.** 2/3, + 3/4 and 3/8 run the L-phase machine over a band-M design: the zeros stride + by M, the rows by L, so every row crosses every M-th zero and the trimmed + dot still holds them — 20 of 65 MACs per superblock for 2/3 at `economy`, + 20 of 87 for 3/4, 22 of 191 for 3/8. Interpolators, decimators and the + mixed ratios going up (band L, every zero in the centre phase) multiply + none. Sparse rows are a codegen lever after M6, as the symmetry-halved + table is; the matrix's MAC figures count what runs today. + **Order and gating.** M0 lands in DspTap and the family bumps its pin -(`../CLAUDE.md`, "Substrate discipline"); M1–M4 are done; the 2.2 +(`../CLAUDE.md`, "Substrate discipline"); M1–M5 are done; the 2.2 follow-up landed before M4, so nothing waits for it. Every milestone's PR carries its measurements, as `bridge`'s M7 entries do. diff --git a/rational/README.md b/rational/README.md index 66531a6..c8e7806 100644 --- a/rational/README.md +++ b/rational/README.md @@ -14,7 +14,7 @@ substrate ([DspTap](https://github.com/tap/DspTap): the L-th-band designer and the stage composition landed there first, with the float/Q15/Q31 sample-format traits, the dot kernels and the row-sum quantization). -> **Status: M4 of [PLAN.md](PLAN.md) section 6.** The single stages ship +> **Status: M5 of [PLAN.md](PLAN.md) section 6.** The single stages ship > for every ratio of the vocabulary — ↑2, ↓2, ↑3, ↓3, ↑6, ↓6, ↑8, ↓8 and the > mixed 3/2, 2/3, 4/3, 3/4, 8/3, 3/8 — as `converter>` (float, > the golden model pinned sample-for-sample against committed scipy @@ -23,20 +23,20 @@ sample-format traits, the dot kernels and the row-sum quantization). > `flush`, `reset`, the latency as an exact rational. Each stage is one > L-th-band filter at the pinned length of the M2 design spike > ([`notebooks/design_spike.ipynb`](notebooks/design_spike.ipynb)); its -> structural zeros are never multiplied, so the half-band decimator costs -> 23 MACs per output of its 43 taps and an interpolator's centre phase is a -> copy. The chains ship too (`chain.h`): `basic_chain` runs stages -> in sequence with each stage designed at its own rate — the plan's design -> divisor, with pinned lengths per divisor — and the 20 named multi-stage -> chains of the coverage matrix (`up_2_up_2`, `down_3_down_8_down_2`, …), -> and the 14 × 14 matrix of PLAN.md section 3 — one chain for every ordered -> pair of the family's fourteen rates, the cross-family rows through -> `bridge` — is generated from the pinned lengths and pinned row by row by -> `tests/test_matrix.cpp` (MACs per output and latency exactly, the -> passband and the stopband promise by a tone battery). The fixed-point -> floors (M5), the C ABI and the ratchet baselines (M6) follow. The plan is -> authoritative: charter, the decisions R1–R16, the generated matrix, -> layout, test strategy, non-goals and risks. +> structural zeros are never multiplied by an interpolator or a decimator, +> so the half-band decimator costs 23 MACs per output of its 43 taps and an +> interpolator's centre phase is a copy. The chains ship too (`chain.h`): +> `basic_chain` runs stages in sequence with each stage designed at +> its own rate, and the 20 named multi-stage chains of the 14 × 14 coverage +> matrix (PLAN.md section 3), pinned row by row by `tests/test_matrix.cpp`. +> The fixed-point profiles are measured per stage (`tests/test_fixed_point.cpp`, +> PLAN.md section 6): exact-unity rows and full-scale DC in Q15 and Q31, +> bit-pinned tables, saturation without wrap, Q31 within 3.4e−9 of double; +> Q15 is format-limited, and its numbers are stated per stage — at Q15 use +> `economy`, and note that a Q15 decimator by 6 or 8 attains −65 / −63 dB +> of stopband, not 70. The C ABI and the ratchet baselines (M6) follow. The +> plan is authoritative: charter, the decisions R1–R16, the generated +> matrix, layout, test strategy, non-goals and risks. ## Quick start diff --git a/rational/include/tap/sr/rational/stage.h b/rational/include/tap/sr/rational/stage.h index c331f5a..aff00aa 100644 --- a/rational/include/tap/sr/rational/stage.h +++ b/rational/include/tap/sr/rational/stage.h @@ -58,7 +58,18 @@ // flush() and reset() are noexcept and allocation-free; one stream per // instance, channels planar inside and interleaved at the API, every // channel sharing the coefficient row per frame; -// - the stage satisfies tap::dsp::sync_stage, so chains compose it. +// - the stage satisfies tap::dsp::sync_stage, so chains compose it; +// - fixed point (PLAN.md section 6, M5; pinned by test_fixed_point.cpp): +// every row sums to exact unity in the format, so full-scale DC of +// either sign comes out at exactly full scale; full-scale drive +// saturates in the trait's finalize and never wraps; Q31 tracks double +// within 3.4e-9 of full scale; Q15 is format-limited — RMS -85.6 to +// -95.8 dBFS from double, and a decimator's quantized table (h / M) +// attains about 20 log10 M dB less stopband than an interpolator's +// (by 8 at economy: -62.9 dB), stated per stage in PLAN.md; +// - MACs: no structural zero enters the dot of an interpolator, a +// decimator or a mixed ratio going up; a mixed ratio going down (band +// M, rows of stride L) multiplies the band's zeros its rows cross. #pragma once #include diff --git a/rational/tests/CMakeLists.txt b/rational/tests/CMakeLists.txt index 76957be..d6d32e8 100644 --- a/rational/tests/CMakeLists.txt +++ b/rational/tests/CMakeLists.txt @@ -28,6 +28,7 @@ FetchContent_MakeAvailable(googletest) add_executable(tap_sr_rational_tests test_chain.cpp test_design.cpp + test_fixed_point.cpp test_output_hash.cpp test_ratio.cpp test_stage.cpp) diff --git a/rational/tests/bare_metal_main.cpp b/rational/tests/bare_metal_main.cpp index 4a0d803..0d9ab75 100644 --- a/rational/tests/bare_metal_main.cpp +++ b/rational/tests/bare_metal_main.cpp @@ -6,10 +6,11 @@ // sweep the 16384-point design grid over long designs or search it (the // transparent rows up to N = 399 and the relaxation tables, whose searches // run every candidate length through the grid, up to N = 735, the -// coarse-grid comparison, the unpinned search) and the -// coverage-matrix suite (182 chains in double through a tone battery, a -// host measurement): target-independent design math and measurement -// already covered on every host. +// coarse-grid comparison, the unpinned search, the quantized tables' +// stopbands through a 16384-point DFT) and the coverage-matrix suite (182 +// chains in double through a tone battery, a host measurement): +// target-independent design math and measurement already covered on every +// host. // The 70 dB rows' pins, minimality and structure run on target. #include @@ -23,7 +24,8 @@ int main() { "Design.UnmeetableSpecThrows:" "Design.RelaxationTablesAreTheSearchAt70dB:" "Design.RelaxationTablesAreTheSearchAtTransparent:" - "Matrix.*"; + "Matrix.*:" + "FixedPoint.QuantizedTablesAttainTheirStatedStopbands"; ::testing::InitGoogleTest(); const int rc = RUN_ALL_TESTS(); // A filter typo selects zero tests and RUN_ALL_TESTS() returns 0 — an diff --git a/rational/tests/test_fixed_point.cpp b/rational/tests/test_fixed_point.cpp new file mode 100644 index 0000000..eaa19b6 --- /dev/null +++ b/rational/tests/test_fixed_point.cpp @@ -0,0 +1,590 @@ +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place and the SampleRateTap contributors +// +// Contract battery for the fixed-point profiles (milestone M5). The stage is +// format-generic (stage.h through tap::dsp::sample_traits); what this file +// proves is each format's numeric contract, as numbers, for every ratio of +// the vocabulary at every named profile: +// +// - every row of every table sums to exact unity in the format (a phase +// row of an interpolator or mixed ratio to 2^14 / 2^30; a decimator's +// whole filter, quantized as one row, to the same), so DC gain is exactly +// 1 and full-scale DC comes out at exactly full scale from every phase; +// - the tables are bit-pinned (FNV-1a-64 per ratio, profile and format); +// - the structural zeros never enter the dot of an interpolator, a +// decimator or a mixed ratio going up: every stored row is trimmed to its +// quantized nonzero span and no design zero lies inside it, so the MACs +// counted are the design's nonzero count in Q31 (and float) and the +// quantized span in Q15, pinned per stage; a mixed ratio going down +// multiplies exactly its band's structural zeros that fall inside its +// L-phase rows, and the count is printed; +// - Q31 tracks the double golden model within a tenth of float's floor; +// - Q15 is format-limited, and its limits are stated per stage: the RMS +// deviation from double on the reference noise, and the stopband the +// quantized Q1.14 table attains (a decimator's table holds h / M, M times +// smaller than an interpolator's coefficients on the same LSB, so its +// attained stopband loses about 20 log10 M dB: by 8 at economy reaches +// -62.9 dB, not 70; the 70 dB / 120 dB promises are float's and Q31's); +// - full-scale drive saturates, never wraps, in both formats. +// +// Numbers measured 2026-10-02 (clang 18, x86-64; the formats are exact +// integer arithmetic, so every host and target computes the same bits). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "reference/reference_vectors.h" +#include "tap/sr/rational/rational.h" + +namespace { + + using namespace tap::sr::rational; // NOLINT(google-build-using-namespace) + + template + constexpr double full_scale() { + if constexpr (std::is_floating_point_v) { + return 1.0; + } + else { + return static_cast(std::numeric_limits::max()); + } + } + + template + S to_sample(double v) { + if constexpr (std::is_floating_point_v) { + return static_cast(v); + } + else { + return tap::dsp::detail::round_sat(v * full_scale()); + } + } + + /// One stage over a stream of doubles in full-scale units, through format + /// S (input quantized to S, output back to full-scale units). + template + std::vector run_stage(const profile& p, const std::vector& x) { + basic_stage c(1, p); + std::vector xs(x.size()); + for (std::size_t i = 0; i < x.size(); ++i) { + xs[i] = to_sample(x[i]); + } + std::vector y(c.outputs_for(xs.size())); + c.process(xs.data(), xs.size(), y.data()); + std::vector out(y.size()); + for (std::size_t i = 0; i < y.size(); ++i) { + out[i] = static_cast(y[i]) / full_scale(); + } + return out; + } + + std::vector reference_noise() { + return {rational_ref::k_input.begin(), rational_ref::k_input.end()}; + } + + /// Rows of a stage's table: L phases, or M decimating branches. + template + constexpr std::size_t k_rows = R::k_up == 1 ? R::k_down : R::k_up; + + // FNV-1a-64 over the coefficient bytes, rows in order, each row in + // storage order (little-endian on every CI host and target; a one-LSB + // move in any tap changes it). The pattern of DspTap's + // Decimate.FixedPointTablesAreBitPinned. + template + std::uint64_t table_fnv1a64(const basic_stage& c) { + std::uint64_t h = 0xcbf29ce484222325ULL; + for (std::size_t r = 0; r < k_rows; ++r) { + for (std::size_t t = 0; t < c.row_length(); ++t) { + const auto v = c.coefficient(r, t); + unsigned char bytes[sizeof v]; + std::memcpy(bytes, &v, sizeof bytes); + for (const unsigned char b : bytes) { + h ^= b; + h *= 0x100000001b3ULL; + } + } + } + return h; + } + + /// The design index of row r's storage tap t (tap-reversed rows): an + /// L-phase row holds taps p + i L, a decimating branch taps j + s M. + template + std::size_t design_index(std::size_t r, std::size_t t, std::size_t row_len) { + const std::size_t step = R::k_up == 1 ? R::k_down : R::k_up; + return r + (row_len - 1 - t) * step; + } + + // ------------------------------------------------------------------ + // The pins, per (ratio, profile): float's MACs per superblock (the + // design's nonzero count, which Q31's equals), Q15's (the quantized + // span: the 120 dB designs' outer taps fall below half a Q1.14 LSB and + // are trimmed with the structural zeros), the FNV-1a-64 of the Q15 and + // Q31 tables, the worst stopband the quantized Q15 table attains on the + // 16384-point design grid (dB), and Q15's RMS deviation from double on + // the reference noise (dBFS). Generated from the measurement the file + // header describes; a change that moves one is a numeric change to the + // fixed-point datapath. + // clang-format off +#define TAP_SR_RATIONAL_FIXED_POINT_ROWS(ROW) \ + ROW(up_2, super_economy, 19, 19, 0x3cba7ba5bf0200f1ULL, 0x65cde4429b552ad1ULL, -71.1, -94.0) \ + ROW(up_2, economy, 23, 23, 0x62302898b65a370dULL, 0xe57108ee5dcd11a5ULL, -72.3, -95.8) \ + ROW(up_2, balanced, 27, 27, 0x597a9bff98e1d2fdULL, 0x35690983e0275251ULL, -71.1, -92.8) \ + ROW(up_2, transparent, 63, 55, 0xcd3f2561c2e2d955ULL, 0x805ecb7ea3994265ULL, -77.3, -93.4) \ + ROW(down_2, super_economy, 19, 19, 0x62d9f46424b7acbdULL, 0x23868914d48461fdULL, -69.3, -91.9) \ + ROW(down_2, economy, 23, 23, 0xcd4d8ac606e35409ULL, 0x8e72f5156b104145ULL, -71.6, -92.8) \ + ROW(down_2, balanced, 27, 27, 0x29bdc8713b154ca5ULL, 0xecc3467d30d76709ULL, -70.6, -91.0) \ + ROW(down_2, transparent, 63, 53, 0xb5e7101a73dc98d9ULL, 0x6ed3d49c1c32ca15ULL, -69.2, -89.3) \ + ROW(up_3, super_economy, 33, 33, 0xfd3b44a261da711dULL, 0xdb52b0963a9827a9ULL, -71.7, -94.3) \ + ROW(up_3, economy, 45, 45, 0xf843d9f1da25e251ULL, 0xffcac366d1724905ULL, -70.2, -93.5) \ + ROW(up_3, balanced, 53, 53, 0x862f2f9c1f13e64dULL, 0xe9b4f733f5f69a49ULL, -71.5, -94.0) \ + ROW(up_3, transparent, 101, 89, 0xd39ee5ef4c648899ULL, 0x658e039e0cf17da1ULL, -76.1, -91.2) \ + ROW(down_3, super_economy, 33, 33, 0xdf9e89c5c14bc226ULL, 0x0e531ee9e040c832ULL, -67.8, -91.5) \ + ROW(down_3, economy, 45, 45, 0xf3703d79f19957aeULL, 0x81e16a033f326f76ULL, -72.4, -90.8) \ + ROW(down_3, balanced, 53, 53, 0x9208a806f6896410ULL, 0xc11d78c7a4530ef8ULL, -71.5, -88.0) \ + ROW(down_3, transparent, 101, 82, 0x1db996525c50297eULL, 0x6506baa7c5d51262ULL, -63.8, -86.4) \ + ROW(up_6, super_economy, 81, 81, 0x7376502a75faf5f5ULL, 0x9d6e292d48601b85ULL, -73.2, -93.1) \ + ROW(up_6, economy, 121, 121, 0x36e3c046fa633d21ULL, 0x39d801984353d381ULL, -71.5, -92.4) \ + ROW(up_6, balanced, 141, 141, 0x3237cb76f59e248dULL, 0x7c7d5cef3507c531ULL, -72.2, -92.3) \ + ROW(up_6, transparent, 251, 219, 0xe46f033362196a39ULL, 0xb9fec6bbf1288489ULL, -78.5, -90.7) \ + ROW(down_6, super_economy, 81, 79, 0xc420c84b1741805dULL, 0x782ec334d8cf342bULL, -68.1, -87.1) \ + ROW(down_6, economy, 121, 119, 0x15b9f56b5749781dULL, 0x92a24b5cea1909cfULL, -64.7, -86.5) \ + ROW(down_6, balanced, 141, 139, 0xb585c907d418ea53ULL, 0x82885ec398c57779ULL, -65.6, -87.3) \ + ROW(down_6, transparent, 251, 193, 0xbb86f404f08bbf0dULL, 0x6a3f54f3e1587481ULL, -62.9, -85.6) \ + ROW(up_8, super_economy, 113, 113, 0xeb745d04141f1bb1ULL, 0x878a639d0252fde5ULL, -72.0, -92.9) \ + ROW(up_8, economy, 169, 169, 0xdc280c16130175d1ULL, 0x192bcfb9abf918edULL, -71.7, -92.3) \ + ROW(up_8, balanced, 197, 197, 0x8cd458eb725f53ddULL, 0xbcda6fae5723088dULL, -72.6, -91.4) \ + ROW(up_8, transparent, 351, 303, 0x4b96078b2928931dULL, 0x11b5febcf65e1475ULL, -78.2, -90.2) \ + ROW(down_8, super_economy, 113, 111, 0x21171296dcd98df1ULL, 0xc6ed1a337eadf041ULL, -66.1, -87.4) \ + ROW(down_8, economy, 169, 165, 0xe15527b8a17a4c19ULL, 0x43d2f7ee82afa8c5ULL, -62.9, -86.8) \ + ROW(down_8, balanced, 197, 187, 0x97a54407216efc95ULL, 0xdc5baf7a000445a9ULL, -62.0, -86.9) \ + ROW(down_8, transparent, 351, 259, 0x4287b3116ed71645ULL, 0x5a6e47c4d822ebb1ULL, -61.1, -85.6) \ + ROW(ratio_3_2, super_economy, 33, 33, 0xfd3b44a261da711dULL, 0xdb52b0963a9827a9ULL, -71.7, -94.4) \ + ROW(ratio_3_2, economy, 45, 45, 0xf843d9f1da25e251ULL, 0xffcac366d1724905ULL, -70.2, -93.4) \ + ROW(ratio_3_2, balanced, 53, 53, 0x862f2f9c1f13e64dULL, 0xe9b4f733f5f69a49ULL, -71.5, -94.0) \ + ROW(ratio_3_2, transparent, 101, 89, 0xd39ee5ef4c648899ULL, 0x658e039e0cf17da1ULL, -76.1, -91.1) \ + ROW(ratio_2_3, super_economy, 47, 47, 0xdb8cfd8e21dd8209ULL, 0x73dd9f039a900588ULL, -69.1, -92.1) \ + ROW(ratio_2_3, economy, 65, 65, 0xbe9edd5a10f87fedULL, 0xd990f930d9d2e66fULL, -69.7, -91.0) \ + ROW(ratio_2_3, balanced, 77, 77, 0x626ef4bf4a08d9b5ULL, 0xe2019e0deca448ddULL, -71.1, -91.5) \ + ROW(ratio_2_3, transparent, 149, 125, 0xdde5bec80bdf050dULL, 0x174688556c046e2bULL, -73.9, -90.6) \ + ROW(ratio_4_3, super_economy, 49, 49, 0xaba05cd262d404a9ULL, 0xf789914bb74deab5ULL, -70.6, -94.0) \ + ROW(ratio_4_3, economy, 67, 67, 0x54b85d414b471985ULL, 0xf803f1ea55624179ULL, -71.7, -93.6) \ + ROW(ratio_4_3, balanced, 85, 85, 0x441dab53737b273dULL, 0x088ccad6c8e4a9b9ULL, -70.4, -92.9) \ + ROW(ratio_4_3, transparent, 151, 131, 0x2766dd02d84bd799ULL, 0x5491f247aa9ad425ULL, -77.2, -90.6) \ + ROW(ratio_3_4, super_economy, 63, 63, 0x0ec9089ed99ced7dULL, 0xcacc9866f85a1482ULL, -70.7, -92.3) \ + ROW(ratio_3_4, economy, 87, 87, 0x6c0c99983b8387b2ULL, 0x5b0bcd2edd57e081ULL, -72.8, -91.0) \ + ROW(ratio_3_4, balanced, 111, 111, 0xf55da00d5439796fULL, 0xddc050765dd8bd23ULL, -68.2, -91.0) \ + ROW(ratio_3_4, transparent, 199, 167, 0xe50acc0baa4ec77dULL, 0x78b0ec09e7310bffULL, -75.8, -89.2) \ + ROW(ratio_8_3, super_economy, 113, 113, 0xeb745d04141f1bb1ULL, 0x878a639d0252fde5ULL, -72.0, -93.0) \ + ROW(ratio_8_3, economy, 169, 169, 0xdc280c16130175d1ULL, 0x192bcfb9abf918edULL, -71.7, -92.2) \ + ROW(ratio_8_3, balanced, 197, 197, 0x8cd458eb725f53ddULL, 0xbcda6fae5723088dULL, -72.6, -91.4) \ + ROW(ratio_8_3, transparent, 351, 303, 0x4b96078b2928931dULL, 0x11b5febcf65e1475ULL, -78.2, -90.4) \ + ROW(ratio_3_8, super_economy, 127, 127, 0xaebfc38e6ba5a325ULL, 0xca47a7f8e9a6c523ULL, -69.2, -90.6) \ + ROW(ratio_3_8, economy, 191, 189, 0x58767e8f10428845ULL, 0x3cc9b1b3c17f0e3bULL, -71.2, -87.9) \ + ROW(ratio_3_8, balanced, 223, 221, 0x48312e1a4a99cc49ULL, 0x3c098ec5243f25ecULL, -69.4, -89.3) \ + ROW(ratio_3_8, transparent, 399, 325, 0x4f12dd31639b2c9dULL, 0x7e280258af2f97a4ULL, -69.6, -87.1) + // clang-format on + + struct pin_row { + const char* ratio; + const char* profile; + std::size_t float_macs; + std::size_t q15_macs; + std::uint64_t fnv_q15; + std::uint64_t fnv_q31; + double q15_stop_db; + double q15_rms_dbfs; + }; + + // Runs F(row, profile) for every row of the table. + template