chebax 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.
Files changed (41) hide show
  1. chebax/__init__.py +38 -0
  2. chebax/_src/__init__.py +0 -0
  3. chebax/_src/algorithms.py +74 -0
  4. chebax/_src/generate.py +177 -0
  5. chebax/_src/pytree.py +59 -0
  6. chebax/_src/recipes/__init__.py +0 -0
  7. chebax/_src/recipes/_common.py +95 -0
  8. chebax/_src/recipes/_gen_common.py +121 -0
  9. chebax/_src/recipes/besseli.py +209 -0
  10. chebax/_src/recipes/besseli_gen.py +84 -0
  11. chebax/_src/recipes/besseli_table.py +138 -0
  12. chebax/_src/recipes/besselj.py +176 -0
  13. chebax/_src/recipes/besselj_gen.py +146 -0
  14. chebax/_src/recipes/besselj_table.py +39 -0
  15. chebax/_src/recipes/besselj_table_ext.py +87 -0
  16. chebax/_src/recipes/besselk.py +184 -0
  17. chebax/_src/recipes/besselk_gen.py +107 -0
  18. chebax/_src/recipes/besselk_table.py +208 -0
  19. chebax/_src/recipes/bessely.py +185 -0
  20. chebax/_src/recipes/bessely_gen.py +103 -0
  21. chebax/_src/recipes/bessely_table.py +284 -0
  22. chebax/_src/recipes/betainc.py +128 -0
  23. chebax/_src/recipes/betainc_gen.py +78 -0
  24. chebax/_src/recipes/betainc_table.py +1791 -0
  25. chebax/_src/recipes/erf_family.py +63 -0
  26. chebax/_src/recipes/erf_gen.py +69 -0
  27. chebax/_src/recipes/erf_table.py +51 -0
  28. chebax/_src/recipes/lambertw.py +106 -0
  29. chebax/_src/recipes/quantiles.py +339 -0
  30. chebax/_src/recipes/spherical.py +96 -0
  31. chebax/_src/recipes/vonmises.py +113 -0
  32. chebax/_src/recipes/vonmises_gen.py +68 -0
  33. chebax/_src/recipes/vonmises_table.py +127 -0
  34. chebax/_src/series.py +223 -0
  35. chebax/bake/__init__.py +178 -0
  36. chebax/bake/_jaxpr_emit.py +264 -0
  37. chebax-0.1.0.dist-info/METADATA +94 -0
  38. chebax-0.1.0.dist-info/RECORD +41 -0
  39. chebax-0.1.0.dist-info/WHEEL +5 -0
  40. chebax-0.1.0.dist-info/licenses/LICENSE +28 -0
  41. chebax-0.1.0.dist-info/top_level.txt +1 -0
chebax/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """chebax: differentiable Chebyshev approximants for special functions.
2
+
3
+ Full float64 accuracy needs jax's x64 mode (jax.config.update("jax_enable_x64", True)).
4
+ """
5
+
6
+ from chebax._src.generate import fit
7
+ from chebax._src.recipes.besseli import besseli, besseli_dnu, besseli_fn
8
+ from chebax._src.recipes.erf_family import dawsn, erfcx
9
+ from chebax._src.recipes.lambertw import lambertw
10
+ from chebax._src.recipes.spherical import spherical_jn, spherical_yn
11
+ from chebax._src.recipes.besselj import besselj, besselj_dnu
12
+ from chebax._src.recipes.besselk import (besselk, besselk_dnu, besselk_fn,
13
+ log_besselk_fn)
14
+ from chebax._src.recipes.bessely import bessely, bessely_dnu
15
+ from chebax._src.recipes.betainc import betainc, betainc_fn
16
+ from chebax._src.recipes.quantiles import betaincinv, gammaincinv, stdtr, stdtrit
17
+ from chebax._src.recipes.vonmises import vonmises_cdf, vonmises_icdf
18
+ from chebax._src.series import ChebSeries, PiecewiseCheb
19
+
20
+ # single-sourced from pyproject.toml via package metadata
21
+ try:
22
+ from importlib.metadata import version as _pkg_version
23
+ __version__ = _pkg_version("chebax")
24
+ except Exception: # not installed (e.g. running from a source checkout)
25
+ __version__ = "0+unknown"
26
+ __all__ = ["ChebSeries", "PiecewiseCheb", "besseli", "besseli_dnu", "besseli_fn",
27
+ "besselj", "besselj_dnu", "besselk", "besselk_dnu", "besselk_fn",
28
+ "log_besselk_fn",
29
+ "bessely", "bessely_dnu", "betainc", "betainc_fn", "betaincinv",
30
+ "dawsn", "erfcx", "fit", "gammaincinv", "lambertw", "spherical_jn",
31
+ "spherical_yn", "stdtr", "stdtrit", "vonmises_cdf", "vonmises_icdf",
32
+ "__version__"]
33
+
34
+ # submodule re-export so `import chebax; chebax.bake` works as documented
35
+ # (bake reads __version__, so this import must stay below its definition)
36
+ from chebax import bake # noqa: E402,F401
37
+
38
+ __all__.append("bake")
File without changes
@@ -0,0 +1,74 @@
1
+ """The textbook algorithms, in plain numpy, on [-1, 1].
2
+
3
+ Coefficient convention is numpy.polynomial's: plain c[0], lowest order first,
4
+ p(t) = sum(c[k] * T_k(t)). These are the reference implementations; the jax
5
+ runtime in series.py must agree with them (tested against numpy.polynomial).
6
+ Domain handling lives in the series classes, not here.
7
+ """
8
+
9
+ import numpy as np
10
+
11
+
12
+ def chebpts(n):
13
+ """First-kind Chebyshev points cos(pi*(2i+1)/(2n)), decreasing from ~1 to ~-1."""
14
+ i = np.arange(n)
15
+ return np.cos(np.pi * (2 * i + 1) / (2 * n))
16
+
17
+
18
+ def chebfit_dct(samples):
19
+ """Interpolation coefficients from samples taken at chebpts(len(samples)).
20
+
21
+ Discrete orthogonality of cosines on the first-kind points; O(n^2), which
22
+ is fine at generator degrees.
23
+ """
24
+ n = len(samples)
25
+ i = np.arange(n)
26
+ j = np.arange(n)[:, None]
27
+ # reduce the angle with exact integer arithmetic; the raw product reaches
28
+ # hundreds of radians at large n and costs ~angle*eps of cosine accuracy
29
+ weights = np.cos(np.pi * ((j * (2 * i + 1)) % (4 * n)) / (2 * n))
30
+ c = (2.0 / n) * (weights @ np.asarray(samples, dtype=float))
31
+ c[0] /= 2
32
+ return c
33
+
34
+
35
+ def chebval(t, c):
36
+ """Clenshaw evaluation of a plain Chebyshev series."""
37
+ t = np.asarray(t, dtype=float)
38
+ b1 = np.zeros_like(t)
39
+ b2 = np.zeros_like(t)
40
+ for ck in np.asarray(c, dtype=float)[:0:-1]:
41
+ b1, b2 = 2 * t * b1 - b2 + ck, b1
42
+ return t * b1 - b2 + c[0]
43
+
44
+
45
+ def chebder(c):
46
+ """Coefficients of the derivative (numpy.polynomial's exact recurrence)."""
47
+ c = np.array(c, dtype=float)
48
+ n = c.size - 1
49
+ if n == 0:
50
+ return np.zeros(1)
51
+ der = np.zeros(n)
52
+ for j in range(n, 2, -1):
53
+ der[j - 1] = 2 * j * c[j]
54
+ c[j - 2] += j * c[j] / (j - 2)
55
+ if n > 1:
56
+ der[1] = 4 * c[2]
57
+ der[0] = c[1]
58
+ return der
59
+
60
+
61
+ def chebint(c):
62
+ """Coefficients of the antiderivative that vanishes at t=0
63
+ (numpy.polynomial's k=0, lbnd=0 convention)."""
64
+ c = np.asarray(c, dtype=float)
65
+ n = c.size
66
+ out = np.zeros(n + 1)
67
+ out[1] = c[0]
68
+ if n > 1:
69
+ out[2] = c[1] / 4
70
+ for j in range(2, n):
71
+ out[j + 1] = c[j] / (2 * (j + 1))
72
+ out[j - 1] -= c[j] / (2 * (j - 1))
73
+ out[0] -= chebval(0.0, out)
74
+ return out
@@ -0,0 +1,177 @@
1
+ """Build-time fitting: sample at Chebyshev points, DCT to coefficients.
2
+
3
+ The float64 path uses numpy. Passing dps switches sampling and the DCT to
4
+ mpmath at that precision; mpmath is imported inside that branch only (the
5
+ runtime-imports rule in CLAUDE.md).
6
+
7
+ Adaptive fitting doubles the node count until the coefficient tail sits at
8
+ the fit's own noise floor, then truncates there. A converged-looking tail
9
+ on one grid is never trusted by itself: on n first-kind points T_{2n-k}
10
+ samples exactly as -T_k, so an aliased fit can look converged (T_34 on 17
11
+ nodes reproduces -T_0 to the last bit). Every candidate is therefore
12
+ validated against fresh samples at off-grid points before it is returned.
13
+
14
+ The noise floor is estimated from the trailing quarter of the candidate's
15
+ own coefficients rather than assumed: float64 sampling noise scales with
16
+ the function's derivative (measured: ~1e-16 relative for exp, ~2.5e-15 for
17
+ sin(50x)), so a fixed tolerance either fails to converge on oscillatory
18
+ functions or keeps hundreds of noise coefficients.
19
+ """
20
+
21
+ import numpy as np
22
+
23
+ from chebax._src import algorithms
24
+ from chebax._src.series import ChebSeries, PiecewiseCheb
25
+
26
+ # ~2*eps: the practical floor of the float64 build path. Use dps= to go below.
27
+ _DEFAULT_TOL = 5e-16
28
+
29
+
30
+ def fit(f, domain=(-1.0, 1.0), *, deg=None, tol=None, breaks=None, dps=None, max_deg=256):
31
+ """Chebyshev interpolant of f, as a ChebSeries (or PiecewiseCheb if breaks given).
32
+
33
+ f: callable on the domain. Vectorized over numpy arrays if possible;
34
+ with dps set it must accept mpmath floats instead.
35
+ domain: (a, b). Ignored when breaks is given.
36
+ deg: fixed degree; otherwise the degree adapts until tol is met.
37
+ tol: target for the relative coefficient tail (default 5e-16). The
38
+ effective chop level is max(tol, the fit's measured noise floor);
39
+ float64 sampling noise can sit above tol for functions with
40
+ large derivatives. Build with dps to control the floor.
41
+ breaks: strictly increasing knots, endpoints included; one series per
42
+ segment, each fit independently, padded to a common degree.
43
+ dps: mpmath working precision for sampling and the DCT. Derivatives of
44
+ a float64-built fit sit at a ~deg^2 * eps floor (differentiation
45
+ amplifies sample noise); build with dps when that matters.
46
+ max_deg: hard cap on the returned degree. Reaching it without a validated
47
+ converged fit raises: f is not smooth enough on the interval
48
+ (split the domain) or genuinely needs a higher degree.
49
+ """
50
+ if tol is None:
51
+ tol = _DEFAULT_TOL
52
+ if breaks is not None:
53
+ br = [float(t) for t in breaks]
54
+ cs = [_fit_interval(f, br[i], br[i + 1], deg, tol, max_deg, dps)
55
+ for i in range(len(br) - 1)]
56
+ n = max(c.size for c in cs)
57
+ coef = np.zeros((len(cs), n))
58
+ for i, c in enumerate(cs):
59
+ coef[i, : c.size] = c
60
+ return PiecewiseCheb(coef, br)
61
+ a, b = float(domain[0]), float(domain[1])
62
+ return ChebSeries(_fit_interval(f, a, b, deg, tol, max_deg, dps), (a, b))
63
+
64
+
65
+ def _fit_interval(f, a, b, deg, tol, max_deg, dps):
66
+ dct, sample_unit = (_dct_mp(f, a, b, dps) if dps is not None
67
+ else _dct_np(f, a, b))
68
+ if deg is not None:
69
+ return dct(deg + 1)
70
+ return _adaptive(dct, sample_unit, tol, max_deg, (a, b))
71
+
72
+
73
+ def _chop(c, tol):
74
+ """Truncate at the noise level; returns (candidate, absolute level).
75
+
76
+ The level is max(tol, noise floor), where the noise floor is read off
77
+ the last eighth of the coefficients: for a still-decaying tail those
78
+ sit below tol and the requested tol governs (measured: exp's last
79
+ coefficients decay through 1e-16); for a converged fit they are the
80
+ sampling-noise plateau (sin(50x): ~5e-16 relative at n=129, edge-
81
+ singular sampling can push higher) and doubling the largest of them
82
+ clears the plateau's own scatter."""
83
+ scale = np.max(np.abs(c))
84
+ noise = 2.0 * float(np.max(np.abs(c[-max(2, c.size // 16):])))
85
+ level = max(tol * scale, noise)
86
+ keep = np.nonzero(np.abs(c) > level)[0]
87
+ return (c[: keep[-1] + 1] if keep.size else c[:1]), level
88
+
89
+
90
+ def _validated(cand, level, sample_unit):
91
+ """Check the candidate against fresh samples at off-grid points.
92
+
93
+ chebpts(m) for m != n shares no points with the fitting grid, so an
94
+ aliased fit cannot pass: its off-grid error is O(scale), ~1e15x the
95
+ chop level, while an honest fit's is ~1x. The 1000x margin makes this
96
+ a gross-error detector, not the accuracy contract.
97
+ """
98
+ m = cand.size + 11
99
+ t = algorithms.chebpts(m)
100
+ y = sample_unit(t)
101
+ err = np.max(np.abs(algorithms.chebval(t, cand) - y))
102
+ allow = max(1000.0 * level,
103
+ 100.0 * np.finfo(float).eps * float(np.sum(np.abs(cand))))
104
+ return err <= allow
105
+
106
+
107
+ def _adaptive(dct, sample_unit, tol, max_deg, interval):
108
+ n = min(17, max_deg + 1)
109
+ prev_rel = np.inf
110
+ while True:
111
+ c = dct(n)
112
+ scale = float(np.max(np.abs(c)))
113
+ if scale == 0.0:
114
+ return np.zeros(1)
115
+ cand, level = _chop(c, tol)
116
+ rel = level / scale
117
+ # The tail has settled if the chop level reached the requested tol,
118
+ # or if it stopped improving under node doubling while being far
119
+ # below the function scale: that is the sampling-noise plateau, and
120
+ # more nodes cannot beat it. A small-but-still-shrinking tail (a
121
+ # segment one doubling short of converged) must escalate, not chop.
122
+ settled = level <= 2.0 * tol * scale or (rel <= 1e-10 and rel >= prev_rel / 4.0)
123
+ if settled and cand.size <= c.size - max(2, c.size // 8):
124
+ if _validated(cand, level, sample_unit):
125
+ return cand
126
+ prev_rel = rel
127
+ if n >= max_deg + 1:
128
+ raise ValueError(
129
+ f"no validated convergence to tol={tol:g} by degree {max_deg} on "
130
+ f"{interval}; f may not be smooth there (split the domain), may "
131
+ "need a higher max_deg, or float64 sampling noise may sit above "
132
+ "tol (build with dps)")
133
+ n = min(2 * n - 1, max_deg + 1)
134
+
135
+
136
+ def _dct_np(f, a, b):
137
+ def sample_unit(t):
138
+ xk = a + (b - a) * (t + 1) / 2
139
+ return _sample(f, xk)
140
+
141
+ def dct(n):
142
+ return algorithms.chebfit_dct(sample_unit(algorithms.chebpts(n)))
143
+ return dct, sample_unit
144
+
145
+
146
+ def _sample(f, xk):
147
+ # scalar-only callables (wrapped special functions) are common; fall back.
148
+ try:
149
+ y = np.asarray(f(xk), dtype=float)
150
+ if y.shape == xk.shape:
151
+ return y
152
+ except (TypeError, ValueError):
153
+ pass
154
+ return np.array([float(f(x)) for x in xk])
155
+
156
+
157
+ def _dct_mp(f, a, b, dps):
158
+ import mpmath as mp
159
+
160
+ def sample_unit(t):
161
+ with mp.workdps(dps):
162
+ mid = (mp.mpf(b) + mp.mpf(a)) / 2
163
+ half = (mp.mpf(b) - mp.mpf(a)) / 2
164
+ return np.array([float(f(mid + half * mp.mpf(float(tk)))) for tk in t])
165
+
166
+ def dct(n):
167
+ with mp.workdps(dps):
168
+ mid = (mp.mpf(b) + mp.mpf(a)) / 2
169
+ half = (mp.mpf(b) - mp.mpf(a)) / 2
170
+ fk = [f(mid + half * mp.cos(mp.pi * (2 * i + 1) / (2 * n))) for i in range(n)]
171
+ c = []
172
+ for j in range(n):
173
+ s = mp.fsum(fk[i] * mp.cos(mp.pi * j * (2 * i + 1) / (2 * n)) for i in range(n))
174
+ c.append(2 * s / n)
175
+ c[0] /= 2
176
+ return np.array([float(x) for x in c])
177
+ return dct, sample_unit
chebax/_src/pytree.py ADDED
@@ -0,0 +1,59 @@
1
+ """Base class for recipe callables, retiring per-class pytree boilerplate.
2
+
3
+ A recipe instance is a pytree whose children are ChebSeries (listed in
4
+ _series_fields) and whose auxiliary data are static scalars (listed in
5
+ _static_fields). Constructors take statics first, series after, in field
6
+ order; _post_init derives cached constants (called by both __init__ and
7
+ tree_unflatten). astype casts every series; __repr__ shows the statics and
8
+ the coefficient dtype.
9
+
10
+ Subclasses register themselves with the standard
11
+ @jax.tree_util.register_pytree_node_class decorator at the class site.
12
+ """
13
+
14
+ class Recipe:
15
+ _static_fields = ()
16
+ _series_fields = ()
17
+
18
+ def __init__(self, *args):
19
+ names = self._static_fields + self._series_fields
20
+ if len(args) != len(names):
21
+ raise TypeError(f"{type(self).__name__} expects {names}, got {len(args)} args")
22
+ for name, val in zip(names, args):
23
+ setattr(self, name, val)
24
+ self._post_init()
25
+
26
+ def _post_init(self):
27
+ pass
28
+
29
+ def astype(self, dtype):
30
+ return type(self)(*[getattr(self, f) for f in self._static_fields],
31
+ *[getattr(self, f).astype(dtype) for f in self._series_fields])
32
+
33
+ def __repr__(self):
34
+ statics = ", ".join(f"{f}={getattr(self, f)!r}" for f in self._static_fields)
35
+ dtype = "-"
36
+ # first series child with a coefficient array; children may be
37
+ # nested recipes (spherical wraps a cylindrical instance)
38
+ node = self
39
+ while getattr(node, "_series_fields", ()):
40
+ node = getattr(node, node._series_fields[0])
41
+ coef = getattr(node, "coef", None)
42
+ if coef is not None:
43
+ dtype = coef.dtype
44
+ break
45
+ return f"{type(self).__name__}({statics}, dtype={dtype})"
46
+
47
+ def tree_flatten(self):
48
+ return (tuple(getattr(self, f) for f in self._series_fields),
49
+ tuple(getattr(self, f) for f in self._static_fields))
50
+
51
+ @classmethod
52
+ def tree_unflatten(cls, aux, children):
53
+ obj = object.__new__(cls)
54
+ for name, val in zip(cls._static_fields, aux):
55
+ setattr(obj, name, val)
56
+ for name, val in zip(cls._series_fields, children):
57
+ setattr(obj, name, val)
58
+ obj._post_init()
59
+ return obj
File without changes
@@ -0,0 +1,95 @@
1
+ """Shared runtime machinery for recipes: parameter-table reconstruction,
2
+ domain checks, the safeguarded root solver, and a float64 digamma.
3
+
4
+ Reconstruction convention (matches _gen_common's emitted axis note): for a
5
+ 2-D table, TABLE[k, j] is the j-th parameter-direction coefficient of the
6
+ k-th argument-direction coefficient, the parameter mapped affinely from
7
+ [lo, hi] onto [-1, 1]. `param_coefs` is the eager numpy path used by the
8
+ cached factories; `traced_coefs` is the jax path used by the *_fn traced
9
+ variants (differentiable in the parameter through the series custom_jvp).
10
+ """
11
+
12
+ import math
13
+
14
+ import jax
15
+ import jax.numpy as jnp
16
+ import numpy as np
17
+
18
+ from chebax._src import algorithms
19
+ from chebax._src.series import _chebval
20
+
21
+
22
+ def canon_tag():
23
+ """Cache-key tag for the canonical float dtype: factory instances built
24
+ under different x64 settings must not share a cache slot."""
25
+ return str(jnp.empty(()).dtype)
26
+
27
+
28
+ def canon_float(x):
29
+ """Promote to the canonical float dtype (float64 under x64).
30
+
31
+ Solvers mix inputs with reconstructed float64 tables inside a fori_loop
32
+ carry; an explicit float32 input under x64 raised a carry dtype
33
+ mismatch instead of computing. One deliberate dtype for everything."""
34
+ return jnp.asarray(x, dtype=jnp.empty(()).dtype)
35
+
36
+
37
+ def param_coefs(table, lo, hi, p):
38
+ """Coefficient vector at parameter p: Clenshaw across table rows. numpy."""
39
+ t = 2.0 * (p - lo) / (hi - lo) - 1.0
40
+ return np.array([algorithms.chebval(t, row) for row in table])
41
+
42
+
43
+ def param_coefs_der(table, lo, hi, p):
44
+ """d/dp of the coefficient vector, via chebder along the parameter axis."""
45
+ t = 2.0 * (p - lo) / (hi - lo) - 1.0
46
+ d = np.array([algorithms.chebval(t, algorithms.chebder(row)) for row in table])
47
+ return d * (2.0 / (hi - lo))
48
+
49
+
50
+ def traced_coefs(table, lo, hi, p):
51
+ """jax version of param_coefs: p may be a tracer (uniform per call)."""
52
+ t = 2.0 * (p - lo) / (hi - lo) - 1.0
53
+ return jax.vmap(lambda row: _chebval(t, row, (-1.0, 1.0)))(jnp.asarray(table))
54
+
55
+
56
+ def check_range(owner, name, v, lo, hi):
57
+ v = float(v)
58
+ if not lo <= v <= hi:
59
+ raise ValueError(f"{owner} table covers {name} in [{lo}, {hi}], got {v}")
60
+ return v
61
+
62
+
63
+ def newton_bisect(f_and_df, x0, lo0, hi0, iters):
64
+ """Fixed-count safeguarded Newton for increasing f: keep [lo, hi]
65
+ bracketing the root, fall back to the midpoint (or doubling while hi is
66
+ inf) when the Newton step leaves the bracket. The bounds test is
67
+ INCLUSIVE: a Newton step that stalls exactly at the current iterate
68
+ (converged) must be accepted, or a one-sided approach whose far bound
69
+ never tightened catapults a converged iterate to the midpoint of the
70
+ half-open bracket (measured, the quantile-toolkit bug)."""
71
+
72
+ def step(_, carry):
73
+ x, lo, hi = carry
74
+ f, df = f_and_df(x)
75
+ lo = jnp.where(f < 0, x, lo)
76
+ hi = jnp.where(f < 0, hi, x)
77
+ xn = x - f / df
78
+ mid = jnp.where(jnp.isfinite(hi), 0.5 * (lo + hi), 2.0 * x + 1.0)
79
+ ok = (xn >= lo) & (xn <= hi) & jnp.isfinite(xn)
80
+ return jnp.where(ok, xn, mid), lo, hi
81
+
82
+ x, _, _ = jax.lax.fori_loop(0, iters, step, (x0, lo0, hi0))
83
+ return x
84
+
85
+
86
+ def digamma64(z):
87
+ """float64 psi(z) for z >= 1: recurrence up to z >= 15, then the
88
+ Bernoulli series through z^-12 (A&S 6.3.5 / 6.3.18). Truncation < 1e-16."""
89
+ acc = 0.0
90
+ while z < 15.0:
91
+ acc -= 1.0 / z
92
+ z += 1.0
93
+ zi = 1.0 / (z * z)
94
+ tail = 1 / 12 - zi * (1 / 120 - zi * (1 / 252 - zi * (1 / 240 - zi * (1 / 132 - zi * (691 / 32760)))))
95
+ return acc + math.log(z) - 0.5 / z - zi * tail
@@ -0,0 +1,121 @@
1
+ """Shared build-time machinery for all recipe generators.
2
+
3
+ Every generator uses the same two-stage (or three-stage) construction:
4
+ Chebyshev-Gauss nodes, a plain DCT to coefficients, a parameter-direction
5
+ fit per coefficient, and a writer that emits the baked table as a diffable
6
+ python module. All fit arithmetic stays in mpmath (the M1 floor rule:
7
+ rounding between stages puts eps-level sample noise under later fits).
8
+
9
+ Table axis convention, written into every emitted header: for a 2-D table,
10
+ TABLE[k, j] is the j-th parameter-direction coefficient of the k-th
11
+ argument-direction coefficient; parameters map affinely onto [-1, 1] from
12
+ the domain recorded in META. Emission is deterministic (repr of python
13
+ floats round-trips; no timestamps), so identical inputs give identical
14
+ bytes regardless of numpy version.
15
+ """
16
+
17
+ import hashlib
18
+ import importlib
19
+ import pathlib
20
+
21
+ import numpy as np
22
+
23
+ DPS = 40 # mpmath working precision for every generator
24
+
25
+
26
+ def table_path(out_dir, generator_file, name):
27
+ """Output location for an emitted table: the package directory by
28
+ default, or out_dir (the regeneration tests emit to a temp dir and
29
+ compare full files against the packaged ones)."""
30
+ base = pathlib.Path(out_dir) if out_dir is not None else pathlib.Path(generator_file).parent
31
+ return base / name
32
+
33
+
34
+ def _generator_sha(generator_module):
35
+ """sha256 over the generator's own source plus this shared module: the
36
+ emitted file records exactly the code that built it, so any edit to
37
+ either shows up as a hash mismatch until the table is regenerated."""
38
+ h = hashlib.sha256()
39
+ for mod_name in (generator_module, __name__):
40
+ m = importlib.import_module(mod_name)
41
+ h.update(pathlib.Path(m.__file__).read_bytes())
42
+ return h.hexdigest()[:16]
43
+
44
+
45
+ def nodes(mp, n):
46
+ """First-kind Chebyshev points, the sampling nodes of every fit."""
47
+ return [mp.cos(mp.pi * (2 * i + 1) / (2 * n)) for i in range(n)]
48
+
49
+
50
+ def dct(mp, samples):
51
+ """Plain Chebyshev coefficients from samples at nodes(mp, len(samples))."""
52
+ n = len(samples)
53
+ c = []
54
+ for j in range(n):
55
+ s = mp.fsum(samples[i] * mp.cos(mp.pi * j * (2 * i + 1) / (2 * n)) for i in range(n))
56
+ c.append(2 * s / n)
57
+ c[0] /= 2
58
+ return c
59
+
60
+
61
+ def param_fit(mp, rows):
62
+ """Second stage: fit each coefficient across the parameter nodes, in mp."""
63
+ nc = len(rows[0])
64
+ return [dct(mp, [rows[i][k] for i in range(len(rows))]) for k in range(nc)]
65
+
66
+
67
+ def to_f64(rows):
68
+ return np.array([[float(x) for x in row] for row in rows])
69
+
70
+
71
+ def _fmt_array(name, arr):
72
+ arr = np.asarray(arr)
73
+ lines = []
74
+ if arr.ndim == 1:
75
+ lines.append(f"{name} = np.array([")
76
+ for i in range(0, arr.size, 4):
77
+ lines.append(" " + ", ".join(repr(float(v)) for v in arr[i:i + 4]) + ",")
78
+ lines.append("])")
79
+ elif arr.ndim == 2:
80
+ lines.append(f"{name} = np.array([")
81
+ for row in arr:
82
+ lines.append(" [" + ", ".join(repr(float(v)) for v in row) + "],")
83
+ lines.append("])")
84
+ elif arr.ndim == 3:
85
+ lines.append(f"{name} = np.array([")
86
+ for block in arr:
87
+ lines.append(" [")
88
+ for row in block:
89
+ lines.append(" [" + ", ".join(repr(float(v)) for v in row) + "],")
90
+ lines.append(" ],")
91
+ lines.append("])")
92
+ else:
93
+ raise ValueError(f"unsupported table rank {arr.ndim}")
94
+ return lines
95
+
96
+
97
+ def write_table_module(path, generator_module, axis_note, meta, scalars, arrays):
98
+ """Emit a baked table as a python module. Deterministic bytes."""
99
+ import mpmath
100
+
101
+ meta = dict(meta, dps=DPS, mpmath=mpmath.__version__,
102
+ generator_sha256=_generator_sha(generator_module))
103
+ lines = [
104
+ f'"""Baked table. Generated by {generator_module}; do not edit.',
105
+ f"Regenerate with: python -m {generator_module}",
106
+ "",
107
+ f"Axes: {axis_note}",
108
+ '"""',
109
+ "",
110
+ "import numpy as np",
111
+ "",
112
+ f"META = {meta!r}",
113
+ ]
114
+ for k, v in scalars.items():
115
+ lines.append(f"{k} = {v!r}")
116
+ for name, arr in arrays.items():
117
+ lines.append("")
118
+ lines.extend(_fmt_array(name, arr))
119
+ path.write_text("\n".join(lines) + "\n")
120
+ shapes = ", ".join(f"{k} {np.asarray(v).shape}" for k, v in arrays.items())
121
+ print(f"wrote {path.name}: {shapes}")