dsp-props 0.1.0__py3-none-any.whl

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.
dsp_props/__init__.py ADDED
@@ -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
+ ]
dsp_props/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
dsp_props/adapters.py ADDED
@@ -0,0 +1,93 @@
1
+ """Adapters for DSP code written in C (shared libraries loaded with ctypes).
2
+
3
+ Two calling conventions are supported, both on contiguous ``double`` arrays:
4
+
5
+ * stateless: ``void f(const double *x, size_t n, double *y)``, where ``y`` holds
6
+ ``out_length(n)`` samples (``n`` by default);
7
+ * block with state: ``void f(const double *x, size_t n, double *y, double *state)``,
8
+ where ``state`` holds ``state_size`` doubles carried from one block to the next.
9
+
10
+ The returned Python callables can then be declared like any other operator::
11
+
12
+ lib = build_shared("biquad.c")
13
+ lowpass = dp.lti_filter(transfer=(b, a))(c_function(lib, "biquad", state_size=2))
14
+ Block = dp.streaming()(c_block_processor(lib, "biquad", state_size=2))
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ctypes
20
+ import os
21
+ import pathlib
22
+ import shutil
23
+ import subprocess
24
+ import tempfile
25
+
26
+ import numpy as np
27
+
28
+ _DOUBLE_P = ctypes.POINTER(ctypes.c_double)
29
+
30
+
31
+ def build_shared(*sources, out=None, cc=None, flags=("-O2",)):
32
+ """Compile C sources into a shared library and load it (needs a C compiler)."""
33
+ cc = cc or os.environ.get("CC") or shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
34
+ if cc is None:
35
+ raise RuntimeError("no C compiler found (set CC)")
36
+ if out is None:
37
+ out = pathlib.Path(tempfile.mkdtemp(prefix="dsp_props_")) / "libdsp.so"
38
+ cmd = [cc, *flags, "-shared", "-fPIC", "-o", str(out), *map(str, sources), "-lm"]
39
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
40
+ return ctypes.CDLL(str(out))
41
+
42
+
43
+ def _load(lib):
44
+ return lib if isinstance(lib, ctypes.CDLL) else ctypes.CDLL(str(lib))
45
+
46
+
47
+ def _symbol(lib, name, with_state):
48
+ f = getattr(_load(lib), name)
49
+ f.restype = None
50
+ f.argtypes = [_DOUBLE_P, ctypes.c_size_t, _DOUBLE_P] + ([_DOUBLE_P] if with_state else [])
51
+ return f
52
+
53
+
54
+ def _ptr(a):
55
+ return a.ctypes.data_as(_DOUBLE_P)
56
+
57
+
58
+ def c_function(lib, name, *, out_length=None, state_size=None):
59
+ """A Python callable ``y = f(x)`` around a C function (state, if any, starts at zero)."""
60
+ f = _symbol(lib, name, state_size is not None)
61
+ out_length = out_length or (lambda n: n)
62
+
63
+ def call(x):
64
+ x = np.ascontiguousarray(x, dtype=np.float64)
65
+ y = np.zeros(out_length(len(x)))
66
+ if state_size is None:
67
+ f(_ptr(x), len(x), _ptr(y))
68
+ else:
69
+ state = np.zeros(state_size)
70
+ f(_ptr(x), len(x), _ptr(y), _ptr(state))
71
+ return y
72
+
73
+ call.__name__ = name
74
+ call.__qualname__ = name
75
+ return call
76
+
77
+
78
+ def c_block_processor(lib, name, *, state_size, init_state=None):
79
+ """A factory of block processors (``process(block)``) for ``dp.streaming``."""
80
+ f = _symbol(lib, name, True)
81
+
82
+ class Processor:
83
+ def __init__(self):
84
+ self.state = np.zeros(state_size) if init_state is None else np.array(init_state, dtype=float)
85
+
86
+ def process(self, block):
87
+ x = np.ascontiguousarray(block, dtype=np.float64)
88
+ y = np.zeros(len(x))
89
+ f(_ptr(x), len(x), _ptr(y), _ptr(self.state))
90
+ return y
91
+
92
+ Processor.__name__ = Processor.__qualname__ = name
93
+ return Processor
dsp_props/api.py ADDED
@@ -0,0 +1,285 @@
1
+ """Declarative API: describe an operator once, check every applicable relation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Callable, Optional
8
+
9
+ import numpy as np
10
+
11
+ from hypothesis import HealthCheck, given, settings
12
+ from hypothesis.errors import FlakyFailure, HypothesisException
13
+
14
+ from .core.tolerance import FFTTolerance, ToleranceModel
15
+ from .report import Failure, PropertyViolation, Report
16
+ from .relations import LTI_RELATIONS, MULTIRATE_RELATIONS, STREAMING_RELATIONS, TRANSFORM_RELATIONS
17
+
18
+
19
+ KINDS = ("dft", "rdft", "generic")
20
+
21
+
22
+ def hermitian_extend(half, n):
23
+ """Full length-``n`` DFT from the ``n // 2 + 1`` bins of a real-input DFT."""
24
+ half = np.asarray(half)
25
+ full = np.empty(n, dtype=np.result_type(half.dtype, np.complex64))
26
+ m = n // 2 + 1
27
+ full[:m] = half[:m]
28
+ if n > m:
29
+ full[m:] = np.conj(half[1 : n - m + 1][::-1])
30
+ return full
31
+
32
+
33
+ @dataclass
34
+ class TransformSpec:
35
+ func: Callable
36
+ inverse: Optional[Callable] = None
37
+ kind: str = "dft"
38
+ linear: bool = True
39
+ parseval: bool = False
40
+ parseval_scale: Callable[[int], float] = lambda n: n
41
+ real_input_hermitian: bool = False
42
+ real_input_only: bool = False
43
+ inverse_takes_length: bool = False
44
+ tolerance: ToleranceModel = field(default_factory=FFTTolerance)
45
+ max_n: int = 512
46
+
47
+ @property
48
+ def relations(self):
49
+ return [r for r in TRANSFORM_RELATIONS if r.applies(self)]
50
+
51
+ @property
52
+ def is_fourier(self):
53
+ return self.kind in ("dft", "rdft")
54
+
55
+ def full(self, x):
56
+ """The transform of ``x`` as a full spectrum (rdft outputs are Hermitian-extended)."""
57
+ y = np.asarray(self.func(x))
58
+ if self.kind == "rdft":
59
+ n = len(x)
60
+ if y.shape != (n // 2 + 1,):
61
+ raise AssertionError(f"rdft output has shape {y.shape}, expected ({n // 2 + 1},) for N={n}")
62
+ return hermitian_extend(y, n)
63
+ return y
64
+
65
+ def invert(self, X, n):
66
+ return np.asarray(self.inverse(X, n) if self.inverse_takes_length else self.inverse(X))
67
+
68
+
69
+ @dataclass
70
+ class StreamingSpec:
71
+ make: Callable
72
+ tolerance: ToleranceModel = field(default_factory=lambda: FFTTolerance(c=1000.0))
73
+ max_n: int = 512
74
+
75
+ @property
76
+ def relations(self):
77
+ return [r for r in STREAMING_RELATIONS if r.applies(self)]
78
+
79
+
80
+ @dataclass
81
+ class LTISpec:
82
+ func: Callable
83
+ causal: bool = True
84
+ transfer: Optional[tuple] = None
85
+ max_gain: Optional[float] = None
86
+ tolerance: ToleranceModel = field(default_factory=lambda: FFTTolerance(c=1000.0))
87
+ max_n: int = 512
88
+
89
+ @property
90
+ def relations(self):
91
+ return [r for r in LTI_RELATIONS if r.applies(self)]
92
+
93
+
94
+ def lti_filter(*, causal=True, transfer=None, max_gain=None, tolerance=None, max_n=512):
95
+ """Declare a linear time-invariant filter applied with zero initial state.
96
+
97
+ Checks linearity, time invariance and (if ``causal``) causality. With
98
+ ``transfer=(b, a)`` it also checks the measured frequency response against
99
+ B(z)/A(z), and the tolerance defaults to ``IIRTolerance(b, a)``, which
100
+ follows the conditioning of the recursion. With ``max_gain`` it checks
101
+ that no input bounded by 1 gives an output above ``max_gain`` (BIBO
102
+ stability with a declared bound, e.g. the l1 norm of the impulse response).
103
+ """
104
+ if tolerance is None:
105
+ if transfer is not None:
106
+ from .core.iir import IIRTolerance
107
+
108
+ tolerance = IIRTolerance(*transfer)
109
+ else:
110
+ tolerance = FFTTolerance(c=1000.0)
111
+
112
+ def decorate(func):
113
+ func.__dsp_props__ = LTISpec(func=func, causal=causal, transfer=transfer, max_gain=max_gain,
114
+ tolerance=tolerance, max_n=max_n)
115
+ return func
116
+
117
+ return decorate
118
+
119
+
120
+ @dataclass
121
+ class MultirateSpec:
122
+ func: Callable
123
+ up: int = 1
124
+ down: int = 1
125
+ causal: bool = False
126
+ length: Optional[Callable[[int], int]] = None
127
+ dc_gain: Optional[float] = 1.0
128
+ ripple: float = 1e-2
129
+ stopband: Optional[float] = 0.1
130
+ tolerance: ToleranceModel = field(default_factory=lambda: FFTTolerance(c=1000.0))
131
+ max_n: int = 512
132
+
133
+ @property
134
+ def relations(self):
135
+ return [r for r in MULTIRATE_RELATIONS if r.applies(self)]
136
+
137
+ def expected_length(self, n):
138
+ return self.length(n)
139
+
140
+
141
+ def resampler(up=1, down=1, *, causal=False, length="ceil", dc_gain=1.0, ripple=1e-2,
142
+ stopband=0.1, tolerance=None, max_n=512):
143
+ """Declare a rational resampler (up by ``up``, down by ``down``), like ``resample_poly``.
144
+
145
+ Checks linearity and periodic time invariance (an input delay of
146
+ ``down`` samples gives an output delay of ``up`` samples). Also, unless
147
+ disabled with ``None``: the output length (``"ceil"``: ceil(N up / down),
148
+ ``"floor"``, or a callable), the DC gain within ``ripple`` away from the
149
+ edges, and, when decimating, that a tone above the output Nyquist
150
+ frequency comes out below ``stopband`` (relative amplitude).
151
+ """
152
+ if up < 1 or down < 1:
153
+ raise ValueError("up and down must be positive integers")
154
+ rules = {
155
+ "ceil": lambda n: -(-n * up // down),
156
+ "floor": lambda n: n * up // down,
157
+ None: None,
158
+ }
159
+ rule = rules[length] if (length is None or isinstance(length, str)) else length
160
+
161
+ def decorate(func):
162
+ func.__dsp_props__ = MultirateSpec(func=func, up=up, down=down, causal=causal, length=rule,
163
+ dc_gain=dc_gain, ripple=ripple, stopband=stopband,
164
+ tolerance=tolerance or FFTTolerance(c=1000.0), max_n=max_n)
165
+ return func
166
+
167
+ return decorate
168
+
169
+
170
+ def streaming(*, tolerance=None, max_n=512):
171
+ """Declare a block processor: a class (or factory) whose instances have ``process(block)``.
172
+
173
+ ``dp.verify`` then checks that any split into blocks, including empty
174
+ blocks and single samples, gives the same output as one call.
175
+ """
176
+
177
+ def decorate(make):
178
+ make.__dsp_props__ = StreamingSpec(make=make, tolerance=tolerance or FFTTolerance(c=1000.0),
179
+ max_n=max_n)
180
+ return make
181
+
182
+ return decorate
183
+
184
+
185
+ class _StatefulProcessor:
186
+ def __init__(self, func, init_state):
187
+ self.func = func
188
+ self.state = init_state() if callable(init_state) else np.array(init_state, copy=True)
189
+
190
+ def process(self, block):
191
+ y, self.state = self.func(block, self.state)
192
+ return y
193
+
194
+
195
+ def stateful(func, init_state, **kwargs):
196
+ """Wrap ``func(block, state) -> (out, state)`` (e.g. lfilter with zi) as a streaming operator."""
197
+ return streaming(**kwargs)(lambda: _StatefulProcessor(func, init_state))
198
+
199
+
200
+ def transform(inverse=None, *, kind="dft", linear=True, parseval=False, parseval_scale=None,
201
+ real_input_hermitian=False, real_input_only=None, inverse_takes_length=None,
202
+ tolerance=None, max_n=512):
203
+ """Declare a function as a transform; ``dp.verify`` then checks it.
204
+
205
+ ``kind="dft"``: complex DFT (shift and convolution theorems apply).
206
+ ``kind="rdft"``: real-input DFT returning ``N // 2 + 1`` bins, like
207
+ ``numpy.fft.rfft``; its inverse is called as ``inverse(X, N)``.
208
+ ``kind="generic"``: any linear transform (DCT, wavelets, STFT...): only
209
+ linearity, Parseval and inversibility are checked.
210
+ """
211
+ if kind not in KINDS:
212
+ raise ValueError(f"kind must be one of {KINDS}, got {kind!r}")
213
+ if real_input_only is None:
214
+ real_input_only = kind == "rdft"
215
+ if inverse_takes_length is None:
216
+ inverse_takes_length = kind == "rdft"
217
+
218
+ def decorate(func):
219
+ func.__dsp_props__ = TransformSpec(
220
+ func=func,
221
+ inverse=inverse,
222
+ kind=kind,
223
+ linear=linear,
224
+ parseval=parseval,
225
+ parseval_scale=parseval_scale or (lambda n: n),
226
+ real_input_hermitian=real_input_hermitian,
227
+ real_input_only=real_input_only,
228
+ inverse_takes_length=inverse_takes_length,
229
+ tolerance=tolerance or FFTTolerance(),
230
+ max_n=max_n,
231
+ )
232
+ return func
233
+
234
+ return decorate
235
+
236
+
237
+ def _hypothesis_test(rel, spec, max_examples, derandomize):
238
+ @settings(max_examples=max_examples, deadline=None, database=None,
239
+ derandomize=derandomize, suppress_health_check=list(HealthCheck),
240
+ print_blob=False)
241
+ @given(rel.inputs(spec))
242
+ def run(inputs):
243
+ trace["calls"] += 1
244
+ try:
245
+ rel.check(spec, inputs)
246
+ except Exception:
247
+ # Hypothesis replays the shrunk example last, so this ends as the minimal input.
248
+ trace["example"] = inputs
249
+ if trace["first_failure"] is None:
250
+ trace["first_failure"] = trace["calls"]
251
+ raise
252
+
253
+ trace = {"calls": 0, "example": None, "first_failure": None}
254
+ return run, trace
255
+
256
+
257
+ def verify(func, *, max_examples=200, raise_on_failure=True, derandomize=False) -> Report:
258
+ """Run every relation declared for ``func`` and return a report."""
259
+ spec = getattr(func, "__dsp_props__", None)
260
+ if spec is None:
261
+ raise TypeError(f"{func!r} has no dsp-props declaration (use @dp.transform, @dp.lti_filter, @dp.resampler or @dp.streaming)")
262
+ failures, checked, seconds = [], [], {}
263
+ for rel in spec.relations:
264
+ checked.append(rel.name)
265
+ run, trace = _hypothesis_test(rel, spec, max_examples, derandomize)
266
+ t0 = time.perf_counter()
267
+ error = None
268
+ try:
269
+ run()
270
+ except FlakyFailure as exc:
271
+ # the operator gave different results for the same input: hidden state
272
+ error = AssertionError(f"{rel.name}: non-deterministic operator (same input, different result): {exc}")
273
+ except HypothesisException:
274
+ raise # misuse of the framework, not a property violation
275
+ except Exception as exc: # noqa: BLE001 - every failure is reported
276
+ error = exc
277
+ seconds[rel.name] = time.perf_counter() - t0
278
+ if error is not None:
279
+ failures.append(Failure(rel.name, error, trace["example"], trace["first_failure"]))
280
+ report = Report(getattr(func, "__name__", repr(func)), checked, failures,
281
+ kind=type(spec).__name__.replace("Spec", "").lower(),
282
+ examples_per_relation=max_examples, seconds=seconds)
283
+ if failures and raise_on_failure:
284
+ raise PropertyViolation(report)
285
+ return report
dsp_props/cli.py ADDED
@@ -0,0 +1,123 @@
1
+ """Command line: find every declared operator in modules or files and verify it.
2
+
3
+ dsp-props check mypkg.filters # a module
4
+ dsp-props check src/filters.py # a file
5
+ dsp-props check mypkg.filters:my_fft # one operator
6
+ dsp-props check mypkg --json report.json --examples 500
7
+
8
+ Exit status: 0 if every operator passes, 1 if any relation fails, 2 on usage errors.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import importlib
15
+ import importlib.util
16
+ import json
17
+ import pathlib
18
+ import sys
19
+ import time
20
+
21
+ from . import __version__
22
+ from .api import verify
23
+
24
+
25
+ def _load_module(target):
26
+ path = pathlib.Path(target)
27
+ if path.suffix == ".py" and path.exists():
28
+ name = "_dsp_props_target_" + path.stem
29
+ spec = importlib.util.spec_from_file_location(name, path)
30
+ module = importlib.util.module_from_spec(spec)
31
+ sys.path.insert(0, str(path.resolve().parent))
32
+ sys.modules[name] = module
33
+ spec.loader.exec_module(module)
34
+ return module
35
+ return importlib.import_module(target)
36
+
37
+
38
+ def discover(target):
39
+ """``[(qualified name, operator)]`` for a ``module``, ``file.py`` or ``module:attribute`` target."""
40
+ module_part, _, attr = target.partition(":")
41
+ module = _load_module(module_part)
42
+ label = module_part if module_part.endswith(".py") else module.__name__
43
+ if attr:
44
+ obj = module
45
+ for part in attr.split("."):
46
+ obj = getattr(obj, part)
47
+ if not hasattr(obj, "__dsp_props__"):
48
+ raise SystemExit(f"dsp-props: {target} has no dsp-props declaration")
49
+ return [(f"{label}:{attr}", obj)]
50
+ found, seen = [], set()
51
+ for name in sorted(vars(module)):
52
+ obj = vars(module)[name]
53
+ if name.startswith("_") or id(obj) in seen:
54
+ continue
55
+ if getattr(obj, "__dsp_props__", None) is not None:
56
+ seen.add(id(obj))
57
+ found.append((f"{label}:{name}", obj))
58
+ elif isinstance(obj, dict): # registries such as {"name": operator}
59
+ for key, value in obj.items():
60
+ if getattr(value, "__dsp_props__", None) is not None and id(value) not in seen:
61
+ seen.add(id(value))
62
+ found.append((f"{label}:{name}[{key!r}]", value))
63
+ return found
64
+
65
+
66
+ def _check(args):
67
+ sys.path.insert(0, ".")
68
+ targets = []
69
+ for t in args.targets:
70
+ try:
71
+ targets += discover(t)
72
+ except (ImportError, AttributeError, FileNotFoundError) as exc:
73
+ print(f"dsp-props: cannot load {t}: {exc}", file=sys.stderr)
74
+ return 2
75
+ if not targets:
76
+ print("dsp-props: no declared operator found (use @dp.transform, @dp.lti_filter, ...)", file=sys.stderr)
77
+ return 2
78
+
79
+ reports, failed = [], 0
80
+ for name, op in targets:
81
+ t0 = time.perf_counter()
82
+ report = verify(op, max_examples=args.examples, raise_on_failure=False, derandomize=args.derandomize)
83
+ dt = time.perf_counter() - t0
84
+ failed += not report.ok
85
+ reports.append((name, report))
86
+ print(f"{'ok ' if report.ok else 'FAIL'} {name} ({len(report.checked)} relations, {dt:.1f}s)")
87
+ if not report.ok or args.verbose:
88
+ print("\n".join(" " + line for line in str(report).splitlines()[1:]))
89
+
90
+ print(f"\n{len(targets) - failed}/{len(targets)} operators pass")
91
+ if args.json:
92
+ data = {"dsp_props": __version__, "examples": args.examples,
93
+ "operators": [{**r.to_dict(), "target": n} for n, r in reports]}
94
+ pathlib.Path(args.json).write_text(json.dumps(data, indent=2))
95
+ if args.markdown:
96
+ pathlib.Path(args.markdown).write_text("\n\n".join(r.to_markdown() for _, r in reports) + "\n")
97
+ if args.html:
98
+ from .report import render_html
99
+
100
+ for n, r in reports:
101
+ r.func_name = n
102
+ pathlib.Path(args.html).write_text(render_html([r for _, r in reports]))
103
+ return 1 if failed else 0
104
+
105
+
106
+ def main(argv=None):
107
+ parser = argparse.ArgumentParser(prog="dsp-props", description="Property-based checks for DSP code.")
108
+ parser.add_argument("--version", action="version", version=f"dsp-props {__version__}")
109
+ sub = parser.add_subparsers(dest="command", required=True)
110
+ check = sub.add_parser("check", help="verify every declared operator in modules or files")
111
+ check.add_argument("targets", nargs="+", help="module, file.py, or module:attribute")
112
+ check.add_argument("--examples", type=int, default=200, help="inputs per relation (default 200)")
113
+ check.add_argument("--derandomize", action="store_true", help="reproducible inputs (for CI)")
114
+ check.add_argument("--json", help="write the full report as JSON")
115
+ check.add_argument("--markdown", help="write the report as Markdown")
116
+ check.add_argument("--html", help="write the report as a standalone HTML page")
117
+ check.add_argument("-v", "--verbose", action="store_true", help="list relations of passing operators too")
118
+ args = parser.parse_args(argv)
119
+ return _check(args)
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main())
@@ -0,0 +1,19 @@
1
+ from .calibration import (
2
+ CalibratedTolerance,
3
+ CalibrationError,
4
+ calibrate,
5
+ longdouble_dft,
6
+ relative_error,
7
+ )
8
+ from .iir import IIRTolerance, longdouble_lfilter, recursion_gain
9
+ from .tolerance import ToleranceModel, FFTTolerance, ToleranceError, assert_close, norm
10
+
11
+ __all__ = [
12
+ "IIRTolerance",
13
+ "longdouble_lfilter",
14
+ "recursion_gain",
15
+ "CalibratedTolerance",
16
+ "CalibrationError",
17
+ "calibrate",
18
+ "longdouble_dft",
19
+ "relative_error","ToleranceModel", "FFTTolerance", "ToleranceError", "assert_close", "norm"]
@@ -0,0 +1,116 @@
1
+ """Empirical error models: calibrate a tolerance against extended precision.
2
+
3
+ A fixed formula such as c * eps * log2(N) cannot describe every
4
+ implementation: an FFT library may switch algorithm with the factorisation
5
+ of N (mixed radix, naive small DFTs, Bluestein), and its error then varies by
6
+ two orders of magnitude between neighbouring lengths. ``calibrate`` measures
7
+ the actual relative error of an operator against an extended-precision
8
+ reference for each length and turns it into a tolerance model.
9
+
10
+ Calibration doubles as a sanity check: an error far above what rounding can
11
+ explain (``max_plausible``) means the operator is wrong, not imprecise, and
12
+ calibration refuses to absorb it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+
19
+ import numpy as np
20
+
21
+ from .tolerance import FFTTolerance, ToleranceError, ToleranceModel, norm
22
+
23
+
24
+ def longdouble_dft(x):
25
+ """Extended-precision DFT reference (NumPy computes FFTs in long double)."""
26
+ return np.fft.fft(np.asarray(x).astype(np.clongdouble))
27
+
28
+
29
+ def _eps(dtype):
30
+ return float(np.finfo(np.result_type(dtype, np.float32)).eps)
31
+
32
+
33
+ def relative_error(op, reference, x):
34
+ """||op(x) - reference(x)|| / (eps * ||reference(x)||), eps of op's output dtype."""
35
+ y = np.asarray(op(x))
36
+ ref = np.asarray(reference(x))
37
+ if y.shape != ref.shape:
38
+ raise ToleranceError(f"shape {y.shape} != reference shape {ref.shape}")
39
+ ref_norm = float(norm(ref.astype(np.complex128)))
40
+ if ref_norm == 0.0:
41
+ return 0.0 if norm(y) == 0 else np.inf
42
+ diff = (y.astype(np.clongdouble) - ref).astype(np.complex128)
43
+ return float(norm(diff)) / (_eps(y.dtype) * ref_norm)
44
+
45
+
46
+ @dataclass
47
+ class CalibratedTolerance(ToleranceModel):
48
+ """Analytic bound, raised where measurements show the implementation is worse.
49
+
50
+ bound(N) = max(base.bound(N), margin * chained * eps * ratio(N) * ||y||)
51
+
52
+ ``ratios[N]`` is the worst measured error for length N, in units of eps.
53
+ Unmeasured lengths fall back to the worst ratio over all measured lengths,
54
+ which keeps the model conservative. The analytic ``base`` model covers
55
+ the inputs calibration did not try.
56
+ """
57
+
58
+ ratios: dict = field(default_factory=dict)
59
+ margin: float = 8.0
60
+ chained: int = 2 # relations chain a few operations (e.g. inverse(f(x)))
61
+ base: ToleranceModel = field(default_factory=FFTTolerance)
62
+
63
+ @property
64
+ def worst(self):
65
+ return max(self.ratios.values(), default=1.0)
66
+
67
+ def ratio(self, n):
68
+ return max(self.ratios.get(n, self.worst), 1.0)
69
+
70
+ def bound(self, n, dtype, ref_norm):
71
+ measured = self.margin * self.chained * _eps(dtype) * self.ratio(n) * ref_norm
72
+ return max(self.base.bound(n, dtype, ref_norm), measured)
73
+
74
+ def summary(self, top=5):
75
+ worst = sorted(self.ratios.items(), key=lambda kv: -kv[1])[:top]
76
+ lines = [f"calibrated on {len(self.ratios)} lengths, worst ratio {self.worst:.1f} eps"]
77
+ lines += [f" N={n:<6} {r:8.1f} eps" for n, r in worst]
78
+ return "\n".join(lines)
79
+
80
+
81
+ class CalibrationError(AssertionError):
82
+ pass
83
+
84
+
85
+ def calibrate(op, reference=longdouble_dft, lengths=range(1, 257), trials=6, *,
86
+ margin=8.0, max_plausible=1e6, seed=0) -> CalibratedTolerance:
87
+ """Measure ``op`` against ``reference`` on random signals of each length.
88
+
89
+ Raises ``CalibrationError`` when the error is too large to be rounding
90
+ (``max_plausible`` eps): calibrating a wrong operator would hide its bug.
91
+ """
92
+ rng = np.random.default_rng(seed)
93
+ ratios = {}
94
+ for n in lengths:
95
+ worst = 0.0
96
+ for x in _probe_signals(n, trials, rng):
97
+ worst = max(worst, relative_error(op, reference, x))
98
+ if not worst <= max_plausible:
99
+ raise CalibrationError(
100
+ f"error of {worst:.3g} eps at N={n}: the operator disagrees with the reference "
101
+ "beyond any plausible rounding error"
102
+ )
103
+ ratios[n] = worst
104
+ return CalibratedTolerance(ratios=ratios, margin=margin)
105
+
106
+
107
+ def _probe_signals(n, trials, rng):
108
+ """Gaussian noise plus the structured signals that stress FFT rounding."""
109
+ t = np.arange(n)
110
+ for _ in range(trials):
111
+ yield rng.standard_normal(n)
112
+ x = np.zeros(n)
113
+ x[rng.integers(n)] = 1.0
114
+ yield x
115
+ yield np.cos(2 * np.pi * rng.uniform(0, 0.5) * t + rng.uniform(-np.pi, np.pi))
116
+ yield np.cos(np.pi * t) + 1e-6 * rng.standard_normal(n) # near Nyquist, small detail