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.
Files changed (56) hide show
  1. funcprops/__init__.py +234 -0
  2. funcprops/_semialg.py +246 -0
  3. funcprops/api.py +70 -0
  4. funcprops/branchcuts/__init__.py +141 -0
  5. funcprops/branchcuts/computation.py +664 -0
  6. funcprops/branchcuts/core.py +1204 -0
  7. funcprops/branchcuts/post_simplify.py +461 -0
  8. funcprops/conditions.py +124 -0
  9. funcprops/data/catalogue.json +127449 -0
  10. funcprops/data/dlmf_audit.json +3382 -0
  11. funcprops/dlmf.py +29 -0
  12. funcprops/engine.py +324 -0
  13. funcprops/exceptions.py +29 -0
  14. funcprops/proof_cache.py +373 -0
  15. funcprops/real_analysis/__init__.py +52 -0
  16. funcprops/real_analysis/advanced_convexity.py +217 -0
  17. funcprops/real_analysis/boundedness.py +168 -0
  18. funcprops/real_analysis/common.py +244 -0
  19. funcprops/real_analysis/convexity.py +698 -0
  20. funcprops/real_analysis/differentiability.py +112 -0
  21. funcprops/real_analysis/domain.py +188 -0
  22. funcprops/real_analysis/mapping.py +374 -0
  23. funcprops/real_analysis/monotonic_certificates.py +867 -0
  24. funcprops/real_analysis/monotonicity.py +673 -0
  25. funcprops/real_analysis/oscillation.py +151 -0
  26. funcprops/real_analysis/piecewise.py +174 -0
  27. funcprops/real_analysis/range.py +552 -0
  28. funcprops/real_analysis/sign.py +561 -0
  29. funcprops/real_analysis/smoothness.py +268 -0
  30. funcprops/real_analysis/special_functions.py +515 -0
  31. funcprops/real_analysis/structural_properties.py +558 -0
  32. funcprops/real_analysis/zeros.py +327 -0
  33. funcprops/registry.py +274 -0
  34. funcprops/results.py +239 -0
  35. funcprops/singularities.py +1767 -0
  36. funcprops/stratification.py +494 -0
  37. funcprops/sympy_compare.py +63 -0
  38. funcprops/transcendental/__init__.py +140 -0
  39. funcprops/transcendental/algebraize.py +201 -0
  40. funcprops/transcendental/analytic.py +261 -0
  41. funcprops/transcendental/complex_roots.py +542 -0
  42. funcprops/transcendental/exp_fourier.py +221 -0
  43. funcprops/transcendental/extrema.py +821 -0
  44. funcprops/transcendental/periodic.py +222 -0
  45. funcprops/transcendental/proof.py +104 -0
  46. funcprops/transcendental/real_interval.py +128 -0
  47. funcprops/transcendental/real_sign.py +117 -0
  48. funcprops/transcendental/reduce.py +288 -0
  49. funcprops/transcendental/roots.py +1380 -0
  50. funcprops/transcendental/zero_geometry.py +1197 -0
  51. funcprops/verification.py +410 -0
  52. funcprops-0.1.0.dist-info/METADATA +208 -0
  53. funcprops-0.1.0.dist-info/RECORD +56 -0
  54. funcprops-0.1.0.dist-info/WHEEL +5 -0
  55. funcprops-0.1.0.dist-info/licenses/LICENSE +674 -0
  56. 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)