funcprops 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.
- funcprops/__init__.py +234 -0
- funcprops/_semialg.py +246 -0
- funcprops/api.py +70 -0
- funcprops/branchcuts/__init__.py +141 -0
- funcprops/branchcuts/computation.py +664 -0
- funcprops/branchcuts/core.py +1204 -0
- funcprops/branchcuts/post_simplify.py +461 -0
- funcprops/conditions.py +124 -0
- funcprops/data/catalogue.json +127449 -0
- funcprops/data/dlmf_audit.json +3382 -0
- funcprops/dlmf.py +29 -0
- funcprops/engine.py +324 -0
- funcprops/exceptions.py +29 -0
- funcprops/proof_cache.py +373 -0
- funcprops/real_analysis/__init__.py +52 -0
- funcprops/real_analysis/advanced_convexity.py +217 -0
- funcprops/real_analysis/boundedness.py +168 -0
- funcprops/real_analysis/common.py +244 -0
- funcprops/real_analysis/convexity.py +698 -0
- funcprops/real_analysis/differentiability.py +112 -0
- funcprops/real_analysis/domain.py +188 -0
- funcprops/real_analysis/mapping.py +374 -0
- funcprops/real_analysis/monotonic_certificates.py +867 -0
- funcprops/real_analysis/monotonicity.py +673 -0
- funcprops/real_analysis/oscillation.py +151 -0
- funcprops/real_analysis/piecewise.py +174 -0
- funcprops/real_analysis/range.py +552 -0
- funcprops/real_analysis/sign.py +561 -0
- funcprops/real_analysis/smoothness.py +268 -0
- funcprops/real_analysis/special_functions.py +515 -0
- funcprops/real_analysis/structural_properties.py +558 -0
- funcprops/real_analysis/zeros.py +327 -0
- funcprops/registry.py +274 -0
- funcprops/results.py +239 -0
- funcprops/singularities.py +1767 -0
- funcprops/stratification.py +494 -0
- funcprops/sympy_compare.py +63 -0
- funcprops/transcendental/__init__.py +140 -0
- funcprops/transcendental/algebraize.py +201 -0
- funcprops/transcendental/analytic.py +261 -0
- funcprops/transcendental/complex_roots.py +542 -0
- funcprops/transcendental/exp_fourier.py +221 -0
- funcprops/transcendental/extrema.py +821 -0
- funcprops/transcendental/periodic.py +222 -0
- funcprops/transcendental/proof.py +104 -0
- funcprops/transcendental/real_interval.py +128 -0
- funcprops/transcendental/real_sign.py +117 -0
- funcprops/transcendental/reduce.py +288 -0
- funcprops/transcendental/roots.py +1380 -0
- funcprops/transcendental/zero_geometry.py +1197 -0
- funcprops/verification.py +410 -0
- funcprops-0.1.0.dist-info/METADATA +208 -0
- funcprops-0.1.0.dist-info/RECORD +56 -0
- funcprops-0.1.0.dist-info/WHEEL +5 -0
- funcprops-0.1.0.dist-info/licenses/LICENSE +674 -0
- funcprops-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import sympy as sp
|
|
7
|
+
from sympy import S
|
|
8
|
+
|
|
9
|
+
from ..exceptions import SYMBOLIC_QUERY_ERRORS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IndexedZero(sp.Expr):
|
|
13
|
+
"""Exact symbolic indexed zero of a named special-function family.
|
|
14
|
+
|
|
15
|
+
The object is intentionally unevaluated: it carries exact identity/order
|
|
16
|
+
information without pretending the zero has an elementary closed form.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
is_real = True
|
|
20
|
+
|
|
21
|
+
def __new__(cls, family, index, order=S.NaN, derivative_order=0, branch="real"):
|
|
22
|
+
fam = sp.Symbol(str(family)) if not isinstance(family, sp.Symbol) else family
|
|
23
|
+
idx = sp.sympify(index)
|
|
24
|
+
ordv = sp.sympify(order)
|
|
25
|
+
d = sp.Integer(derivative_order)
|
|
26
|
+
br = sp.Symbol(str(branch))
|
|
27
|
+
return sp.Expr.__new__(cls, fam, idx, ordv, d, br)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def family(self):
|
|
31
|
+
return str(self.args[0])
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def index(self):
|
|
35
|
+
return self.args[1]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def order(self):
|
|
39
|
+
return None if self.args[2] is S.NaN else self.args[2]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def derivative_order(self):
|
|
43
|
+
return int(self.args[3])
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def branch(self):
|
|
47
|
+
return str(self.args[4])
|
|
48
|
+
|
|
49
|
+
def _sympystr(self, printer):
|
|
50
|
+
fam = self.family
|
|
51
|
+
k = printer.doprint(self.index)
|
|
52
|
+
d = self.derivative_order
|
|
53
|
+
if fam in ("BesselJ", "BesselY"):
|
|
54
|
+
nu = printer.doprint(self.args[2])
|
|
55
|
+
base = "j" if fam == "BesselJ" else "y"
|
|
56
|
+
return f"{base}_{nu},{k}" + ("'" * d)
|
|
57
|
+
if fam in ("AiryAi", "AiryBi"):
|
|
58
|
+
base = "a" if fam == "AiryAi" else "b"
|
|
59
|
+
return f"{base}_{k}" + ("'" * d)
|
|
60
|
+
return f"Zero[{fam},{k},d={d}]"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ZeroOrdering:
|
|
65
|
+
left: object
|
|
66
|
+
right: object
|
|
67
|
+
relation: str = "<"
|
|
68
|
+
guard: sp.Expr = S.true
|
|
69
|
+
source: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ZeroInterlacing:
|
|
74
|
+
first_family: str
|
|
75
|
+
second_family: str
|
|
76
|
+
pattern: tuple[ZeroOrdering, ...]
|
|
77
|
+
guard: sp.Expr = S.true
|
|
78
|
+
source: str = ""
|
|
79
|
+
complete: bool = True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class ZeroGeometryCertificate:
|
|
84
|
+
family: str
|
|
85
|
+
support: sp.Set
|
|
86
|
+
count: object
|
|
87
|
+
guard: sp.Expr = S.true
|
|
88
|
+
simple: bool | None = None
|
|
89
|
+
indexed_family: tuple[IndexedZero, ...] = ()
|
|
90
|
+
orderings: tuple[ZeroOrdering, ...] = ()
|
|
91
|
+
interlacings: tuple[ZeroInterlacing, ...] = ()
|
|
92
|
+
derivative_order: int = 0
|
|
93
|
+
complete_on_support: bool = True
|
|
94
|
+
source: str = ""
|
|
95
|
+
notes: tuple[object, ...] = ()
|
|
96
|
+
real_exhaustive: bool = False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class IntervalPropertyCell:
|
|
101
|
+
interval: object
|
|
102
|
+
value: object
|
|
103
|
+
guard: sp.Expr = S.true
|
|
104
|
+
proof: tuple[object, ...] = ()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class PropertyIntervalDecomposition:
|
|
109
|
+
derivative_order: int
|
|
110
|
+
domain: object
|
|
111
|
+
cells: tuple[IntervalPropertyCell, ...] = ()
|
|
112
|
+
boundary_geometry: tuple[ZeroGeometryCertificate, ...] = ()
|
|
113
|
+
alternating: bool | None = None
|
|
114
|
+
first_value: object = None
|
|
115
|
+
complete: bool = False
|
|
116
|
+
source: str = ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_named(expr, name):
|
|
120
|
+
return getattr(getattr(expr, "func", None), "__name__", "") == name
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _bessel_geometry(expr, var, derivative_order=0):
|
|
124
|
+
f = expr.func
|
|
125
|
+
if f not in (sp.besselj, sp.bessely) or len(expr.args) != 2 or expr.args[1] != var:
|
|
126
|
+
return None
|
|
127
|
+
nu = expr.args[0]
|
|
128
|
+
fam = "BesselJ" if f is sp.besselj else "BesselY"
|
|
129
|
+
support = sp.Interval.open(0, sp.oo)
|
|
130
|
+
# DLMF 10.21(i): infinitely many positive real zeros for real order;
|
|
131
|
+
# derivative simplicity has the sharper order guards stated there.
|
|
132
|
+
if derivative_order == 0:
|
|
133
|
+
guard = sp.Contains(nu, S.Reals)
|
|
134
|
+
simple = True
|
|
135
|
+
elif fam == "BesselJ":
|
|
136
|
+
guard = sp.And(sp.Contains(nu, S.Reals), nu >= -1)
|
|
137
|
+
simple = True
|
|
138
|
+
else:
|
|
139
|
+
guard = sp.And(sp.Contains(nu, S.Reals), nu >= -sp.Rational(1, 2))
|
|
140
|
+
simple = True
|
|
141
|
+
k = sp.Symbol("k", integer=True, positive=True)
|
|
142
|
+
z = IndexedZero(fam, k, nu, derivative_order, "positive")
|
|
143
|
+
orders = []
|
|
144
|
+
inter = []
|
|
145
|
+
if derivative_order == 0:
|
|
146
|
+
# Adjacent orders interlace for nu >= 0, DLMF 10.21.E2.
|
|
147
|
+
a = IndexedZero(fam, k, nu, 0, "positive")
|
|
148
|
+
b = IndexedZero(fam, k, nu + 1, 0, "positive")
|
|
149
|
+
c = IndexedZero(fam, k + 1, nu, 0, "positive")
|
|
150
|
+
rel = (
|
|
151
|
+
ZeroOrdering(a, b, "<", nu >= 0, "DLMF 10.21.E2"),
|
|
152
|
+
ZeroOrdering(b, c, "<", nu >= 0, "DLMF 10.21.E2"),
|
|
153
|
+
)
|
|
154
|
+
inter.append(
|
|
155
|
+
ZeroInterlacing(
|
|
156
|
+
fam + f"({nu})", fam + f"({nu + 1})", rel, nu >= 0, "DLMF 10.21.E2"
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
# Function/derivative zeros alternate; E3 gives the combined chain for nu>=0.
|
|
160
|
+
dp = IndexedZero(fam, k, nu, 1, "positive")
|
|
161
|
+
dn = IndexedZero(fam, k + 1, nu, 1, "positive")
|
|
162
|
+
rel2 = (
|
|
163
|
+
ZeroOrdering(dp, a, "<", nu >= 0, "DLMF 10.21.E3"),
|
|
164
|
+
ZeroOrdering(a, dn, "<", nu >= 0, "DLMF 10.21.E3"),
|
|
165
|
+
)
|
|
166
|
+
inter.append(ZeroInterlacing(fam + "'", fam, rel2, nu >= 0, "DLMF 10.21.E3"))
|
|
167
|
+
src = "DLMF 10.21(i), 10.21.E2-E3"
|
|
168
|
+
return ZeroGeometryCertificate(
|
|
169
|
+
fam,
|
|
170
|
+
support,
|
|
171
|
+
sp.oo,
|
|
172
|
+
guard,
|
|
173
|
+
simple,
|
|
174
|
+
(z,),
|
|
175
|
+
tuple(orders),
|
|
176
|
+
tuple(inter),
|
|
177
|
+
derivative_order,
|
|
178
|
+
True,
|
|
179
|
+
src,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _airy_geometry(expr, var, derivative_order=0):
|
|
184
|
+
mapping = {
|
|
185
|
+
sp.airyai: ("AiryAi", 0),
|
|
186
|
+
sp.airyaiprime: ("AiryAi", 1),
|
|
187
|
+
sp.airybi: ("AiryBi", 0),
|
|
188
|
+
sp.airybiprime: ("AiryBi", 1),
|
|
189
|
+
}
|
|
190
|
+
if expr.func not in mapping or len(expr.args) != 1 or expr.args[0] != var:
|
|
191
|
+
return None
|
|
192
|
+
fam, builtin_d = mapping[expr.func]
|
|
193
|
+
d = builtin_d + derivative_order
|
|
194
|
+
if d not in (0, 1):
|
|
195
|
+
return None
|
|
196
|
+
support = sp.Interval.open(-sp.oo, 0)
|
|
197
|
+
k = sp.Symbol("k", integer=True, positive=True)
|
|
198
|
+
z = IndexedZero(fam, k, S.NaN, d, "negative")
|
|
199
|
+
if fam == "AiryAi":
|
|
200
|
+
# a'_{k+1} < a_k < a'_k
|
|
201
|
+
rel = (
|
|
202
|
+
ZeroOrdering(
|
|
203
|
+
IndexedZero(fam, k + 1, S.NaN, 1, "negative"),
|
|
204
|
+
IndexedZero(fam, k, S.NaN, 0, "negative"),
|
|
205
|
+
"<",
|
|
206
|
+
S.true,
|
|
207
|
+
"DLMF 9.9 + Sturm separation",
|
|
208
|
+
),
|
|
209
|
+
ZeroOrdering(
|
|
210
|
+
IndexedZero(fam, k, S.NaN, 0, "negative"),
|
|
211
|
+
IndexedZero(fam, k, S.NaN, 1, "negative"),
|
|
212
|
+
"<",
|
|
213
|
+
S.true,
|
|
214
|
+
"DLMF 9.9 + Sturm separation",
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
# b_{k+1} < b'_k < b_k
|
|
219
|
+
rel = (
|
|
220
|
+
ZeroOrdering(
|
|
221
|
+
IndexedZero(fam, k + 1, S.NaN, 0, "negative"),
|
|
222
|
+
IndexedZero(fam, k, S.NaN, 1, "negative"),
|
|
223
|
+
"<",
|
|
224
|
+
S.true,
|
|
225
|
+
"DLMF 9.9 + Sturm separation",
|
|
226
|
+
),
|
|
227
|
+
ZeroOrdering(
|
|
228
|
+
IndexedZero(fam, k, S.NaN, 1, "negative"),
|
|
229
|
+
IndexedZero(fam, k, S.NaN, 0, "negative"),
|
|
230
|
+
"<",
|
|
231
|
+
S.true,
|
|
232
|
+
"DLMF 9.9 + Sturm separation",
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
inter = ZeroInterlacing(
|
|
236
|
+
fam, fam + "'", rel, S.true, "DLMF 9.9(i), E1-E4 + Sturm separation"
|
|
237
|
+
)
|
|
238
|
+
return ZeroGeometryCertificate(
|
|
239
|
+
fam,
|
|
240
|
+
support,
|
|
241
|
+
sp.oo,
|
|
242
|
+
S.true,
|
|
243
|
+
True,
|
|
244
|
+
(z,),
|
|
245
|
+
(),
|
|
246
|
+
(inter,),
|
|
247
|
+
d,
|
|
248
|
+
True,
|
|
249
|
+
"DLMF 9.9(i), E1-E4",
|
|
250
|
+
real_exhaustive=True,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _orthogonal_geometry(expr, var):
|
|
255
|
+
if not expr.args or expr.args[-1] != var:
|
|
256
|
+
return None
|
|
257
|
+
f = expr.func
|
|
258
|
+
n = expr.args[0]
|
|
259
|
+
n_guard = sp.And(sp.Contains(n, S.Integers), n >= 1)
|
|
260
|
+
if f is sp.jacobi and len(expr.args) == 4:
|
|
261
|
+
a, b = expr.args[1:3]
|
|
262
|
+
support = sp.Interval.open(-1, 1)
|
|
263
|
+
guard = sp.And(n_guard, a > -1, b > -1)
|
|
264
|
+
fam = "Jacobi"
|
|
265
|
+
src = "DLMF 18.16.E1 + 18.2(vi)"
|
|
266
|
+
elif f is sp.gegenbauer and len(expr.args) == 3:
|
|
267
|
+
lam = expr.args[1]
|
|
268
|
+
support = sp.Interval.open(-1, 1)
|
|
269
|
+
guard = sp.And(n_guard, lam > -sp.Rational(1, 2), sp.Ne(lam, 0))
|
|
270
|
+
fam = "Gegenbauer"
|
|
271
|
+
src = "DLMF 18.16(iii) + 18.2(vi)"
|
|
272
|
+
elif f is sp.assoc_laguerre and len(expr.args) == 3:
|
|
273
|
+
a = expr.args[1]
|
|
274
|
+
support = sp.Interval.open(0, sp.oo)
|
|
275
|
+
guard = sp.And(n_guard, a > -1)
|
|
276
|
+
fam = "Laguerre"
|
|
277
|
+
src = "DLMF 18.16.E9 + 18.2(vi)"
|
|
278
|
+
elif f is sp.laguerre and len(expr.args) == 2:
|
|
279
|
+
support = sp.Interval.open(0, sp.oo)
|
|
280
|
+
guard = n_guard
|
|
281
|
+
fam = "Laguerre"
|
|
282
|
+
src = "DLMF 18.16.E9 + 18.2(vi)"
|
|
283
|
+
elif f in tuple(
|
|
284
|
+
q
|
|
285
|
+
for q in (getattr(sp, "hermite", None), getattr(sp, "hermite_prob", None))
|
|
286
|
+
if q is not None
|
|
287
|
+
):
|
|
288
|
+
support = S.Reals
|
|
289
|
+
guard = n_guard
|
|
290
|
+
fam = "Hermite"
|
|
291
|
+
src = "DLMF 18.16(v) + 18.2(vi)"
|
|
292
|
+
elif f in (sp.chebyshevt, sp.chebyshevu) and len(expr.args) == 2:
|
|
293
|
+
support = sp.Interval.open(-1, 1)
|
|
294
|
+
guard = n_guard
|
|
295
|
+
fam = "ChebyshevT" if f is sp.chebyshevt else "ChebyshevU"
|
|
296
|
+
src = "DLMF 18.16(iii) + 18.2(vi)"
|
|
297
|
+
else:
|
|
298
|
+
return None
|
|
299
|
+
k = sp.Symbol("k", integer=True, positive=True)
|
|
300
|
+
z = IndexedZero(fam, k, n, 0, "support")
|
|
301
|
+
return ZeroGeometryCertificate(
|
|
302
|
+
fam,
|
|
303
|
+
support,
|
|
304
|
+
n,
|
|
305
|
+
guard,
|
|
306
|
+
True,
|
|
307
|
+
(z,),
|
|
308
|
+
(),
|
|
309
|
+
(),
|
|
310
|
+
0,
|
|
311
|
+
True,
|
|
312
|
+
src,
|
|
313
|
+
("consecutive degrees interlace",),
|
|
314
|
+
True,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _legendre_geometry(expr, var):
|
|
319
|
+
# SymPy's assoc_legendre is Ferrers P on (-1,1) in the integer-degree use case.
|
|
320
|
+
if expr.func is sp.assoc_legendre and len(expr.args) == 3 and expr.args[2] == var:
|
|
321
|
+
nu, mu = expr.args[:2]
|
|
322
|
+
support = sp.Interval.open(-1, 1)
|
|
323
|
+
guard = sp.And(
|
|
324
|
+
sp.Contains(nu, S.Naturals0), sp.Contains(mu, S.Integers), sp.Abs(mu) <= nu
|
|
325
|
+
)
|
|
326
|
+
count = sp.Max(nu - sp.Abs(mu), 0)
|
|
327
|
+
return ZeroGeometryCertificate(
|
|
328
|
+
"FerrersP",
|
|
329
|
+
support,
|
|
330
|
+
count,
|
|
331
|
+
guard,
|
|
332
|
+
True,
|
|
333
|
+
(),
|
|
334
|
+
(),
|
|
335
|
+
(),
|
|
336
|
+
0,
|
|
337
|
+
True,
|
|
338
|
+
"DLMF 14.16(ii)",
|
|
339
|
+
)
|
|
340
|
+
# DLMF-native Q may appear through catalogue/user-defined SymPy Function.
|
|
341
|
+
if _is_named(expr, "LegendreQ") or _is_named(expr, "FerrersQ"):
|
|
342
|
+
args = expr.args
|
|
343
|
+
if len(args) >= 3 and args[-1] == var:
|
|
344
|
+
nu, mu = args[0], args[1]
|
|
345
|
+
elif len(args) >= 2 and args[-1] == var:
|
|
346
|
+
nu, mu = args[0], S.Zero
|
|
347
|
+
else:
|
|
348
|
+
return None
|
|
349
|
+
support = sp.Interval.open(-1, 1)
|
|
350
|
+
base = sp.Max(sp.ceiling(nu - sp.Abs(mu)), 0)
|
|
351
|
+
# DLMF 14.16(ii): Q zeros interlace P; count is base+k, k in {-1,0,1,2}
|
|
352
|
+
# subject to a parity condition. Preserve this as an exact finite choice set.
|
|
353
|
+
count = sp.FiniteSet(base - 1, base, base + 1, base + 2)
|
|
354
|
+
guard = sp.And(
|
|
355
|
+
sp.Contains(nu, S.Reals), sp.Contains(mu, S.Reals), nu >= -sp.Rational(1, 2)
|
|
356
|
+
)
|
|
357
|
+
IndexedZero(
|
|
358
|
+
"FerrersP", sp.Symbol("k", integer=True, positive=True), nu, 0, "(-1,1)"
|
|
359
|
+
)
|
|
360
|
+
q = IndexedZero(
|
|
361
|
+
"FerrersQ", sp.Symbol("k", integer=True, positive=True), nu, 0, "(-1,1)"
|
|
362
|
+
)
|
|
363
|
+
inter = ZeroInterlacing(
|
|
364
|
+
"FerrersP", "FerrersQ", (), guard, "DLMF 14.16(ii): Q zeros interlace P"
|
|
365
|
+
)
|
|
366
|
+
return ZeroGeometryCertificate(
|
|
367
|
+
"FerrersQ",
|
|
368
|
+
support,
|
|
369
|
+
count,
|
|
370
|
+
guard,
|
|
371
|
+
True,
|
|
372
|
+
(q,),
|
|
373
|
+
(),
|
|
374
|
+
(inter,),
|
|
375
|
+
0,
|
|
376
|
+
True,
|
|
377
|
+
"DLMF 14.16(ii)",
|
|
378
|
+
("count parity depends on cos(pi*nu), cos(pi*mu)",),
|
|
379
|
+
)
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _zero_geometry_impl(expr, var, *, derivative_order=0, domain=S.Reals):
|
|
384
|
+
"""Return exact zero-geometry certificates known for ``expr`` or its derivative.
|
|
385
|
+
|
|
386
|
+
This layer does not numerically locate zeros. It records indexed families,
|
|
387
|
+
counts, simplicity, support, and interlacing/order relations.
|
|
388
|
+
"""
|
|
389
|
+
expr = sp.sympify(expr)
|
|
390
|
+
var = sp.sympify(var)
|
|
391
|
+
# Strip nonzero variable-independent scalar factors.
|
|
392
|
+
if expr.func is sp.Mul:
|
|
393
|
+
c, dep = expr.as_independent(var, as_Add=False)
|
|
394
|
+
if c != 0 and dep != expr:
|
|
395
|
+
return _zero_geometry_impl(
|
|
396
|
+
dep, var, derivative_order=derivative_order, domain=domain
|
|
397
|
+
)
|
|
398
|
+
# For requested derivative geometry, prefer the base family's theorem.
|
|
399
|
+
for fn in (_bessel_geometry, _airy_geometry):
|
|
400
|
+
g = fn(expr, var, derivative_order)
|
|
401
|
+
if g is not None:
|
|
402
|
+
return (g,)
|
|
403
|
+
if derivative_order == 0:
|
|
404
|
+
for fn in (_orthogonal_geometry, _legendre_geometry):
|
|
405
|
+
g = fn(expr, var)
|
|
406
|
+
if g is not None:
|
|
407
|
+
return (g,)
|
|
408
|
+
# Generic derivative closure: many orthogonal families differentiate into
|
|
409
|
+
# another member of the same hierarchy. Recurse only on the explicit
|
|
410
|
+
# derivative expression, never numerically.
|
|
411
|
+
if derivative_order:
|
|
412
|
+
try:
|
|
413
|
+
from ..proof_cache import cached_derivative
|
|
414
|
+
|
|
415
|
+
d = cached_derivative(expr, var, order=derivative_order, factored=True)
|
|
416
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
417
|
+
d = None
|
|
418
|
+
if d is not None and d != expr:
|
|
419
|
+
return _zero_geometry_impl(d, var, derivative_order=0, domain=domain)
|
|
420
|
+
return ()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def zero_geometry(expr, var, *, derivative_order=0, domain=S.Reals):
|
|
424
|
+
"""Cached exact zero-geometry proof primitive."""
|
|
425
|
+
from ..proof_cache import property_proof_context
|
|
426
|
+
|
|
427
|
+
return property_proof_context(expr, var, domain=domain).zero_geometry(
|
|
428
|
+
derivative_order=derivative_order
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _interval_subset_exact(a, b):
|
|
433
|
+
"""Prove ``a`` is a subset of interval ``b`` from exact endpoint facts."""
|
|
434
|
+
if not isinstance(a, sp.Interval) or not isinstance(b, sp.Interval):
|
|
435
|
+
return None
|
|
436
|
+
# left endpoint
|
|
437
|
+
if b.start is -sp.oo:
|
|
438
|
+
left = True
|
|
439
|
+
elif a.start is -sp.oo:
|
|
440
|
+
left = False
|
|
441
|
+
else:
|
|
442
|
+
if (
|
|
443
|
+
b.start == 0
|
|
444
|
+
and isinstance(a.start, IndexedZero)
|
|
445
|
+
and a.start.branch == "positive"
|
|
446
|
+
):
|
|
447
|
+
left = True
|
|
448
|
+
elif (
|
|
449
|
+
b.start == 0
|
|
450
|
+
and isinstance(a.start, IndexedZero)
|
|
451
|
+
and a.start.branch == "negative"
|
|
452
|
+
):
|
|
453
|
+
left = False
|
|
454
|
+
else:
|
|
455
|
+
d = sp.simplify(a.start - b.start)
|
|
456
|
+
if d.is_positive is True:
|
|
457
|
+
left = True
|
|
458
|
+
elif d.is_zero is True:
|
|
459
|
+
left = not (b.left_open and not a.left_open)
|
|
460
|
+
elif d.is_negative is True:
|
|
461
|
+
left = False
|
|
462
|
+
else:
|
|
463
|
+
left = None
|
|
464
|
+
# right endpoint
|
|
465
|
+
if b.end is sp.oo:
|
|
466
|
+
right = True
|
|
467
|
+
elif a.end is sp.oo:
|
|
468
|
+
right = False
|
|
469
|
+
else:
|
|
470
|
+
if b.end == 0 and isinstance(a.end, IndexedZero) and a.end.branch == "negative":
|
|
471
|
+
right = True
|
|
472
|
+
elif (
|
|
473
|
+
b.end == 0 and isinstance(a.end, IndexedZero) and a.end.branch == "positive"
|
|
474
|
+
):
|
|
475
|
+
right = False
|
|
476
|
+
else:
|
|
477
|
+
d = sp.simplify(b.end - a.end)
|
|
478
|
+
if d.is_positive is True:
|
|
479
|
+
right = True
|
|
480
|
+
elif d.is_zero is True:
|
|
481
|
+
right = not (b.right_open and not a.right_open)
|
|
482
|
+
elif d.is_negative is True:
|
|
483
|
+
right = False
|
|
484
|
+
else:
|
|
485
|
+
right = None
|
|
486
|
+
if left is False or right is False:
|
|
487
|
+
return False
|
|
488
|
+
if left is True and right is True:
|
|
489
|
+
return True
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def geometry_complete_on_domain(g, domain):
|
|
494
|
+
"""Whether a zero-geometry certificate exhausts zeros on ``domain``."""
|
|
495
|
+
if not g.complete_on_support:
|
|
496
|
+
return False
|
|
497
|
+
try:
|
|
498
|
+
if domain == g.support or domain.is_subset(g.support) is True:
|
|
499
|
+
return True
|
|
500
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
501
|
+
pass
|
|
502
|
+
if _interval_subset_exact(domain, g.support) is True:
|
|
503
|
+
return True
|
|
504
|
+
if g.real_exhaustive:
|
|
505
|
+
try:
|
|
506
|
+
if domain is S.Reals or domain.is_subset(S.Reals) is True:
|
|
507
|
+
return True
|
|
508
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
509
|
+
pass
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def geometry_support_inside_domain(g, domain):
|
|
514
|
+
"""Whether the certified indexed support is contained in ``domain``."""
|
|
515
|
+
try:
|
|
516
|
+
if (
|
|
517
|
+
domain is S.Reals
|
|
518
|
+
or g.support == domain
|
|
519
|
+
or g.support.is_subset(domain) is True
|
|
520
|
+
):
|
|
521
|
+
return True
|
|
522
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
523
|
+
pass
|
|
524
|
+
return _interval_subset_exact(g.support, domain) is True
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def property_interval_decomposition(
|
|
528
|
+
expr,
|
|
529
|
+
var,
|
|
530
|
+
*,
|
|
531
|
+
derivative_order=1,
|
|
532
|
+
domain=S.Reals,
|
|
533
|
+
positive_value=None,
|
|
534
|
+
negative_value=None,
|
|
535
|
+
derivative=None,
|
|
536
|
+
):
|
|
537
|
+
"""Build an exact interval-pattern certificate from derivative zero geometry.
|
|
538
|
+
|
|
539
|
+
Infinite indexed boundaries are represented by ``boundary_geometry`` and an
|
|
540
|
+
alternating flag rather than materializing infinitely many intervals.
|
|
541
|
+
"""
|
|
542
|
+
expr = sp.sympify(expr)
|
|
543
|
+
var = sp.sympify(var)
|
|
544
|
+
geoms = zero_geometry(expr, var, derivative_order=derivative_order, domain=domain)
|
|
545
|
+
if geoms:
|
|
546
|
+
simple = all(g.simple is True for g in geoms)
|
|
547
|
+
|
|
548
|
+
def guard_true(g):
|
|
549
|
+
if g.guard is S.true:
|
|
550
|
+
return True
|
|
551
|
+
try:
|
|
552
|
+
return sp.simplify(g.guard) is S.true
|
|
553
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
554
|
+
return False
|
|
555
|
+
|
|
556
|
+
complete = all(
|
|
557
|
+
geometry_complete_on_domain(g, domain) and guard_true(g) for g in geoms
|
|
558
|
+
)
|
|
559
|
+
# A simple zero of the derivative changes its sign. Thus the property
|
|
560
|
+
# (monotonicity for order 1; curvature for order 2) alternates by cell.
|
|
561
|
+
return PropertyIntervalDecomposition(
|
|
562
|
+
derivative_order,
|
|
563
|
+
domain,
|
|
564
|
+
(),
|
|
565
|
+
geoms,
|
|
566
|
+
True if simple else None,
|
|
567
|
+
None,
|
|
568
|
+
complete,
|
|
569
|
+
"certified derivative-zero geometry",
|
|
570
|
+
)
|
|
571
|
+
# Generic finite exact derivative roots: materialize concrete cells.
|
|
572
|
+
if isinstance(domain, sp.Interval):
|
|
573
|
+
try:
|
|
574
|
+
from ..proof_cache import cached_derivative
|
|
575
|
+
|
|
576
|
+
d = (
|
|
577
|
+
sp.factor(derivative)
|
|
578
|
+
if derivative is not None
|
|
579
|
+
else cached_derivative(expr, var, order=derivative_order, factored=True)
|
|
580
|
+
)
|
|
581
|
+
sol = sp.solveset(sp.Eq(d, 0), var, domain=domain)
|
|
582
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
583
|
+
sol = None
|
|
584
|
+
if sol is S.EmptySet or isinstance(sol, sp.FiniteSet):
|
|
585
|
+
pts = []
|
|
586
|
+
for q in () if sol is S.EmptySet else sol:
|
|
587
|
+
placed = False
|
|
588
|
+
for i, r in enumerate(pts):
|
|
589
|
+
delta = sp.simplify(q - r)
|
|
590
|
+
if delta.is_negative is True:
|
|
591
|
+
pts.insert(i, q)
|
|
592
|
+
placed = True
|
|
593
|
+
break
|
|
594
|
+
if delta.is_positive is True:
|
|
595
|
+
continue
|
|
596
|
+
if delta.is_zero is True:
|
|
597
|
+
placed = True
|
|
598
|
+
break
|
|
599
|
+
pts = None
|
|
600
|
+
break
|
|
601
|
+
if pts is None:
|
|
602
|
+
break
|
|
603
|
+
if not placed:
|
|
604
|
+
pts.append(q)
|
|
605
|
+
if pts is not None:
|
|
606
|
+
# Critical points at interval endpoints do not split the open
|
|
607
|
+
# interior into additional monotonic cells. Keeping them here
|
|
608
|
+
# creates empty cells such as Interval.open(a,a) and can turn a
|
|
609
|
+
# complete monotonicity proof into an artificial failure.
|
|
610
|
+
pts = [q for q in pts if q != domain.start and q != domain.end]
|
|
611
|
+
simple = True
|
|
612
|
+
try:
|
|
613
|
+
from ..proof_cache import cached_derivative
|
|
614
|
+
|
|
615
|
+
dn = cached_derivative(d, var, order=1)
|
|
616
|
+
for q in pts:
|
|
617
|
+
if sp.simplify(dn.subs(var, q)).is_zero is not False:
|
|
618
|
+
simple = False
|
|
619
|
+
break
|
|
620
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
621
|
+
simple = False
|
|
622
|
+
bounds = [domain.start, *pts, domain.end]
|
|
623
|
+
cells = []
|
|
624
|
+
complete = True
|
|
625
|
+
for a, b in itertools.pairwise(bounds):
|
|
626
|
+
if a is -sp.oo and b is sp.oo:
|
|
627
|
+
sample = S.Zero
|
|
628
|
+
elif a is -sp.oo:
|
|
629
|
+
sample = b - 1
|
|
630
|
+
elif b is sp.oo:
|
|
631
|
+
sample = a + 1
|
|
632
|
+
else:
|
|
633
|
+
sample = sp.simplify((a + b) / 2)
|
|
634
|
+
sg = sp.simplify(d.subs(var, sample))
|
|
635
|
+
if sg.is_positive is True:
|
|
636
|
+
val = positive_value
|
|
637
|
+
elif sg.is_negative is True:
|
|
638
|
+
val = negative_value
|
|
639
|
+
else:
|
|
640
|
+
complete = False
|
|
641
|
+
val = None
|
|
642
|
+
cells.append(
|
|
643
|
+
IntervalPropertyCell(
|
|
644
|
+
sp.Interval.open(a, b),
|
|
645
|
+
val,
|
|
646
|
+
S.true,
|
|
647
|
+
(("derivative_sample", sample, sg),),
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
fg = ZeroGeometryCertificate(
|
|
651
|
+
"finite-derivative-zeros",
|
|
652
|
+
sp.FiniteSet(*pts),
|
|
653
|
+
len(pts),
|
|
654
|
+
S.true,
|
|
655
|
+
simple,
|
|
656
|
+
(),
|
|
657
|
+
(),
|
|
658
|
+
(),
|
|
659
|
+
derivative_order,
|
|
660
|
+
True,
|
|
661
|
+
"exact solveset + derivative multiplicity test",
|
|
662
|
+
)
|
|
663
|
+
return PropertyIntervalDecomposition(
|
|
664
|
+
derivative_order,
|
|
665
|
+
domain,
|
|
666
|
+
tuple(cells),
|
|
667
|
+
(fg,),
|
|
668
|
+
simple if pts else False,
|
|
669
|
+
None,
|
|
670
|
+
complete,
|
|
671
|
+
"finite exact derivative-root decomposition",
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
# Airy second derivative: y'' = x*y. This supplies exact curvature
|
|
675
|
+
# boundaries from the function zeros plus x=0.
|
|
676
|
+
if (
|
|
677
|
+
derivative_order == 2
|
|
678
|
+
and expr.func in (sp.airyai, sp.airybi)
|
|
679
|
+
and expr.args == (var,)
|
|
680
|
+
):
|
|
681
|
+
base = zero_geometry(expr, var, domain=domain)
|
|
682
|
+
if base:
|
|
683
|
+
z0 = ZeroGeometryCertificate(
|
|
684
|
+
"linear-factor-x",
|
|
685
|
+
sp.FiniteSet(0),
|
|
686
|
+
1,
|
|
687
|
+
S.true,
|
|
688
|
+
True,
|
|
689
|
+
(IndexedZero("x", 1, S.NaN, 0, "zero"),),
|
|
690
|
+
(),
|
|
691
|
+
(),
|
|
692
|
+
0,
|
|
693
|
+
True,
|
|
694
|
+
"Airy ODE y''=x y",
|
|
695
|
+
)
|
|
696
|
+
return PropertyIntervalDecomposition(
|
|
697
|
+
2,
|
|
698
|
+
domain,
|
|
699
|
+
(),
|
|
700
|
+
(*base, z0),
|
|
701
|
+
True,
|
|
702
|
+
positive_value,
|
|
703
|
+
True,
|
|
704
|
+
"Airy ODE + certified Airy zeros",
|
|
705
|
+
)
|
|
706
|
+
return PropertyIntervalDecomposition(
|
|
707
|
+
derivative_order,
|
|
708
|
+
domain,
|
|
709
|
+
(),
|
|
710
|
+
(),
|
|
711
|
+
None,
|
|
712
|
+
None,
|
|
713
|
+
False,
|
|
714
|
+
"insufficient zero geometry",
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def zero_ordering_bounds(zero):
|
|
719
|
+
"""Return exact symbolic neighbor inequalities known for an ``IndexedZero``."""
|
|
720
|
+
if not isinstance(zero, IndexedZero):
|
|
721
|
+
return ()
|
|
722
|
+
k = zero.index
|
|
723
|
+
d = zero.derivative_order
|
|
724
|
+
fam = zero.family
|
|
725
|
+
out = []
|
|
726
|
+
if fam in ("BesselJ", "BesselY") and zero.order is not None:
|
|
727
|
+
nu = zero.order
|
|
728
|
+
if d == 0:
|
|
729
|
+
# DLMF 10.21.E2 (adjacent order) and E3 (function/derivative chain).
|
|
730
|
+
out.append(
|
|
731
|
+
ZeroOrdering(
|
|
732
|
+
zero,
|
|
733
|
+
IndexedZero(fam, k, nu + 1, 0, "positive"),
|
|
734
|
+
"<",
|
|
735
|
+
nu >= 0,
|
|
736
|
+
"DLMF 10.21.E2",
|
|
737
|
+
)
|
|
738
|
+
)
|
|
739
|
+
out.append(
|
|
740
|
+
ZeroOrdering(
|
|
741
|
+
IndexedZero(fam, k, nu, 1, "positive"),
|
|
742
|
+
zero,
|
|
743
|
+
"<",
|
|
744
|
+
nu >= 0,
|
|
745
|
+
"DLMF 10.21.E3",
|
|
746
|
+
)
|
|
747
|
+
)
|
|
748
|
+
out.append(
|
|
749
|
+
ZeroOrdering(
|
|
750
|
+
zero,
|
|
751
|
+
IndexedZero(fam, k + 1, nu, 1, "positive"),
|
|
752
|
+
"<",
|
|
753
|
+
nu >= 0,
|
|
754
|
+
"DLMF 10.21.E3",
|
|
755
|
+
)
|
|
756
|
+
)
|
|
757
|
+
out.append(
|
|
758
|
+
ZeroOrdering(
|
|
759
|
+
IndexedZero(fam, k - 1, nu + 1, 0, "positive"),
|
|
760
|
+
zero,
|
|
761
|
+
"<",
|
|
762
|
+
sp.And(nu >= 0, k >= 2),
|
|
763
|
+
"DLMF 10.21.E2",
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
elif d == 1:
|
|
767
|
+
out.append(
|
|
768
|
+
ZeroOrdering(
|
|
769
|
+
zero,
|
|
770
|
+
IndexedZero(fam, k, nu, 0, "positive"),
|
|
771
|
+
"<",
|
|
772
|
+
nu >= 0,
|
|
773
|
+
"DLMF 10.21.E3",
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
elif fam == "AiryAi":
|
|
777
|
+
if d == 0:
|
|
778
|
+
out.extend(
|
|
779
|
+
(
|
|
780
|
+
ZeroOrdering(
|
|
781
|
+
IndexedZero(fam, k + 1, S.NaN, 1, "negative"),
|
|
782
|
+
zero,
|
|
783
|
+
"<",
|
|
784
|
+
S.true,
|
|
785
|
+
"DLMF 9.9 + Sturm separation",
|
|
786
|
+
),
|
|
787
|
+
ZeroOrdering(
|
|
788
|
+
zero,
|
|
789
|
+
IndexedZero(fam, k, S.NaN, 1, "negative"),
|
|
790
|
+
"<",
|
|
791
|
+
S.true,
|
|
792
|
+
"DLMF 9.9 + Sturm separation",
|
|
793
|
+
),
|
|
794
|
+
)
|
|
795
|
+
)
|
|
796
|
+
elif d == 1:
|
|
797
|
+
out.extend(
|
|
798
|
+
(
|
|
799
|
+
ZeroOrdering(
|
|
800
|
+
IndexedZero(fam, k, S.NaN, 0, "negative"),
|
|
801
|
+
zero,
|
|
802
|
+
"<",
|
|
803
|
+
S.true,
|
|
804
|
+
"DLMF 9.9 + Sturm separation",
|
|
805
|
+
),
|
|
806
|
+
ZeroOrdering(
|
|
807
|
+
zero,
|
|
808
|
+
IndexedZero(fam, k - 1, S.NaN, 0, "negative"),
|
|
809
|
+
"<",
|
|
810
|
+
k >= 2,
|
|
811
|
+
"DLMF 9.9 + Sturm separation",
|
|
812
|
+
),
|
|
813
|
+
)
|
|
814
|
+
)
|
|
815
|
+
elif fam == "AiryBi":
|
|
816
|
+
if d == 1:
|
|
817
|
+
out.extend(
|
|
818
|
+
(
|
|
819
|
+
ZeroOrdering(
|
|
820
|
+
IndexedZero(fam, k + 1, S.NaN, 0, "negative"),
|
|
821
|
+
zero,
|
|
822
|
+
"<",
|
|
823
|
+
S.true,
|
|
824
|
+
"DLMF 9.9 + Sturm separation",
|
|
825
|
+
),
|
|
826
|
+
ZeroOrdering(
|
|
827
|
+
zero,
|
|
828
|
+
IndexedZero(fam, k, S.NaN, 0, "negative"),
|
|
829
|
+
"<",
|
|
830
|
+
S.true,
|
|
831
|
+
"DLMF 9.9 + Sturm separation",
|
|
832
|
+
),
|
|
833
|
+
)
|
|
834
|
+
)
|
|
835
|
+
return tuple(out)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
@dataclass(frozen=True)
|
|
839
|
+
class CertifiedIntegerBracket:
|
|
840
|
+
"""Rigorous integer enclosure for an exact indexed-tail selector."""
|
|
841
|
+
|
|
842
|
+
lower: object
|
|
843
|
+
upper: object
|
|
844
|
+
guard: sp.Expr = S.true
|
|
845
|
+
source: str = ""
|
|
846
|
+
proof: tuple[object, ...] = ()
|
|
847
|
+
|
|
848
|
+
@property
|
|
849
|
+
def exact(self):
|
|
850
|
+
return sp.simplify(self.lower - self.upper) == 0
|
|
851
|
+
|
|
852
|
+
def as_interval(self):
|
|
853
|
+
return sp.Interval(sp.sympify(self.lower), sp.sympify(self.upper))
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _besselj_zero_linear_bounds(order, derivative_order=0):
|
|
857
|
+
"""Return rigorous affine-in-k bounds for positive J zeros.
|
|
858
|
+
|
|
859
|
+
For nu>=0, DLMF 10.21(iv) gives monotonicity in order and 10.21.E2
|
|
860
|
+
gives adjacent-order interlacing. Anchoring at J_{-1/2}(x), whose kth
|
|
861
|
+
positive zero is (k-1/2)pi, yields
|
|
862
|
+
|
|
863
|
+
(k-1/2)pi <= j_{nu,k} < (k+N-1/2)pi,
|
|
864
|
+
N = ceiling(nu+1/2).
|
|
865
|
+
|
|
866
|
+
DLMF 10.21.E3 then gives, for k>=2,
|
|
867
|
+
j_{nu,k-1} < j'_{nu,k} < j_{nu,k}.
|
|
868
|
+
"""
|
|
869
|
+
nu = sp.sympify(order)
|
|
870
|
+
if nu.is_nonnegative is not True:
|
|
871
|
+
return None
|
|
872
|
+
k = sp.Symbol("_k", integer=True, positive=True)
|
|
873
|
+
# Upper anchor: choose the first half-integer order 1/2+M >= nu.
|
|
874
|
+
# Repeated DLMF 10.21.E2 interlacing gives
|
|
875
|
+
# j_{1/2+M,k} < j_{1/2,k+M}=(k+M)pi.
|
|
876
|
+
M = sp.Max(0, sp.ceiling(nu - sp.Rational(1, 2)))
|
|
877
|
+
uj = (k + M) * sp.pi
|
|
878
|
+
# Lower anchor is always J_-1/2; when nu>=1/2 we can strengthen it
|
|
879
|
+
# to J_1/2, whose kth zero is k*pi.
|
|
880
|
+
half_or_more = (nu - sp.Rational(1, 2)).is_nonnegative is True
|
|
881
|
+
base_lj = k * sp.pi if half_or_more else (k - sp.Rational(1, 2)) * sp.pi
|
|
882
|
+
if derivative_order == 0:
|
|
883
|
+
return (
|
|
884
|
+
k,
|
|
885
|
+
base_lj,
|
|
886
|
+
uj,
|
|
887
|
+
sp.Ge(nu, 0),
|
|
888
|
+
(
|
|
889
|
+
"DLMF 10.21(iv) order monotonicity",
|
|
890
|
+
"DLMF 10.21.E2 interlacing",
|
|
891
|
+
"J_±1/2 elementary zeros",
|
|
892
|
+
),
|
|
893
|
+
)
|
|
894
|
+
if derivative_order == 1:
|
|
895
|
+
prev_lj = (k - 1) * sp.pi if half_or_more else (k - sp.Rational(3, 2)) * sp.pi
|
|
896
|
+
lj = sp.Piecewise((S.Zero, sp.Eq(k, 1)), (prev_lj, True))
|
|
897
|
+
return (
|
|
898
|
+
k,
|
|
899
|
+
lj,
|
|
900
|
+
uj,
|
|
901
|
+
sp.Ge(nu, 0),
|
|
902
|
+
(
|
|
903
|
+
"DLMF 10.21.E3 derivative/function interlacing",
|
|
904
|
+
"DLMF 10.21(iv) order monotonicity",
|
|
905
|
+
"J_±1/2 elementary zeros",
|
|
906
|
+
),
|
|
907
|
+
)
|
|
908
|
+
return None
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def indexed_tail_integer_bracket(
|
|
912
|
+
zero_template, cutoff, *, direction="right", inclusive=True
|
|
913
|
+
):
|
|
914
|
+
"""Certify an integer bracket for the first included indexed zero.
|
|
915
|
+
|
|
916
|
+
Currently formula-level rigorous for positive Bessel-J function and first
|
|
917
|
+
derivative zero families with nonnegative real order. Unsupported families
|
|
918
|
+
return ``None`` rather than using asymptotic estimates without error bounds.
|
|
919
|
+
"""
|
|
920
|
+
if not isinstance(zero_template, IndexedZero):
|
|
921
|
+
raise TypeError("zero_template must be an IndexedZero")
|
|
922
|
+
A = sp.sympify(cutoff)
|
|
923
|
+
if direction != "right" or zero_template.branch != "positive":
|
|
924
|
+
return None
|
|
925
|
+
if zero_template.family != "BesselJ":
|
|
926
|
+
return None
|
|
927
|
+
data = _besselj_zero_linear_bounds(
|
|
928
|
+
zero_template.args[2], zero_template.derivative_order
|
|
929
|
+
)
|
|
930
|
+
if data is None:
|
|
931
|
+
return None
|
|
932
|
+
_k, _L, _U, guard, proof = data
|
|
933
|
+
nu = zero_template.args[2]
|
|
934
|
+
M = sp.Max(0, sp.ceiling(nu - sp.Rational(1, 2)))
|
|
935
|
+
# U(k)=(k+M)pi. If U(k)<A (or <=A for a strict tail), k is
|
|
936
|
+
# certainly excluded. L(k)>=A (or >A) certifies inclusion.
|
|
937
|
+
qlo = A / sp.pi - M
|
|
938
|
+
lower = sp.Max(1, sp.ceiling(qlo)) if inclusive else sp.Max(1, sp.floor(qlo) + 1)
|
|
939
|
+
half_or_more = (nu - sp.Rational(1, 2)).is_nonnegative is True
|
|
940
|
+
if zero_template.derivative_order == 0:
|
|
941
|
+
shift = S.Zero if half_or_more else sp.Rational(1, 2)
|
|
942
|
+
qhi = A / sp.pi + shift
|
|
943
|
+
upper = sp.Max(1, sp.ceiling(qhi) if inclusive else sp.floor(qhi) + 1)
|
|
944
|
+
else:
|
|
945
|
+
shift = S.One if half_or_more else sp.Rational(3, 2)
|
|
946
|
+
qhi = A / sp.pi + shift
|
|
947
|
+
upper = sp.Max(2, sp.ceiling(qhi) if inclusive else sp.floor(qhi) + 1)
|
|
948
|
+
return CertifiedIntegerBracket(
|
|
949
|
+
sp.simplify(lower),
|
|
950
|
+
sp.simplify(upper),
|
|
951
|
+
guard,
|
|
952
|
+
"rigorous Bessel-J zero bounds derived from DLMF 10.21",
|
|
953
|
+
proof,
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
class IndexedTailIndex(sp.Expr):
|
|
958
|
+
"""Exact inverse index for a monotone indexed zero family.
|
|
959
|
+
|
|
960
|
+
``direction='right'`` means the least positive integer ``k`` whose zero is
|
|
961
|
+
at or to the right of ``cutoff``. ``direction='left'`` is the analogous
|
|
962
|
+
first zero encountered moving left. The object is exact and intentionally
|
|
963
|
+
unevaluated when no closed integer formula is known.
|
|
964
|
+
"""
|
|
965
|
+
|
|
966
|
+
is_integer = True
|
|
967
|
+
is_positive = True
|
|
968
|
+
|
|
969
|
+
def __new__(
|
|
970
|
+
cls,
|
|
971
|
+
family,
|
|
972
|
+
cutoff,
|
|
973
|
+
order=S.NaN,
|
|
974
|
+
derivative_order=0,
|
|
975
|
+
direction="right",
|
|
976
|
+
inclusive=True,
|
|
977
|
+
branch="real",
|
|
978
|
+
):
|
|
979
|
+
fam = sp.Symbol(str(family))
|
|
980
|
+
cutoff = sp.sympify(cutoff)
|
|
981
|
+
order = sp.sympify(order)
|
|
982
|
+
d = sp.Integer(derivative_order)
|
|
983
|
+
direction = sp.Symbol(str(direction))
|
|
984
|
+
inc = S.true if inclusive else S.false
|
|
985
|
+
branch = sp.Symbol(str(branch))
|
|
986
|
+
return sp.Expr.__new__(cls, fam, cutoff, order, d, direction, inc, branch)
|
|
987
|
+
|
|
988
|
+
@property
|
|
989
|
+
def family(self):
|
|
990
|
+
return str(self.args[0])
|
|
991
|
+
|
|
992
|
+
@property
|
|
993
|
+
def cutoff(self):
|
|
994
|
+
return self.args[1]
|
|
995
|
+
|
|
996
|
+
@property
|
|
997
|
+
def order(self):
|
|
998
|
+
return None if self.args[2] is S.NaN else self.args[2]
|
|
999
|
+
|
|
1000
|
+
@property
|
|
1001
|
+
def derivative_order(self):
|
|
1002
|
+
return int(self.args[3])
|
|
1003
|
+
|
|
1004
|
+
@property
|
|
1005
|
+
def direction(self):
|
|
1006
|
+
return str(self.args[4])
|
|
1007
|
+
|
|
1008
|
+
@property
|
|
1009
|
+
def inclusive(self):
|
|
1010
|
+
return self.args[5] is S.true
|
|
1011
|
+
|
|
1012
|
+
@property
|
|
1013
|
+
def branch(self):
|
|
1014
|
+
return str(self.args[6])
|
|
1015
|
+
|
|
1016
|
+
def indexed_zero(self):
|
|
1017
|
+
return IndexedZero(
|
|
1018
|
+
self.family, self, self.args[2], self.derivative_order, self.branch
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
def _sympystr(self, printer):
|
|
1022
|
+
op = (
|
|
1023
|
+
">="
|
|
1024
|
+
if self.direction == "right" and self.inclusive
|
|
1025
|
+
else ">"
|
|
1026
|
+
if self.direction == "right"
|
|
1027
|
+
else "<="
|
|
1028
|
+
if self.inclusive
|
|
1029
|
+
else "<"
|
|
1030
|
+
)
|
|
1031
|
+
return f"first_k[{self.family}: zero_k {op} {printer.doprint(self.cutoff)}]"
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@dataclass(frozen=True)
|
|
1035
|
+
class IndexedTailInversionCertificate:
|
|
1036
|
+
family: str
|
|
1037
|
+
cutoff: object
|
|
1038
|
+
direction: str
|
|
1039
|
+
inclusive: bool
|
|
1040
|
+
first_index: object
|
|
1041
|
+
zero_template: IndexedZero
|
|
1042
|
+
guard: sp.Expr = S.true
|
|
1043
|
+
complete: bool = True
|
|
1044
|
+
source: str = ""
|
|
1045
|
+
proof: tuple[object, ...] = ()
|
|
1046
|
+
integer_bracket: object = None
|
|
1047
|
+
|
|
1048
|
+
@property
|
|
1049
|
+
def first_zero(self):
|
|
1050
|
+
return IndexedZero(
|
|
1051
|
+
self.family,
|
|
1052
|
+
self.first_index,
|
|
1053
|
+
self.zero_template.args[2],
|
|
1054
|
+
self.zero_template.derivative_order,
|
|
1055
|
+
self.zero_template.branch,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _same_zero_sequence(a, b):
|
|
1060
|
+
return (
|
|
1061
|
+
isinstance(a, IndexedZero)
|
|
1062
|
+
and isinstance(b, IndexedZero)
|
|
1063
|
+
and a.family == b.family
|
|
1064
|
+
and a.args[2] == b.args[2]
|
|
1065
|
+
and a.derivative_order == b.derivative_order
|
|
1066
|
+
and a.branch == b.branch
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def indexed_tail_inversion(zero_template, cutoff, *, direction="right", inclusive=True):
|
|
1071
|
+
"""Invert an indexed monotone zero family exactly at a cutoff.
|
|
1072
|
+
|
|
1073
|
+
The routine first applies exact same-family/interlacing identities. For a
|
|
1074
|
+
general cutoff it returns an ``IndexedTailIndex`` whose semantics are the
|
|
1075
|
+
certified least included index; no floating root finder is used.
|
|
1076
|
+
"""
|
|
1077
|
+
if not isinstance(zero_template, IndexedZero):
|
|
1078
|
+
raise TypeError("zero_template must be an IndexedZero")
|
|
1079
|
+
cutoff = sp.sympify(cutoff)
|
|
1080
|
+
fam = zero_template.family
|
|
1081
|
+
d = zero_template.derivative_order
|
|
1082
|
+
ordv = zero_template.args[2]
|
|
1083
|
+
branch = zero_template.branch
|
|
1084
|
+
if direction not in ("right", "left"):
|
|
1085
|
+
raise ValueError("direction must be 'right' or 'left'")
|
|
1086
|
+
|
|
1087
|
+
# Positive Bessel zero sequences increase with k; negative Airy sequences
|
|
1088
|
+
# decrease with k (indices run away from the origin).
|
|
1089
|
+
increasing = branch == "positive"
|
|
1090
|
+
decreasing = branch == "negative"
|
|
1091
|
+
|
|
1092
|
+
# Exact inversion at a member of the same indexed family.
|
|
1093
|
+
if isinstance(cutoff, IndexedZero) and _same_zero_sequence(zero_template, cutoff):
|
|
1094
|
+
m = cutoff.index
|
|
1095
|
+
if (increasing and direction == "right") or (
|
|
1096
|
+
decreasing and direction == "left"
|
|
1097
|
+
):
|
|
1098
|
+
idx = m if inclusive else m + 1
|
|
1099
|
+
elif (increasing and direction == "left") or (
|
|
1100
|
+
decreasing and direction == "right"
|
|
1101
|
+
):
|
|
1102
|
+
idx = m if inclusive else sp.Max(1, m - 1)
|
|
1103
|
+
else:
|
|
1104
|
+
idx = IndexedTailIndex(fam, cutoff, ordv, d, direction, inclusive, branch)
|
|
1105
|
+
return IndexedTailInversionCertificate(
|
|
1106
|
+
fam,
|
|
1107
|
+
cutoff,
|
|
1108
|
+
direction,
|
|
1109
|
+
inclusive,
|
|
1110
|
+
sp.simplify(idx),
|
|
1111
|
+
zero_template,
|
|
1112
|
+
S.true,
|
|
1113
|
+
True,
|
|
1114
|
+
"same indexed zero family",
|
|
1115
|
+
("strict monotonicity of indexed zeros in k",),
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
# Bessel J/Y function-vs-derivative interlacing, DLMF 10.21.E3:
|
|
1119
|
+
# z'_{nu,m} < z_{nu,m} < z'_{nu,m+1} for the supported nonnegative orders.
|
|
1120
|
+
if (
|
|
1121
|
+
isinstance(cutoff, IndexedZero)
|
|
1122
|
+
and fam in ("BesselJ", "BesselY")
|
|
1123
|
+
and (
|
|
1124
|
+
cutoff.family == fam
|
|
1125
|
+
and cutoff.args[2] == ordv
|
|
1126
|
+
and d == 1
|
|
1127
|
+
and cutoff.derivative_order == 0
|
|
1128
|
+
)
|
|
1129
|
+
):
|
|
1130
|
+
m = cutoff.index
|
|
1131
|
+
idx = m + 1 if direction == "right" else m
|
|
1132
|
+
guard = sp.Ge(ordv, 0) if ordv is not S.NaN else S.true
|
|
1133
|
+
return IndexedTailInversionCertificate(
|
|
1134
|
+
fam,
|
|
1135
|
+
cutoff,
|
|
1136
|
+
direction,
|
|
1137
|
+
inclusive,
|
|
1138
|
+
sp.simplify(idx),
|
|
1139
|
+
zero_template,
|
|
1140
|
+
guard,
|
|
1141
|
+
True,
|
|
1142
|
+
"DLMF 10.21.E3",
|
|
1143
|
+
("derivative/function zeros interlace",),
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
# Airy function/derivative interlacing. For Ai:
|
|
1147
|
+
# a'_{m+1} < a_m < a'_m. Bi has b_{m+1} < b'_m < b_m.
|
|
1148
|
+
if (
|
|
1149
|
+
isinstance(cutoff, IndexedZero)
|
|
1150
|
+
and fam in ("AiryAi", "AiryBi")
|
|
1151
|
+
and cutoff.family == fam
|
|
1152
|
+
):
|
|
1153
|
+
m = cutoff.index
|
|
1154
|
+
if d == 1 and cutoff.derivative_order == 0:
|
|
1155
|
+
if fam == "AiryAi":
|
|
1156
|
+
idx = m if direction == "right" else m + 1
|
|
1157
|
+
else: # b_{m+1} < b'_m < b_m
|
|
1158
|
+
idx = m if direction == "left" else sp.Max(1, m - 1)
|
|
1159
|
+
return IndexedTailInversionCertificate(
|
|
1160
|
+
fam,
|
|
1161
|
+
cutoff,
|
|
1162
|
+
direction,
|
|
1163
|
+
inclusive,
|
|
1164
|
+
sp.simplify(idx),
|
|
1165
|
+
zero_template,
|
|
1166
|
+
S.true,
|
|
1167
|
+
True,
|
|
1168
|
+
"DLMF 9.9 + Sturm separation",
|
|
1169
|
+
("Airy function/derivative zeros interlace",),
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
idx = IndexedTailIndex(fam, cutoff, ordv, d, direction, inclusive, branch)
|
|
1173
|
+
# The selector itself is an exact inverse of the certified monotone indexed
|
|
1174
|
+
# family. Rigorous zero bounds may further collapse it to a concrete integer.
|
|
1175
|
+
src = "exact symbolic inversion of certified indexed-zero ordering"
|
|
1176
|
+
bracket = indexed_tail_integer_bracket(
|
|
1177
|
+
zero_template, cutoff, direction=direction, inclusive=inclusive
|
|
1178
|
+
)
|
|
1179
|
+
if bracket is not None and bracket.exact:
|
|
1180
|
+
idx = sp.sympify(bracket.lower)
|
|
1181
|
+
src += " + certified integer zero-bound inversion"
|
|
1182
|
+
return IndexedTailInversionCertificate(
|
|
1183
|
+
fam,
|
|
1184
|
+
cutoff,
|
|
1185
|
+
direction,
|
|
1186
|
+
inclusive,
|
|
1187
|
+
idx,
|
|
1188
|
+
zero_template,
|
|
1189
|
+
S.true,
|
|
1190
|
+
True,
|
|
1191
|
+
src,
|
|
1192
|
+
(
|
|
1193
|
+
"no numerical root approximation",
|
|
1194
|
+
"least included positive integer index",
|
|
1195
|
+
),
|
|
1196
|
+
bracket,
|
|
1197
|
+
)
|