dsp-props
Property-based testing for digital signal processing code
DSP code is usually tested by comparing its output with a reference (MATLAB, SciPy) on a few signals, with a hand-picked tolerance. dsp-props instead checks relations that must always hold (linearity, shift theorem, Parseval, Hermitian symmetry, invertibility, convolution theorem, causality, time invariance, consistency across block sizes), on automatically generated signals from Hypothesis, chosen to trip up DSP code: impulses, off-grid sinusoids, close tones, near-Nyquist tones, extreme amplitudes, lengths 1, 2, prime or 2^k ± 1, empty and one-sample blocks. Tolerances follow an error model of each operation (O(ε log N) error for an FFT) instead of a fixed atol, and every failure is reported with a small counterexample.
Status: alpha. The six phases of the development plan (in French) are complete; version 0.1.1 on PyPI.
Example
import numpy as np
import dsp_props as dp
@dp.transform(inverse=np.fft.ifft, parseval=True, real_input_hermitian=True)
def my_fft(x):
return np.fft.fft(x)
print(dp.verify(my_fft))
dsp-props report for my_fft
ok linearity
ok circular shift
ok parseval
ok hermitian symmetry
ok inversibility
ok convolution theorem
On failure, verify raises PropertyViolation with, for each violated relation, the counterexample shrunk by Hypothesis.
Other declarations:
# Real FFT: the inverse receives the length N
dp.transform(inverse=np.fft.irfft, kind="rdft", parseval=True)(np.fft.rfft)
# Any linear transform (DCT, wavelets...)
dp.transform(kind="generic", inverse=idct_ortho, parseval=True, parseval_scale=lambda n: 1.0)(dct_ortho)
# LTI filter: linearity, time invariance, causality, frequency response = B(z)/A(z).
# The tolerance follows the conditioning of the recursion (IIRTolerance).
dp.lti_filter(causal=True, transfer=(b, a))(lambda x: scipy.signal.lfilter(b, a, x))
# BIBO stability: no input bounded by 1 may exceed the declared gain (worst-case input is constructed)
dp.lti_filter(max_gain=l1_norm_of_h)(my_filter)
# Rational L/M resampler: linearity, a delay of M input samples = a delay of L output samples,
# length ceil(N L / M), DC gain, and alias rejection when decimating
dp.resampler(2, 3)(lambda x: scipy.signal.resample_poly(x, 2, 3))
# Block processing: any split into blocks (empty and one-sample blocks included) must give the same output
@dp.streaming()
class MyFilter:
def process(self, block): ...
# Function with explicit state, for example lfilter with zi
dp.stateful(lambda block, z: scipy.signal.lfilter(b, a, block, zi=z), init_state=np.zeros(2))
Command line
dsp-props check finds every operator declared in a module or file and verifies it:
$ dsp-props check examples/filters.py --examples 100
FAIL examples/filters.py:buggy_fft (4 relations, 3.4s)
FAIL inversibility
inversibility: L2 error 2.000e+00 exceeds tolerance 6.280e-15 (N=2, ref norm 1.414e+00)
minimal input: [1, 1]
ok examples/filters.py:fft (6 relations, 6.5s)
ok examples/filters.py:lowpass (4 relations, 3.1s)
ok examples/filters.py:resample_2_3 (5 relations, 2.8s)
3/4 operators pass
Targets: package.module, file.py, module:operator. Options: --json, --markdown, --html, --examples N, --derandomize (reproducible inputs in CI). The exit code is 1 if a relation fails, which is enough to block a CI run.
C code
dsp_props.adapters loads a C function (shared library, through ctypes) and declares it like any other operator. Convention: void f(const double *x, size_t n, double *y, double *state).
from dsp_props.adapters import build_shared, c_function, c_block_processor
lib = build_shared("examples/c/biquad.c")
dp.verify(dp.lti_filter(transfer=(b, a))(c_function(lib, "biquad", state_size=2)))
dp.verify(dp.streaming()(c_block_processor(lib, "biquad", state_size=2)))
examples/c/biquad.c contains a correct biquad and two buggy variants. The one that forgets a state variable between blocks passes every test on a single call and is caught only by block invariance.
Calibrated tolerances
A fixed ε · log N bound does not describe every library: over N = 2…1200, the PyTorch FFT (MKL) reaches 195 ε against 4 ε for NumPy, depending on the factorisation of N. dp.calibrate measures the actual error against an extended-precision reference and derives the tolerance from it:
tol = dp.calibrate(torch_fft, lengths=range(1, 513)) # refuses a wrong operator
print(tol.summary())
dp.transform(inverse=torch_ifft, parseval=True, tolerance=tol)(torch_fft)
python benchmarks/error_profile.py produces the full error profile (benchmarks/error_profile.csv), and python benchmarks/plot_error_profile.py the figure below.
Validating the tool: the mutant suite
mutants/ contains 31 deliberately buggy implementations, in six families:
| Family | Mutants |
|---|---|
| Complex FFT | missing 1/N, missing conjugation, off-by-one index, wrong normalisation, float32 computation, twiddle sign, last sample ignored, constant offset |
Real FFT (rfft) |
one bin short, inverse that ignores N, Nyquist bin halved, conjugated spectrum, inverse without Hermitian symmetry |
| Orthonormal DCT-II | DC coefficient weight forgotten, inverse of the wrong type, float32 computation |
| IIR filter (order 4, resonant) | float32 recursion, filtfilt declared causal, coefficients that change midway, non-zero initial state, gain wrong by 1 % |
| 2/3 resampler | interpolation gain forgotten, anti-aliasing too weak, length rounded down, phase that depends on the parity of N, float32 filtering, global state leaking from one call to the next |
| Block streaming | state reset, history shifted by one sample, empty block that clears the state, short blocks left unfiltered |
python mutants/run_mutants.py
...
Mutation score: 31/31 (100%)
Counterexamples are tiny: for 27 of 30 mutants (excluding iir_wrong_gain, detected on the impulse response), the signal shrunk by Hypothesis has at most 3 samples (median: 2), and 27 mutants fail within the first 3 generated inputs. The exceptions are expected: 97 samples for a filter that changes after 64, and 192 for the DC-gain and aliasing relations, which need long signals. python mutants/run_mutants.py --json benchmarks/mutant_results.json records these measurements.
Every report gives the smallest input found and can be exported:
report = dp.verify(my_fft, raise_on_failure=False)
print(report) # FAIL inversibility ... minimal input: [1, 1]
report.to_json() # for CI or a results table
report.to_markdown() # for a PR or an issue
Known blind spot: a filter that is wrong but still linear, time-invariant and causal (gain multiplied by 1.01) passes every relation unless its transfer function is declared. With transfer=(b, a), it is detected.
Bug hunting in real libraries
benchmarks/campaign.py applies the relations to SciPy, NumPy, PyTorch, librosa and torchaudio (46 targets).
It found that scipy.signal.lfilter and sosfilt fail on an empty block with an initial state, reported in scipy/scipy#26406. The lfilter part was already known (scipy/scipy#22571, fixed in the development version of SciPy): dsp-props rediscovered it independently. The sosfilt part, which fails on any input that is empty along the filtered axis, is new. It also found that scipy.signal.tf2sos drops pure delays (scipy/scipy#26417) and that torchaudio's deemphasis clamps its output to [-1, 1] (pytorch/audio#4238). Detailed results are in benchmarks/RESULTS-*.md (benchmarks/).
Installation
From PyPI (pypi.org/project/dsp-props):
python -m pip install "dsp-props[scipy]" # [scipy] for IIR filters and transfer functions
For development:
git clone https://github.com/clement-basdevant/dsp-props
cd dsp-props
python -m pip install -e ".[dev]"
python -m pytest
Layout
| Folder | Content |
|---|---|
src/dsp_props/core/ |
Tolerance models, robust comparison |
src/dsp_props/generators/ |
Hypothesis strategies for signals |
src/dsp_props/relations/ |
One metamorphic relation = one class |
mutants/ |
Buggy implementations and mutation score |
tests/ |
Tests of the tool itself |
benchmarks/ |
Bug-hunting campaign, error profiles, evaluation of the article |
proofs/ |
Coq proofs of the relations and of the IIR error bound |
docs/ |
Development plan and documentation |
paper/, papers/ |
Article, bibliography and drafts |
Roadmap
- Foundations: generators, basic relations, tests on
numpy.fft(done) - Tolerance engine: error models per operator class, calibration against extended precision (done)
- Advanced relations: causality, stability, block streaming, multirate (done)
- Mutant suite: about forty mutants, trial on
scipy.signal(31 mutants, campaign on 46 real targets; to be extended) - Shrinking and reporting: small counterexamples, HTML report (done)
- Auto-discovery and C adapter (done:
dsp-props check,dsp_props.adapters)
License
BSD-3-Clause, like NumPy and SciPy.
Metadata
Release files for dsp-props 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dsp_props-0.1.1.tar.gz | 34.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dsp_props-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 67.5 kB
Release files / dsp_props-0.1.1.tar.gz
| Download URL | dsp_props-0.1.1.tar.gz |
|---|---|
| Size | 34.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
796d54d1ed62ac43327b141d683faa3fde187e4549486afae53f1d81cb44faa8
|
|
BLAKE2b-256 checksum How to use checksums |
74425c7d63d77ca640afad84b725e8542edd3199af9767eab83de61198fd6664
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency logRelease files / dsp_props-0.1.1-py3-none-any.whl
| Download URL | dsp_props-0.1.1-py3-none-any.whl |
|---|---|
| Size | 32.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
766f44b5d9e0bbb3498156f183e3747763d1a0a0a65dd0eb265de15875f5efd8
|
|
BLAKE2b-256 checksum How to use checksums |
a0e3e4ea795a12dce53e45588744eac7b3055ecbb514fe86c5a9226ebc5f5770
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency log