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,821 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import sympy as sp
|
|
6
|
+
from sympy import S
|
|
7
|
+
|
|
8
|
+
from ..exceptions import SYMBOLIC_QUERY_ERRORS
|
|
9
|
+
from .zero_geometry import (
|
|
10
|
+
IndexedZero,
|
|
11
|
+
ZeroGeometryCertificate,
|
|
12
|
+
geometry_complete_on_domain,
|
|
13
|
+
indexed_tail_inversion,
|
|
14
|
+
zero_geometry,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IndexedExtremumValue(sp.Expr):
|
|
19
|
+
"""Exact value of a function at an indexed critical point.
|
|
20
|
+
|
|
21
|
+
This is an unevaluated proof object, not a numerical approximation. The
|
|
22
|
+
underlying exact SymPy value is available through ``.value``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
is_real = True
|
|
26
|
+
|
|
27
|
+
def __new__(cls, family, index, point, value, kind="extremum"):
|
|
28
|
+
fam = sp.Symbol(str(family))
|
|
29
|
+
idx = sp.sympify(index)
|
|
30
|
+
point = sp.sympify(point)
|
|
31
|
+
value = sp.sympify(value)
|
|
32
|
+
kind = sp.Symbol(str(kind))
|
|
33
|
+
return sp.Expr.__new__(cls, fam, idx, point, value, kind)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def family(self):
|
|
37
|
+
return str(self.args[0])
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def index(self):
|
|
41
|
+
return self.args[1]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def point(self):
|
|
45
|
+
return self.args[2]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def value(self):
|
|
49
|
+
return self.args[3]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def kind(self):
|
|
53
|
+
return str(self.args[4])
|
|
54
|
+
|
|
55
|
+
def _sympystr(self, printer):
|
|
56
|
+
return f"Extremum[{self.family},{printer.doprint(self.index)}]={printer.doprint(self.value)}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ExtremumEnvelope(sp.Expr):
|
|
60
|
+
"""Exact infimum/supremum over an indexed extremum family.
|
|
61
|
+
|
|
62
|
+
``direction`` is ``'inf'`` or ``'sup'``. The object intentionally stays
|
|
63
|
+
symbolic when no theorem proves which indexed member realizes the bound.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
is_real = True
|
|
67
|
+
|
|
68
|
+
def __new__(cls, direction, template, index, index_guard=S.true):
|
|
69
|
+
d = sp.Symbol(str(direction))
|
|
70
|
+
template = sp.sympify(template)
|
|
71
|
+
index = sp.sympify(index)
|
|
72
|
+
guard = sp.sympify(index_guard)
|
|
73
|
+
return sp.Expr.__new__(cls, d, template, index, guard)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def direction(self):
|
|
77
|
+
return str(self.args[0])
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def template(self):
|
|
81
|
+
return self.args[1]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def index(self):
|
|
85
|
+
return self.args[2]
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def index_guard(self):
|
|
89
|
+
return self.args[3]
|
|
90
|
+
|
|
91
|
+
def _sympystr(self, printer):
|
|
92
|
+
op = "inf" if self.direction == "inf" else "sup"
|
|
93
|
+
return f"{op}_{{{printer.doprint(self.index)}: {printer.doprint(self.index_guard)}}}({printer.doprint(self.template)})"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ExtremumFamilyCertificate:
|
|
98
|
+
family: str
|
|
99
|
+
domain: object
|
|
100
|
+
critical_geometry: tuple[ZeroGeometryCertificate, ...]
|
|
101
|
+
extrema: tuple[IndexedExtremumValue, ...] = ()
|
|
102
|
+
alternating: bool | None = None
|
|
103
|
+
guard: sp.Expr = S.true
|
|
104
|
+
complete: bool = False
|
|
105
|
+
source: str = ""
|
|
106
|
+
notes: tuple[object, ...] = ()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ExtremumOrderingTheorem:
|
|
111
|
+
"""Exact theorem ordering successive indexed extrema.
|
|
112
|
+
|
|
113
|
+
``magnitude_direction='decreasing'`` means ``|E_{k+1}| < |E_k|``
|
|
114
|
+
on ``index_guard``. ``global_max_index``/``global_min_index`` identify
|
|
115
|
+
a member when the alternating sign pattern plus magnitude ordering proves
|
|
116
|
+
first-member dominance. The proof field records the exact Lyapunov/Sonin
|
|
117
|
+
quantity used by funcprops; it is not numerical evidence.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
family: str
|
|
121
|
+
magnitude_direction: str
|
|
122
|
+
guard: sp.Expr = S.true
|
|
123
|
+
index_guard: sp.Expr = S.true
|
|
124
|
+
global_max_index: object = None
|
|
125
|
+
global_min_index: object = None
|
|
126
|
+
proof: object = None
|
|
127
|
+
source: str = ""
|
|
128
|
+
notes: tuple[object, ...] = ()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class ExtremumRange:
|
|
133
|
+
"""Certified symbolic interval image from endpoint and critical-point data."""
|
|
134
|
+
|
|
135
|
+
lower: object
|
|
136
|
+
upper: object
|
|
137
|
+
lower_attained: bool | None
|
|
138
|
+
upper_attained: bool | None
|
|
139
|
+
domain: object
|
|
140
|
+
complete: bool
|
|
141
|
+
candidates: tuple[object, ...] = ()
|
|
142
|
+
evidence: tuple[object, ...] = ()
|
|
143
|
+
source: str = ""
|
|
144
|
+
|
|
145
|
+
def as_condition(self, y):
|
|
146
|
+
y = sp.sympify(y)
|
|
147
|
+
if not self.complete:
|
|
148
|
+
return None
|
|
149
|
+
lo = (
|
|
150
|
+
y >= self.lower
|
|
151
|
+
if self.lower_attained is True
|
|
152
|
+
else y > self.lower
|
|
153
|
+
if self.lower_attained is False
|
|
154
|
+
else None
|
|
155
|
+
)
|
|
156
|
+
hi = (
|
|
157
|
+
y <= self.upper
|
|
158
|
+
if self.upper_attained is True
|
|
159
|
+
else y < self.upper
|
|
160
|
+
if self.upper_attained is False
|
|
161
|
+
else None
|
|
162
|
+
)
|
|
163
|
+
if lo is None or hi is None:
|
|
164
|
+
return None
|
|
165
|
+
return sp.And(lo, hi)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _domain_interval(domain, var):
|
|
169
|
+
if isinstance(domain, sp.Interval):
|
|
170
|
+
return domain
|
|
171
|
+
try:
|
|
172
|
+
from ..real_analysis.common import condition_to_set
|
|
173
|
+
|
|
174
|
+
return condition_to_set(domain, var)
|
|
175
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _endpoint_candidate(expr, var, endpoint, side, attained):
|
|
180
|
+
from ..proof_cache import cached_endpoint_limit
|
|
181
|
+
|
|
182
|
+
val = cached_endpoint_limit(expr, var, endpoint, direction=side, attained=attained)
|
|
183
|
+
if val is None or getattr(val, "has", lambda *_: False)(var):
|
|
184
|
+
return None
|
|
185
|
+
return (sp.simplify(val), bool(attained))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def extremum_geometry(expr, var, *, domain=S.Reals, partition=None):
|
|
189
|
+
"""Return exact indexed critical-point/extremum families for ``expr``."""
|
|
190
|
+
expr = sp.sympify(expr)
|
|
191
|
+
var = sp.sympify(var)
|
|
192
|
+
if partition is None:
|
|
193
|
+
try:
|
|
194
|
+
from ..real_analysis.monotonic_certificates import monotonic_partition
|
|
195
|
+
|
|
196
|
+
partition = monotonic_partition(expr, var, domain=domain)
|
|
197
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
198
|
+
partition = None
|
|
199
|
+
geoms = ()
|
|
200
|
+
if partition is not None and getattr(partition, "indexed", None) is not None:
|
|
201
|
+
geoms = tuple(partition.indexed.boundary_geometry)
|
|
202
|
+
if not geoms:
|
|
203
|
+
geoms = zero_geometry(expr, var, derivative_order=1, domain=domain)
|
|
204
|
+
if not geoms:
|
|
205
|
+
return ()
|
|
206
|
+
out = []
|
|
207
|
+
for g in geoms:
|
|
208
|
+
extrema = []
|
|
209
|
+
for z in g.indexed_family:
|
|
210
|
+
val = sp.simplify(expr.xreplace({var: z}))
|
|
211
|
+
kind = "extremum"
|
|
212
|
+
# DLMF 9.9.E4/E5 fixes the Airy-Ai extrema sign parity.
|
|
213
|
+
if g.family == "AiryAi" and z.derivative_order == 1:
|
|
214
|
+
kind = "max_if_odd_min_if_even"
|
|
215
|
+
elif g.family == "AiryBi" and z.derivative_order == 1:
|
|
216
|
+
kind = "min_if_odd_max_if_even"
|
|
217
|
+
extrema.append(IndexedExtremumValue(g.family, z.index, z, val, kind))
|
|
218
|
+
out.append(
|
|
219
|
+
ExtremumFamilyCertificate(
|
|
220
|
+
g.family,
|
|
221
|
+
domain,
|
|
222
|
+
(g,),
|
|
223
|
+
tuple(extrema),
|
|
224
|
+
alternating=True if g.simple is True else None,
|
|
225
|
+
guard=g.guard,
|
|
226
|
+
complete=geometry_complete_on_domain(g, domain),
|
|
227
|
+
source=g.source,
|
|
228
|
+
notes=("critical points are derivative zeros",),
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
return tuple(out)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _contains_support(domain, support):
|
|
235
|
+
"""Three-valued exact support containment; only True is accepted."""
|
|
236
|
+
try:
|
|
237
|
+
r = domain.is_superset(support)
|
|
238
|
+
if r is True or r is S.true:
|
|
239
|
+
return True
|
|
240
|
+
if r is False or r is S.false:
|
|
241
|
+
return False
|
|
242
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
243
|
+
pass
|
|
244
|
+
try:
|
|
245
|
+
d = sp.simplify(support - domain)
|
|
246
|
+
if d is S.EmptySet:
|
|
247
|
+
return True
|
|
248
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
249
|
+
pass
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def extremum_ordering_theorems(expr, var):
|
|
254
|
+
"""Return exact ordering theorems for indexed extremum families.
|
|
255
|
+
|
|
256
|
+
The Airy proof is derived from ``E=y'^2-x*y^2``: ``E'=-y^2`` and
|
|
257
|
+
``(E/(-x))'=y'^2/x^2>0`` for ``x<0``. At a critical point this ratio is
|
|
258
|
+
``y^2``, hence amplitudes decrease as the Airy zero index increases away
|
|
259
|
+
from zero.
|
|
260
|
+
|
|
261
|
+
For Bessel J, with ``E=x^2*y'^2+(x^2-nu^2)*y^2``, Bessel's equation gives
|
|
262
|
+
``E'=2*x*y^2`` and
|
|
263
|
+
``(E/(x^2-nu^2))'=-2*x^3*y'^2/(x^2-nu^2)^2 < 0`` for ``x>nu``. At
|
|
264
|
+
critical points the ratio is ``y^2``. DLMF 10.21.E3 places the positive
|
|
265
|
+
J_nu derivative zeros at or beyond nu (strictly beyond for the nonzero
|
|
266
|
+
critical points), yielding decreasing successive extremum magnitudes.
|
|
267
|
+
"""
|
|
268
|
+
expr = sp.sympify(expr)
|
|
269
|
+
var = sp.sympify(var)
|
|
270
|
+
if (
|
|
271
|
+
expr.func in (sp.airyai, sp.airybi)
|
|
272
|
+
and len(expr.args) == 1
|
|
273
|
+
and expr.args[0] == var
|
|
274
|
+
):
|
|
275
|
+
fam = "AiryAi" if expr.func is sp.airyai else "AiryBi"
|
|
276
|
+
# DLMF 9.9.E4 fixes sign parity at derivative zeros.
|
|
277
|
+
if fam == "AiryAi":
|
|
278
|
+
gmax, gmin = sp.Integer(1), sp.Integer(2)
|
|
279
|
+
else:
|
|
280
|
+
gmax, gmin = sp.Integer(2), sp.Integer(1)
|
|
281
|
+
return (
|
|
282
|
+
ExtremumOrderingTheorem(
|
|
283
|
+
fam,
|
|
284
|
+
"decreasing",
|
|
285
|
+
S.true,
|
|
286
|
+
sp.Symbol("k", integer=True, positive=True) >= 1,
|
|
287
|
+
gmax,
|
|
288
|
+
gmin,
|
|
289
|
+
"Airy energy: E=y'^2-x*y^2; (E/(-x))'=y'^2/x^2>0 on x<0; at y'=0, E/(-x)=y^2",
|
|
290
|
+
"derived from Airy ODE + DLMF 9.9.E4; DLMF 9.8/9.9",
|
|
291
|
+
("index k increases away from zero, so |extremum_{k+1}|<|extremum_k|",),
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
if (
|
|
295
|
+
expr.func in (sp.besselj, sp.bessely)
|
|
296
|
+
and len(expr.args) == 2
|
|
297
|
+
and expr.args[1] == var
|
|
298
|
+
):
|
|
299
|
+
nu = expr.args[0]
|
|
300
|
+
k = sp.Symbol("k", integer=True, positive=True)
|
|
301
|
+
fam = "BesselJ" if expr.func is sp.besselj else "BesselY"
|
|
302
|
+
# For nu>0: first critical point is a positive maximum, second a
|
|
303
|
+
# negative minimum. nu=0 uses the DLMF convention j'_{0,1}=0, so the
|
|
304
|
+
# first *interior* critical point is index 2 and is a minimum.
|
|
305
|
+
return (
|
|
306
|
+
ExtremumOrderingTheorem(
|
|
307
|
+
fam,
|
|
308
|
+
"decreasing",
|
|
309
|
+
sp.And(sp.Contains(nu, S.Reals), nu >= 0),
|
|
310
|
+
k >= 1,
|
|
311
|
+
sp.Piecewise((3, sp.Eq(nu, 0)), (1, True)),
|
|
312
|
+
sp.Piecewise((2, sp.Eq(nu, 0)), (2, True)),
|
|
313
|
+
"Bessel energy: E=x^2*y'^2+(x^2-nu^2)*y^2; (E/(x^2-nu^2))'=-2*x^3*y'^2/(x^2-nu^2)^2<0 for x>nu",
|
|
314
|
+
"derived from Bessel ODE + DLMF 10.21.E1,E3; J bound additionally uses DLMF 10.14.E1",
|
|
315
|
+
(
|
|
316
|
+
"for nu=0, j'_{0,1}=0 is an endpoint critical point, not a positive interior extremum",
|
|
317
|
+
),
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
return ()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _indexed_value(expr, var, family, index, order=S.NaN, derivative_order=1):
|
|
324
|
+
z = IndexedZero(
|
|
325
|
+
family,
|
|
326
|
+
sp.sympify(index),
|
|
327
|
+
order,
|
|
328
|
+
derivative_order,
|
|
329
|
+
"positive" if family.startswith("Bessel") else "negative",
|
|
330
|
+
)
|
|
331
|
+
return sp.simplify(expr.xreplace({var: z})), z
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _first_odd_index(k):
|
|
335
|
+
k = sp.sympify(k)
|
|
336
|
+
return sp.simplify(k + sp.Mod(k + 1, 2))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _first_even_index(k):
|
|
340
|
+
k = sp.sympify(k)
|
|
341
|
+
return sp.simplify(k + sp.Mod(k, 2))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _tail_inversion_for_extrema(expr, var, domain):
|
|
345
|
+
"""Exact first critical-index certificate for an oscillatory semi-infinite tail."""
|
|
346
|
+
if not isinstance(domain, sp.Interval):
|
|
347
|
+
return None
|
|
348
|
+
certs = extremum_geometry(expr, var, domain=domain)
|
|
349
|
+
if not certs or not certs[0].extrema:
|
|
350
|
+
return None
|
|
351
|
+
z = certs[0].extrema[0].point
|
|
352
|
+
if not isinstance(z, IndexedZero):
|
|
353
|
+
return None
|
|
354
|
+
if z.branch == "positive" and domain.end is sp.oo and domain.start is not -sp.oo:
|
|
355
|
+
return indexed_tail_inversion(
|
|
356
|
+
z, domain.start, direction="right", inclusive=not domain.left_open
|
|
357
|
+
)
|
|
358
|
+
if z.branch == "negative" and domain.start is -sp.oo and domain.end is not sp.oo:
|
|
359
|
+
return indexed_tail_inversion(
|
|
360
|
+
z, domain.end, direction="left", inclusive=not domain.right_open
|
|
361
|
+
)
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _simplify_indexed_tail(expr, var, domain, certs, end_candidates):
|
|
366
|
+
"""Collapse an indexed envelope on a truncated oscillatory tail.
|
|
367
|
+
|
|
368
|
+
The first included critical index is exact even when represented by an
|
|
369
|
+
``IndexedTailIndex``. Alternating sign plus decreasing extremum magnitude
|
|
370
|
+
then reduces the infinite family to the first included odd/even extrema.
|
|
371
|
+
"""
|
|
372
|
+
inv = _tail_inversion_for_extrema(expr, var, domain)
|
|
373
|
+
if inv is None:
|
|
374
|
+
return None
|
|
375
|
+
evidence = list(certs) + list(extremum_ordering_theorems(expr, var)) + [inv]
|
|
376
|
+
k0 = sp.sympify(inv.first_index)
|
|
377
|
+
|
|
378
|
+
if expr.func in (sp.besselj, sp.bessely):
|
|
379
|
+
nu = expr.args[0]
|
|
380
|
+
if nu.is_nonnegative is not True:
|
|
381
|
+
return None
|
|
382
|
+
# For J_0, DLMF counts j'_{0,1}=0. Any strictly positive tail starts
|
|
383
|
+
# with an interior critical index >=2.
|
|
384
|
+
if (
|
|
385
|
+
expr.func is sp.besselj
|
|
386
|
+
and nu.is_zero is True
|
|
387
|
+
and domain.start.is_positive is True
|
|
388
|
+
):
|
|
389
|
+
k0 = sp.Max(2, k0)
|
|
390
|
+
kmax = _first_odd_index(k0)
|
|
391
|
+
kmin = _first_even_index(k0)
|
|
392
|
+
fam = "BesselJ" if expr.func is sp.besselj else "BesselY"
|
|
393
|
+
vmax, zmax = _indexed_value(expr, var, fam, kmax, nu)
|
|
394
|
+
vmin, zmin = _indexed_value(expr, var, fam, kmin, nu)
|
|
395
|
+
candidates = [
|
|
396
|
+
("critical", zmin, vmin, True),
|
|
397
|
+
("critical", zmax, vmax, True),
|
|
398
|
+
*list(end_candidates),
|
|
399
|
+
]
|
|
400
|
+
finite_ends = [
|
|
401
|
+
(v, att) for side, pt, v, att in end_candidates if pt not in (-sp.oo, sp.oo)
|
|
402
|
+
]
|
|
403
|
+
lo = sp.Min(vmin, *(v for v, _ in finite_ends)) if finite_ends else vmin
|
|
404
|
+
hi = sp.Max(vmax, *(v for v, _ in finite_ends)) if finite_ends else vmax
|
|
405
|
+
# A cutoff can lie just beyond a larger excluded extremum, so f(A) may
|
|
406
|
+
# dominate the first same-sign included extremum. On a closed tail it
|
|
407
|
+
# is attained; on an open tail exact attainment depends on that symbolic
|
|
408
|
+
# comparison and remains unknown.
|
|
409
|
+
if domain.left_open and finite_ends:
|
|
410
|
+
la = ua = None
|
|
411
|
+
else:
|
|
412
|
+
la = ua = True
|
|
413
|
+
return ExtremumRange(
|
|
414
|
+
lo,
|
|
415
|
+
hi,
|
|
416
|
+
la,
|
|
417
|
+
ua,
|
|
418
|
+
domain,
|
|
419
|
+
True,
|
|
420
|
+
tuple(candidates),
|
|
421
|
+
tuple(evidence),
|
|
422
|
+
"indexed-tail inversion + Bessel extremum amplitude ordering",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if expr.func in (sp.airyai, sp.airybi):
|
|
426
|
+
# Oscillatory Airy tails run to -infinity; indices increase leftward.
|
|
427
|
+
if domain.start is not -sp.oo:
|
|
428
|
+
return None
|
|
429
|
+
fam = "AiryAi" if expr.func is sp.airyai else "AiryBi"
|
|
430
|
+
if fam == "AiryAi":
|
|
431
|
+
kmax = _first_odd_index(k0)
|
|
432
|
+
kmin = _first_even_index(k0)
|
|
433
|
+
else:
|
|
434
|
+
kmin = _first_odd_index(k0)
|
|
435
|
+
kmax = _first_even_index(k0)
|
|
436
|
+
vmax, zmax = _indexed_value(expr, var, fam, kmax)
|
|
437
|
+
vmin, zmin = _indexed_value(expr, var, fam, kmin)
|
|
438
|
+
# The -infinity Airy limit is 0 and is strictly between the first
|
|
439
|
+
# included positive/negative extrema, so it cannot determine a bound.
|
|
440
|
+
vals = [(v, att) for _, pt, v, att in end_candidates if pt is not -sp.oo]
|
|
441
|
+
lo = sp.Min(vmin, *(v for v, _ in vals)) if vals else vmin
|
|
442
|
+
hi = sp.Max(vmax, *(v for v, _ in vals)) if vals else vmax
|
|
443
|
+
# If the finite endpoint belongs to the interval, every candidate in
|
|
444
|
+
# the Min/Max is attained. For an open cutoff, whether its limiting
|
|
445
|
+
# value dominates is intentionally left unresolved.
|
|
446
|
+
if domain.right_open:
|
|
447
|
+
la = ua = None
|
|
448
|
+
else:
|
|
449
|
+
la = ua = True
|
|
450
|
+
candidates = [
|
|
451
|
+
("critical", zmin, vmin, True),
|
|
452
|
+
("critical", zmax, vmax, True),
|
|
453
|
+
*list(end_candidates),
|
|
454
|
+
]
|
|
455
|
+
return ExtremumRange(
|
|
456
|
+
lo,
|
|
457
|
+
hi,
|
|
458
|
+
la,
|
|
459
|
+
ua,
|
|
460
|
+
domain,
|
|
461
|
+
True,
|
|
462
|
+
tuple(candidates),
|
|
463
|
+
tuple(evidence),
|
|
464
|
+
"indexed-tail inversion + Airy extremum amplitude ordering",
|
|
465
|
+
)
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _simplify_indexed_extrema(expr, var, domain, certs, end_candidates):
|
|
470
|
+
"""Apply exact first-extremum dominance theorems to an indexed envelope."""
|
|
471
|
+
thms = extremum_ordering_theorems(expr, var)
|
|
472
|
+
if not thms:
|
|
473
|
+
return None
|
|
474
|
+
vals = [(v, att, side) for side, _, v, att in end_candidates]
|
|
475
|
+
t = thms[0]
|
|
476
|
+
evidence = [*list(certs), t]
|
|
477
|
+
|
|
478
|
+
# Airy: theorem covers every negative critical point. It is safe to choose
|
|
479
|
+
# the first positive and first negative extrema only when the requested
|
|
480
|
+
# domain contains the entire negative critical support.
|
|
481
|
+
if expr.func is sp.airyai:
|
|
482
|
+
support = sp.Interval.open(-sp.oo, 0)
|
|
483
|
+
if _contains_support(domain, support) is not True:
|
|
484
|
+
return None
|
|
485
|
+
fam = "AiryAi"
|
|
486
|
+
vmax, zmax = _indexed_value(expr, var, fam, t.global_max_index)
|
|
487
|
+
vmin, zmin = _indexed_value(expr, var, fam, t.global_min_index)
|
|
488
|
+
lo = vmin
|
|
489
|
+
hi = vmax
|
|
490
|
+
la = True
|
|
491
|
+
ua = True
|
|
492
|
+
candidates = [
|
|
493
|
+
("critical", zmin, vmin, True),
|
|
494
|
+
("critical", zmax, vmax, True),
|
|
495
|
+
*list(end_candidates),
|
|
496
|
+
]
|
|
497
|
+
# Endpoint/limit candidates can dominate a signed family member.
|
|
498
|
+
for v, att, _side in vals:
|
|
499
|
+
d = sp.simplify(v - hi)
|
|
500
|
+
if d.is_positive is True or d.is_zero is True:
|
|
501
|
+
hi = v
|
|
502
|
+
ua = att
|
|
503
|
+
d = sp.simplify(v - lo)
|
|
504
|
+
if d.is_negative is True or d.is_zero is True:
|
|
505
|
+
lo = v
|
|
506
|
+
la = att
|
|
507
|
+
# Known Airy right-half-line behavior makes these comparisons exact
|
|
508
|
+
# even when SymPy cannot order a symbolic indexed value against Ai(0).
|
|
509
|
+
if expr.func is sp.airyai:
|
|
510
|
+
# Ai is positive for x>=0 and decreases to 0; first negative-side
|
|
511
|
+
# maximum/minimum dominate all x>=0.
|
|
512
|
+
if domain.end is sp.oo or (
|
|
513
|
+
domain.end.is_nonnegative is True if domain.end.is_finite else False
|
|
514
|
+
):
|
|
515
|
+
pass
|
|
516
|
+
elif expr.func is sp.airybi:
|
|
517
|
+
# On x>=0 Bi is positive and increasing. If +infinity is included,
|
|
518
|
+
# the upper range is unbounded; on an endpoint 0 the endpoint Bi(0)
|
|
519
|
+
# exceeds all later positive extrema on the negative side only if
|
|
520
|
+
# its comparison can be proved below.
|
|
521
|
+
if domain.end is sp.oo:
|
|
522
|
+
hi = sp.oo
|
|
523
|
+
ua = False
|
|
524
|
+
return ExtremumRange(
|
|
525
|
+
lo,
|
|
526
|
+
hi,
|
|
527
|
+
la,
|
|
528
|
+
ua,
|
|
529
|
+
domain,
|
|
530
|
+
True,
|
|
531
|
+
tuple(candidates),
|
|
532
|
+
tuple(evidence),
|
|
533
|
+
"indexed extrema simplified by exact Airy amplitude ordering",
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# Bessel J: currently simplify the canonical positive semiaxis, where all
|
|
537
|
+
# positive derivative zeros are present. More restricted tails keep the
|
|
538
|
+
# generic envelope because selecting the first included index is a separate
|
|
539
|
+
# integer-inversion problem.
|
|
540
|
+
if expr.func is sp.besselj:
|
|
541
|
+
support = sp.Interval.open(0, sp.oo)
|
|
542
|
+
if _contains_support(domain, support) is not True:
|
|
543
|
+
return None
|
|
544
|
+
nu = expr.args[0]
|
|
545
|
+
if nu.is_zero is True:
|
|
546
|
+
vmin, zmin = _indexed_value(expr, var, "BesselJ", 2, nu)
|
|
547
|
+
lo = vmin
|
|
548
|
+
la = True
|
|
549
|
+
# J_0(0)=1 and DLMF 10.14.E1 gives |J_0(x)|<=1. On (0,oo) the
|
|
550
|
+
# upper endpoint is a nonattained limit; on [0,oo) it is attained.
|
|
551
|
+
hi = S.One
|
|
552
|
+
ua = bool(domain.contains(0) is S.true or domain.contains(0) is True)
|
|
553
|
+
cand = [("critical", zmin, vmin, True), *list(end_candidates)]
|
|
554
|
+
return ExtremumRange(
|
|
555
|
+
lo,
|
|
556
|
+
hi,
|
|
557
|
+
la,
|
|
558
|
+
ua,
|
|
559
|
+
domain,
|
|
560
|
+
True,
|
|
561
|
+
tuple(cand),
|
|
562
|
+
tuple(evidence),
|
|
563
|
+
"Bessel J0 first-interior-extremum theorem",
|
|
564
|
+
)
|
|
565
|
+
if nu.is_positive is True:
|
|
566
|
+
vmax, zmax = _indexed_value(expr, var, "BesselJ", 1, nu)
|
|
567
|
+
vmin, zmin = _indexed_value(expr, var, "BesselJ", 2, nu)
|
|
568
|
+
return ExtremumRange(
|
|
569
|
+
vmin,
|
|
570
|
+
vmax,
|
|
571
|
+
True,
|
|
572
|
+
True,
|
|
573
|
+
domain,
|
|
574
|
+
True,
|
|
575
|
+
(
|
|
576
|
+
("critical", zmin, vmin, True),
|
|
577
|
+
("critical", zmax, vmax, True),
|
|
578
|
+
*tuple(end_candidates),
|
|
579
|
+
),
|
|
580
|
+
tuple(evidence),
|
|
581
|
+
"Bessel J_nu first-extrema dominance for nu>0",
|
|
582
|
+
)
|
|
583
|
+
if expr.func is sp.bessely:
|
|
584
|
+
support = sp.Interval.open(0, sp.oo)
|
|
585
|
+
if _contains_support(domain, support) is not True:
|
|
586
|
+
return None
|
|
587
|
+
nu = expr.args[0]
|
|
588
|
+
if nu.is_nonnegative is True:
|
|
589
|
+
vmax, zmax = _indexed_value(expr, var, "BesselY", 1, nu)
|
|
590
|
+
# Y_nu -> -infinity at 0+ for nu>=0 (Y_0 logarithmically).
|
|
591
|
+
return ExtremumRange(
|
|
592
|
+
-sp.oo,
|
|
593
|
+
vmax,
|
|
594
|
+
False,
|
|
595
|
+
True,
|
|
596
|
+
domain,
|
|
597
|
+
True,
|
|
598
|
+
(("critical", zmax, vmax, True), *tuple(end_candidates)),
|
|
599
|
+
tuple(evidence),
|
|
600
|
+
"Bessel Y_nu first-maximum dominance for nu>=0",
|
|
601
|
+
)
|
|
602
|
+
# Bi on the whole real line: all real critical amplitudes decrease away
|
|
603
|
+
# from zero; b'_1 is the first (negative) minimum, while Bi has no
|
|
604
|
+
# nonnegative zeros and grows to +infinity.
|
|
605
|
+
if expr.func is sp.airybi and domain.start is -sp.oo and domain.end is sp.oo:
|
|
606
|
+
fam = "AiryBi"
|
|
607
|
+
vmin, zmin = _indexed_value(expr, var, fam, 1)
|
|
608
|
+
return ExtremumRange(
|
|
609
|
+
vmin,
|
|
610
|
+
sp.oo,
|
|
611
|
+
True,
|
|
612
|
+
False,
|
|
613
|
+
domain,
|
|
614
|
+
True,
|
|
615
|
+
(("critical", zmin, vmin, True), *tuple(end_candidates)),
|
|
616
|
+
tuple(evidence),
|
|
617
|
+
"Airy Bi global minimum from first-extremum dominance",
|
|
618
|
+
)
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def simplify_extremum_range(expr, var, result):
|
|
623
|
+
"""Simplify an ``ExtremumRange`` using exact indexed-extremum theorems."""
|
|
624
|
+
if not isinstance(result, ExtremumRange) or not result.complete:
|
|
625
|
+
return result
|
|
626
|
+
if not isinstance(result.domain, sp.Interval):
|
|
627
|
+
return result
|
|
628
|
+
certs = tuple(
|
|
629
|
+
e for e in result.evidence if isinstance(e, ExtremumFamilyCertificate)
|
|
630
|
+
)
|
|
631
|
+
# Existing result evidence generally stores certificates directly, while
|
|
632
|
+
# Accept the compact tuple form returned by extremum_geometry.
|
|
633
|
+
if not certs:
|
|
634
|
+
certs = extremum_geometry(expr, var, domain=result.domain)
|
|
635
|
+
ends = tuple(
|
|
636
|
+
c
|
|
637
|
+
for c in result.candidates
|
|
638
|
+
if isinstance(c, tuple) and c and c[0] in ("left", "right")
|
|
639
|
+
)
|
|
640
|
+
expr = sp.sympify(expr)
|
|
641
|
+
var = sp.sympify(var)
|
|
642
|
+
# Preserve the strongest closed-form canonical-semiaxis theorems first.
|
|
643
|
+
# Tail inversion is for genuinely truncated supports.
|
|
644
|
+
simp = _simplify_indexed_extrema(expr, var, result.domain, certs, ends)
|
|
645
|
+
if simp is not None:
|
|
646
|
+
return simp
|
|
647
|
+
tail = _simplify_indexed_tail(expr, var, result.domain, certs, ends)
|
|
648
|
+
return tail if tail is not None else result
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _indexed_envelopes(domain, certs):
|
|
652
|
+
"""Build exact inf/sup envelopes over complete indexed critical families."""
|
|
653
|
+
if not certs or not all(c.complete for c in certs):
|
|
654
|
+
return None
|
|
655
|
+
templates = []
|
|
656
|
+
for c in certs:
|
|
657
|
+
if not c.extrema:
|
|
658
|
+
return None
|
|
659
|
+
e = c.extrema[0]
|
|
660
|
+
k = e.index
|
|
661
|
+
# Filter indexed critical points to the requested interval exactly.
|
|
662
|
+
point = e.point
|
|
663
|
+
guard = sp.And(k >= 1, c.guard)
|
|
664
|
+
if isinstance(domain, sp.Interval):
|
|
665
|
+
if domain.start is not -sp.oo:
|
|
666
|
+
guard = sp.And(
|
|
667
|
+
guard,
|
|
668
|
+
point > domain.start if domain.left_open else point >= domain.start,
|
|
669
|
+
)
|
|
670
|
+
if domain.end is not sp.oo:
|
|
671
|
+
guard = sp.And(
|
|
672
|
+
guard,
|
|
673
|
+
point < domain.end if domain.right_open else point <= domain.end,
|
|
674
|
+
)
|
|
675
|
+
templates.append((e.value, k, guard, e))
|
|
676
|
+
# Multiple derivative families are rare; preserve them as Min/Max of exact envelopes.
|
|
677
|
+
infs = [ExtremumEnvelope("inf", v, k, g) for v, k, g, _ in templates]
|
|
678
|
+
sups = [ExtremumEnvelope("sup", v, k, g) for v, k, g, _ in templates]
|
|
679
|
+
return (
|
|
680
|
+
sp.Min(*infs) if len(infs) > 1 else infs[0],
|
|
681
|
+
sp.Max(*sups) if len(sups) > 1 else sups[0],
|
|
682
|
+
tuple(t[3] for t in templates),
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def symbolic_extremum_range(expr, var, domain=S.Reals, *, partition=None):
|
|
687
|
+
"""Compute an exact range envelope from critical-point geometry.
|
|
688
|
+
|
|
689
|
+
Closed bounded intervals with finitely many exact critical points reduce to
|
|
690
|
+
ordinary exact ``Min``/``Max`` endpoints. Infinite oscillatory critical
|
|
691
|
+
families remain represented by ``ExtremumEnvelope`` objects.
|
|
692
|
+
"""
|
|
693
|
+
expr = sp.sympify(expr)
|
|
694
|
+
var = sp.sympify(var)
|
|
695
|
+
ds = _domain_interval(domain, var)
|
|
696
|
+
if not isinstance(ds, sp.Interval):
|
|
697
|
+
return None
|
|
698
|
+
if partition is None:
|
|
699
|
+
try:
|
|
700
|
+
from ..real_analysis.monotonic_certificates import monotonic_partition
|
|
701
|
+
|
|
702
|
+
partition = monotonic_partition(expr, var, domain=ds)
|
|
703
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
704
|
+
partition = None
|
|
705
|
+
|
|
706
|
+
# Critical points are owned by the shared monotonic partition. Sign-cell
|
|
707
|
+
# completeness is not required for extrema: the separate flag certifies
|
|
708
|
+
# that the derivative-root set itself is exhaustive.
|
|
709
|
+
pts = None
|
|
710
|
+
if (
|
|
711
|
+
partition is not None
|
|
712
|
+
and getattr(partition, "indexed", None) is None
|
|
713
|
+
and getattr(partition, "critical_points_complete", False)
|
|
714
|
+
):
|
|
715
|
+
pts = tuple(getattr(partition, "critical_points", ()))
|
|
716
|
+
if (
|
|
717
|
+
pts is not None
|
|
718
|
+
and ds.start not in (-sp.oo, sp.oo)
|
|
719
|
+
and ds.end not in (-sp.oo, sp.oo)
|
|
720
|
+
):
|
|
721
|
+
candidates = []
|
|
722
|
+
values = []
|
|
723
|
+
lc = _endpoint_candidate(expr, var, ds.start, "+", not ds.left_open)
|
|
724
|
+
rc = _endpoint_candidate(expr, var, ds.end, "-", not ds.right_open)
|
|
725
|
+
if lc is None or rc is None:
|
|
726
|
+
return None
|
|
727
|
+
candidates.extend((("left", ds.start, *lc), ("right", ds.end, *rc)))
|
|
728
|
+
for q in pts:
|
|
729
|
+
try:
|
|
730
|
+
v = sp.simplify(expr.subs(var, q))
|
|
731
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
732
|
+
return None
|
|
733
|
+
values.append((v, True))
|
|
734
|
+
candidates.append(("critical", q, v, True))
|
|
735
|
+
values = [lc, rc, *values]
|
|
736
|
+
lo = sp.Min(*(v for v, _ in values))
|
|
737
|
+
hi = sp.Max(*(v for v, _ in values))
|
|
738
|
+
|
|
739
|
+
def attainment(bound, want_min):
|
|
740
|
+
# First try to prove that an attained candidate realizes the bound.
|
|
741
|
+
for v, att in values:
|
|
742
|
+
if not att:
|
|
743
|
+
continue
|
|
744
|
+
eq = sp.simplify(bound - v)
|
|
745
|
+
if eq.is_zero is True:
|
|
746
|
+
return True
|
|
747
|
+
# Prove non-attainment only when the bound is an open-endpoint limit
|
|
748
|
+
# and every attained candidate lies strictly inside it.
|
|
749
|
+
open_hits = []
|
|
750
|
+
for v, att in values:
|
|
751
|
+
if att:
|
|
752
|
+
continue
|
|
753
|
+
if sp.simplify(bound - v).is_zero is True:
|
|
754
|
+
open_hits.append(v)
|
|
755
|
+
if open_hits:
|
|
756
|
+
ok = True
|
|
757
|
+
for v, att in values:
|
|
758
|
+
if not att:
|
|
759
|
+
continue
|
|
760
|
+
d = sp.simplify(v - bound)
|
|
761
|
+
if want_min and d.is_positive is not True:
|
|
762
|
+
ok = False
|
|
763
|
+
break
|
|
764
|
+
if not want_min and d.is_negative is not True:
|
|
765
|
+
ok = False
|
|
766
|
+
break
|
|
767
|
+
if ok:
|
|
768
|
+
return False
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
la = attainment(lo, True)
|
|
772
|
+
ua = attainment(hi, False)
|
|
773
|
+
return ExtremumRange(
|
|
774
|
+
lo,
|
|
775
|
+
hi,
|
|
776
|
+
la,
|
|
777
|
+
ua,
|
|
778
|
+
ds,
|
|
779
|
+
True,
|
|
780
|
+
tuple(candidates),
|
|
781
|
+
(("critical_points", pts),),
|
|
782
|
+
"finite exact critical-point range",
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
certs = extremum_geometry(expr, var, domain=ds, partition=partition)
|
|
786
|
+
env = _indexed_envelopes(ds, certs)
|
|
787
|
+
if env is None:
|
|
788
|
+
return None
|
|
789
|
+
crit_lo, crit_hi, templates = env
|
|
790
|
+
end_candidates = []
|
|
791
|
+
left = (
|
|
792
|
+
_endpoint_candidate(expr, var, ds.start, "+", not ds.left_open)
|
|
793
|
+
if ds.start is not -sp.oo
|
|
794
|
+
else _endpoint_candidate(expr, var, -sp.oo, "+", False)
|
|
795
|
+
)
|
|
796
|
+
right = (
|
|
797
|
+
_endpoint_candidate(expr, var, ds.end, "-", not ds.right_open)
|
|
798
|
+
if ds.end is not sp.oo
|
|
799
|
+
else _endpoint_candidate(expr, var, sp.oo, "-", False)
|
|
800
|
+
)
|
|
801
|
+
if left is not None:
|
|
802
|
+
end_candidates.append(("left", ds.start, *left))
|
|
803
|
+
if right is not None:
|
|
804
|
+
end_candidates.append(("right", ds.end, *right))
|
|
805
|
+
vals = [v for _, _, v, _ in end_candidates]
|
|
806
|
+
lo = sp.Min(crit_lo, *vals) if vals else crit_lo
|
|
807
|
+
hi = sp.Max(crit_hi, *vals) if vals else crit_hi
|
|
808
|
+
# Infinite envelopes may be attained by a member or only approached; unless
|
|
809
|
+
# a stronger theorem resolves this, preserve the endpoint-attainment state.
|
|
810
|
+
raw = ExtremumRange(
|
|
811
|
+
lo,
|
|
812
|
+
hi,
|
|
813
|
+
None,
|
|
814
|
+
None,
|
|
815
|
+
ds,
|
|
816
|
+
True,
|
|
817
|
+
tuple(end_candidates) + templates,
|
|
818
|
+
tuple(certs),
|
|
819
|
+
"indexed derivative-zero extremum envelope",
|
|
820
|
+
)
|
|
821
|
+
return simplify_extremum_range(expr, var, raw)
|