Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

honeyeater documentation

This is the narrative documentation for the honeyeater DSP library: a Rust library of digital signal processing primitives for radio-frequency and electrical signals.

New to the project? Begin with Start here — a gentle, no-background-assumed introduction. The rest of the documentation is split into three sections.

Getting started:

  • Start here — what honeyeater is for and the handful of terms you need, explained from scratch.

Project — what honeyeater is and how it is built:

  • Vision — what honeyeater is, what it isn’t, and the design principles that follow. The fuller, slightly denser statement once Start here has oriented you.
  • Roadmap — the test-driven implementation plan, phased kernel ordering, tolerance vocabulary, and oracle stack per kernel category. The source of truth for “is this the right next step?” and “what oracle do I validate this kernel against?”
  • Architecture decisions — numbered design decisions with rationale. The reference document for “why is the API like this?”
  • Policies — cross-cutting policies (unsafe stance, clippy posture, licence allowlist, contribution licensing and DCO sign-off, MSRV, panic vs Result). These apply across the whole codebase and across all contributors.
  • Downstream context — what specific downstream use shapes the library’s API. The most-cited example is the Panop flowgraph runtime; honeyeater itself remains general-purpose.

Using honeyeater — practical guides for working with the library:

  • Testing — the seven tolerance assertion macros from honeyeater-test, how to use each, default thresholds per kernel class, and patterns for testing filters, FEC, and stochastic kernels.

Reference:

  • Glossary — plain-English definitions of the recurring DSP, RF, and Rust terms used across these pages. Look here whenever a term is unfamiliar.

Status

honeyeater is pre-v0.0.1. No DSP kernels (individual signal-processing building blocks) are implemented yet.

API reference

The auto-generated API reference (rustdoc, Rust’s built-in documentation generator) is the source of truth for what types and functions actually exist. Build it with cargo doc --workspace --open.

Licence

Dual-licensed under MIT or Apache-2.0, at your option. Contributions are accepted under the same terms (inbound = outbound), and every commit must be signed off certifying the Developer Certificate of Origin (DCO.txt at the repository root) — see Policies.

Start here

New to honeyeater? This page is the gentle on-ramp. It explains what the library is for and the handful of terms you need to read the rest of the documentation — no prior signal-processing background assumed. Words in bold link to the Glossary, where each is defined in a sentence or two.

What honeyeater is, in one paragraph

honeyeater is a Rust toolbox of building blocks for processing radio and electrical signals on a regular computer — not inside the radio chip itself. A radio or digitiser captures a signal as a stream of IQ samples (pairs of numbers describing the signal moment by moment); honeyeater gives you the pieces to turn that stream into something useful — filtering it, transforming it, decoding it. Each piece is a kernel: one self-contained operation, like a filter or an FFT.

Who it’s for

honeyeater is aimed at people building software that takes a stream of IQ samples — from a radio front-end, a digitiser, or a simulation — and does something with it on a host computer. For example:

  • Satellite ground-station software
  • Telemetry decoders (turning a spacecraft’s or instrument’s transmitted data back into readable values)
  • Spectrum-analyser back-ends
  • Signal-intelligence processing chains (extracting information from captured radio traffic)
  • Modem implementations, from workstations down to microcontrollers
  • Bench-test and instrumentation tooling

If you work with sampled signals and want memory-safe, well-tested primitives in Rust, you are the target reader.

A five-minute concept map

A short tour of the ideas the rest of the docs lean on. You do not need to master these — just recognise them.

Signals and samples. A radio signal reaches software as IQ samples produced by an ADC (the chip that turns the analog signal into numbers). honeyeater works on those numbers; it does not touch the analog hardware or the high-rate logic inside the radio.

Kernels, generic over sample type. A kernel is one DSP operation. honeyeater’s kernels are written once and work across several number formats — high-precision floats for design work, and the compact fixed-point integers (Q-format) that real SDR hardware streams — with no runtime penalty, thanks to the compiler specialising each (monomorphisation).

Tested against an oracle. Every kernel is checked against an oracle: a trusted reference (an established library, or correct values published in a standard) that says what the right answer is. This is the project’s central discipline — a kernel isn’t done until it matches its oracle.

Tolerance. Because floating-point results are rarely identical to the last bit, “correct” means “within a stated tolerance” — close enough, measured precisely. How honeyeater defines and tests that is the subject of the Testing page.

Where to go next

  • New to the project? You’re in the right place — then read the Vision for the fuller statement of what honeyeater is and isn’t.
  • Want to test a kernel? Testing is the practical guide.
  • Curious why the API looks the way it does? Architecture decisions.
  • Hit an unfamiliar term? The Glossary defines them all in one place.

A note on status

honeyeater is pre-v0.0.1: the foundations and test harness exist, but no DSP kernels are implemented yet. So this page describes ideas rather than runnable code — the first hands-on tutorial will arrive with the first kernel.

honeyeater — vision

What it is

A Rust library of digital signal processing primitives for radio-frequency and electrical signals, intended for host-side processing of sample streams from radios, digitisers, and simulators. CPU-first today, with GPU acceleration designed for as a feature-flagged long-term addition (see Long-term).

What it is for

The target user is someone building software that takes a stream of IQ samples (the pairs of numbers a radio hands to software, describing the signal moment by moment) produced by a radio front-end, a digitiser, or a simulation, and does something useful with it on a host. Concretely:

  • Satellite ground-station software
  • Telemetry decoders
  • Spectrum-analyser back-ends
  • Signal-intelligence processing chains
  • Modem implementations on workstation, server, embedded Linux, and microcontroller targets
  • Bench-test and instrumentation tooling

What it is not

  • Not FPGA or ASIC gateware. The high-rate sample-by-sample work that runs inside the radio itself is a different toolchain (VHDL/Verilog/HLS) with different verification methodology. honeyeater starts where the ADC’s samples (the digitised signal, straight off the analog-to-digital converter) reach the host.
  • Not an audio-perceptual library. Loudness, dynamics, reverb, codec primitives, and pitch detection are served by existing Rust libraries. General-purpose primitives (filters, transforms, resampling) work on audio-rate signals, but the design priorities are RF and electrical.
  • Not a flowgraph runtime (GNU Radio’s role). Not a radio HAL (SoapySDR’s role). honeyeater is a library of primitives.

Exclusion on principle

Civilian, general-purpose. Waveforms defined only in restricted military standards (LPI/LPD, anti-jam, milspec SATCOM, tactical-link physical layers) and anything controlled under US ITAR or restricted EAR classifications are excluded by policy, regardless of contributor enthusiasm. General primitives used in defence systems — FFT, Reed-Solomon, LDPC, PLLs — are in scope as dual-use civilian-standard technology used openly across satcom, broadcast, and instrumentation.

Design principles

  1. Test-driven from the start. Every kernel is validated against a named oracle (a trusted reference that says what the right answer is): an established reference implementation (scipy.signal, liquid-dsp, libfec, AFF3CT) or bit-exact vectors from a published standard (CCSDS Blue Books, ETSI, 3GPP). Numerical tolerance — how close counts as correct — is a first-class part of every public API.
  2. A small, principled tolerance vocabulary. The codebase uses one consistent set of tolerance measures (elementwise atol + rtol·|b| close-comparison, SNR in dB, bit-exact, spectral mask, BER at Eb/N0, Parseval energy, Kolmogorov-Smirnov distribution test) rather than ad-hoc thresholds scattered through test files. Each measure is defined, with worked examples, on the Testing page.
  3. Memory-safe by construction. Written in Rust. The class of bugs that dominate C DSP libraries (buffer overruns, use-after-free, data races) is structurally excluded for safe code.
  4. Generic over sample type, including fixed-point. Algorithms are written once over a Sample trait satisfied by f32, f64, and the signed fixed-point sample types common to SDR hardware (i16 and i8 with Q-format scaling — a way of storing fractional values inside plain integers) — and monomorphised per concrete type by the compiler (specialised into a dedicated copy for each type, so the generality costs nothing at runtime). This serves both the high-precision case (f64 for filter design and high-dynamic-range work) and the high-rate case (native fixed-point for streaming receivers where the boundary conversion to float is prohibitive at full rate). The RTL-SDR’s biased Complex<u8> is supported via boundary debiasing helpers rather than as a kernel sample type — see docs/architecture-planning.md decisions 5 and 6.
  5. Plain buffers, no metadata wrapper. Hot-path APIs — the per-sample code that runs most often — take &[T] and &mut [T]. Sample-rate metadata is the runtime’s or the application’s concern, not the library’s — matching what every other pure DSP library (rustfft, scipy.signal, liquid-dsp) does.
  6. Pay only for what you use. A Cargo workspace of small crates, split when real dependency boundaries appear, not pre-emptively.

Comparators

honeyeater sits in a landscape dominated by:

  • liquid-dsp — C, permissive licence, single-maintainer, RF-focused. The primary numerical reference for modulation and synchronisation. Macro-driven type families. honeyeater’s most direct comparator.
  • GNU Radio — C++ flowgraph runtime with foundation governance. Different shape of project (runtime + block library). GPL-3.
  • scipy.signal — Python, BSD-3, the de facto reference for filter and spectral algorithms in scientific computing. honeyeater’s primary numerical reference for Tier 1 kernels.
  • rustfft, realfft, futuresdr, rustradio — existing Rust DSP work, narrower in scope. honeyeater does not duplicate rustfft; it depends on it. realfft (real-input variant by the same family of authors) is a likely future addition but not part of 0.0.1.

The whitespace in Rust is large: IIR design pipeline, full window family, Parks-McClellan, modems, FEC beyond toy CRC, OFDM, symbol/carrier sync, channel models, and electrical-instrumentation primitives are all sparsely covered or absent.

Long-term (deferred, but designed-for)

  • GPU backend — CUDA first. Not part of 0.0.1. When added, will live behind a feature flag in a separate crate (honeyeater-cuda) so the core library has no GPU dependencies.
  • no_std support — for embedded targets (Cortex-M, RISC-V microcontrollers). Not part of 0.0.1. Kernel signatures designed to avoid gratuitous heap allocation so this remains tractable later.
  • Formal certification posture — IEC 61508, DO-178C, ISO 26262 are heavyweight regimes that require concrete evidence (requirements traceability, MISRA-equivalent lints, documented worst-case error bounds). Long-term aspiration only. No claims made until concrete evidence exists.

honeyeater — roadmap

Status

Pre-v0.0.1. Private development workspace. No code yet; no scaffolding files (no Cargo.toml, no CI). When a 0.0.1 release is ready, a new public repository will be created and the codebase migrated.

The plan in one sentence

Stand up a small test harness with a principled tolerance vocabulary, then implement Tier-1 kernels test-first against named oracles, with CCSDS Reed-Solomon (255, 223) as the first concrete standards-conformance demo that justifies cutting 0.0.1.

Crate layout at 0.0.1

A Cargo workspace, deliberately small at first release:

honeyeater         facade crate
honeyeater-core    sample type, signal container, device traits, tolerance vocabulary
honeyeater-test    cross-validation helpers (dev-only, not shipped to users)

Additional crates split out when real dependency boundaries appear (a CUDA backend, a proc-macro, a heavy optional dep) — not pre-emptively. Reference points: rustfft and realfft are single-crate libraries (rustfft auto-detects AVX at runtime and exposes opt-in features for NEON and other paths); tokio and ratatui split crates only when a real boundary forces it.

A separate tools/oracle-gen/ workspace, outside the published crate set, holds scripts that run libfec (LGPL) and other non-permissive oracles to produce binary test fixtures. The library’s link graph never touches LGPL code. This pattern is used by ring for NIST CAVP vectors.

Test methodology (planned)

Nothing in this section is implemented yet. It describes the intended shape of the test harness once it is built in Phase 0.

Tolerance vocabulary

A small, fixed set of measures, intended to be used consistently across the codebase once implemented. Per-test thresholds will be documented per kernel; the set of measures themselves should be stable:

Macro (planned)PredicateIntended for
assert_close|a − b| ≤ atol + rtol·|b|, elementwise (numpy/MATLAB convention)pointwise array comparison: FIR/IIR output, FFT bins, resampler output, window taps
assert_snr_db10·log10(Σ|ref|² / Σ|ref − actual|²) ≥ min_dbstructured signal-vs-reference: filters, FFT round-trips, resamplers, modulators, AGC
assert_bit_exactexact equality at the byte (packed) or bit (unpacked) level, per the kernel’s output representationFEC encoders, fixed-point kernels, CRCs, scramblers
assert_spectral_maskeach bin within [lower(f), upper(f)] dBfilter design verification, transmit-spectrum compliance
assert_ber_at_ebn0BER ≤ target at stated Eb/N0 over Monte-Carlo trialsFEC decoders, demodulator slicers
assert_parsevalone-sided PSD integral ≈ time-domain energyspectral estimators (resolves the scipy/MATLAB/Octave Welch-scaling trap)
assert_distribution_ksKolmogorov-Smirnov test against target CDFPRNGs, AWGN generators, noise sources

Percentage (relative) tolerance is not the planned default. The field consensus (numpy, scipy, MATLAB, EBU, liquid-dsp) is the mixed predicate above for pointwise tests, because percentage breaks on zero crossings and is insensitive to dynamic range. Percentage survives only as an aggregate scalar metric (EVM, BER, loudness offsets) where it is genuinely appropriate.

Default thresholds per module class (intended)

Module classPrimary measureThresholdSource
FFT (f64)SNR≥ 120 dBcomfortably loose vs. FFTW’s typical O(log N · ε) ≈ 280 dB at N=2²⁰; Higham §24
FFT (f32)SNR≥ 60 dBRustFFT, scipy convention
FIR outputSNR≥ 100 dB (f64), ≥ 60 dB (f32)liquid-dsp practice
FIR designspectral maskpassband ±0.1 dB, stopband per specscipy remez tests
IIRSNR vs scipy lfilter≥ 80 dB (f64)scipy.signal tests
Polyphase resamplerSNR≥ 80 dBliquid-dsp resamp_crcf
Window functionsmixedrtol=1e-12, atol=1e-15scipy.signal.windows tests
Linear modulatorSNR vs analytic reference≥ 100 dB (f64)liquid autotest convention
FEC encoderbit-exactbyte equalityG.191 / CCSDS regime
FEC decoder (iterative)BER at Eb/N0spec-dependentDVB-S2, 3GPP, CCSDS
EVM aggregatepercentper 3GPP TS 36.1043GPP
AWGN / PRNGKS + moment matchKS α=0.01 (with fixed seed in CI), mean/var within 3σnumpy practice
AGCSNR + settlingwithin ±0.5 dB steady-stateliquid agc_crcf_autotest

Cross-platform reproducibility

Floating-point reproducibility across x86 / aarch64 / glibc / musl / Apple libm is not free. Mitigations to bake into the test harness when it is built:

  • Force -ffp-contract=off in test config (or pin a no-FMA reference path) so FMA contraction doesn’t change results across ISAs.
  • Offer a “deterministic” feature flag that forces sequential reduction in tests, so SIMD/parallel reduction order doesn’t change FFT/dot-product results across CPUs.
  • Bake reference vectors into tests/vectors/ as .npy files — never recompute libm references in CI, since sin/exp/log differ by a few ULP across libm implementations.
  • Force IEEE compliance in test config (no subnormal flush-to-zero).
  • Pin oracle versions: requirements.txt next to fixture-generation scripts should record exact scipy / numpy versions so vectors are reproducible.

Statistical tests in CI

Tests that look statistical but run in PR CI should use a fixed seed and act as deterministic vector regressions — flake-free, no real distributional claim. The actual statistical question (does the implementation match the target distribution?) belongs in a separate nightly / weekly job that runs across many independent seeds and checks that the resulting p-values are uniform on [0,1] under the null. This only works cleanly for tests with continuous null distributions (KS, Anderson-Darling); chi-square’s discrete bins distort uniformity at small N and need a coarser pass/fail criterion.

Oracle stack by module category (planned)

The library’s content is intended to be organised into six categories. Each has a primary numerical oracle (and a secondary cross-check where one is genuinely independent). None of these oracles are wired up yet:

Cat 0 — Numerical kernels (prerequisite layer)

Matrix decompositions, polynomial roots, special functions, sequence generators (Gold, Kasami, m-sequences, Zadoff-Chu, Barker, PN).

  • Primary oracle: scipy.linalg + Boost.Math
  • Tested first, before anything that depends on it.

Cat 1 — Transforms and spectral decomposition

FFT, DCT, STFT, Hilbert, wavelet (deferred), spectral-estimation algorithms (Welch, periodogram, multitaper).

  • Primary oracle: scipy.signal + scipy.fft (BSD-3)
  • Secondary: FFTW via pyfftw, because scipy uses pocketfft and FFTW is genuinely independent
  • Known weaknesses: scipy’s high-order elliptic and firls have documented divergences from MATLAB; Octave’s signal package is the tiebreaker for those corner cases. Always generate filter coefficients in SOS form when comparing.
  • FFT delegation (planned): rustfft will be the implementation. honeyeater will wrap it for API consistency rather than reimplement.

Cat 2 — Filters and resampling

FIR/IIR/biquad design, polyphase, adaptive filters in their filter role (LMS as denoiser), integer/rational/Farrow resamplers.

  • Primary oracle: scipy.signal for design coefficients and execution
  • Tests RF resampler quality on aliasing rejection / SNR in dB, not perceptual metrics.

Cat 3 — Modulation, synchronisation, framing, equalisation

PSK/QAM/FSK/CPM mod/demod, OFDM (standards-conformant only, deferred to Tier 2 work), Costas/PLL/Gardner/M&M, adaptive filters in their equaliser role, frame sync.

  • Primary oracle: liquid-dsp (MIT), vendored at a pinned commit, bindgen regenerated against current toolchain. The liquid-dsp-bindings-sys crate on crates.io is stale (2019) — do not depend on it; regenerate.
  • Secondary: GNU Radio QA test patterns via subprocess for sync-loop convergence (GPL-3, so subprocess-only, never linked).
  • Critical gap to fill ourselves: liquid-dsp’s modem autotests are noiseless round-trip equality checks, not BER curves. honeyeater will need to supply its own closed-form AWGN BER assertions (BPSK/QPSK/16-QAM/64-QAM via the standard Q(sqrt(2·Eb/N0))-family formulas). This is expected to be the single most important piece of test infrastructure for Cat 3.
  • Standards-conformant OFDM (LTE / 5G NR / DVB-T2 / DVB-S2X): MATLAB-captured vectors, shipped as opaque test data with attribution. liquid’s own ofdmflexframe is a non-standard hand-rolled waveform — useful as a self-consistency oracle, not a conformance one.

Cat 4 — Forward error correction

CRC, Hamming, Golay, Reed-Solomon, convolutional + Viterbi, BCH, LDPC.

  • Per code-family oracle:
    • CRC: reveng catalogue (public) — constants compiled in, no library dependency, "123456789" standard check value
    • Hamming / Golay: textbook generator matrices, exhaustive enumeration of small codes
    • Reed-Solomon (CCSDS RS(255, 223)): KA9Q libfec (LGPL) — vectors captured ahead-of-time in tools/oracle-gen/, shipped as opaque binary blobs, no link dependency. Normative cross-check against CCSDS 131.0-B-5 §4 (which fixes the code parameters, generator polynomial, and dual-basis representation) plus JPL TMOD 810-005 module 208 for worked numerical examples. CCSDS 131.0-B-5 Annex F documents the Berlekamp↔conventional basis transformation needed when comparing against any oracle that operates in the conventional basis.
    • Convolutional K=7 r=1/2 (CCSDS / Voyager standard): libfec for encoder bit-exactness; BER curve for decoder
    • BCH (DVB-S2 outer): AFF3CT (MIT) and ETSI EN 302 307-1 §5.1.1 polynomial
    • LDPC (DVB-S2, CCSDS AR4JA): AFF3CT for encoder vectors; BER curves vs ETSI TR 102 376-1 and published CCSDS plots
  • Decoders generally: bit-exact testing is impossible for iterative soft-decision decoders (turbo, LDPC, SCL polar) because implementations diverge on quantisation and scheduling. BER vs Eb/N0 is the only sensible test, with 0.2 dB tolerance at waterfall and 0.5 dB in the error floor.
  • AFF3CT (MIT) is the intended workhorse oracle for everything iterative; it ships reference BER curves that honeyeater would compare against.
  • 5G NR Polar codes and LTE turbo codes are not Tier 1 (limited deployment outside cellular infrastructure) — defer.

Cat 5 — Stochastic sources and channel models

PRNGs, AWGN, Rayleigh/Rician/Nakagami fading, 3GPP TDL, statistical properties of estimators.

  • PRNG strategy: delegate to the Rust rand and rand_distr crates rather than reimplementing. One-time qualification report via TestU01 BigCrush and PractRand to 1 TB, archived in docs/prng-qualification.md. ChaCha and PCG both pass; the report is a formality but a citable one.
  • AWGN and distributions: scipy.stats for CDF/moment cross-checks. Anderson-Darling on large sample sizes (N ≥ 10⁶) to catch tail-handling bugs (Ziggurat has historical edge-case bugs in the tail).
  • Fading channels: 3GPP TR 38.901 §7.7.2 (TDL profiles A–E, Tables 7.7.2-1 through 7.7.2-5) and §7.7.1 (CDL profiles A–E) as the spec targets. No bit-exact reference exists for these — test marginal envelope (KS against Rayleigh/Rice), Doppler PSD shape against Clarke/Jakes analytical, tap PDP against the relevant table within ±0.1 dB.

Implementation order

Phase 0 — Scaffolding

Before any kernel is implemented:

  1. Cargo workspace skeleton: workspace Cargo.toml at root, honeyeater (facade), honeyeater-core (sample types, trait definitions, signal containers), honeyeater-test (cross-validation helpers).
  2. The Sample trait plus the fixed-point sample type set in honeyeater-core: Complex<i16> and Complex<i8> as kernel sample types (Sample-implementing); Complex<u8> as a transport-only type at the SDR boundary, debiased to one of the others before any kernel touches it (the three integer formats produced by SDR hardware across the field — see docs/architecture-planning.md for the landscape and decisions 5–6 for the rationale). The trait is satisfied by f32, f64, i16, i8 (and their Complex<…> wrappings). Without this wiring, generic kernels can’t be written and fixed-point can’t ship at 0.0.1. Goes in before any kernel.
  3. Wire up the rustfft dependency in honeyeater-core (used by Phase 1 step 4, the FFT wrapper). num-complex re-exported through honeyeater-core so user code has a stable import path independent of rustfft’s version pinning.
  4. honeyeater-test: the seven assertion macros, a .npy loader for committed reference vectors, a scipy-subprocess helper for live cross-validation. This is the highest-leverage piece of infrastructure in the project.
  5. The tools/oracle-gen/ workspace, outside the published crate set, for generating reference vectors from libfec / AFF3CT / etc. without those libraries entering the library’s link graph.
  6. CI: a single ci.yml with cargo fmt --check, cargo clippy -D warnings, cargo test, cargo doc with RUSTDOCFLAGS=-Dwarnings, plus cargo deny check. Stable + MSRV + nightly.
  7. Repo hygiene files: CONTRIBUTING.md, CODE_OF_CONDUCT.md (copy Rust’s), SECURITY.md, rustfmt.toml, clippy.toml, deny.toml.
  8. Reserve honeyeater, honeyeater-core, honeyeater-test on crates.io as 0.0.1-alpha.0.

Phase 1 — Tier-1 RF/electrical primitives, in order

In rough order of “fastest validation win × highest user value”:

  1. Hann window — trivial, scipy bit-near oracle, exercises the entire test harness end-to-end before anything risky is built. Float-only initially (windows aren’t typically fixed-point).
  2. Hamming, Blackman-Harris, Kaiser windows — same harness, rounds out the window family.
  3. RBJ biquad coefficients + execution — first filter, validates the design-coefficient testing path (formulas are the spec; cross-check execution against scipy sosfilt).
  4. FFT wrapper delegating to rustfft (complex-in / complex-out) — establishes the signal-type plumbing. Float-only (FFT in fixed-point is a separate non-trivial implementation). Real-input FFT (real → conjugate-symmetric complex, the realfft shape) is not part of 0.0.1; add when a kernel needs it.
  5. CRC-32 (Castagnoli) and CRC-16 — first bit-exact test, reveng oracle, tiny code, no external dep.
  6. NCO / DDS — first stateful kernel, exercises SFDR property testing. Implemented in both float and fixed-point (Complex<i16> and Complex<i8>) — this is the first fixed-point kernel; it’s small and well-defined so it’s a good first proof of the trait machinery.
  7. SDR sample boundary helpers — conversions between Complex<i16> / Complex<i8> (kernel sample types) and Complex<f32> / Complex<f64>, with Q-format scaling as a parameter (so the same i16 conversion serves USRP sc16 at Q1.15 and BladeRF SC16_Q11 at Q1.11 by passing the right scale), plus Complex<u8>Complex<i8> / Complex<f32> debiasing for RTL-SDR (subtracting the 127.5 midpoint), plus optional deinterleave to separate I/Q arrays. Trivial code, but unblocks every SDR user. Tested by round-trip identity (for the lossless paths), value-range checks, and bias-handling correctness for the RTL-SDR path. Ships alongside a q_format module of named per-radio constants covering every radio with a current SoapySDR support module (USRP, BladeRF including SC16_Q11, SC16_Q11_PACKED, and SC8_Q7 modes, HackRF, RTL-SDR, Airspy R2/Mini, Airspy HF+, SDRplay, Pluto with separate RX/TX constants, LimeSDR including the CS12 packed variant, FCDPP, Sidekiq, Mirics, Red Pitaya, XTRX, Iris, NetSDR/Afedri, plus the SoapyOsmo/SoapyAudio/SoapyRemote shims) so users pass the constant for the radio they own rather than typing a raw scale. See docs/architecture-planning.md decision 6 for the full table.
  8. FIR filter executionimplemented in both float and fixed-point. Hot-path kernel; native fixed-point is what makes high-rate streaming receivers viable across SDR vendors. Cross-validate float version against scipy lfilter; cross-validate fixed-point against the float version (within Q-format quantisation bounds).
  9. Complex multiply (the mixer primitive) — implemented in both float and fixed-point. Tiny but ubiquitous; needed alongside the NCO for downconversion.
  10. CCSDS Reed-Solomon (255, 223) encoderfirst standards-conformance demo. Bit-exact against CCSDS 131.0-B-5 Annex F worked examples plus libfec-generated vectors. This is the milestone that justifies cutting 0.0.1.

Tier 1 then continues (no fixed-point unless explicitly noted): FIR design (window method, Parks-McClellan), IIR design (Butterworth/Chebyshev/Elliptic), polyphase resampling (consider fixed-point), mixer / IQ imbalance / DC offset removal (consider fixed-point), AGC (consider fixed-point), AWGN channel, PLL / Costas loop, Mueller & Müller timing recovery, linear modems (BPSK / QPSK / 8PSK / 16-QAM / 64-QAM), CPFSK / GMSK, Viterbi decoder, LMS / RLS equaliser. None of these block 0.0.1 — they ship as they’re ready.

Phase 2 — Tier-2 specialty (post-0.0.1)

3GPP TDL channel models, ITU-R IMT channel models, standards-conformant OFDM (LTE / 5G NR / DVB-T2 / DVB-S2X) with MATLAB-captured vectors, DVB-S2 LDPC, CCSDS LDPC (AR4JA family), BCH (DVB-S2 outer), spectral-estimation rigour (multitaper, Lomb-Scargle), EVM / MER / SNR estimators, spurious / SFDR / phase-noise measurement primitives.

Deferred indefinitely

  • Wavelet transforms — no demand signal for an RF/electrical library
  • 5G NR Polar codes — niche outside cellular infrastructure
  • LTE turbo codes — superseded by LDPC in new designs
  • LDPC for Wi-Fi specifically — Wi-Fi isn’t a typical defence/space target
  • ISDB / ATSC FEC variants
  • Audio-perceptual kernels (entire category — out of scope; existing Rust library covers it)
  • High-assurance certification tooling (interval arithmetic, formal harness, requirements-traceability infrastructure) — premature without a target certification authority locked in

Cutting 0.0.1

When the milestone in Phase 1 step 10 is achieved (CCSDS RS(255, 223) bit-exact), and the harness, CI, scaffolding files, and the minimal kernel set are all green, cut 0.0.1 to a fresh public repository. The minimum kernel set for 0.0.1 is:

  • Window family (Hann, Hamming, Blackman-Harris, Kaiser)
  • RBJ biquad design and execution (float)
  • FFT wrapper around rustfft, complex-in / complex-out (float)
  • CRC-32 and CRC-16
  • NCO / DDS (float and fixed-point)
  • SDR sample boundary helpers (integer↔float, Q-format-aware)
  • FIR filter execution (float and fixed-point)
  • Complex multiply (float and fixed-point)
  • CCSDS RS(255, 223) encoder (bit-exact against Blue Book vectors)

The current working tree remains the private development workspace; the public repo gets the polished cut.

Architecture planning

Architectural decisions for honeyeater pre-0.0.1, with rationale. All decisions below (numbered 1–10, with 3a as a sub-decision under 3) are now resolved. This document is the authoritative reference for design intent; if a future contributor wants to deviate from one of these, the deviation needs to update this file with reasoning.

Constraints

Downstream use: the Panop flowgraph

honeyeater is open-source and general-purpose, but it has a known downstream consumer: an internal Panop flowgraph runtime, GNU Radio-style, used for RF DSP. honeyeater stays a library of primitives — the runtime is a separate project, not part of honeyeater.

Two properties of the Panop runtime constrain honeyeater’s design:

  1. The flowgraph is built at compile time. Different waveforms (AIS receiver, DVB-S2 receiver, etc.) are pre-compiled into separate graph artefacts. Hot-swap of waveforms is “stop running graph A, start running graph B,” not “reconfigure the existing graph in place.” Each graph’s blocks have their parameters known at compile time.
  2. Multiple coexisting RX/TX chains. The runtime can run several independent graphs at different sample rates simultaneously. honeyeater must not assume there’s one global sample rate, one global processor configuration, or one graph in flight.

This rules out a JUCE-style prepare(spec) step separated from construction, because the spec is known at compile time and there’s no separate runtime configuration step. (JUCE is a widely used C++ audio framework; its processors take a one-time prepare() call carrying the sample rate and block size before processing begins — a pattern that suits a single audio device but not honeyeater’s many independent RF chains.) It matches liquid-dsp’s pattern: parameters baked in at construction, processor immutable for its lifetime, sample rate handled in normalised form (cycles per sample) wherever possible.

Hardware: SDR sample-format landscape

honeyeater is a general-purpose RF DSP library and must be cheap to use with the full range of SDR hardware in the field. The realistic intake set, ordered by frequency of occurrence (verified against SDK sources):

  • Interleaved Complex<i16> — the dominant native wire format. USRP (UHD sc16, full-scale ±1.0 ↔ ±32767, i.e. Q1.15), BladeRF (SC16_Q11: signed 16-bit container, 11 fractional bits, full-scale ±1.0 ↔ ±2048, leaving 4 unused MSBs of sign-extension headroom), LimeSDR (I16, 12-bit LMS7002M MSB-aligned in i16, Q1.15), Epiq Sidekiq, ADALM-Pluto (12-bit AD9361 sample — RX Q1.11 LSB-aligned, TX Q1.15 MSB-aligned), Airspy R2/Mini (12-bit MSB-aligned, Q1.15), SDRplay (14-bit MSB-aligned, Q1.15), SoapySDR CS16. Almost every recent SDR delivers this by default.
  • Interleaved Complex<i8> — high-rate / oversample mode. BladeRF (SC8_Q7, 122.88 MS/s), USRP sc8, HackRF One (only format).
  • Interleaved Complex<u8> with DC midpoint at 127.5 — RTL-SDR only. The hardware delivers unsigned-8 samples in [0, 255], with the true zero-signal value sitting at 127.5 (the midpoint of the range); processing code conventionally subtracts either 127 or 128 depending on whether it’s targeting i8 arithmetic or preserving the exact midpoint. Common in entry-level and hobbyist captures.
  • Interleaved Complex<f32> — the host-side lingua franca after conversion. SoapySDR CF32, UHD fc32, SigMF default for capture files.
  • Interleaved Complex<f64> — rare; UHD fc64 and Soapy CF64 only. Acceptable to handle by conversion rather than zero-copy.

The various 12-bit-in-int16 sub-variants (LimeSuite I12, Pluto, Sidekiq) are bit-identical to the generic interleaved Complex<i16> from a memory-layout standpoint; only the Q-format scale factor differs. So they are handled by the same memory layout plus a scaling constant.

This means honeyeater must support native fixed-point processing on its hot-path kernels, not just floating-point with boundary conversion:

  • At 122.88 MS/s on one core (the BladeRF oversample case), each sample has ~8 ns of compute budget. Conversion to f32 at the boundary eats meaningfully into that. Native int8 SIMD on AVX2 has roughly 4× the per-cycle throughput of f32 SIMD.
  • The same argument applies in milder form to any high-rate streaming receiver on any radio in the list.
  • A library that requires float at the boundary effectively limits its users to perhaps 30–60 MS/s on a modern CPU — well below what BladeRF, USRP X310, LimeSDR, and Sidekiq X4 can deliver.

Library design implication: ship Complex<i16>, Complex<i8>, Complex<u8> as first-class hot-path sample types alongside Complex<f32> and Complex<f64>, with the Sample trait satisfied by all of them.

(Sources: SoapySDR Formats.h, UHD configuration, Sidekiq SDK manual, hackrf.h, LimeSuite.h, rtl-sdr.h, libiio AD9361 example, libbladeRF.h.)

Reference libraries to look to

When a design question arises, look to liquid-dsp (RF, permissive, single-maintainer but the closest peer) and scipy.signal (BSD, the de facto reference for filter and spectral algorithms) first. JUCE and other audio-DSP libraries are not appropriate references because their design centres a single-rate single-processor “plugin in a DAW” model that doesn’t match the multi-RX/TX-chain RF case. Flowgraph SDR frameworks (GNU Radio, FutureSDR) are useful for understanding stream-metadata patterns but not for honeyeater’s library shape, since honeyeater isn’t a runtime.

Wherever a claim is made about how another library behaves, verify it. The ecosystem-claim about JUCE carrying sample rate on its buffer was wrong on inspection; sample-rate metadata in JUCE lives only in a ProcessSpec passed once to prepare(). Verify by reading source / docs, not from memory.

Decisions

1. MSRV — latest stable minus two

The Rust compiler version honeyeater promises to compile under is the version released about three months before the most recent stable (i.e. N-2 across Rust’s six-week release cadence). This is a common pattern in production-leaning crates; the ecosystem has no unified MSRV policy (tokio uses roughly N-4, serde leaves it open, others vary), so we pick the spot that balances toolchain lag in regulated environments against not freezing on genuinely old compilers.

2. Sample-data ownership — borrowed-output and stateful processors primary; owned-output as documented convenience

The hot path uses two complementary forms:

  • Borrowed outputfn process(input: &[T], output: &mut [T]). Caller pre-allocates both buffers. Zero allocations per call. Primary form for stateless operations (windows, gain adjustment, fixed-point↔float conversion).
  • Stateful processors — objects constructed once, holding internal state, called repeatedly. Primary form for anything carrying state between calls (filters, NCOs, PLLs, demodulators, AGCs, equalisers).

An owned-output form (fn process(input) -> Vec<T>) is offered as a thin convenience layer for offline analysis, filter design, one-shot processing. It is a three-line wrapper on top of the borrowed-output primitive, not a separate implementation.

Documentation for the owned-output form must include words to this effect: “Allocates a fresh output buffer on every call. Suitable for offline analysis, filter design, one-shot processing, and any use where occasional unpredictable delays are acceptable. Not suitable for hard real-time streaming pipelines (e.g. live SDR receive chains where a missed buffer drops samples).”

Naming convention for the owned variants to be settled at first-kernel time (likely _owned suffix or a convenience submodule).

3. Signal type — plain buffers, no metadata wrapper

honeyeater uses plain Rust slices everywhere on the hot path. There is no Signal<T> struct carrying sample rate; sample rate is either a normal constructor argument to a processor (when needed) or handled in normalised form (cycles per sample) so the processor doesn’t need to know the absolute rate.

This matches what every pure DSP library does: rustfft, ndarray, nalgebra, scipy arrays, and liquid-dsp all use plain buffers. Where stream metadata exists (GNU Radio’s stream tags, scipy’s dlti.dt), it lives on the stream or the system object — not on the sample buffer. honeyeater isn’t a runtime, so stream-tag machinery doesn’t belong in it; the Panop flowgraph runtime will provide that.

Sample-rate metadata that genuinely needs to flow with data (e.g. for a spectrum-analyser GUI displaying frequencies in Hz) is the application’s responsibility, not the library’s. honeyeater provides bin spacing in normalised form and a helper to convert to Hz given fs.

3a. Block parameter mutability — per-block, mutators only where safe

A flowgraph block can have its parameters fixed in three ways:

  1. Compile-time constants the compiler folds into the hot path (Panop’s typical case for waveform-defining parameters).
  2. Construction-time values stored in the struct (the default for everything else).
  3. Runtime-mutable parameters with setter methods.

honeyeater’s policy: every processor takes its parameters at construction. Mutator methods are exposed only on processors where in-place mutation is safe — meaning the change applies cleanly to future samples without producing a transient and doesn’t require re-deriving derived state. Examples:

  • Exposes setters: Nco::set_frequency, Agc::set_reference, Gain::set_db.
  • No setters; reconstruct to change: FirFilter (coefficient change causes delay-line transient), IirFilter (same), RrcShaper (precomputed coefficient tables).

Graph-level hot-swap (stop graph A, start graph B) is independent of per-block mutability and works regardless of which form a block uses.

4. Complex-number representation — num_complex::Complex<T>

IQ samples use num_complex::Complex<T> from the num-complex crate. This is a struct of two T values (real and imaginary parts) laid out adjacent in memory, so an array of Complex<f32> has the same byte layout as interleaved [I0, Q0, I1, Q1, ...] arrays. This is the layout every SDR driver in the field produces — zero-copy at the hardware boundary regardless of which radio supplies the samples.

num_complex is what rustfft uses, so honeyeater’s FFT wrapper has the same type at its interface. Compile-time real-vs-complex distinction is free: a function written for &[f32] cannot be called with &[Complex<f32>] and vice versa, ruling out a common class of bug in C SDR code.

For fixed-point IQ, the same wrapping applies: Complex<i16> and Complex<i8> are valid sample types with the same struct layout and the same compile-time real/complex discipline. Complex<u8> (RTL-SDR’s biased format) uses the same struct layout but is treated as a transport-only type at the SDR boundary, not a kernel sample type — see decision 6. Q-format scaling (Q1.15, Q1.11, Q1.7, etc.) is a property of how the sample is interpreted by surrounding code, not a property of the type itself.

5. Generic over sample type — T: Sample

Every kernel that can be is written once, generic over a placeholder sample type T, with a trait bound T: Sample. The Rust compiler stamps out specialised versions per concrete type (monomorphisation) — zero runtime cost compared to hand-written duplicates.

T: Sample is a honeyeater-defined trait, not num_traits::Float and deliberately not named Real (which would collide with num_traits::real::Real, the existing real-number trait in that crate). (num_traits is the de-facto Rust crate of numeric abstraction traits; reusing one of its trait names for a different concept would confuse anyone who already knows it.) The distinction matters: Float excludes fixed-point types by definition (it requires NaN, infinity, etc.), and decision 6 below commits honeyeater to fixed-point sample types. Sample is a strictly smaller trait — addition, multiplication, negation, comparison, conversion to/from a few standard scalar types — satisfied by f32, f64, the signed integer sample types i16 and i8 (with sign-symmetric arithmetic that matches signal semantics), and anything else useful future contributors define. Complex<u8> is not a Sample type; see decision 6.

The trait bound design is load-bearing for the fixed-point story; getting it right at the start saves a breaking change later. The name Sample is the universal term in DSP for “one element of a signal stream” and avoids the readability tax of a name clash with num_traits.

6. Fixed-point support — float and fixed-point from day one

Native fixed-point sample types ship at 0.0.1, alongside f32 and f64. The rationale:

  • Every major SDR family (USRP, BladeRF, LimeSDR, Sidekiq, HackRF, RTL-SDR, Pluto) delivers fixed-point samples natively. f32 is a host-side convenience after conversion; the hardware speaks integer.
  • At streaming-receiver rates above roughly 30–60 MS/s on a modern CPU core, the boundary conversion from integer to f32 begins to eat a meaningful slice of the per-sample budget. By 122.88 MS/s (the high end currently in use) it dominates everything else the kernel does.
  • A library that requires float at the boundary effectively locks streaming users out of high-rate modes on every SDR in the list. This is incompatible with serving the RF field generally.
  • Other fixed-point-only contexts (space-qualified processors with no FPU, ITU-T G.191 codec test vectors, FPGA-coprocessor interop) reinforce the same conclusion.

Scope is bounded by importance, not by mechanism: every hot-path kernel that runs at sample rate gets a fixed-point implementation; everything else (filter design, FEC, channel models, spectral estimators that run once per buffer rather than per sample) stays float-only until a user proves otherwise.

At 0.0.1, the fixed-point sample types are:

  • Complex<i16> — the dominant native kernel format across USRP, BladeRF, LimeSDR, Sidekiq, Pluto. The Q-format scaling (Q1.11 for BladeRF’s SC16_Q11, Q1.15 for full-scale USRP sc16, intermediate fractional widths for 12- and 14-bit ADCs delivered in a 16-bit container) is not baked into the type; it’s a property of the surrounding code’s interpretation. Conversion helpers take the Q-format as a parameter (or are named with the Q-format) so the same Complex<i16> works for any radio.
  • Complex<i8> — high-rate kernel format for BladeRF, USRP, and HackRF (where it’s the only format).
  • Complex<u8> with DC midpoint at 127.5 — RTL-SDR. A transport-only type at the SDR boundary; not a sample type any kernel operates on. Rationale: u8 + u8 is unsigned wrapping arithmetic on biased values, which is the wrong arithmetic for signal addition (two near-zero samples around 128 wrap to “maximum negative”). Rather than teach the Sample trait to do bias-aware arithmetic for one radio’s format, the boundary helper debiases unconditionally — it takes a &[Complex<u8>] and produces a &mut [Complex<i8>] or &mut [Complex<f32>] with the bias subtracted. From that point on the rest of the library never sees a biased sample. Cost: one mandatory copy per RTL-SDR buffer (invisible at RTL-SDR’s hobbyist rates and noise floor); benefit: every kernel’s arithmetic semantics stay uniform and signed.

Complex<i16> and Complex<i8> implement T: Sample (via Rust’s normal i16 / i8 types — signed arithmetic matches signal arithmetic modulo overflow). Complex<u8> does not implement Sample and is accepted only by boundary helpers, by design.

Boundary conversion helpers ship at 0.0.1: float-to-fixed and fixed-to-float for Complex<i16> and Complex<i8>, fixed-to-fixed/float for Complex<u8> (debiasing), with Q-format scaling as a parameter where applicable, and optional deinterleave-to-separate-arrays for users who want that shape. These are trivial code but they’re what every SDR user needs immediately on receiving a sample buffer.

Fixed-point implementations at 0.0.1 of: FIR filtering, NCO/DDS, complex multiply, magnitude/power calculation. These are the hot-path kernels a streaming receiver spends most of its time in. Everything else remains float-only at 0.0.1.

The fixed-point footprint grows post-0.0.1 as users find kernels that need it. The T: Sample discipline (decision 5) guarantees this growth is non-breaking.

Named Q-format constants per radio

A Complex<i16> from a USRP and a Complex<i16> from a BladeRF look identical at the byte level but mean different things numerically (Q1.15 vs Q1.11 — a 16× scaling factor). A user who owns several radios should not have to know this off the top of their head; making them pass a raw scaling number to a boundary helper is a footgun.

honeyeater therefore ships a q_format module of named constants, one per supported radio’s wire format. The table below targets full coverage of every radio with a current SoapySDR support module, with the Q-format inferred from the driver’s fullScale parameter (fullScale == 2^n ⇒ Q1.n in the signed container).

Radio familySoapySDR moduleNative sample formatConstantQ-format
Ettus / NI USRP (B/N/X/E/N3xx)SoapyUHDCS16 / CS8 / CF32 (wire: sc16 / sc12 / sc8)USRP_SC16 / USRP_SC8Q1.15 / Q1.7 (defaults; per-radio AD936x variants documented inline)
BladeRF (x40 / x115 / 2.0 micro), default 16-bitSoapyBladeRF / libbladeRF SC16_Q11Complex<i16>, fullScale 2048BLADERF_SC16_Q11Q1.11 (LSB-aligned in i16, 4 MSB headroom)
BladeRF 2.0 micro, packed 12-bit transportlibbladeRF SC16_Q11_PACKED12-bit packed over the wire; same Q1.11 to the userBLADERF_SC16_Q11_PACKEDQ1.11 (transport-equivalent to SC16_Q11)
BladeRF 2.0 micro, 122.88 MS/s oversamplelibbladeRF SC8_Q7Complex<i8>, fullScale 128BLADERF_SC8_Q7Q1.7 (the libbladeRF.h doc-comment for this format has a copy-paste bug — see Nuand issue #939 — the symbol name is authoritative)
HackRF OneSoapyHackRFComplex<i8>, fullScale 128HACKRF_SC8Q1.7
RTL-SDR (RTL2832U)SoapyRTLSDRraw is Complex<u8>, the SoapySDR driver re-biases to Complex<i8> fullScale 128RTL_SDR_U8 (raw biased) / RTL_SDR_SC8 (post-bias)unsigned 127.5 midpoint (raw) / Q1.7 (post-bias)
Airspy R2 / MiniSoapyAirspyComplex<i16>, fullScale 32767AIRSPY_R2_SC16Q1.15 (12-bit ADC MSB-aligned in i16)
Airspy HF+ / DiscoverySoapyAirspyHFComplex<f32> onlyAIRSPYHF_CF32n/a (float)
SDRplay RSP1A / RSPdx / RSPduoSoapySDRPlay3Complex<i16>, fullScale 32767SDRPLAY_SC16Q1.15 (14-bit ADC MSB-aligned)
ADALM-PlutoSoapyPlutoSDRComplex<i16>, RX fullScale 2048 / TX fullScale 32768PLUTO_RX_SC16_Q11 / PLUTO_TX_SC16_Q15RX Q1.11 (LSB-aligned), TX Q1.15 (MSB-aligned)
LimeSDR / LimeSDR-Mini / LimeSDR-USBSoapyLMS7Complex<i16>, fullScale 32767 (also CS12 packed)LIMESDR_SC16 (LIMESDR_CS12 for packed)Q1.15 (12-bit LMS7002M MSB-aligned in i16); packed 12-bit transports the same values
FUNcube Dongle Pro+SoapyFCDPPComplex<i16> via ALSA (16-bit audio path)FCDPP_SC16Q1.15
Epiq Solutions SidekiqSoapySidekiqComplex<i16>SIDEKIQ_SC16not consistently documented per model — confirm against SDK manual at first integration
Mirics MSi2500 / MSi001SoapyMiri (community)Complex<i16>MIRI_SC16not documented — confirm at integration
Red Pitaya STEMlabSoapyRedPitayaComplex<i16> over TCPREDPITAYA_SC16Q1.15 (14-bit ADC MSB-aligned)
Fairwaves XTRX (LMS7002M)SoapyXTRX (community)Complex<i16>XTRX_SC16Q1.15 (same family as LMS7)
Skylark Iris / Faros (massive-MIMO)SoapyIris (community)Complex<i16>IRIS_SC16Q1.15
RFSpace NetSDR / AfedriSoapyNetSDR / SoapyAfedri (community)Complex<i16>NETSDR_SC16Q1.15
gr-osmosdr umbrella (RFSpace, MiriSDR, etc.)SoapyOsmovariesper underlying driverper underlying driver
Sound-card / FunCube-classSoapyAudio (community)Complex<f32> from ALSA/PortAudion/a (float)n/a
Network transport (not a radio)SoapyRemotepassthroughn/an/a

User code reads as: let floats = sdr::to_complex_f32(samples, q_format::BLADERF_SC16_Q11); — they pick the constant for the radio they own and the right numerical scaling falls out. The table also lives as a rustdoc page so it’s discoverable in the published docs.

This is not vendor-specific code (no driver bindings, no I/O paths, no licence entanglements) — just named constants and a docs table. It makes the bare Complex<i16> / Complex<i8> representation safe in practice: the Q-format-mixing bug only fires if a user types raw scaling numbers, and the constants make that unnecessary. The SoapySDR API itself does not standardise Q-format per radio — only fullScale — so this table is the canonical mapping; it should be updated whenever a new SoapySDR module appears or an existing one changes its native format.

Open question for first implementation: whether Complex<i16> and Complex<i8> are used directly, or whether they’re wrapped in newtypes (Sc16, Sc8 or similar) for type clarity at API boundaries. The trade-off: direct use is more familiar and composes more easily with other Rust crates; newtypes catch a residual class of “I passed USRP Q1.15 samples to a function expecting BladeRF Q1.11 samples” mistakes at compile time, even when the named constants above are used at conversion time. With the named constants in place the bare representation is likely sufficient, but the decision is deferred to first-kernel time; whichever choice is made should be consistent across both. (Complex<u8> is already decided: transport-only, no newtype needed because it never reaches a generic Sample-bounded API.)

7. Workspace versioning — synchronised across crates

All crates in the workspace (honeyeater, honeyeater-core, honeyeater-test, and future siblings) share one version number. Every release bumps every crate. This matches tokio’s policy and makes the downstream audit story simple: a user pins honeyeater 0.5.x and gets a coherent set.

The cost is occasional dead version bumps in crates that didn’t actually change; this is cheap relative to the audit-clarity benefit, especially for defence and aerospace users who care about exact-version pinning.

8. Error handling — mixed: panic on contract violations, Result on data-driven failures

Panic when the caller has violated the API contract — passed mismatched buffer lengths, asked for a filter of order zero, requested an FFT of length 1, etc. These are programmer bugs the caller should fix; turning them into Result values forces error-handling boilerplate on call sites that have no recovery path.

Return Result<T, E> (with a honeyeater error enum) when something can fail based on data the caller cannot validate up-front — filter design that doesn’t converge to a stable design, file read that fails, SDR capture format that doesn’t match what was expected. These are recoverable conditions and the caller deserves the chance to handle them.

This is the current consensus in the Rust scientific computing ecosystem (ndarray, nalgebra, rustfft all use it). It’s also appropriate for honeyeater specifically because the Panop runtime runs as a long-lived process where unbounded panicking is unacceptable: data-driven errors must be recoverable.

A panic policy needs to be documented per public API. The first-kernel recipe (see roadmap) will set the precedent.

9. Concurrency — Send by default, Sync only where trivially correct, no internal parallelism at 0.0.1

honeyeater types implement Send (movable between threads) by default. This is the typical SDR pattern: a USB-reader thread receives buffers from the radio and hands ownership to a demodulation thread, which hands to a decoder thread. Each filter or NCO lives on exactly one thread at a time, but moves between threads at handoff points.

Sync (multiple threads using the same instance simultaneously) is opt-in per type and only implemented where it is trivially free — stateless function-objects, immutable lookup tables. Most stateful processors are not Sync, because making them so would require atomics or locking that the typical caller doesn’t want to pay for.

No internal parallelism (no rayon, no thread pools, no parallel-by-default kernels) at 0.0.1. Parallelism is the runtime’s job, not the library’s. Adding rayon-based parallel versions of specific kernels later is non-breaking; building them in now and forcing users to opt out is.

Non-permissively-licensed reference implementations (libfec under LGPL, GNU Radio under GPL-3, MATLAB toolboxes as data-only) are used only in a separate tools/oracle-gen/ workspace that exists outside the published crate set. That workspace runs the non-permissive references to generate reference vectors, which are then committed to tests/vectors/ as opaque binary blobs with attribution.

The published library’s link graph never touches non-permissive code, not even at test time. The library and its dev-dependencies are MIT/Apache/BSD throughout.

This is the same pattern ring uses for NIST CAVP cryptographic test vectors. It keeps honeyeater unambiguously redistributable under MIT-OR-Apache-2.0, with no LGPL-relinking obligation on downstream users.

Style notes for future contributors and AI assistants

  • Verify claims about other libraries before relying on them in a design decision. Reading source on GitHub or current docs beats memory or training data.
  • Don’t lean on audio libraries (JUCE, fundsp, etc.) as references. Their single-rate single-processor model doesn’t match honeyeater’s multi-RX/TX-chain case.
  • liquid-dsp and scipy.signal are the primary references for “what does the field do here.” liquid-dsp is permissively licensed so its decisions can be observed in source and its outputs used.
  • The Panop downstream context (docs/downstream-context.md) shapes API decisions but doesn’t enter the library — honeyeater stays a general-purpose library, not a Panop SDK.

honeyeater — policies

Cross-cutting policies that apply to the whole codebase. Where a policy has an underlying architectural decision, this document summarises the policy and links to the decision in docs/architecture-planning.md.

This file is the source of truth for contributor-facing rules. CONTRIBUTING.md is the front door to the project; it points here for the substance.

unsafe code

honeyeater contains no unsafe code, mechanically enforced by #![forbid(unsafe_code)] at the root of every crate in the workspace.

forbid is the strongest possible level: the compiler will refuse to build any crate in this workspace that contains an unsafe block. The attribute cannot be overridden by an inner #[allow]; bypassing it requires editing the crate root itself, which makes any future relaxation of the policy a deliberate, public, code-review-visible change.

Why. Vision principle #3 (docs/vision.md) is “memory-safe by construction.” The class of bugs that dominate C DSP libraries — buffer overruns, use-after-free, data races on mutable state — is the reason a meaningful share of honeyeater’s target users picked Rust. Allowing internal unsafe would compromise that claim. A policy of forbid keeps the claim mechanically true and avoids the slow drift toward “well, just this one block” that softer policies invite.

What this does not affect. unsafe in dependencies is fine. rustfft uses unsafe extensively for its SIMD kernels; that is rustfft’s policy, not ours. The forbid(unsafe_code) attribute is per-crate, not transitive. Standard library, num-complex, and any other dependency operates under its own policy.

Future SIMD work. If honeyeater ever wants hand-tuned SIMD intrinsics of its own (not delegated to a SIMD-aware dependency), the policy will need to be revisited. The relaxation should not be a quiet #[allow]; it should be either (a) a separate crate that opts in with its own attribute and clippy::undocumented_unsafe_blocks = "deny" enforcing // SAFETY: comments on every block, or (b) a documented amendment to this policy. Either way, the conversation is loud. This is the point of using forbid rather than deny.

Clippy posture

Lints are configured workspace-wide via [workspace.lints] in the root Cargo.toml, with each member crate doing [lints] workspace = true.

The posture:

  • clippy::pedantic is set to warn (so all pedantic lints fire as warnings, and CI treats warnings as errors).
  • clippy::nursery stays allow (off). Nursery contains experimental lints with known false-positive rates that aren’t worth fighting.
  • clippy::cargo is set to warn (catches metadata issues in Cargo.toml).
  • A handful of pedantic lints with high noise and low value are explicitly silenced — currently clippy::module_name_repetitions.

Why pedantic. DSP code’s single most error-prone pattern is silent numerical casting: i32 as f32 quietly loses precision, f32 as i16 quietly truncates, == on floats is almost always wrong. Default clippy is silent on most of these; pedantic catches them all (cast_precision_loss, cast_possible_truncation, cast_sign_loss, float_cmp, unreadable_literal, etc.). For a library whose entire job is numerical work — and whose fixed-point story (architecture decision 6) is full of integer↔float conversions where Q-format scaling matters — these are exactly the warnings that should fire.

Why comply rather than silence. Pedantic produces friction: it will suggest #[must_use] on every getter, demand # Errors and # Panics docs on Result-returning and panicking functions, and occasionally object to legitimate patterns. The friction is mostly correct. honeyeater is modular by design — most public functions are pure — so the bug-class must_use_candidate protects against (calling a pure function and discarding its result) is unambiguously a bug when it occurs, and the cost of complying with the lint is the cost of typing #[must_use]. The library’s stance is to take that cost.

The one silenced lint. clippy::module_name_repetitions complains when a type inside mod fir is named FirCoefficients (it wants Coefficients). Sometimes that is right; often the qualified name is more discoverable in rustdoc and at use sites. The rule is silenced workspace-wide; individual cases that violate the spirit are caught in review.

Other pedantic lints may be silenced over time when concrete evidence accumulates that they cost more than they catch. Each silencing must come with a written rationale in this section.

Dependency policy and licences (cargo-deny)

The workspace is policed by cargo-deny, configured in deny.toml. CI fails the build on violations.

Licence allowlist

honeyeater is dual-licensed MIT OR Apache-2.0. Both are permissive: downstream consumers, including closed-source commercial users, can use honeyeater without copyleft obligations. That story only holds if every crate in the dependency tree is also permissive.

Allowed licences:

LicenceNotes
MITMost common permissive licence.
Apache-2.0Permissive with explicit patent grant. honeyeater’s own.
Apache-2.0 WITH LLVM-exceptionApache-2.0 with the LLVM linking-exception clause; used by several core Rust crates.
BSD-2-Clause, BSD-3-ClauseOlder permissive licences.
ISCSimplified BSD-style; common in crypto and network crates.
Unicode-DFS-2016, Unicode-3.0Required for unicode-ident (used by the Rust compiler itself) and its modern replacement.
ZlibPermissive; used by zlib ports.
0BSDPublic-domain-equivalent; used by small utility crates.
CC0-1.0Public-domain dedication; used by some data crates.

Notably excluded:

  • All GPL family (GPL-2.0, GPL-3.0, LGPL-2.1, LGPL-3.0, AGPL-3.0) — copyleft obligations.
  • MPL-2.0 — file-level copyleft. Much weaker than LGPL, but still creates a publication obligation on modifications that doesn’t fit a “MIT OR Apache-2.0, full stop” story. Excluded by default; if a future dep genuinely needs it, the failing build forces the decision into the open.
  • CDDL, SSPL, Elastic-2.0, BUSL-1.1 — various flavours of copyleft, source-available, or anti-cloud licences.
  • “Custom” / unrecognised licences — must be reviewed individually.

LGPL specifically affects the FEC-oracle story (architecture decision 10): libfec is LGPL and is used only in tools/oracle-gen/, a separate workspace whose outputs (binary reference vectors) are committed to tests/vectors/. The published library’s link graph never touches LGPL code. The cargo-deny licence check enforces this mechanically.

Other cargo-deny checks

  • Advisories. RustSec advisory-database check enabled. Yanked crates fail the build (yanked = "deny").
  • Bans. Multiple versions of the same crate warn but do not fail (multiple-versions = "warn"). Wildcard version requirements (crate = "*") deny.
  • Sources. Crates must come from crates.io. Unknown registries and arbitrary git sources are denied.

Contribution licensing and sign-off (DCO)

Contributions are accepted inbound = outbound: unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in honeyeater, as defined in the Apache-2.0 licence, is dual licensed MIT OR Apache-2.0 — the same terms the library ships under — without any additional terms or conditions. The canonical paragraph is in the README’s “Licence” section.

Every commit must carry a Signed-off-by: trailer (git commit -s), certifying the Developer Certificate of Origin 1.1 — reproduced verbatim at the repository root as DCO.txt — that the contributor wrote the change or otherwise has the right to submit it under the project licence. CI fails any commit lacking a valid trailer; unsigned commits are not merged. The mechanics, including the fix for a forgotten sign-off, are in CONTRIBUTING.md (“Licensing and sign-off”).

Why. The licence allowlist above keeps the outbound story clean: everything honeyeater ships is permissively licensed all the way down. This policy keeps the inbound side equally clean: every contributor’s copyright enters the project under the terms it ships under, with per-commit provenance, and without CLA overhead. It is the model used by the Linux kernel (where the DCO originates) and GitLab, so it is familiar to contributors and legible to downstream compliance review.

MSRV (minimum supported Rust version)

The MSRV is the Rust stable release approximately three months prior to the most recent stable — i.e. the latest-minus-two minor versions across Rust’s six-week cadence. This is captured as rust-version in [workspace.package].

This balances toolchain lag in regulated and corporate environments (where the freshest stable is often unavailable for months) against not freezing on genuinely old compilers (which would limit honeyeater’s access to language and library improvements).

See architecture decision 1 for the rationale and comparison to other ecosystem patterns (tokio uses ~N-4; serde leaves it open; the ecosystem has no consensus).

Practical implication. Code in honeyeater must compile on the pinned MSRV. CI runs a build job on the pinned MSRV alongside stable and nightly. When a new MSRV is adopted, it counts as a minor breaking change and gets a CHANGELOG entry.

Panic vs Result

honeyeater mixes the two deliberately. The policy is documented in full at architecture decision 8; the short form for contributors is:

  • Panic when the caller has violated the API contract — mismatched buffer lengths, filter order zero, FFT of length 1, etc. These are programmer bugs the caller should fix.
  • Return Result<T, E> when the failure is data-driven and the caller cannot validate up-front — filter design that doesn’t converge, file read that fails, SDR capture format mismatch. These deserve to be recoverable.

Every public API documents its panic conditions in a # Panics rustdoc section, and its error conditions in # Errors. The pedantic lints missing_panics_doc and missing_errors_doc enforce this.

Workspace versioning

All crates in the workspace share one version number. Every release bumps every crate, including crates with no changes in that release. See architecture decision 7.

This affects how releases are made (one version bump, not per-crate) and how downstream users pin (honeyeater = "0.5" gets a coherent set across all member crates).

Deprecation and breaking changes

Pre-0.0.1, the codebase is unstable and breaking changes are free. CHANGELOG.md tracks them but nothing more is required.

Once 0.1.0 ships:

  • Breaking changes require a minor version bump (per semver pre-1.0 convention).
  • Deprecated items get #[deprecated(since = "x.y.z", note = "...")] and remain for at least one minor cycle before removal.
  • Removal of a deprecated item is itself a breaking change requiring a minor bump.

This is conventional Rust ecosystem practice. The policy will be expanded as 0.1.0 approaches and real public surface accumulates.

Downstream context

honeyeater is open-source and general-purpose, but its design is informed by specific downstream use at Panop. This document captures the constraints those use cases impose. The library itself contains no Panop-specific code or APIs, and no SDR-vendor-specific code or APIs — see docs/architecture-planning.md for the general sample-format landscape that drives the fixed-point story.

The Panop flowgraph runtime

honeyeater will be the DSP layer underneath a GNU Radio-style flowgraph runtime used internally at Panop for RF processing. The runtime is a separate project, not part of honeyeater. honeyeater stays a library of primitives.

Two properties of the runtime constrain honeyeater’s design:

  1. Compile-time graph construction. Different waveforms (AIS receiver, DVB-S2 receiver, ADS-B receiver, etc.) are pre-compiled as separate graph artefacts. Waveform hot-swap is “stop running graph A, start running graph B” — both already-compiled and ready to run — not “reconfigure graph A’s blocks in place.” Switching is fast because there’s nothing to compute at the moment of switch.

  2. Multiple coexisting RX/TX chains. The runtime can run several independent graphs at different sample rates simultaneously: one wideband spectrum survey at high rate alongside a narrowband decoder at low rate. honeyeater must not embed any assumption that there’s one global sample rate, one global configuration, or one graph in flight.

What this rules in and out for honeyeater

  • No prepare(spec) step separated from construction. A JUCE-style two-stage configuration is the wrong shape: the spec is known at compile time and the compiler can constant-fold every parameter. Processors take their parameters at construction. (See docs/architecture-planning.md decisions 3 and 3a.)
  • No stream-tag mechanism inside honeyeater. GNU Radio attaches metadata (sample rate, time, frequency) to streams as offset-indexed tags. honeyeater doesn’t do this — that’s runtime infrastructure. The runtime owns connection topology, tag propagation, and rate-change handling. honeyeater just provides processors with known parameters baked in.
  • Per-block parameter mutability where it’s safe. A few parameters are routinely retuned in operational radios: NCO frequency, AGC reference level, gain. Those blocks expose mutator methods so the runtime can adjust them without rebuilding the graph. Parameters whose change would cause numerical transients (FIR coefficients) or require re-derivation (filter prototypes) are construction-only.
  • No threading, scheduling, or runtime infrastructure of any kind in honeyeater itself. No internal thread pools, no rayon, no async. Concurrency is the runtime’s job.

Panop’s hardware: high-rate fixed-point matters

Panop uses Nuand BladeRF SDRs in production, including the radio’s 122.88 MS/s oversample mode where samples arrive as 8-bit fixed-point integers. At that rate (~8 ns per sample on one core), boundary conversion to f32 eats too much budget; the hot-path kernels must process native int8.

This is one concrete example of why honeyeater commits to native fixed-point processing at 0.0.1. It is not the only reason — the general SDR sample-format landscape (docs/architecture-planning.md, “Hardware: SDR sample-format landscape”) shows that every major SDR family delivers integer samples natively, and the same boundary-conversion cost argument applies to USRP, LimeSDR, Sidekiq, HackRF, and others operating at high rates.

honeyeater handles this with general-purpose fixed-point types (Complex<i16>, Complex<i8>, Complex<u8>) plus boundary conversion helpers, all of which work uniformly across SDR vendors. There is no BladeRF-specific code path; there’s no USRP-specific code path either. honeyeater treats every SDR’s samples identically once they arrive as one of those three integer types.

What this document is and isn’t

This is design context, not API surface. Nothing here defines what honeyeater exports. honeyeater’s published surface is general-purpose; the Panop runtime is one consumer it’s designed to support well, but the library has no Panop-specific exports, no BladeRF-specific exports, no USRP-specific exports, and no flowgraph types.

SDR-specific bindings (a bladerf-rs crate, a uhd-rs crate, the integration with SoapySDR, the Panop flowgraph) all live in separate crates that depend on honeyeater. They are not part of honeyeater.

Testing

honeyeater is test-driven: every kernel ships with a test against a named oracle — a trusted reference (an established library, or correct values from a standard) that says what the right answer is. This page documents the test harness for kernel authors. Unfamiliar term? The Glossary has a one-line definition for each.

The page has three sections:

  • Overview — what the harness is. Read this if honeyeater is new to you.
  • How-to guides — task-shaped recipes (“how do I test a filter?”). Read this when writing a specific test.
  • Reference — exhaustive description of each macro and helper. Read this when looking up a parameter or threshold.

Overview

honeyeater commits to a small, fixed vocabulary of tolerance measures — precise definitions of how close a result must be to count as correct. Every kernel is validated against a named oracle (an established reference implementation, or bit-exact vectors from a published standard) using one of seven assertion macros:

MacroPredicate
assert_close!elementwise |a − b| ≤ atol + rtol·|b|
assert_snr_db!10·log₁₀(Σ|ref|² / Σ|ref − actual|²) ≥ min_db
assert_bit_exact!exact equality at the byte or bit level
assert_spectral_mask!each bin within [lower(f), upper(f)] dB
assert_ber_at_ebn0!BER ≤ target at stated Eb/N0
assert_parseval!one-sided PSD integral ≈ time-domain energy
assert_distribution_ks!Kolmogorov-Smirnov against a target CDF

These are the only tolerance predicates honeyeater uses across the codebase. Percentage tolerance is not in the set — it breaks on zero crossings and is insensitive to dynamic range. Ad-hoc thresholds invented per-test are discouraged.

The harness lives in the dev-only honeyeater-test crate. It is not published; you do not depend on it in production code. The published honeyeater crate has no test dependencies.

The seven macros are implemented and tested. The .npy reference-vector loader (honeyeater_test::npy) and the scipy-subprocess runner (honeyeater_test::scipy) are currently signature-only stubs.

Importing the harness

[dev-dependencies]
honeyeater-test = { path = "path/to/honeyeater/crates/honeyeater-test" }

How-to guides

Task-shaped recipes for common testing situations. Kernels named in the examples (my_fir_filter, my_rs_encoder, etc.) are placeholders — substitute your own.

How to test a filter

Filters get tested two ways: against a precomputed reference vector for exactness, and against a known input signal for a property the filter should preserve.

#![allow(unused)]
fn main() {
use honeyeater_test::{assert_close, assert_snr_db, npy};
use std::path::Path;

#[test]
fn my_fir_filter_matches_scipy_reference() {
    let input = npy::load_f64(Path::new("tests/vectors/impulse_response_input.npy"));
    let expected = npy::load_f64(Path::new("tests/vectors/fir_lpf_64tap_output.npy"));

    let actual = my_fir_filter(&input, 64, 0.25);

    assert_close!(actual, expected, rtol = 1e-12, atol = 1e-15);
}

#[test]
fn my_fir_filter_preserves_in_band_signal_to_100_db() {
    let input = sinusoid(0.1, 1024);
    let output = my_fir_filter(&input, 64, 0.25);

    assert_snr_db!(output, input, min_db = 100.0);
}
}

Both tests belong in the suite. The reference vector catches deviations from scipy’s lfilter output; the SNR property (signal-to-noise ratio, in decibels) catches structural bugs (sign flip, off-by-one in tap indexing) that might still match the reference if the reference is regenerated from the same bug.

How to test an FEC encoder

FEC (forward error correction) encoders are deterministic and must match the spec byte-for-byte. Bit-exact comparison is the right predicate.

#![allow(unused)]
fn main() {
use honeyeater_test::assert_bit_exact;

#[test]
fn my_rs_encoder_matches_libfec_vector() {
    let message: Vec<u8> = std::fs::read("tests/vectors/rs_message.bin").unwrap();
    let expected: Vec<u8> = std::fs::read("tests/vectors/rs_codeword.bin").unwrap();

    let actual = my_rs_encoder(&message);

    assert_bit_exact!(actual, expected);
}
}

The reference vector here is a binary blob committed alongside the kernel. The generator that produced it lives in tools/oracle-gen/, not in the main crate’s link graph, so the LGPL-licensed libfec never enters honeyeater’s published dependencies.

How to test an FEC decoder

Decoders — especially iterative soft-decision ones (turbo, LDPC, polar-SCL) — cannot be bit-exact-tested because different implementations diverge on quantisation and scheduling. BER (bit error rate — the fraction of bits the link gets wrong) at Eb/N0 (a normalised signal-to-noise measure) is the only meaningful measure.

#![allow(unused)]
fn main() {
use honeyeater_test::assert_ber_at_ebn0;

#[test]
fn my_ldpc_decoder_meets_waterfall_at_3_5_db() {
    let (errors, total_bits) = run_ldpc_trial(/* ebn0_db = */ 3.5, /* trials = */ 100_000);

    assert_ber_at_ebn0!(
        errors,
        total_bits,
        target_ber = 1e-5,
        ebn0_db = 3.5,
    );
}
}

The test name refers to the waterfall — the steep part of the BER-versus-Eb/N0 curve, where a small gain in signal-to-noise sharply drops the error rate.

Trial size matters: targeting a BER of 10⁻⁵ requires order 10⁷ bits transmitted to see ~100 errors and have a stable estimate. Tests that demand much lower BERs at small trial sizes are not useful.

How to test a PRNG or noise source

Use Kolmogorov-Smirnov (a statistical test for “do these samples come from the distribution I claim?”) against the target distribution — supplied as a CDF (cumulative distribution function) — with a fixed seed. (PRNG: a pseudo-random number generator, which a fixed seed makes repeatable.)

#![allow(unused)]
fn main() {
use honeyeater_test::assert_distribution_ks;

#[test]
fn my_uniform_prng_passes_ks_at_alpha_001() {
    let samples = my_uniform_prng(/* seed = */ 0xC0FFEE, /* count = */ 10_000);

    let uniform_cdf = |x: f64| x.clamp(0.0, 1.0);
    assert_distribution_ks!(samples, cdf = uniform_cdf, alpha = 0.01);
}
}

The fixed seed makes this a deterministic regression test, not a statistical claim. See the assert_distribution_ks! reference for the critical distinction between CI use and real statistical validation — getting this wrong leads to flaky tests.

How to validate a filter design against a spectral mask

Filter designs are validated by response shape, not by individual sample equality. The mask sets per-bin upper and lower bounds in dB.

#![allow(unused)]
fn main() {
use honeyeater_test::assert_spectral_mask;

#[test]
fn my_fir_lpf_meets_design_spec() {
    let response_db = compute_frequency_response_db(&my_fir_lpf_64tap());

    let (lower, upper) = build_lpf_mask(
        /* passband_edge = */ 0.20,
        /* stopband_edge = */ 0.30,
        /* passband_ripple_db = */ 0.1,
        /* stopband_atten_db = */ 60.0,
    );

    assert_spectral_mask!(response_db, lower = lower, upper = upper);
}
}

Use f64::NEG_INFINITY and f64::INFINITY for “no bound here” in the appropriate ends of the spectrum. Most filter masks have constraints only on the upper bound in the stopband and only on the lower bound in the passband.

How to validate a spectral estimator (Welch, periodogram, multitaper)

Spectral estimators have a notorious failure mode: getting the bin-width or window-correction factor wrong produces a PSD (power spectral density — how the signal’s power is spread across frequency) that looks plausible at first glance but doesn’t integrate to the true signal energy. The Parseval assertion — which checks that the energy adds up the same measured in time or in frequency — catches this exactly.

#![allow(unused)]
fn main() {
use honeyeater_test::assert_parseval;

#[test]
fn my_welch_psd_conserves_energy() {
    let signal = generate_test_signal();
    let psd = my_welch(&signal, /* nperseg = */ 256, /* overlap = */ 128);

    let fs = 1.0;
    let bin_width = fs / 256.0;
    let signal_energy: f64 = signal.iter().map(|x| x * x / fs).sum();

    assert_parseval!(
        psd,
        bin_width_hz = bin_width,
        signal_energy = signal_energy,
        rtol = 1e-6,
    );
}
}

How to pick a threshold

Start from the per-class default table:

Module classPrimary assertionThreshold
FFT (f64)assert_snr_db!≥ 120 dB
FFT (f32)assert_snr_db!≥ 60 dB
FIR output (f64)assert_snr_db!≥ 100 dB
FIR output (f32)assert_snr_db!≥ 60 dB
FIR designassert_spectral_mask!passband ±0.1 dB; stopband per spec
IIR (f64)assert_snr_db! vs scipy lfilter≥ 80 dB
Polyphase resamplerassert_snr_db!≥ 80 dB
Window functionsassert_close!rtol = 1e-12, atol = 1e-15
Linear modulator (f64)assert_snr_db! vs analytic reference≥ 100 dB
FEC encoderassert_bit_exact!byte equality
FEC decoder (iterative)assert_ber_at_ebn0!spec-dependent — 0.2 dB at the waterfall, 0.5 dB in the error floor
EVM aggregatepercentper the relevant 3GPP TS
AWGN / PRNGassert_distribution_ks! + moment matchKS α = 0.01 with fixed seed; mean / variance within 3σ
AGCassert_snr_db! + settlingwithin ±0.5 dB steady-state

Loosen only when you can articulate why the kernel cannot meet the listed threshold. Legitimate reasons exist (narrowband filters with high Q, low-bit-width fixed-point kernels) but the burden of explanation is on the test author.

How to write a cross-platform test without flakes

A test that passes on x86 may not pass on aarch64 without help. Three categories of variation cause failures, in order of how often they bite:

  1. FMA contraction. The compiler may fuse a * b + c into a single fused multiply-add instruction, which is a different (more accurate) operation than the separate multiply and add. Test reference paths should set -ffp-contract=off or pin a no-FMA reference computation.
  2. SIMD / parallel reduction order. Summing eight numbers as ((a+b) + (c+d)) + ((e+f) + (g+h)) differs from sequential summation by a few ULP (units in the last place — the gap between adjacent floating-point values). The harness offers a deterministic feature flag that forces sequential reduction in tests where this matters.
  3. libm. sin, exp, log, pow differ by a few ULP across glibc, musl, Apple libm, and Windows ucrt (the system math libraries on each platform). Never recompute reference vectors in CI from libm calls; bake them into tests/vectors/ once and load them with npy::load_*.

You only hit these when pushing tight tolerances (rtol ≤ 1e-13, SNR ≥ 140 dB). For the default thresholds in the table above, FMA contraction does not move the needle.


Reference

Exhaustive description of every macro and helper. Each section is self-contained; jump in by name.

assert_close!

#![allow(unused)]
fn main() {
assert_close!(actual, expected, rtol = R, atol = A);
}

Elementwise comparison with the mixed numpy / MATLAB / scipy tolerance: |a − b| ≤ atol + rtol·|b| at every index.

Parameters

  • actual — array under test. Any type that dereferences to &[f64] or &[f32].
  • expected — reference array. Must have the same element type and length as actual.
  • rtol — relative tolerance, a multiplier on |expected|. For a kernel that introduces some fraction of error proportional to the signal amplitude, this catches the deviation.
  • atol — absolute tolerance, a floor for values near zero. Without it, zero-valued reference entries would require exact equality, which is rarely realistic after floating-point arithmetic.

Both rtol and atol are required by design. There is no useful default — the right values depend on the kernel.

Use it for: FIR / IIR output samples versus a precomputed reference; FFT bins versus a closed-form spectrum; resampler output; window taps versus scipy’s window functions.

Do not use it for: stochastic outputs (use the SNR or KS assertions); FEC encoder output (use assert_bit_exact!); spectral magnitude validation (use assert_spectral_mask!).

Failure diagnostic: the panic message reports the first failing index, both values, the measured |a − b|, and the computed threshold. Length mismatches between actual and expected panic outright; they are usually a wiring bug in the test.

assert_snr_db!

#![allow(unused)]
fn main() {
assert_snr_db!(actual, reference, min_db = MIN);
}

Computes the signal-to-noise ratio in dB, treating reference as the true signal and actual − reference as the noise. Passes when the SNR is at least min_db.

Parameters

  • actual — array under test.
  • reference — the reference signal. Typically the input to the kernel, or an analytic ground truth.
  • min_db — minimum acceptable SNR in dB.

Use it for: filter output versus a noiseless input; FFT round-trip (forward then inverse) versus the original samples; polyphase resampler output; linear modulator output versus an analytic reference; AGC output during steady state.

Do not use it for: cases where the reference has zero energy (SNR is undefined; the assertion panics). For all-zero reference signals, use assert_bit_exact! instead.

Notes

  • The SNR is computed in f64 regardless of the sample type, so f32 and integer kernels can be tested without worrying about precision in the test itself.
  • If the kernel’s output is identical to the reference, the SNR is +∞ and any finite min_db passes. The diagnostic still reports the threshold so you can see how much headroom the kernel had.

Failure diagnostic: measured SNR in dB, the threshold, and the constituent reference / error energies.

assert_bit_exact!

#![allow(unused)]
fn main() {
assert_bit_exact!(actual, expected);
}

Element-by-element exact equality. The element type must implement PartialEq and Debug.

Use it for: kernels whose output representation is deterministic and required to match a spec exactly. FEC encoders (output bytes are defined by the standard); CRC outputs; scramblers; fixed-point arithmetic kernels (where the integer arithmetic is itself exact).

Do not use it for:

  • Floating-point kernels. Float kernels are subject to platform-dependent rounding (FMA contraction, libm differences). Bit-exactness across platforms is not achievable in safe code.
  • Iterative soft-decision decoders (turbo, LDPC, polar-SCL). Different implementations diverge on quantisation and scheduling; bit-exactness is the wrong correctness criterion. Use assert_ber_at_ebn0! instead.

Failure diagnostic: first failing index with both values printed via Debug.

assert_spectral_mask!

#![allow(unused)]
fn main() {
assert_spectral_mask!(bins_db, lower = lower_db, upper = upper_db);
}

Tests that every bin in bins_db lies within [lower_db[i], upper_db[i]]. The bounds are per-bin, so the mask can be frequency-dependent — passband flat, transition steep, stopband per spec.

Parameters

  • bins_db — slice of bin magnitudes in dB (one per frequency bin). The caller converts from linear magnitude to dB before passing in; the macro does not assume what your 0 dB reference is.
  • lower_db — slice of lower bounds in dB, same length as bins_db. Use f64::NEG_INFINITY for “no lower bound at this frequency.”
  • upper_db — slice of upper bounds in dB, same length. Use f64::INFINITY for “no upper bound at this frequency.”

Use it for: validating filter designs against passband ripple / stopband attenuation specifications; transmit-spectrum compliance against regulatory masks (ETSI / FCC out-of-band emission limits).

Do not use it for: pointwise filter output sample comparison (use assert_close! or assert_snr_db!). The mask is about the response shape, not individual samples.

Failure diagnostic: first failing bin index, the measured dB level, and which bound was violated.

assert_ber_at_ebn0!

#![allow(unused)]
fn main() {
assert_ber_at_ebn0!(
    errors,
    total_bits,
    target_ber = TARGET,
    ebn0_db = EBN0,
);
}

Asserts that the observed bit error rate (errors / total_bits) is at most target_ber. The ebn0_db parameter is the Eb/N0 the trial was run at — it does not affect the predicate, but it appears in the diagnostic so a failure is interpretable without consulting the test setup.

Parameters

  • errors — number of bit errors observed (u64).
  • total_bits — total bits transmitted in the trial (u64).
  • target_ber — maximum acceptable BER.
  • ebn0_db — Eb/N0 in dB at which the trial was run.

Use it for: FEC decoder validation against a published BER curve at known Eb/N0 points; demodulator slicer validation against closed-form AWGN BER formulas (BPSK / QPSK / 16-QAM / 64-QAM via the Q-function family).

Do not use it for: encoder validation (encoders are deterministic; use assert_bit_exact!).

Conventional tolerances for iterative decoders: 0.2 dB at the waterfall (the steep portion of the BER curve where small Eb/N0 changes cause large BER changes) and 0.5 dB in the error floor (where the curve flattens out at very low BER). Translate the Eb/N0 tolerance into a BER threshold at the operating point.

Trial size matters: a target BER of 10⁻⁶ needs on the order of 10⁸ bits transmitted to see ten errors and have a stable estimate. Tests that demand much lower BERs at small trial sizes are not useful.

Failure diagnostic: measured BER, target, and the Eb/N0 at which the trial was run.

assert_parseval!

#![allow(unused)]
fn main() {
assert_parseval!(
    psd,
    bin_width_hz = DF,
    signal_energy = E,
    rtol = R,
);
}

Asserts that integrating the one-sided power spectral density (Σ psd[k] · bin_width_hz) recovers the time-domain signal energy ((1/fs) · Σ |x|²) within relative tolerance rtol.

Parameters

  • psd — slice of PSD values (one-sided, units of power per Hz).
  • bin_width_hz — the bin width in Hz (fs / nfft for unwindowed PSD; the caller is responsible for any window-correction factor).
  • signal_energy — the reference signal’s time-domain energy.
  • rtol — relative tolerance on the integrated / reference ratio.

Use it for: spectral estimator validation (Welch, periodogram, multitaper). Different libraries scale their PSD differently; a kernel that gets the bin-width or window-correction factor wrong produces a PSD that looks plausible at first glance but does not integrate to the correct total energy.

Do not use it for: peak detection, frequency localisation, or spectral shape (use assert_spectral_mask! for shape). Parseval is about total energy conservation, not about whether the PSD points to the right frequency.

Failure diagnostic: integrated energy, reference energy, the ratio, and the tolerance.

assert_distribution_ks!

#![allow(unused)]
fn main() {
assert_distribution_ks!(
    samples,
    cdf = TARGET_CDF_CLOSURE,
    alpha = ALPHA,
);
}

Runs the one-sample Kolmogorov-Smirnov test against cdf (a closure returning the target CDF’s value at a point). Computes the D-statistic — the maximum vertical distance between the empirical and target CDFs — and compares against the critical value for the given significance level.

Parameters

  • samples — slice of samples drawn from the implementation under test (&[f64]).
  • cdf — a function fn(f64) -> f64 returning the target CDF’s value at a point.
  • alpha — significance level. Supported values: 0.10, 0.05, 0.01, 0.001. Other values panic.

Use it for: PRNG output against a uniform distribution; AWGN generator output against a Gaussian distribution; similar “do these samples come from the distribution I claim they do” questions.

Do not use it for: dependencies between samples (autocorrelation, period structure). KS tests the marginal distribution only.

Critical: fixed seed for CI, multi-seed for real statistical claims

This macro is intended for CI use with a fixed seed. In that mode it acts as a deterministic vector regression — the assertion always passes (or always fails) for a given seed, with no statistical claim about the distribution.

To make a real statistical claim — the implementation does produce samples from the target distribution — you need many independent seeds, observing whether the p-values are uniform on [0, 1] under the null hypothesis. That kind of test belongs in a nightly or weekly job, not in PR CI: a true-positive rate of 1% at α = 0.01 means a one-in-100 chance of false failure per CI run, which is intolerable for a PR gate.

The harness does not enforce either pattern. It is on you to know which mode you’re in:

  • Fixed-seed CI test: deterministic vector regression. Set the seed at the top of the test, pick α once, get a flake-free test that catches regressions in this corner.
  • Nightly multi-seed validation: run with many seeds, collect p-values, verify uniformity. The KS macro is one ingredient of that pipeline, not the whole pipeline.

Failure diagnostic: measured D-statistic, critical value, α, and sample size.

honeyeater_test::npy

Loader for .npy reference-vector files committed under tests/vectors/.

#![allow(unused)]
fn main() {
use honeyeater_test::npy;
use std::path::Path;

let f32_data    : Vec<f32>             = npy::load_f32(Path::new("tests/vectors/x.npy"));
let f64_data    : Vec<f64>             = npy::load_f64(Path::new("tests/vectors/x.npy"));
let cf32_data   : Vec<Complex<f32>>    = npy::load_complex_f32(Path::new("tests/vectors/x.npy"));
let cf64_data   : Vec<Complex<f64>>    = npy::load_complex_f64(Path::new("tests/vectors/x.npy"));
}

numpy’s complex format stores interleaved real / imaginary pairs, which is the same memory layout as a slice of num_complex::Complex<T>, so the loader returns the data in honeyeater’s preferred type without a caller-side cast.

The loader supports 1-D arrays only. It refuses .npy files saved with allow_pickle = True as defence in depth.

Reference vectors are committed to the repository as opaque binary blobs. They are not regenerated in CI. The expectation is that whoever lands a kernel commits the reference vectors alongside it, with attribution to the oracle in a sibling text file.

For which oracles validate which kernels, see Roadmap §Oracle stack.

honeyeater_test::scipy

Subprocess runner for live scipy cross-validation, for one-off checks where committing a .npy vector is overkill.

#![allow(unused)]
fn main() {
use honeyeater_test::scipy;

let json = scipy::run(r#"
import json
import numpy as np
from scipy.signal.windows import hann
print(json.dumps(hann(32).tolist()))
"#);
let reference: Vec<f64> = serde_json::from_str(&json).unwrap();
}

The runner requires Python with scipy and numpy installed locally. It is skipped (not failed) when no interpreter is available, so contributors without Python can still run the bulk of the suite.

Pin your scipy version. Different scipy releases have differed by a few ULP in higher-precision windows (notably Kaiser) and by more than that in elliptic IIR design at high order. The version pinning lives in tools/oracle-gen/requirements.txt.

Glossary

Plain-English definitions of the recurring terms in honeyeater’s documentation. Each entry is intentionally brief — just enough to keep reading; the page that uses a term is where its full treatment lives.

Signals and hardware

IQ samples

Pairs of numbers (in-phase and quadrature) that together describe a radio signal at an instant — the raw form a radio front-end or digitiser hands to software. A stream of IQ samples is what most honeyeater kernels consume.

ADC

Analog-to-digital converter: the chip inside a radio or digitiser that turns the continuous analog signal into a stream of numbers. honeyeater starts where the ADC’s samples reach the host computer.

SNR (and dB)

Signal-to-noise ratio: how large the wanted signal is compared to the error or noise riding on it. Measured in decibels (dB), a logarithmic scale where larger is cleaner — every 10 dB is a tenfold ratio.

PSD (power spectral density)

A description of how a signal’s power is spread across frequency — power per hertz. Spectral estimators (Welch, periodogram, multitaper) compute a PSD from a finite chunk of samples.

Spectral mask

A pair of upper and lower bounds, one per frequency bin, that a signal’s spectrum must stay within. Used to check that a filter’s response or a transmitter’s emissions have the right shape (flat passband, steep transition, attenuated stopband).

Q-format (fixed-point)

A convention for storing fractional numbers inside plain integers by fixing where the binary point sits (e.g. Q1.15 = a 16-bit integer read as a value in roughly [-1, 1)). It is how the SDR hardware that produces i16/i8 samples represents fractions — a property of how surrounding code interprets the integer, not of the integer type itself.

FFT

Fast Fourier transform: an efficient algorithm that converts a block of samples between the time domain and the frequency domain. A workhorse primitive; honeyeater depends on the rustfft crate rather than reimplementing it.

Coding and modulation

FEC (forward error correction)

Forward error correction: adding structured redundancy to data so the receiver can detect and repair bit errors without asking for a retransmission. An encoder adds the redundancy; a decoder uses it to recover the original.

LDPC, turbo, polar-SCL

Families of modern FEC codes used in real links (satellite, cellular, broadcast). Their decoders are iterative and work on soft (probabilistic) inputs, so two correct implementations can produce slightly different outputs — which is why they are tested by error rate, not bit-for-bit.

BER (bit error rate)

Bit error rate: the fraction of bits a link gets wrong (errors ÷ bits transmitted). The standard measure of how well a decoder performs.

Eb/N0

A normalised signal-to-noise measure for digital links: energy per bit divided by noise power density. The conventional x-axis for a BER curve — “how many errors at this much signal-to-noise.”

Waterfall

The steep part of a BER curve, where a small improvement in Eb/N0 buys a large drop in error rate. Below it the curve flattens into the error floor.

EVM (error vector magnitude)

A measure of how far received modulation symbols land from their ideal positions — a single number summarising modulation quality, often quoted as a percentage against a standard.

OFDM

Orthogonal frequency-division multiplexing: a modulation scheme that spreads data across many narrow subcarriers at once. Used in Wi-Fi, LTE, DVB, and many other systems.

Parks-McClellan

A classic algorithm for designing FIR filters with the best possible (equiripple) response for a given length. Named after its authors.

Testing and numerics

Oracle

A trusted reference that honeyeater checks its own results against — either an established implementation (scipy, liquid-dsp, libfec) or a set of correct outputs published in a standard. Every kernel is validated against a named oracle.

Tolerance (atol / rtol)

How close “close enough” is when comparing floating-point results. honeyeater uses a mixed measure: an absolute tolerance (atol, a floor near zero) plus a relative tolerance (rtol, scaled to the value’s size), combined as atol + rtol·|b|.

Bit-exact

Required to match the reference exactly, byte for byte. The right standard for deterministic outputs like FEC encoders and CRCs; the wrong one for floating-point kernels, whose last bits vary across platforms.

Parseval’s theorem

The fact that a signal’s total energy is the same whether you measure it in the time domain or add it up across frequency. honeyeater uses it to check that a spectral estimator’s output “adds up” to the right total energy.

Kolmogorov-Smirnov (KS) test

A statistical test for “do these samples come from the distribution I claim?” It measures the largest gap between the samples’ observed distribution and the target one. Used to check PRNGs and noise generators.

CDF (cumulative distribution function)

A function giving the probability that a random draw falls at or below a given value. The KS test compares a target CDF against what the samples actually produced.

PRNG

Pseudo-random number generator: an algorithm that produces a repeatable stream of numbers that look random. “Pseudo” because a fixed seed always yields the same stream — which is what makes seeded tests deterministic.

ULP

Unit in the last place: the gap between two adjacent representable floating-point numbers. Results that differ “by a few ULP” differ only in their final bits — the unavoidable noise of floating-point arithmetic.

FMA (fused multiply-add)

A CPU instruction that computes a * b + c in one step, more accurately than doing the multiply and add separately. Because it rounds differently, it can make the same code give slightly different results on different machines.

libm

The system math library that supplies sin, exp, log, pow, and friends. Different implementations (glibc, musl, Apple, Windows) differ by a few ULP, so reference values are baked in once rather than recomputed per platform.

Rust and project

Kernel

A single DSP building block — one filter, one transform, one encoder. honeyeater is a library of kernels. (Nothing to do with operating-system kernels.)

Workspace crate

A crate (Rust package) that is one member of a Cargo workspace — a set of related crates built together. honeyeater is split into a few small workspace crates rather than one large one.

MSRV

Minimum supported Rust version: the oldest Rust toolchain the project promises to compile on.

Monomorphisation

The compiler’s trick of taking generic code written once and emitting a specialised, fully-optimised copy for each concrete type it is used with — so generic kernels pay no runtime cost for being generic.

Hot path

The code that runs most often and most must be fast — here, the per-sample work in a streaming receiver. APIs on the hot path take plain slices (&[T]) to stay allocation-free.

rustdoc

Rust’s built-in API-documentation generator. It produces the reference for what types and functions actually exist; build it with cargo doc.

Tier-1

honeyeater’s label for the first batch of foundational RF/electrical primitives (windows, basic filters, FFT wrapper, CRCs, and so on) — the kernels implemented first because later work builds on them. The ordered list lives in the Roadmap.