dsp-props 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. dsp_props-0.1.0/.gitignore +8 -0
  2. dsp_props-0.1.0/CITATION.cff +12 -0
  3. dsp_props-0.1.0/LICENSE +28 -0
  4. dsp_props-0.1.0/PKG-INFO +221 -0
  5. dsp_props-0.1.0/README.md +187 -0
  6. dsp_props-0.1.0/examples/c/biquad.c +43 -0
  7. dsp_props-0.1.0/examples/filters.py +29 -0
  8. dsp_props-0.1.0/pyproject.toml +54 -0
  9. dsp_props-0.1.0/src/dsp_props/__init__.py +39 -0
  10. dsp_props-0.1.0/src/dsp_props/__main__.py +5 -0
  11. dsp_props-0.1.0/src/dsp_props/adapters.py +93 -0
  12. dsp_props-0.1.0/src/dsp_props/api.py +285 -0
  13. dsp_props-0.1.0/src/dsp_props/cli.py +123 -0
  14. dsp_props-0.1.0/src/dsp_props/core/__init__.py +19 -0
  15. dsp_props-0.1.0/src/dsp_props/core/calibration.py +116 -0
  16. dsp_props-0.1.0/src/dsp_props/core/iir.py +90 -0
  17. dsp_props-0.1.0/src/dsp_props/core/tolerance.py +74 -0
  18. dsp_props-0.1.0/src/dsp_props/generators/__init__.py +29 -0
  19. dsp_props-0.1.0/src/dsp_props/generators/signals.py +125 -0
  20. dsp_props-0.1.0/src/dsp_props/relations/__init__.py +45 -0
  21. dsp_props-0.1.0/src/dsp_props/relations/base.py +24 -0
  22. dsp_props-0.1.0/src/dsp_props/relations/lti.py +164 -0
  23. dsp_props-0.1.0/src/dsp_props/relations/multirate.py +123 -0
  24. dsp_props-0.1.0/src/dsp_props/relations/streaming.py +37 -0
  25. dsp_props-0.1.0/src/dsp_props/relations/transforms.py +185 -0
  26. dsp_props-0.1.0/src/dsp_props/report.py +194 -0
  27. dsp_props-0.1.0/tests/test_adapters.py +47 -0
  28. dsp_props-0.1.0/tests/test_calibration.py +41 -0
  29. dsp_props-0.1.0/tests/test_cli.py +38 -0
  30. dsp_props-0.1.0/tests/test_generators.py +21 -0
  31. dsp_props-0.1.0/tests/test_iir.py +37 -0
  32. dsp_props-0.1.0/tests/test_multirate.py +43 -0
  33. dsp_props-0.1.0/tests/test_numpy_fft.py +40 -0
  34. dsp_props-0.1.0/tests/test_report.py +43 -0
  35. dsp_props-0.1.0/tests/test_streaming.py +41 -0
  36. dsp_props-0.1.0/tests/test_tolerance.py +39 -0
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .hypothesis/
4
+ .pytest_cache/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .venv/
@@ -0,0 +1,12 @@
1
+ cff-version: 1.2.0
2
+ message: "Si vous utilisez dsp-props, merci de le citer comme suit."
3
+ title: "dsp-props: property-based testing for digital signal processing"
4
+ type: software
5
+ authors:
6
+ - family-names: "Basdevant"
7
+ given-names: "Clément"
8
+ orcid: "https://orcid.org/0009-0004-7496-2365"
9
+ license: BSD-3-Clause
10
+ repository-code: "https://github.com/clement-basdevant/dsp-props"
11
+ version: 0.1.0
12
+ date-released: 2026-10-14
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, dsp-props contributors
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.5
2
+ Name: dsp-props
3
+ Version: 0.1.0
4
+ Summary: Property-based testing for digital signal processing code
5
+ Project-URL: Homepage, https://github.com/clement-basdevant/dsp-props
6
+ Project-URL: Source, https://github.com/clement-basdevant/dsp-props
7
+ Project-URL: Issues, https://github.com/clement-basdevant/dsp-props/issues
8
+ Author: Clément Basdevant
9
+ License-Expression: BSD-3-Clause
10
+ License-File: LICENSE
11
+ Keywords: DSP,FFT,filters,hypothesis,metamorphic testing,property-based testing,signal processing
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Hypothesis
14
+ Classifier: Framework :: Pytest
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: hypothesis>=6.80
25
+ Requires-Dist: numpy>=1.24
26
+ Provides-Extra: dev
27
+ Requires-Dist: build; extra == 'dev'
28
+ Requires-Dist: pytest>=7; extra == 'dev'
29
+ Requires-Dist: scipy>=1.11; extra == 'dev'
30
+ Requires-Dist: twine; extra == 'dev'
31
+ Provides-Extra: scipy
32
+ Requires-Dist: scipy>=1.11; extra == 'scipy'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # dsp-props
36
+
37
+ **Tests de propriété pour le traitement du signal** · *Property-based testing for DSP code*
38
+
39
+ Le code DSP est souvent testé en comparant sa sortie à une référence (MATLAB, SciPy) sur quelques signaux, avec une tolérance choisie à la main. `dsp-props` vérifie plutôt des **relations qui doivent toujours tenir** (linéarité, théorème du décalage, Parseval, symétrie hermitienne, inversibilité, théorème de convolution), sur des **signaux générés automatiquement** par [Hypothesis](https://hypothesis.works/) et choisis pour piéger le code : impulsions, sinusoïdes hors grille, tons proches, quasi-Nyquist, amplitudes extrêmes, longueurs 1, premières ou 2^k ± 1. Les tolérances dépendent de l'opération (erreur en O(ε log N) pour une FFT) au lieu d'un `atol` fixe.
40
+
41
+ > Statut : pré-alpha, phase 1 du [schéma directeur](docs/schema-directeur.md).
42
+
43
+ ## Exemple
44
+
45
+ ```python
46
+ import numpy as np
47
+ import dsp_props as dp
48
+
49
+ @dp.transform(inverse=np.fft.ifft, parseval=True, real_input_hermitian=True)
50
+ def my_fft(x):
51
+ return np.fft.fft(x)
52
+
53
+ print(dp.verify(my_fft))
54
+ ```
55
+
56
+ ```
57
+ dsp-props report for my_fft
58
+ ok linearity
59
+ ok circular shift
60
+ ok parseval
61
+ ok hermitian symmetry
62
+ ok inversibility
63
+ ok convolution theorem
64
+ ```
65
+
66
+ En cas d'échec, `verify` lève `PropertyViolation` avec, pour chaque relation violée, le contre-exemple réduit par Hypothesis.
67
+
68
+ Autres déclarations :
69
+
70
+ ```python
71
+ # FFT réelle : l'inverse reçoit la longueur N
72
+ dp.transform(inverse=np.fft.irfft, kind="rdft", parseval=True)(np.fft.rfft)
73
+
74
+ # Transformée linéaire quelconque (DCT, ondelettes...)
75
+ dp.transform(kind="generic", inverse=idct_ortho, parseval=True, parseval_scale=lambda n: 1.0)(dct_ortho)
76
+
77
+ # Filtre LTI : linéarité, invariance temporelle, causalité, réponse en fréquence = B(z)/A(z).
78
+ # La tolérance suit le conditionnement de la récursion (IIRTolerance).
79
+ dp.lti_filter(causal=True, transfer=(b, a))(lambda x: scipy.signal.lfilter(b, a, x))
80
+
81
+ # Stabilité EBSB : aucune entrée bornée par 1 ne doit dépasser le gain déclaré (entrée pire cas construite)
82
+ dp.lti_filter(max_gain=l1_de_h)(mon_filtre)
83
+
84
+ # Rééchantillonneur rationnel L/M : linéarité, retard de M échantillons en entrée = retard de L en sortie,
85
+ # longueur ceil(N L / M), gain DC, et rejet du repliement quand on décime
86
+ dp.resampler(2, 3)(lambda x: scipy.signal.resample_poly(x, 2, 3))
87
+
88
+ # Traitement par blocs : tout découpage (blocs vides et d'un échantillon compris) doit donner la même sortie
89
+ @dp.streaming()
90
+ class MonFiltre:
91
+ def process(self, bloc): ...
92
+
93
+ # Fonction à état explicite, par exemple lfilter avec zi
94
+ dp.stateful(lambda bloc, z: scipy.signal.lfilter(b, a, bloc, zi=z), init_state=np.zeros(2))
95
+ ```
96
+
97
+ ## En ligne de commande
98
+
99
+ `dsp-props check` trouve tous les opérateurs déclarés dans un module ou un fichier et les vérifie :
100
+
101
+ ```
102
+ $ dsp-props check examples/filters.py --examples 100
103
+ FAIL examples/filters.py:buggy_fft (4 relations, 3.4s)
104
+ FAIL inversibility
105
+ inversibility: L2 error 2.000e+00 exceeds tolerance 6.280e-15 (N=2, ref norm 1.414e+00)
106
+ minimal input: [1, 1]
107
+ ok examples/filters.py:fft (6 relations, 6.5s)
108
+ ok examples/filters.py:lowpass (4 relations, 3.1s)
109
+ ok examples/filters.py:resample_2_3 (5 relations, 2.8s)
110
+
111
+ 3/4 operators pass
112
+ ```
113
+
114
+ Cibles possibles : `paquet.module`, `fichier.py`, `module:operateur`. Options : `--json`, `--markdown`, `--html`, `--examples N`, `--derandomize` (entrées reproductibles en CI). Code de sortie 1 si une relation échoue, ce qui suffit pour bloquer une CI.
115
+
116
+ ## Code C
117
+
118
+ `dsp_props.adapters` charge une fonction C (bibliothèque partagée, via ctypes) et la déclare comme n'importe quel opérateur. Convention : `void f(const double *x, size_t n, double *y, double *etat)`.
119
+
120
+ ```python
121
+ from dsp_props.adapters import build_shared, c_function, c_block_processor
122
+
123
+ lib = build_shared("examples/c/biquad.c")
124
+ dp.verify(dp.lti_filter(transfer=(b, a))(c_function(lib, "biquad", state_size=2)))
125
+ dp.verify(dp.streaming()(c_block_processor(lib, "biquad", state_size=2)))
126
+ ```
127
+
128
+ `examples/c/biquad.c` contient un biquad correct et deux variantes boguées. Celle qui oublie une variable d'état entre deux blocs passe tous les tests sur un appel unique et n'est détectée que par l'invariance au découpage en blocs.
129
+
130
+ ## Tolérances calibrées
131
+
132
+ Une borne fixe en ε · log N ne décrit pas toutes les bibliothèques : sur N = 2…1200, la FFT de PyTorch (MKL) atteint 195 ε contre 4 ε pour NumPy, selon la factorisation de N. `dp.calibrate` mesure l'erreur réelle contre une référence en précision étendue et en déduit la tolérance :
133
+
134
+ ```python
135
+ tol = dp.calibrate(torch_fft, lengths=range(1, 513)) # refuse un opérateur faux
136
+ print(tol.summary())
137
+ dp.transform(inverse=torch_ifft, parseval=True, tolerance=tol)(torch_fft)
138
+ ```
139
+
140
+ `python benchmarks/error_profile.py` produit le profil d'erreur complet (`benchmarks/error_profile.csv`), et `python benchmarks/plot_error_profile.py` la figure ci-dessous.
141
+
142
+ ![Erreur relative de la FFT selon N, NumPy contre PyTorch](https://raw.githubusercontent.com/clement-basdevant/dsp-props/main/docs/figures/error_profile.png)
143
+
144
+ ## Valider l'outil : la suite de mutants
145
+
146
+ `mutants/` contient 31 implémentations volontairement boguées, réparties en six familles :
147
+
148
+ | Famille | Mutants |
149
+ | --- | --- |
150
+ | FFT complexe | 1/N oublié, conjugaison manquante, décalage d'un indice, normalisation erronée, calcul en float32, signe des twiddles, dernier échantillon ignoré, offset constant |
151
+ | FFT réelle (`rfft`) | un bin de moins, inverse qui ignore N, bin de Nyquist divisé par 2, spectre conjugué, inverse sans symétrie hermitienne |
152
+ | DCT-II orthonormée | poids du coefficient DC oublié, inverse du mauvais type, calcul en float32 |
153
+ | Filtre IIR (ordre 4, résonant) | récursion en float32, filtfilt déclaré causal, coefficients qui changent en cours de route, état initial non nul, gain faux de 1 % |
154
+ | Rééchantillonneur 2/3 | gain d'interpolation oublié, anti-repliement trop faible, longueur arrondie par défaut, phase qui dépend de la parité de N, filtrage en float32, état global qui fuit d'un appel à l'autre |
155
+ | Streaming par blocs | état remis à zéro, historique décalé d'un échantillon, bloc vide qui efface l'état, blocs courts non filtrés |
156
+
157
+ ```
158
+ python mutants/run_mutants.py
159
+ ...
160
+ Mutation score: 31/31 (100%)
161
+ ```
162
+
163
+ Les contre-exemples sont minuscules : pour 27 mutants sur 30 (hors `iir_wrong_gain`, détecté sur la réponse impulsionnelle), le signal réduit par Hypothesis fait au plus 3 échantillons (médiane : 2), et 27 mutants tombent dans les 3 premières entrées générées. Les exceptions sont attendues : 97 échantillons pour un filtre qui change après 64, et 192 pour les relations de gain DC et de repliement, qui exigent des signaux longs. `python mutants/run_mutants.py --json benchmarks/mutant_results.json` enregistre ces mesures.
164
+
165
+ Chaque rapport donne l'entrée minimale et s'exporte :
166
+
167
+ ```python
168
+ report = dp.verify(ma_fft, raise_on_failure=False)
169
+ print(report) # FAIL inversibility ... minimal input: [1, 1]
170
+ report.to_json() # pour la CI ou un tableau de résultats
171
+ report.to_markdown() # pour une PR ou un ticket
172
+ ```
173
+
174
+ Angle mort documenté : un filtre faux mais toujours linéaire, invariant et causal (gain multiplié par 1,01) passe toutes les relations si l'on ne déclare pas sa fonction de transfert. Avec `transfer=(b, a)`, il est détecté.
175
+
176
+ ## Chasse aux bugs dans de vraies bibliothèques
177
+
178
+ `benchmarks/campaign.py` applique les relations à SciPy, NumPy, PyTorch, librosa et torchaudio (46 cibles).
179
+ Premier bug signalé : [scipy/scipy#26406](https://github.com/scipy/scipy/issues/26406). Résultats détaillés dans `benchmarks/RESULTS-*.md`.
180
+
181
+ ## Installation
182
+
183
+ Une fois publié sur PyPI :
184
+
185
+ ```
186
+ python -m pip install "dsp-props[scipy]" # [scipy] pour les filtres IIR et les fonctions de transfert
187
+ ```
188
+
189
+ En développement :
190
+
191
+ ```
192
+ git clone https://github.com/clement-basdevant/dsp-props
193
+ cd dsp-props
194
+ python -m pip install -e ".[dev]"
195
+ python -m pytest
196
+ ```
197
+
198
+ ## Organisation
199
+
200
+ | Dossier | Contenu |
201
+ | --- | --- |
202
+ | `src/dsp_props/core/` | Modèles de tolérance, comparaison robuste |
203
+ | `src/dsp_props/generators/` | Stratégies Hypothesis pour signaux |
204
+ | `src/dsp_props/relations/` | Une relation métamorphique = une classe |
205
+ | `mutants/` | Implémentations boguées et calcul du score de mutation |
206
+ | `tests/` | Tests de l'outil lui-même |
207
+ | `docs/` | Schéma directeur et documentation |
208
+ | `papers/` | Bibliographie et brouillons d'articles |
209
+
210
+ ## Feuille de route
211
+
212
+ 1. **Fondations** : générateurs, relations de base, tests sur `numpy.fft` *(fait)*
213
+ 2. **Moteur de tolérance** : modèles d'erreur par classe d'opérateur, calibration contre précision étendue *(fait)*
214
+ 3. **Relations avancées** : causalité, stabilité, streaming par blocs, multicadence *(fait)*
215
+ 4. **Suite de mutants** : une quarantaine de mutants, essai sur `scipy.signal` *(31 mutants, campagne sur 46 cibles réelles ; à compléter)*
216
+ 5. **Shrinking et rapport** : contre-exemples minimaux, rapport HTML *(fait)*
217
+ 6. **Auto-découverte et adaptateur C** *(fait : `dsp-props check`, `dsp_props.adapters`)*
218
+
219
+ ## Licence
220
+
221
+ BSD-3-Clause, comme NumPy et SciPy.
@@ -0,0 +1,187 @@
1
+ # dsp-props
2
+
3
+ **Tests de propriété pour le traitement du signal** · *Property-based testing for DSP code*
4
+
5
+ Le code DSP est souvent testé en comparant sa sortie à une référence (MATLAB, SciPy) sur quelques signaux, avec une tolérance choisie à la main. `dsp-props` vérifie plutôt des **relations qui doivent toujours tenir** (linéarité, théorème du décalage, Parseval, symétrie hermitienne, inversibilité, théorème de convolution), sur des **signaux générés automatiquement** par [Hypothesis](https://hypothesis.works/) et choisis pour piéger le code : impulsions, sinusoïdes hors grille, tons proches, quasi-Nyquist, amplitudes extrêmes, longueurs 1, premières ou 2^k ± 1. Les tolérances dépendent de l'opération (erreur en O(ε log N) pour une FFT) au lieu d'un `atol` fixe.
6
+
7
+ > Statut : pré-alpha, phase 1 du [schéma directeur](docs/schema-directeur.md).
8
+
9
+ ## Exemple
10
+
11
+ ```python
12
+ import numpy as np
13
+ import dsp_props as dp
14
+
15
+ @dp.transform(inverse=np.fft.ifft, parseval=True, real_input_hermitian=True)
16
+ def my_fft(x):
17
+ return np.fft.fft(x)
18
+
19
+ print(dp.verify(my_fft))
20
+ ```
21
+
22
+ ```
23
+ dsp-props report for my_fft
24
+ ok linearity
25
+ ok circular shift
26
+ ok parseval
27
+ ok hermitian symmetry
28
+ ok inversibility
29
+ ok convolution theorem
30
+ ```
31
+
32
+ En cas d'échec, `verify` lève `PropertyViolation` avec, pour chaque relation violée, le contre-exemple réduit par Hypothesis.
33
+
34
+ Autres déclarations :
35
+
36
+ ```python
37
+ # FFT réelle : l'inverse reçoit la longueur N
38
+ dp.transform(inverse=np.fft.irfft, kind="rdft", parseval=True)(np.fft.rfft)
39
+
40
+ # Transformée linéaire quelconque (DCT, ondelettes...)
41
+ dp.transform(kind="generic", inverse=idct_ortho, parseval=True, parseval_scale=lambda n: 1.0)(dct_ortho)
42
+
43
+ # Filtre LTI : linéarité, invariance temporelle, causalité, réponse en fréquence = B(z)/A(z).
44
+ # La tolérance suit le conditionnement de la récursion (IIRTolerance).
45
+ dp.lti_filter(causal=True, transfer=(b, a))(lambda x: scipy.signal.lfilter(b, a, x))
46
+
47
+ # Stabilité EBSB : aucune entrée bornée par 1 ne doit dépasser le gain déclaré (entrée pire cas construite)
48
+ dp.lti_filter(max_gain=l1_de_h)(mon_filtre)
49
+
50
+ # Rééchantillonneur rationnel L/M : linéarité, retard de M échantillons en entrée = retard de L en sortie,
51
+ # longueur ceil(N L / M), gain DC, et rejet du repliement quand on décime
52
+ dp.resampler(2, 3)(lambda x: scipy.signal.resample_poly(x, 2, 3))
53
+
54
+ # Traitement par blocs : tout découpage (blocs vides et d'un échantillon compris) doit donner la même sortie
55
+ @dp.streaming()
56
+ class MonFiltre:
57
+ def process(self, bloc): ...
58
+
59
+ # Fonction à état explicite, par exemple lfilter avec zi
60
+ dp.stateful(lambda bloc, z: scipy.signal.lfilter(b, a, bloc, zi=z), init_state=np.zeros(2))
61
+ ```
62
+
63
+ ## En ligne de commande
64
+
65
+ `dsp-props check` trouve tous les opérateurs déclarés dans un module ou un fichier et les vérifie :
66
+
67
+ ```
68
+ $ dsp-props check examples/filters.py --examples 100
69
+ FAIL examples/filters.py:buggy_fft (4 relations, 3.4s)
70
+ FAIL inversibility
71
+ inversibility: L2 error 2.000e+00 exceeds tolerance 6.280e-15 (N=2, ref norm 1.414e+00)
72
+ minimal input: [1, 1]
73
+ ok examples/filters.py:fft (6 relations, 6.5s)
74
+ ok examples/filters.py:lowpass (4 relations, 3.1s)
75
+ ok examples/filters.py:resample_2_3 (5 relations, 2.8s)
76
+
77
+ 3/4 operators pass
78
+ ```
79
+
80
+ Cibles possibles : `paquet.module`, `fichier.py`, `module:operateur`. Options : `--json`, `--markdown`, `--html`, `--examples N`, `--derandomize` (entrées reproductibles en CI). Code de sortie 1 si une relation échoue, ce qui suffit pour bloquer une CI.
81
+
82
+ ## Code C
83
+
84
+ `dsp_props.adapters` charge une fonction C (bibliothèque partagée, via ctypes) et la déclare comme n'importe quel opérateur. Convention : `void f(const double *x, size_t n, double *y, double *etat)`.
85
+
86
+ ```python
87
+ from dsp_props.adapters import build_shared, c_function, c_block_processor
88
+
89
+ lib = build_shared("examples/c/biquad.c")
90
+ dp.verify(dp.lti_filter(transfer=(b, a))(c_function(lib, "biquad", state_size=2)))
91
+ dp.verify(dp.streaming()(c_block_processor(lib, "biquad", state_size=2)))
92
+ ```
93
+
94
+ `examples/c/biquad.c` contient un biquad correct et deux variantes boguées. Celle qui oublie une variable d'état entre deux blocs passe tous les tests sur un appel unique et n'est détectée que par l'invariance au découpage en blocs.
95
+
96
+ ## Tolérances calibrées
97
+
98
+ Une borne fixe en ε · log N ne décrit pas toutes les bibliothèques : sur N = 2…1200, la FFT de PyTorch (MKL) atteint 195 ε contre 4 ε pour NumPy, selon la factorisation de N. `dp.calibrate` mesure l'erreur réelle contre une référence en précision étendue et en déduit la tolérance :
99
+
100
+ ```python
101
+ tol = dp.calibrate(torch_fft, lengths=range(1, 513)) # refuse un opérateur faux
102
+ print(tol.summary())
103
+ dp.transform(inverse=torch_ifft, parseval=True, tolerance=tol)(torch_fft)
104
+ ```
105
+
106
+ `python benchmarks/error_profile.py` produit le profil d'erreur complet (`benchmarks/error_profile.csv`), et `python benchmarks/plot_error_profile.py` la figure ci-dessous.
107
+
108
+ ![Erreur relative de la FFT selon N, NumPy contre PyTorch](https://raw.githubusercontent.com/clement-basdevant/dsp-props/main/docs/figures/error_profile.png)
109
+
110
+ ## Valider l'outil : la suite de mutants
111
+
112
+ `mutants/` contient 31 implémentations volontairement boguées, réparties en six familles :
113
+
114
+ | Famille | Mutants |
115
+ | --- | --- |
116
+ | FFT complexe | 1/N oublié, conjugaison manquante, décalage d'un indice, normalisation erronée, calcul en float32, signe des twiddles, dernier échantillon ignoré, offset constant |
117
+ | FFT réelle (`rfft`) | un bin de moins, inverse qui ignore N, bin de Nyquist divisé par 2, spectre conjugué, inverse sans symétrie hermitienne |
118
+ | DCT-II orthonormée | poids du coefficient DC oublié, inverse du mauvais type, calcul en float32 |
119
+ | Filtre IIR (ordre 4, résonant) | récursion en float32, filtfilt déclaré causal, coefficients qui changent en cours de route, état initial non nul, gain faux de 1 % |
120
+ | Rééchantillonneur 2/3 | gain d'interpolation oublié, anti-repliement trop faible, longueur arrondie par défaut, phase qui dépend de la parité de N, filtrage en float32, état global qui fuit d'un appel à l'autre |
121
+ | Streaming par blocs | état remis à zéro, historique décalé d'un échantillon, bloc vide qui efface l'état, blocs courts non filtrés |
122
+
123
+ ```
124
+ python mutants/run_mutants.py
125
+ ...
126
+ Mutation score: 31/31 (100%)
127
+ ```
128
+
129
+ Les contre-exemples sont minuscules : pour 27 mutants sur 30 (hors `iir_wrong_gain`, détecté sur la réponse impulsionnelle), le signal réduit par Hypothesis fait au plus 3 échantillons (médiane : 2), et 27 mutants tombent dans les 3 premières entrées générées. Les exceptions sont attendues : 97 échantillons pour un filtre qui change après 64, et 192 pour les relations de gain DC et de repliement, qui exigent des signaux longs. `python mutants/run_mutants.py --json benchmarks/mutant_results.json` enregistre ces mesures.
130
+
131
+ Chaque rapport donne l'entrée minimale et s'exporte :
132
+
133
+ ```python
134
+ report = dp.verify(ma_fft, raise_on_failure=False)
135
+ print(report) # FAIL inversibility ... minimal input: [1, 1]
136
+ report.to_json() # pour la CI ou un tableau de résultats
137
+ report.to_markdown() # pour une PR ou un ticket
138
+ ```
139
+
140
+ Angle mort documenté : un filtre faux mais toujours linéaire, invariant et causal (gain multiplié par 1,01) passe toutes les relations si l'on ne déclare pas sa fonction de transfert. Avec `transfer=(b, a)`, il est détecté.
141
+
142
+ ## Chasse aux bugs dans de vraies bibliothèques
143
+
144
+ `benchmarks/campaign.py` applique les relations à SciPy, NumPy, PyTorch, librosa et torchaudio (46 cibles).
145
+ Premier bug signalé : [scipy/scipy#26406](https://github.com/scipy/scipy/issues/26406). Résultats détaillés dans `benchmarks/RESULTS-*.md`.
146
+
147
+ ## Installation
148
+
149
+ Une fois publié sur PyPI :
150
+
151
+ ```
152
+ python -m pip install "dsp-props[scipy]" # [scipy] pour les filtres IIR et les fonctions de transfert
153
+ ```
154
+
155
+ En développement :
156
+
157
+ ```
158
+ git clone https://github.com/clement-basdevant/dsp-props
159
+ cd dsp-props
160
+ python -m pip install -e ".[dev]"
161
+ python -m pytest
162
+ ```
163
+
164
+ ## Organisation
165
+
166
+ | Dossier | Contenu |
167
+ | --- | --- |
168
+ | `src/dsp_props/core/` | Modèles de tolérance, comparaison robuste |
169
+ | `src/dsp_props/generators/` | Stratégies Hypothesis pour signaux |
170
+ | `src/dsp_props/relations/` | Une relation métamorphique = une classe |
171
+ | `mutants/` | Implémentations boguées et calcul du score de mutation |
172
+ | `tests/` | Tests de l'outil lui-même |
173
+ | `docs/` | Schéma directeur et documentation |
174
+ | `papers/` | Bibliographie et brouillons d'articles |
175
+
176
+ ## Feuille de route
177
+
178
+ 1. **Fondations** : générateurs, relations de base, tests sur `numpy.fft` *(fait)*
179
+ 2. **Moteur de tolérance** : modèles d'erreur par classe d'opérateur, calibration contre précision étendue *(fait)*
180
+ 3. **Relations avancées** : causalité, stabilité, streaming par blocs, multicadence *(fait)*
181
+ 4. **Suite de mutants** : une quarantaine de mutants, essai sur `scipy.signal` *(31 mutants, campagne sur 46 cibles réelles ; à compléter)*
182
+ 5. **Shrinking et rapport** : contre-exemples minimaux, rapport HTML *(fait)*
183
+ 6. **Auto-découverte et adaptateur C** *(fait : `dsp-props check`, `dsp_props.adapters`)*
184
+
185
+ ## Licence
186
+
187
+ BSD-3-Clause, comme NumPy et SciPy.
@@ -0,0 +1,43 @@
1
+ /* Biquad low-pass (Butterworth, fc = 0.2 x Nyquist), transposed direct form II.
2
+ *
3
+ * Calling convention understood by dsp_props.adapters:
4
+ * void f(const double *x, size_t n, double *y, double *state)
5
+ * The state (2 doubles) carries the filter memory from one block to the next.
6
+ */
7
+ #include <stddef.h>
8
+
9
+ static const double B[3] = {0.06745527388907189, 0.13491054777814378, 0.06745527388907189};
10
+ static const double A[3] = {1.0, -1.1429805025399011, 0.41280159809618877};
11
+
12
+ void biquad(const double *x, size_t n, double *y, double *s)
13
+ {
14
+ for (size_t i = 0; i < n; i++) {
15
+ double out = B[0] * x[i] + s[0];
16
+ s[0] = B[1] * x[i] - A[1] * out + s[1];
17
+ s[1] = B[2] * x[i] - A[2] * out;
18
+ y[i] = out;
19
+ }
20
+ }
21
+
22
+ /* Bug: the accumulator is single precision. */
23
+ void biquad_float_acc(const double *x, size_t n, double *y, double *s)
24
+ {
25
+ for (size_t i = 0; i < n; i++) {
26
+ float out = (float)(B[0] * x[i] + s[0]);
27
+ s[0] = B[1] * x[i] - A[1] * out + s[1];
28
+ s[1] = B[2] * x[i] - A[2] * out;
29
+ y[i] = out;
30
+ }
31
+ }
32
+
33
+ /* Bug: the second state variable is not carried across blocks (local copy). */
34
+ void biquad_drops_state(const double *x, size_t n, double *y, double *s)
35
+ {
36
+ double s1 = 0.0;
37
+ for (size_t i = 0; i < n; i++) {
38
+ double out = B[0] * x[i] + s[0];
39
+ s[0] = B[1] * x[i] - A[1] * out + s1;
40
+ s1 = B[2] * x[i] - A[2] * out;
41
+ y[i] = out;
42
+ }
43
+ }
@@ -0,0 +1,29 @@
1
+ """Example operators for `dsp-props check examples/filters.py`."""
2
+
3
+ import numpy as np
4
+ from scipy import signal
5
+
6
+ import dsp_props as dp
7
+
8
+ B, A = signal.butter(4, 0.2)
9
+
10
+
11
+ @dp.transform(inverse=np.fft.ifft, parseval=True, real_input_hermitian=True)
12
+ def fft(x):
13
+ return np.fft.fft(x)
14
+
15
+
16
+ @dp.lti_filter(transfer=(B, A))
17
+ def lowpass(x):
18
+ return signal.lfilter(B, A, x)
19
+
20
+
21
+ @dp.resampler(2, 3)
22
+ def resample_2_3(x):
23
+ return signal.resample_poly(x, 2, 3)
24
+
25
+
26
+ @dp.transform(inverse=np.fft.ifft)
27
+ def buggy_fft(x):
28
+ """Off by one bin: dsp-props reports the smallest input that shows it."""
29
+ return np.roll(np.fft.fft(x), 1)
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dsp-props"
7
+ dynamic = ["version"]
8
+ description = "Property-based testing for digital signal processing code"
9
+ readme = "README.md"
10
+ license = "BSD-3-Clause"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Clément Basdevant" }]
14
+ keywords = ["signal processing", "DSP", "property-based testing", "metamorphic testing", "hypothesis", "FFT", "filters"]
15
+ dependencies = ["numpy>=1.24", "hypothesis>=6.80"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Framework :: Hypothesis",
19
+ "Framework :: Pytest",
20
+ "Intended Audience :: Science/Research",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Scientific/Engineering",
27
+ "Topic :: Software Development :: Testing",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/clement-basdevant/dsp-props"
32
+ Source = "https://github.com/clement-basdevant/dsp-props"
33
+ Issues = "https://github.com/clement-basdevant/dsp-props/issues"
34
+
35
+ [project.scripts]
36
+ dsp-props = "dsp_props.cli:main"
37
+
38
+ [project.optional-dependencies]
39
+ # transfer-function checks, IIR error model and the examples use scipy.signal
40
+ scipy = ["scipy>=1.11"]
41
+ dev = ["pytest>=7", "scipy>=1.11", "build", "twine"]
42
+
43
+ [tool.hatch.version]
44
+ path = "src/dsp_props/__init__.py"
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = ["src", "tests", "examples", "README.md", "LICENSE", "CITATION.cff", "pyproject.toml"]
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/dsp_props"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ pythonpath = ["src", "."]
@@ -0,0 +1,39 @@
1
+ """dsp-props: property-based testing for digital signal processing code."""
2
+
3
+ from . import core, generators, relations
4
+ from .core import calibrate
5
+ from .api import (
6
+ PropertyViolation,
7
+ Report,
8
+ LTISpec,
9
+ MultirateSpec,
10
+ resampler,
11
+ StreamingSpec,
12
+ lti_filter,
13
+ TransformSpec,
14
+ stateful,
15
+ streaming,
16
+ transform,
17
+ verify,
18
+ )
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "core",
24
+ "generators",
25
+ "relations",
26
+ "transform",
27
+ "calibrate",
28
+ "streaming",
29
+ "lti_filter",
30
+ "LTISpec",
31
+ "resampler",
32
+ "MultirateSpec",
33
+ "stateful",
34
+ "StreamingSpec",
35
+ "verify",
36
+ "Report",
37
+ "TransformSpec",
38
+ "PropertyViolation",
39
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())