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,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
+ )