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,1380 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import math
5
+ from dataclasses import dataclass
6
+
7
+ import sympy as sp
8
+ from sympy import S
9
+
10
+ from ..exceptions import SYMBOLIC_QUERY_ERRORS
11
+ from .analytic import analytic_neighborhood
12
+ from .periodic import PeriodicSet
13
+ from .real_sign import robust_real_sign
14
+ from .zero_geometry import ZeroGeometryCertificate, zero_geometry
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class RootCertificate:
19
+ domain: object
20
+ root_count: int | None
21
+ method: str
22
+ proof: tuple[object, ...] = ()
23
+ complete: bool = True
24
+ multiplicity: int | None = None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CertifiedRootInterval:
29
+ interval: sp.Interval
30
+ certificate: RootCertificate
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ParametricZeroFamily:
35
+ count: sp.Expr
36
+ support: sp.Set
37
+ guard: sp.Expr = S.true
38
+ simple: bool | None = None
39
+ interlaces_prev_degree: bool | None = None
40
+ interlacing: tuple[str, ...] = ()
41
+ source: str = ""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RootIsolationResult:
46
+ roots: tuple[sp.Expr, ...] = ()
47
+ zero_intervals: tuple[sp.Interval, ...] = ()
48
+ isolated_intervals: tuple[CertifiedRootInterval, ...] = ()
49
+ periodic: PeriodicSet | None = None
50
+ complete: bool = False
51
+ method: str = "unknown"
52
+ evidence: tuple[object, ...] = ()
53
+ certificates: tuple[RootCertificate, ...] = ()
54
+ parametric_families: tuple[ParametricZeroFamily, ...] = ()
55
+ geometry: tuple[ZeroGeometryCertificate, ...] = ()
56
+
57
+ @property
58
+ def certified(self):
59
+ return self.complete
60
+
61
+
62
+ def _sort_unique(vals):
63
+ out = []
64
+ for v in sorted((sp.simplify(q) for q in vals), key=sp.default_sort_key):
65
+ if not any(sp.simplify(v - u) == 0 for u in out):
66
+ out.append(v)
67
+ return tuple(out)
68
+
69
+
70
+ def _affine_data(arg, var):
71
+ try:
72
+ p = sp.Poly(sp.expand(arg), var)
73
+ if p.degree() != 1:
74
+ return None
75
+ return p.nth(1), p.nth(0)
76
+ except SYMBOLIC_QUERY_ERRORS:
77
+ return None
78
+
79
+
80
+ def _periodic(expr, var, domain):
81
+ if expr.func not in (sp.sin, sp.cos, sp.tan):
82
+ return None
83
+ ab = _affine_data(expr.args[0], var)
84
+ if ab is None:
85
+ return None
86
+ a, b = ab
87
+ if a == 0 or a.is_real is False:
88
+ return None
89
+ target = S.Zero if expr.func in (sp.sin, sp.tan) else sp.pi / 2
90
+ p = sp.simplify(sp.pi / sp.Abs(a))
91
+ if p.is_positive is not True:
92
+ return None
93
+ x0 = sp.simplify((target - b) / a)
94
+ base = sp.FiniteSet(sp.simplify(sp.Mod(x0, p)))
95
+ ps = PeriodicSet(base, p, var)
96
+ if domain is S.Reals:
97
+ c = RootCertificate(
98
+ S.Reals, None, "periodic_formula", (sp.Eq(expr, 0), ps), True
99
+ )
100
+ return RootIsolationResult(
101
+ periodic=ps, complete=True, method="structural_periodic", certificates=(c,)
102
+ )
103
+ return None
104
+
105
+
106
+ def _special(expr, var, domain):
107
+ if expr.func in (sp.erf, sp.erfi) and len(expr.args) == 1:
108
+ ab = _affine_data(expr.args[0], var)
109
+ if ab:
110
+ a, b = ab
111
+ r = sp.simplify(-b / a)
112
+ rts = () if sp.Contains(r, domain) is S.false else (r,)
113
+ c = RootCertificate(
114
+ domain,
115
+ len(rts),
116
+ "special_function_zero",
117
+ ("erf/erfi has unique real zero at 0",),
118
+ True,
119
+ )
120
+ return RootIsolationResult(
121
+ rts, complete=True, method="special", certificates=(c,)
122
+ )
123
+ if expr.func is sp.gamma:
124
+ c = RootCertificate(
125
+ domain, 0, "special_function_no_zeros", ("gamma has no zeros",), True
126
+ )
127
+ return RootIsolationResult(
128
+ (), complete=True, method="special_no_zeros", certificates=(c,)
129
+ )
130
+ return None
131
+
132
+
133
+ def _guard_proved(g):
134
+ g = sp.sympify(g)
135
+ if g is S.true:
136
+ return True
137
+ try:
138
+ q = sp.refine(g)
139
+ if q is S.true:
140
+ return True
141
+ except SYMBOLIC_QUERY_ERRORS:
142
+ pass
143
+ try:
144
+ q = sp.simplify(g)
145
+ if q is S.true:
146
+ return True
147
+ except SYMBOLIC_QUERY_ERRORS:
148
+ pass
149
+ return False
150
+
151
+
152
+ def _orthogonal_special(expr, var, domain):
153
+ """Return exact zero-family metadata for audited orthogonal polynomials.
154
+
155
+ Parameter guards are retained symbolically; completeness is asserted only
156
+ when the support and degree hypotheses are proved on the requested domain.
157
+ """
158
+ # Formula-level DLMF zero distributions. For unresolved parameter guards
159
+ # retain exact conditional metadata without claiming completeness.
160
+ if not expr.args or expr.args[-1] != var:
161
+ return None
162
+ f = expr.func
163
+ n = expr.args[0] if expr.args else None
164
+ n_guard = sp.And(sp.Contains(n, S.Integers), n >= 1) if n is not None else S.false
165
+ support = None
166
+ guard = n_guard
167
+ source = ""
168
+ interlace = True
169
+ if f is sp.jacobi and len(expr.args) == 4:
170
+ a, b = expr.args[1:3]
171
+ support = sp.Interval.open(-1, 1)
172
+ guard = sp.And(n_guard, a > -1, b > -1)
173
+ source = "DLMF 18.16.E1 + 18.2(vi)"
174
+ elif f is sp.gegenbauer and len(expr.args) == 3:
175
+ lam = expr.args[1]
176
+ support = sp.Interval.open(-1, 1)
177
+ guard = sp.And(n_guard, lam > -sp.Rational(1, 2), sp.Ne(lam, 0))
178
+ source = "DLMF 18.16(iii) + 18.2(vi)"
179
+ elif f is sp.assoc_laguerre and len(expr.args) == 3:
180
+ a = expr.args[1]
181
+ support = sp.Interval.open(0, sp.oo)
182
+ guard = sp.And(n_guard, a > -1)
183
+ source = "DLMF 18.16.E9 + 18.2(vi)"
184
+ elif f is sp.laguerre and len(expr.args) == 2:
185
+ support = sp.Interval.open(0, sp.oo)
186
+ source = "DLMF 18.16.E9 + 18.2(vi)"
187
+ elif f in tuple(
188
+ q
189
+ for q in (getattr(sp, "hermite", None), getattr(sp, "hermite_prob", None))
190
+ if q is not None
191
+ ):
192
+ support = S.Reals
193
+ source = "DLMF 18.16(v) + 18.2(vi)"
194
+ elif f is sp.chebyshevt and len(expr.args) == 2:
195
+ support = sp.Interval.open(-1, 1)
196
+ source = "DLMF 18.5.E1 + 18.16(iii)"
197
+ if _guard_proved(n_guard):
198
+ k = sp.Dummy("k", integer=True)
199
+ roots = sp.ImageSet(
200
+ sp.Lambda(k, sp.cos((2 * k - 1) * sp.pi / (2 * n))), sp.Range(1, n + 1)
201
+ )
202
+ with contextlib.suppress(SYMBOLIC_QUERY_ERRORS):
203
+ roots = sp.Intersection(roots, domain)
204
+ c = RootCertificate(domain, None, "DLMF_chebyshev_formula", (source,), True)
205
+ fam = ParametricZeroFamily(n, support, n_guard, True, True, (), source)
206
+ return RootIsolationResult(
207
+ complete=True,
208
+ method="dlmf_indexed_zeros",
209
+ evidence=(roots,),
210
+ certificates=(c,),
211
+ parametric_families=(fam,),
212
+ )
213
+ elif f is sp.chebyshevu and len(expr.args) == 2:
214
+ support = sp.Interval.open(-1, 1)
215
+ source = "DLMF 18.5.E2 + 18.16(iii)"
216
+ if _guard_proved(n_guard):
217
+ k = sp.Dummy("k", integer=True)
218
+ roots = sp.ImageSet(
219
+ sp.Lambda(k, sp.cos(k * sp.pi / (n + 1))), sp.Range(1, n + 1)
220
+ )
221
+ with contextlib.suppress(SYMBOLIC_QUERY_ERRORS):
222
+ roots = sp.Intersection(roots, domain)
223
+ c = RootCertificate(domain, None, "DLMF_chebyshev_formula", (source,), True)
224
+ fam = ParametricZeroFamily(n, support, n_guard, True, True, (), source)
225
+ return RootIsolationResult(
226
+ complete=True,
227
+ method="dlmf_indexed_zeros",
228
+ evidence=(roots,),
229
+ certificates=(c,),
230
+ parametric_families=(fam,),
231
+ )
232
+ elif f is sp.assoc_legendre and len(expr.args) == 3:
233
+ mu = expr.args[1]
234
+ support = sp.Interval.open(-1, 1)
235
+ guard = sp.And(
236
+ sp.Contains(n, S.Naturals0), sp.Contains(mu, S.Integers), sp.Abs(mu) <= n
237
+ )
238
+ count = sp.simplify(n - sp.Abs(mu))
239
+ source = "DLMF 14.16(ii), integer-degree case"
240
+ fam = ParametricZeroFamily(count, support, guard, None, None, (), source)
241
+ contained = domain == support
242
+ if _guard_proved(guard) and contained:
243
+ c = RootCertificate(
244
+ domain,
245
+ None,
246
+ "DLMF_Ferrers_zero_count",
247
+ (source, ("count", count), ("support", support)),
248
+ True,
249
+ )
250
+ return RootIsolationResult(
251
+ complete=True,
252
+ method="dlmf_zero_distribution",
253
+ evidence=(sp.ConditionSet(var, sp.Eq(expr, 0), support),),
254
+ certificates=(c,),
255
+ parametric_families=(fam,),
256
+ )
257
+ return RootIsolationResult(
258
+ complete=False,
259
+ method="conditional_dlmf_zero_distribution",
260
+ evidence=(sp.ConditionSet(var, sp.Eq(expr, 0), support),),
261
+ parametric_families=(fam,),
262
+ )
263
+ elif f in (sp.jn, sp.yn) and len(expr.args) == 2:
264
+ support = sp.Interval.open(0, sp.oo)
265
+ guard = sp.And(sp.Contains(n, S.Integers), n >= 0)
266
+ source = "DLMF 10.58.E1 + 10.21.E2"
267
+ rel = ("positive zeros interlace with adjacent order",)
268
+ fam = ParametricZeroFamily(sp.oo, support, guard, True, True, rel, source)
269
+ contained = domain == support
270
+ if _guard_proved(guard) and contained:
271
+ c = RootCertificate(
272
+ domain,
273
+ None,
274
+ "DLMF_spherical_bessel_zeros",
275
+ (source, ("count", sp.oo), ("support", support)),
276
+ True,
277
+ )
278
+ return RootIsolationResult(
279
+ complete=True,
280
+ method="dlmf_zero_distribution",
281
+ evidence=(sp.ConditionSet(var, sp.Eq(expr, 0), support),),
282
+ certificates=(c,),
283
+ parametric_families=(fam,),
284
+ )
285
+ return RootIsolationResult(
286
+ complete=False,
287
+ method="conditional_dlmf_zero_distribution",
288
+ evidence=(sp.ConditionSet(var, sp.Eq(expr, 0), support),),
289
+ parametric_families=(fam,),
290
+ )
291
+ if support is None:
292
+ return None
293
+ fam = ParametricZeroFamily(n, support, guard, True, interlace, (), source)
294
+ contained = domain is S.Reals or domain == support
295
+ if _guard_proved(guard) and contained:
296
+ c = RootCertificate(
297
+ domain,
298
+ None,
299
+ "DLMF_zero_distribution",
300
+ (source, ("count", n), ("support", support)),
301
+ True,
302
+ )
303
+ return RootIsolationResult(
304
+ complete=True,
305
+ method="dlmf_zero_distribution",
306
+ evidence=(sp.ConditionSet(var, sp.Eq(expr, 0), support),),
307
+ certificates=(c,),
308
+ parametric_families=(fam,),
309
+ )
310
+ return RootIsolationResult(
311
+ complete=False,
312
+ method="conditional_dlmf_zero_distribution",
313
+ evidence=(sp.ConditionSet(var, sp.Eq(expr, 0), support),),
314
+ parametric_families=(fam,),
315
+ )
316
+
317
+
318
+ def _concrete_zero_set_parts(solution):
319
+ """Split a concrete real solution set into exact points and zero intervals.
320
+
321
+ ``None`` means the set still contains symbolic/conditional structure and cannot
322
+ support a completeness claim.
323
+ """
324
+ if solution is S.EmptySet:
325
+ return (), ()
326
+ if isinstance(solution, sp.FiniteSet):
327
+ return _sort_unique(solution), ()
328
+ if isinstance(solution, sp.Interval):
329
+ return (), (solution,)
330
+ if isinstance(solution, sp.Union):
331
+ points = []
332
+ intervals = []
333
+ for part in solution.args:
334
+ split = _concrete_zero_set_parts(part)
335
+ if split is None:
336
+ return None
337
+ p, i = split
338
+ points.extend(p)
339
+ intervals.extend(i)
340
+ return _sort_unique(points), tuple(
341
+ sorted(set(intervals), key=sp.default_sort_key)
342
+ )
343
+ return None
344
+
345
+
346
+ def _identically_zero_interval(expr, var, domain):
347
+ """Prove that a complete interval cell belongs to the zero set."""
348
+ if not isinstance(domain, sp.Interval):
349
+ return None
350
+ try:
351
+ reduced = sp.simplify(expr)
352
+ except SYMBOLIC_QUERY_ERRORS:
353
+ reduced = expr
354
+ if reduced == 0 or reduced.is_zero is True:
355
+ cert = RootCertificate(
356
+ domain, None, "identically_zero_interval", (("simplified", reduced),), True
357
+ )
358
+ return RootIsolationResult(
359
+ zero_intervals=(domain,),
360
+ complete=True,
361
+ method="identically_zero_interval",
362
+ certificates=(cert,),
363
+ )
364
+ if isinstance(expr, sp.Piecewise):
365
+ try:
366
+ from ..real_analysis.common import condition_to_set
367
+
368
+ exclusive = sp.piecewise_exclusive(expr, deep=True)
369
+ for branch, condition in exclusive.args:
370
+ branch_set = condition_to_set(condition, var)
371
+ if branch_set is None:
372
+ continue
373
+ if domain.is_subset(branch_set) is True:
374
+ value = sp.simplify(branch)
375
+ if value == 0 or value.is_zero is True:
376
+ cert = RootCertificate(
377
+ domain,
378
+ None,
379
+ "solveset_identically_zero_interval",
380
+ (("branch_condition", condition),),
381
+ True,
382
+ )
383
+ return RootIsolationResult(
384
+ zero_intervals=(domain,),
385
+ complete=True,
386
+ method="identically_zero_interval",
387
+ certificates=(cert,),
388
+ )
389
+ except SYMBOLIC_QUERY_ERRORS:
390
+ pass
391
+ return None
392
+
393
+
394
+ def _solveset(expr, var, domain):
395
+ try:
396
+ sol = sp.solveset(sp.Eq(expr, 0), var, domain=domain)
397
+ except SYMBOLIC_QUERY_ERRORS:
398
+ return None
399
+ concrete = _concrete_zero_set_parts(sol)
400
+ if concrete is None:
401
+ return None
402
+ roots, intervals = concrete
403
+ certs = []
404
+ if intervals:
405
+ certs.extend(
406
+ RootCertificate(
407
+ interval,
408
+ None,
409
+ "solveset_identically_zero_interval",
410
+ (("solution_set", sol),),
411
+ True,
412
+ )
413
+ for interval in intervals
414
+ )
415
+ if roots:
416
+ certs.append(
417
+ RootCertificate(
418
+ sp.FiniteSet(*roots),
419
+ len(roots),
420
+ "solveset",
421
+ (sol,),
422
+ True,
423
+ )
424
+ )
425
+ if not roots and not intervals:
426
+ certs.append(RootCertificate(domain, 0, "solveset", (sol,), True))
427
+ method = "solveset_zero_geometry" if intervals else "solveset"
428
+ return RootIsolationResult(
429
+ roots,
430
+ intervals,
431
+ complete=True,
432
+ method=method,
433
+ certificates=tuple(certs),
434
+ )
435
+
436
+
437
+ def _endpoint_value(expr, var, x, side):
438
+ from ..proof_cache import cached_endpoint_limit
439
+
440
+ return cached_endpoint_limit(expr, var, x, direction=side, attained=False)
441
+
442
+
443
+ def _strict_derivative_sign(d, var, interval):
444
+ # exact positivity/negativity via solveset of the negating condition
445
+ try:
446
+ bad = sp.solveset(d <= 0, var, domain=interval)
447
+ if bad is S.EmptySet:
448
+ return 1
449
+ bad = sp.solveset(d >= 0, var, domain=interval)
450
+ if bad is S.EmptySet:
451
+ return -1
452
+ except SYMBOLIC_QUERY_ERRORS:
453
+ pass
454
+ return 0
455
+
456
+
457
+ def _certified_monotone_interval(expr, var, domain):
458
+ """Certify at most one root on an interval from a strict derivative sign."""
459
+ if not isinstance(domain, sp.Interval):
460
+ return None
461
+ try:
462
+ from ..proof_cache import cached_derivative
463
+
464
+ d = cached_derivative(expr, var, order=1)
465
+ except SYMBOLIC_QUERY_ERRORS:
466
+ return None
467
+ s = _strict_derivative_sign(d, var, domain)
468
+ if not s:
469
+ return None
470
+ ana = (
471
+ analytic_neighborhood(expr, var, domain)
472
+ if domain.start not in (-sp.oo, sp.oo) and domain.end not in (-sp.oo, sp.oo)
473
+ else None
474
+ )
475
+ # real differentiability + strict derivative sign suffices; complex cert is extra evidence
476
+ left = _endpoint_value(expr, var, domain.start, "+")
477
+ right = _endpoint_value(expr, var, domain.end, "-")
478
+ if left is None or right is None:
479
+ return None
480
+ sl = sp.sign(left)
481
+ sr = sp.sign(right)
482
+ cert = RootCertificate(
483
+ domain,
484
+ None,
485
+ "strict_monotonicity",
486
+ (("derivative_sign", s), ("endpoint_values", left, right), ana),
487
+ True,
488
+ )
489
+ if sl.is_zero is True and not domain.left_open:
490
+ cert = RootCertificate(domain, 1, "strict_monotonicity", cert.proof, True)
491
+ return RootIsolationResult(
492
+ (domain.start,),
493
+ complete=True,
494
+ method="certified_monotonic",
495
+ certificates=(cert,),
496
+ )
497
+ if sr.is_zero is True and not domain.right_open:
498
+ cert = RootCertificate(domain, 1, "strict_monotonicity", cert.proof, True)
499
+ return RootIsolationResult(
500
+ (domain.end,),
501
+ complete=True,
502
+ method="certified_monotonic",
503
+ certificates=(cert,),
504
+ )
505
+ if (sl.is_positive and sr.is_positive) or (sl.is_negative and sr.is_negative):
506
+ cert = RootCertificate(domain, 0, "strict_monotonicity", cert.proof, True)
507
+ return RootIsolationResult(
508
+ (), complete=True, method="certified_monotonic", certificates=(cert,)
509
+ )
510
+ if (sl.is_negative and sr.is_positive) or (sl.is_positive and sr.is_negative):
511
+ # Unique root is certified even if there is no closed form. Produce a nested rational isolating interval.
512
+ if domain.start in (-sp.oo, sp.oo) or domain.end in (-sp.oo, sp.oo):
513
+ return None
514
+ a = sp.Rational(domain.start) if domain.start.is_Rational else domain.start
515
+ b = sp.Rational(domain.end) if domain.end.is_Rational else domain.end
516
+ # exact bisection using symbolic signs; stop at 24 certified subdivisions or earlier unknown
517
+ aa, bb = a, b
518
+ sa = sl
519
+ for _ in range(24):
520
+ m = sp.simplify((aa + bb) / 2)
521
+ sm = sp.sign(sp.simplify(expr.subs(var, m)))
522
+ if sm.is_zero is True:
523
+ cert = RootCertificate(
524
+ domain,
525
+ 1,
526
+ "strict_monotonicity+exact_bisection",
527
+ (*cert.proof, ("exact_root", m)),
528
+ True,
529
+ )
530
+ return RootIsolationResult(
531
+ (m,),
532
+ complete=True,
533
+ method="certified_monotonic",
534
+ certificates=(cert,),
535
+ )
536
+ if sm.is_positive is not True and sm.is_negative is not True:
537
+ break
538
+ if (sa.is_negative and sm.is_positive) or (
539
+ sa.is_positive and sm.is_negative
540
+ ):
541
+ bb = m
542
+ else:
543
+ aa = m
544
+ sa = sm
545
+ iv = sp.Interval(aa, bb)
546
+ cert = RootCertificate(
547
+ domain,
548
+ 1,
549
+ "strict_monotonicity+isolation",
550
+ (*cert.proof, ("isolating_interval", iv)),
551
+ True,
552
+ )
553
+ return RootIsolationResult(
554
+ isolated_intervals=(CertifiedRootInterval(iv, cert),),
555
+ complete=True,
556
+ method="certified_monotonic_interval",
557
+ certificates=(cert,),
558
+ )
559
+ return None
560
+
561
+
562
+ def _exact_sign(value):
563
+ """Return -1, 0, or 1 only when the real sign is certified."""
564
+ return robust_real_sign(value)
565
+
566
+
567
+ def _certified_multiplicity(expr, var, root, *, max_order=8):
568
+ """Return a proved finite multiplicity, or ``None`` if not established."""
569
+ current = expr
570
+ for order in range(max_order + 1):
571
+ try:
572
+ value = sp.simplify(current.subs(var, root))
573
+ except SYMBOLIC_QUERY_ERRORS:
574
+ return None
575
+ sign = _exact_sign(value)
576
+ if order == 0 and sign not in (0, None):
577
+ return None
578
+ if order > 0 and sign in (-1, 1):
579
+ return order
580
+ if sign is None:
581
+ return None
582
+ try:
583
+ current = sp.diff(current, var)
584
+ except SYMBOLIC_QUERY_ERRORS:
585
+ return None
586
+ return None
587
+
588
+
589
+ def _dyadic_interior_points(interval, *, max_level=24):
590
+ """Yield exact dyadic points approaching both ends of a finite interval."""
591
+ if not isinstance(interval, sp.Interval):
592
+ return
593
+ try:
594
+ left = float(sp.N(interval.start, 40))
595
+ right = float(sp.N(interval.end, 40))
596
+ except SYMBOLIC_QUERY_ERRORS:
597
+ return
598
+ if not (math.isfinite(left) and math.isfinite(right) and left < right):
599
+ return
600
+ seen = set()
601
+ for level in range(4, max_level + 1):
602
+ den = 1 << level
603
+ lo_num = math.floor(left * den) + 1
604
+ hi_num = math.ceil(right * den) - 1
605
+ for num in (lo_num, hi_num):
606
+ q = sp.Rational(num, den)
607
+ if q in seen:
608
+ continue
609
+ seen.add(q)
610
+ try:
611
+ inside = interval.contains(q)
612
+ except SYMBOLIC_QUERY_ERRORS:
613
+ continue
614
+ if inside is S.true:
615
+ yield q
616
+
617
+
618
+ def _rational_sign_bracket(expr, var, interval, left_sign, right_sign):
619
+ """Find rational points inside ``interval`` with the requested opposite signs."""
620
+ left = None
621
+ right = None
622
+ for q in _dyadic_interior_points(interval):
623
+ sign = _exact_sign(expr.subs(var, q))
624
+ if sign == left_sign and (left is None or q < left):
625
+ left = q
626
+ if sign == right_sign and (right is None or q > right):
627
+ right = q
628
+ if left is not None and right is not None and left < right:
629
+ return left, right
630
+ return None
631
+
632
+
633
+ def _bisect_certified_bracket(expr, var, a, b, sa, *, steps=32):
634
+ """Tighten an exact opposite-sign rational bracket without losing certification."""
635
+ aa, bb = sp.Rational(a), sp.Rational(b)
636
+ left_sign = sa
637
+ for _ in range(steps):
638
+ m = (aa + bb) / 2
639
+ sm = _exact_sign(expr.subs(var, m))
640
+ if sm == 0:
641
+ return m, None
642
+ if sm not in (-1, 1):
643
+ break
644
+ if sm == left_sign:
645
+ aa = m
646
+ else:
647
+ bb = m
648
+ return None, sp.Interval(aa, bb)
649
+
650
+
651
+ def _certified_range_on_interval(expr, var, interval):
652
+ """Return an exact range set on ``interval`` when funcprops can certify it."""
653
+ try:
654
+ from ..real_analysis.common import condition_to_set, set_to_condition
655
+ from ..real_analysis.range import function_range
656
+
657
+ y = sp.Dummy("y", real=True)
658
+ cond = function_range(
659
+ expr,
660
+ var,
661
+ y,
662
+ constraints=set_to_condition(interval, var),
663
+ )
664
+ if hasattr(cond, "cases") and hasattr(cond, "primary"):
665
+ return None
666
+ result = condition_to_set(cond, y)
667
+ except (ImportError, *SYMBOLIC_QUERY_ERRORS):
668
+ return None
669
+ return result if isinstance(result, sp.Set) else None
670
+
671
+
672
+ def _range_sign(range_set):
673
+ """Return a strict common sign for a certified range set, if available."""
674
+ try:
675
+ if sp.Contains(0, range_set) is not S.false:
676
+ return 0
677
+ positive = sp.Intersection(range_set, sp.Interval.open(0, sp.oo))
678
+ negative = sp.Intersection(range_set, sp.Interval.open(-sp.oo, 0))
679
+ except SYMBOLIC_QUERY_ERRORS:
680
+ return 0
681
+ if positive is not S.EmptySet and negative is S.EmptySet:
682
+ return 1
683
+ if negative is not S.EmptySet and positive is S.EmptySet:
684
+ return -1
685
+ return 0
686
+
687
+
688
+ def _certified_interval_resolution(expr, var, domain):
689
+ """Resolve a finite real interval from exact range/derivative-range evidence.
690
+
691
+ This is the funcprops analogue of an interval-arithmetic undecided-cell pass:
692
+ an exact image excluding zero proves a cell root-free; an exact derivative image
693
+ of one strict sign proves monotonicity and reduces the zero question to endpoint
694
+ signs plus rational isolation.
695
+ """
696
+ if (
697
+ not isinstance(domain, sp.Interval)
698
+ or domain.start in (-sp.oo, sp.oo)
699
+ or domain.end in (-sp.oo, sp.oo)
700
+ ):
701
+ return None
702
+
703
+ try:
704
+ from .real_interval import certified_real_interval_evaluate
705
+
706
+ interval_value = certified_real_interval_evaluate(expr, var, domain)
707
+ except ImportError:
708
+ interval_value = None
709
+ if interval_value is not None and interval_value.excludes_zero:
710
+ cert = RootCertificate(
711
+ domain,
712
+ 0,
713
+ "certified_interval_range_exclusion",
714
+ (("interval_evaluation", interval_value),),
715
+ True,
716
+ )
717
+ return RootIsolationResult(
718
+ complete=True,
719
+ method="certified_interval_resolution",
720
+ evidence=(interval_value,),
721
+ certificates=(cert,),
722
+ )
723
+
724
+ image = _certified_range_on_interval(expr, var, domain)
725
+ if image is not None:
726
+ try:
727
+ excludes_zero = sp.Contains(0, image) is S.false
728
+ except SYMBOLIC_QUERY_ERRORS:
729
+ excludes_zero = False
730
+ if excludes_zero:
731
+ cert = RootCertificate(
732
+ domain,
733
+ 0,
734
+ "certified_interval_range_exclusion",
735
+ (("range", image),),
736
+ True,
737
+ )
738
+ return RootIsolationResult(
739
+ complete=True,
740
+ method="certified_interval_resolution",
741
+ evidence=(("range", image),),
742
+ certificates=(cert,),
743
+ )
744
+
745
+ try:
746
+ derivative = sp.diff(expr, var)
747
+ except SYMBOLIC_QUERY_ERRORS:
748
+ return None
749
+ try:
750
+ derivative_interval = certified_real_interval_evaluate(derivative, var, domain)
751
+ except (NameError, ValueError):
752
+ derivative_interval = None
753
+ derivative_image = _certified_range_on_interval(derivative, var, domain)
754
+ derivative_sign = (
755
+ derivative_interval.strict_sign
756
+ if derivative_interval is not None and derivative_interval.excludes_zero
757
+ else _range_sign(derivative_image)
758
+ if derivative_image is not None
759
+ else 0
760
+ )
761
+ if not derivative_sign:
762
+ return None
763
+
764
+ left = _endpoint_value(expr, var, domain.start, "+")
765
+ right = _endpoint_value(expr, var, domain.end, "-")
766
+ if left is None or right is None:
767
+ return None
768
+ sl = _exact_sign(left)
769
+ sr = _exact_sign(right)
770
+ if sl is None or sr is None:
771
+ return None
772
+ proof = (
773
+ ("derivative_interval", derivative_interval),
774
+ ("derivative_range", derivative_image),
775
+ ("derivative_sign", derivative_sign),
776
+ ("endpoint_values", left, right),
777
+ )
778
+
779
+ if sl == 0 and not domain.left_open:
780
+ mult = _certified_multiplicity(expr, var, domain.start)
781
+ cert = RootCertificate(
782
+ domain, 1, "interval_resolution_endpoint_root", proof, True, mult
783
+ )
784
+ return RootIsolationResult(
785
+ (domain.start,),
786
+ complete=True,
787
+ method="certified_interval_resolution",
788
+ certificates=(cert,),
789
+ )
790
+ if sr == 0 and not domain.right_open:
791
+ mult = _certified_multiplicity(expr, var, domain.end)
792
+ cert = RootCertificate(
793
+ domain, 1, "interval_resolution_endpoint_root", proof, True, mult
794
+ )
795
+ return RootIsolationResult(
796
+ (domain.end,),
797
+ complete=True,
798
+ method="certified_interval_resolution",
799
+ certificates=(cert,),
800
+ )
801
+ if sl == sr and sl in (-1, 1):
802
+ cert = RootCertificate(
803
+ domain, 0, "interval_resolution_monotone_same_sign", proof, True
804
+ )
805
+ return RootIsolationResult(
806
+ complete=True,
807
+ method="certified_interval_resolution",
808
+ certificates=(cert,),
809
+ )
810
+ if sl * sr != -1:
811
+ return None
812
+
813
+ bracket = _rational_sign_bracket(expr, var, domain, sl, sr)
814
+ if bracket is None:
815
+ return None
816
+ a, b = bracket
817
+ exact_root, isolating = _bisect_certified_bracket(expr, var, a, b, sl)
818
+ if exact_root is not None:
819
+ mult = _certified_multiplicity(expr, var, exact_root)
820
+ cert = RootCertificate(
821
+ domain,
822
+ 1,
823
+ "interval_resolution_exact_bisection",
824
+ (*proof, ("rational_bracket", a, b), ("exact_root", exact_root)),
825
+ True,
826
+ mult,
827
+ )
828
+ return RootIsolationResult(
829
+ (exact_root,),
830
+ complete=True,
831
+ method="certified_interval_resolution",
832
+ certificates=(cert,),
833
+ )
834
+ cert = RootCertificate(
835
+ domain,
836
+ 1,
837
+ "interval_resolution_rational_isolation",
838
+ (*proof, ("rational_bracket", a, b), ("isolating_interval", isolating)),
839
+ True,
840
+ 1,
841
+ )
842
+ return RootIsolationResult(
843
+ isolated_intervals=(CertifiedRootInterval(isolating, cert),),
844
+ complete=True,
845
+ method="certified_interval_resolution",
846
+ certificates=(cert,),
847
+ )
848
+
849
+
850
+ def _certified_real_interval_isolation(expr, var, domain):
851
+ """Certify all real roots on a finite interval from a complete monotone partition.
852
+
853
+ Candidate discovery is delegated to the existing exact derivative-zero/monotonicity
854
+ machinery. Certification itself uses exact endpoint signs and rational
855
+ opposite-sign brackets. Completeness is asserted only when every monotone cell
856
+ is classified and the partition is complete.
857
+ """
858
+ if (
859
+ not isinstance(domain, sp.Interval)
860
+ or domain.start in (-sp.oo, sp.oo)
861
+ or domain.end in (-sp.oo, sp.oo)
862
+ ):
863
+ return None
864
+ try:
865
+ from ..real_analysis.monotonic_certificates import monotonic_partition
866
+
867
+ partition = monotonic_partition(expr, var, domain=domain)
868
+ except (ImportError, *SYMBOLIC_QUERY_ERRORS):
869
+ return None
870
+ if not partition.finite or not partition.cells:
871
+ return None
872
+
873
+ roots = []
874
+ isolated = []
875
+ certs = []
876
+ complete = bool(partition.complete)
877
+
878
+ # Critical points and attained outer endpoints can themselves be roots,
879
+ # including even-multiplicity roots that do not produce a sign change.
880
+ boundary_points = list(partition.critical_points)
881
+ if not domain.left_open:
882
+ boundary_points.append(domain.start)
883
+ if not domain.right_open:
884
+ boundary_points.append(domain.end)
885
+ for point in _sort_unique(boundary_points):
886
+ try:
887
+ if sp.Contains(point, domain) is S.false:
888
+ continue
889
+ value = expr.subs(var, point)
890
+ except SYMBOLIC_QUERY_ERRORS:
891
+ complete = False
892
+ continue
893
+ if _exact_sign(value) == 0:
894
+ roots.append(point)
895
+ mult = _certified_multiplicity(expr, var, point)
896
+ certs.append(
897
+ RootCertificate(
898
+ sp.FiniteSet(point),
899
+ 1,
900
+ "exact_partition_boundary_root",
901
+ (("value", value), ("partition", partition.source)),
902
+ True,
903
+ mult,
904
+ )
905
+ )
906
+
907
+ for cell in partition.cells:
908
+ interval = cell.interval
909
+ if (
910
+ not isinstance(interval, sp.Interval)
911
+ or not cell.strict
912
+ or not cell.continuous
913
+ ):
914
+ complete = False
915
+ continue
916
+ sl = _exact_sign(cell.left_limit) if cell.left_limit is not None else None
917
+ sr = _exact_sign(cell.right_limit) if cell.right_limit is not None else None
918
+ if sl is None or sr is None:
919
+ complete = False
920
+ continue
921
+ # A zero limit at an excluded critical boundary is handled above if the
922
+ # boundary belongs to the requested domain; strict monotonicity excludes
923
+ # another zero in the open cell on the same side.
924
+ if sl == 0 or sr == 0:
925
+ certs.append(
926
+ RootCertificate(
927
+ interval,
928
+ 0,
929
+ "strict_monotone_boundary_limit",
930
+ (("endpoint_signs", sl, sr), ("direction", cell.direction)),
931
+ True,
932
+ )
933
+ )
934
+ continue
935
+ if sl == sr:
936
+ certs.append(
937
+ RootCertificate(
938
+ interval,
939
+ 0,
940
+ "strict_monotone_same_sign",
941
+ (("endpoint_signs", sl, sr), ("direction", cell.direction)),
942
+ True,
943
+ )
944
+ )
945
+ continue
946
+ bracket = _rational_sign_bracket(expr, var, interval, sl, sr)
947
+ if bracket is None:
948
+ complete = False
949
+ continue
950
+ a, b = bracket
951
+ exact_root, isolating = _bisect_certified_bracket(expr, var, a, b, sl)
952
+ proof = (
953
+ ("endpoint_signs", sl, sr),
954
+ ("rational_bracket", a, b),
955
+ ("direction", cell.direction),
956
+ ("partition", partition.source),
957
+ )
958
+ if exact_root is not None:
959
+ roots.append(exact_root)
960
+ mult = _certified_multiplicity(expr, var, exact_root)
961
+ certs.append(
962
+ RootCertificate(
963
+ interval,
964
+ 1,
965
+ "strict_monotonicity+exact_bisection",
966
+ (*proof, ("exact_root", exact_root)),
967
+ True,
968
+ mult,
969
+ )
970
+ )
971
+ else:
972
+ cert = RootCertificate(
973
+ interval,
974
+ 1,
975
+ "strict_monotonicity+rational_isolation",
976
+ (*proof, ("isolating_interval", isolating)),
977
+ True,
978
+ 1,
979
+ )
980
+ isolated.append(CertifiedRootInterval(isolating, cert))
981
+ certs.append(cert)
982
+
983
+ roots = _sort_unique(roots)
984
+ method = (
985
+ "certified_real_interval_isolation"
986
+ if complete
987
+ else "partial_certified_real_interval_isolation"
988
+ )
989
+ return RootIsolationResult(
990
+ roots,
991
+ isolated_intervals=tuple(isolated),
992
+ complete=complete,
993
+ method=method,
994
+ evidence=(partition,),
995
+ certificates=tuple(certs),
996
+ )
997
+
998
+
999
+ def _scale_root_certificate(cert, factor, *, method_prefix="repeated_factor"):
1000
+ """Lift a certificate through ``g(x)**factor`` without losing provenance."""
1001
+ base_multiplicity = cert.multiplicity
1002
+ if (
1003
+ base_multiplicity is None
1004
+ and cert.root_count == 1
1005
+ and cert.method
1006
+ in {
1007
+ "strict_monotonicity+isolation",
1008
+ "strict_monotonicity+rational_isolation",
1009
+ "strict_monotonicity+exact_bisection",
1010
+ }
1011
+ ):
1012
+ base_multiplicity = 1
1013
+ multiplicity = base_multiplicity * factor if base_multiplicity is not None else None
1014
+ return RootCertificate(
1015
+ cert.domain,
1016
+ cert.root_count,
1017
+ f"{method_prefix}:{cert.method}",
1018
+ (*cert.proof, ("multiplicity_scale", factor)),
1019
+ cert.complete,
1020
+ multiplicity,
1021
+ )
1022
+
1023
+
1024
+ def _repeated_power_roots(expr, var, domain):
1025
+ """Lift certified roots of a repeated factor ``base**m``.
1026
+
1027
+ This is especially important for even powers: the original expression has no
1028
+ sign change at a simple zero of ``base``, while ``base`` itself does. The
1029
+ recursive query disables heuristic numerical roots; every lifted root therefore
1030
+ retains a proof from the underlying factor.
1031
+ """
1032
+ if not isinstance(expr, sp.Pow):
1033
+ return None
1034
+ base, exponent = expr.as_base_exp()
1035
+ if exponent.is_Integer is not True or exponent < 2 or base == expr:
1036
+ return None
1037
+ underlying = transcendental_roots(base, var, domain, allow_numerical=False)
1038
+ if not (
1039
+ underlying.roots
1040
+ or underlying.isolated_intervals
1041
+ or underlying.zero_intervals
1042
+ or underlying.periodic is not None
1043
+ or underlying.complete
1044
+ ):
1045
+ return None
1046
+ factor = int(exponent)
1047
+ lifted_isolated = tuple(
1048
+ CertifiedRootInterval(
1049
+ item.interval, _scale_root_certificate(item.certificate, factor)
1050
+ )
1051
+ for item in underlying.isolated_intervals
1052
+ )
1053
+ lifted_certs = tuple(
1054
+ _scale_root_certificate(cert, factor) for cert in underlying.certificates
1055
+ )
1056
+ # Exact roots can often prove a sharper multiplicity directly (for example
1057
+ # when the base root itself is multiple), so prefer that over mechanical scaling.
1058
+ exact_certs = []
1059
+ for root in underlying.roots:
1060
+ multiplicity = _certified_multiplicity(expr, var, root)
1061
+ exact_certs.append(
1062
+ RootCertificate(
1063
+ sp.FiniteSet(root),
1064
+ 1,
1065
+ "repeated_factor_exact_root",
1066
+ (("base", base), ("power", factor)),
1067
+ True,
1068
+ multiplicity,
1069
+ )
1070
+ )
1071
+ return RootIsolationResult(
1072
+ underlying.roots,
1073
+ underlying.zero_intervals,
1074
+ lifted_isolated,
1075
+ underlying.periodic,
1076
+ underlying.complete,
1077
+ "repeated_factor_root_isolation",
1078
+ (*underlying.evidence, ("repeated_factor", base, factor)),
1079
+ tuple(exact_certs) + lifted_certs,
1080
+ underlying.parametric_families,
1081
+ underlying.geometry,
1082
+ )
1083
+
1084
+
1085
+ def _validated_even_multiplicity_numeric(expr, var, domain, *, max_samples=96):
1086
+ """Discover touching roots numerically and promote only exact validations.
1087
+
1088
+ Numerical data is used solely to propose critical-point candidates. A candidate
1089
+ becomes a certified root only when exact substitution proves ``f(r) == 0`` and
1090
+ repeated differentiation proves an even multiplicity. Consequently this helper
1091
+ can improve coverage without treating a small residual as mathematical equality.
1092
+ """
1093
+ if (
1094
+ not isinstance(domain, sp.Interval)
1095
+ or domain.start in (-sp.oo, sp.oo)
1096
+ or domain.end in (-sp.oo, sp.oo)
1097
+ ):
1098
+ return None
1099
+ try:
1100
+ import mpmath as mp
1101
+
1102
+ a = mp.mpf(str(sp.N(domain.start, 50)))
1103
+ b = mp.mpf(str(sp.N(domain.end, 50)))
1104
+ if not (mp.isfinite(a) and mp.isfinite(b) and a < b):
1105
+ return None
1106
+ derivative = sp.diff(expr, var)
1107
+ f = sp.lambdify(var, expr, "mpmath")
1108
+ df = sp.lambdify(var, derivative, "mpmath")
1109
+ except (ImportError, *SYMBOLIC_QUERY_ERRORS):
1110
+ return None
1111
+
1112
+ old_dps = mp.mp.dps
1113
+ mp.mp.dps = max(old_dps, 80)
1114
+ try:
1115
+ xs = [a + (b - a) * i / max_samples for i in range(max_samples + 1)]
1116
+ candidates = []
1117
+ dvals = []
1118
+ for q in xs:
1119
+ try:
1120
+ value = df(q)
1121
+ dvals.append(mp.mpf(value) if mp.im(value) == 0 else mp.nan)
1122
+ except (ArithmeticError, ValueError, TypeError):
1123
+ dvals.append(mp.nan)
1124
+ for x1, x2, y1, y2 in zip(xs, xs[1:], dvals, dvals[1:], strict=False):
1125
+ if not (mp.isfinite(y1) and mp.isfinite(y2)):
1126
+ continue
1127
+ if y1 == 0:
1128
+ candidates.append(x1)
1129
+ if y1 * y2 < 0:
1130
+ try:
1131
+ candidates.append(mp.findroot(df, (x1, x2), solver="anderson"))
1132
+ except (ArithmeticError, ValueError, ZeroDivisionError):
1133
+ with contextlib.suppress(
1134
+ ArithmeticError, ValueError, ZeroDivisionError
1135
+ ):
1136
+ candidates.append(mp.findroot(df, (x1, x2)))
1137
+ # Sample-local minima of |f| catch stationary roots whose derivative root is
1138
+ # numerically flat enough that a sign-change scan of f' misses it.
1139
+ fvals = []
1140
+ for q in xs:
1141
+ try:
1142
+ value = f(q)
1143
+ fvals.append(abs(value) if mp.im(value) == 0 else mp.inf)
1144
+ except (ArithmeticError, ValueError, TypeError):
1145
+ fvals.append(mp.inf)
1146
+ scale = max([v for v in fvals if mp.isfinite(v)] or [mp.mpf(1)])
1147
+ threshold = max(mp.mpf("1e-30"), scale * mp.mpf("1e-18"))
1148
+ for i in range(1, len(xs) - 1):
1149
+ if (
1150
+ fvals[i] <= fvals[i - 1]
1151
+ and fvals[i] <= fvals[i + 1]
1152
+ and fvals[i] <= threshold
1153
+ ):
1154
+ with contextlib.suppress(
1155
+ ArithmeticError, ValueError, ZeroDivisionError
1156
+ ):
1157
+ candidates.append(mp.findroot(df, (xs[i - 1], xs[i + 1])))
1158
+
1159
+ roots = []
1160
+ certs = []
1161
+ for candidate in candidates:
1162
+ if not mp.isfinite(candidate) or not (a <= candidate <= b):
1163
+ continue
1164
+ approx = sp.Float(str(candidate), 70)
1165
+ guesses = []
1166
+ for constants in ((), (sp.pi, sp.E, sp.sqrt(2), sp.sqrt(3), sp.log(2))):
1167
+ with contextlib.suppress(*SYMBOLIC_QUERY_ERRORS):
1168
+ guesses.append(
1169
+ sp.nsimplify(
1170
+ approx,
1171
+ constants=list(constants),
1172
+ tolerance=1e-28,
1173
+ full=bool(constants),
1174
+ )
1175
+ )
1176
+ # Keep the exact decimal rational as a final candidate. It almost never
1177
+ # validates, but makes the reconstruction policy explicit and deterministic.
1178
+ guesses.append(sp.Rational(str(candidate)))
1179
+ for root in guesses:
1180
+ try:
1181
+ if sp.Contains(root, domain) is S.false:
1182
+ continue
1183
+ if _exact_sign(expr.subs(var, root)) != 0:
1184
+ continue
1185
+ multiplicity = _certified_multiplicity(
1186
+ expr, var, root, max_order=16
1187
+ )
1188
+ except SYMBOLIC_QUERY_ERRORS:
1189
+ continue
1190
+ if multiplicity is None or multiplicity < 2 or multiplicity % 2:
1191
+ continue
1192
+ if root not in roots:
1193
+ roots.append(root)
1194
+ certs.append(
1195
+ RootCertificate(
1196
+ sp.FiniteSet(root),
1197
+ 1,
1198
+ "numerical_candidate+exact_even_multiplicity_validation",
1199
+ (
1200
+ ("approximation", approx),
1201
+ ("derivative_candidate", derivative),
1202
+ ),
1203
+ True,
1204
+ multiplicity,
1205
+ )
1206
+ )
1207
+ break
1208
+ if not roots:
1209
+ return None
1210
+ return RootIsolationResult(
1211
+ _sort_unique(roots),
1212
+ complete=False,
1213
+ method="validated_even_multiplicity_numeric_fallback",
1214
+ evidence=(("numeric_candidates", len(candidates)),),
1215
+ certificates=tuple(certs),
1216
+ )
1217
+ finally:
1218
+ mp.mp.dps = old_dps
1219
+
1220
+
1221
+ def _numeric(expr, var, domain, max_samples=80):
1222
+ if (
1223
+ not isinstance(domain, sp.Interval)
1224
+ or domain.start in (-sp.oo, sp.oo)
1225
+ or domain.end in (-sp.oo, sp.oo)
1226
+ ):
1227
+ return None
1228
+ try:
1229
+ a = float(sp.N(domain.start))
1230
+ b = float(sp.N(domain.end))
1231
+ except SYMBOLIC_QUERY_ERRORS:
1232
+ return None
1233
+ if not math.isfinite(a) or not math.isfinite(b) or not a < b:
1234
+ return None
1235
+ import mpmath as mp
1236
+
1237
+ f = sp.lambdify(var, expr, "mpmath")
1238
+ roots = []
1239
+ xs = [a + (b - a) * i / max_samples for i in range(max_samples + 1)]
1240
+ vals = []
1241
+ for q in xs:
1242
+ try:
1243
+ vals.append(float(f(q)))
1244
+ except SYMBOLIC_QUERY_ERRORS:
1245
+ vals.append(float("nan"))
1246
+ for x1, x2, y1, y2 in zip(xs, xs[1:], vals, vals[1:], strict=False):
1247
+ if not (math.isfinite(y1) and math.isfinite(y2)):
1248
+ continue
1249
+ if y1 * y2 < 0:
1250
+ with contextlib.suppress(SYMBOLIC_QUERY_ERRORS):
1251
+ roots.append(sp.Float(str(mp.findroot(lambda t: f(t), (x1, x2))), 30))
1252
+ return RootIsolationResult(
1253
+ _sort_unique(roots),
1254
+ complete=False,
1255
+ method="numerical_scan",
1256
+ evidence=("candidate roots only",),
1257
+ )
1258
+
1259
+
1260
+ def _exponential_fourier_count(expr, var, domain):
1261
+ """Attach a certified derivative-sequence count for supported exp polynomials."""
1262
+ try:
1263
+ from .exp_fourier import exponential_fourier_count
1264
+
1265
+ count = exponential_fourier_count(expr, var, domain)
1266
+ except (ImportError, *SYMBOLIC_QUERY_ERRORS):
1267
+ return None
1268
+ if count is None or not count.complete or count.root_count is None:
1269
+ return None
1270
+ cert = RootCertificate(
1271
+ domain,
1272
+ count.root_count,
1273
+ "exponential_fourier_derivative_sequence",
1274
+ (count,),
1275
+ True,
1276
+ )
1277
+ if count.root_count == 0:
1278
+ return RootIsolationResult(
1279
+ complete=True,
1280
+ method="exponential_fourier_count",
1281
+ evidence=(count,),
1282
+ certificates=(cert,),
1283
+ )
1284
+ return RootIsolationResult(
1285
+ complete=False,
1286
+ method="exponential_fourier_count",
1287
+ evidence=(count,),
1288
+ certificates=(cert,),
1289
+ )
1290
+
1291
+
1292
+ def transcendental_roots(expr, var, domain=S.Reals, *, allow_numerical=True):
1293
+ expr = sp.sympify(expr)
1294
+ var = sp.sympify(var)
1295
+ if expr == 0:
1296
+ zi = (domain,) if isinstance(domain, sp.Interval) else ()
1297
+ c = RootCertificate(domain, None, "identically_zero", (expr,), True)
1298
+ return RootIsolationResult(
1299
+ (), zi, complete=True, method="identically_zero", certificates=(c,)
1300
+ )
1301
+ if not expr.has(var):
1302
+ c = RootCertificate(domain, 0, "constant", (expr,), True)
1303
+ return RootIsolationResult(
1304
+ (), complete=True, method="constant", certificates=(c,)
1305
+ )
1306
+ # General zero-geometry theorems precede generic solveset. They preserve
1307
+ # indexed families/interlacing even when individual zeros have no closed form.
1308
+ geom = zero_geometry(expr, var, domain=domain)
1309
+ if geom:
1310
+ exact_guard = all(_guard_proved(g.guard) for g in geom)
1311
+ support_ok = all(
1312
+ domain == g.support
1313
+ or (domain is S.Reals and g.family in ("AiryAi", "AiryBi"))
1314
+ for g in geom
1315
+ )
1316
+ priority = all(
1317
+ g.family in ("AiryAi", "AiryBi", "BesselJ", "BesselY", "FerrersQ")
1318
+ for g in geom
1319
+ )
1320
+ if priority and exact_guard and support_ok:
1321
+ certs = tuple(
1322
+ RootCertificate(
1323
+ domain,
1324
+ None,
1325
+ "zero_geometry",
1326
+ ((g.source, g.count, g.support),),
1327
+ g.complete_on_support,
1328
+ )
1329
+ for g in geom
1330
+ )
1331
+ return RootIsolationResult(
1332
+ complete=all(g.complete_on_support for g in geom),
1333
+ method="zero_geometry",
1334
+ evidence=geom,
1335
+ certificates=certs,
1336
+ geometry=geom,
1337
+ )
1338
+ for fn in (
1339
+ _periodic,
1340
+ _special,
1341
+ _orthogonal_special,
1342
+ _repeated_power_roots,
1343
+ _identically_zero_interval,
1344
+ _solveset,
1345
+ _exponential_fourier_count,
1346
+ _certified_interval_resolution,
1347
+ _certified_monotone_interval,
1348
+ _certified_real_interval_isolation,
1349
+ ):
1350
+ r = fn(expr, var, domain)
1351
+ if r is not None:
1352
+ if geom and not r.geometry:
1353
+ r = RootIsolationResult(
1354
+ r.roots,
1355
+ r.zero_intervals,
1356
+ r.isolated_intervals,
1357
+ r.periodic,
1358
+ r.complete,
1359
+ r.method,
1360
+ r.evidence,
1361
+ r.certificates,
1362
+ r.parametric_families,
1363
+ geom,
1364
+ )
1365
+ return r
1366
+ if geom:
1367
+ return RootIsolationResult(
1368
+ complete=False,
1369
+ method="conditional_zero_geometry",
1370
+ evidence=geom,
1371
+ geometry=geom,
1372
+ )
1373
+ if allow_numerical:
1374
+ r = _validated_even_multiplicity_numeric(expr, var, domain)
1375
+ if r is not None:
1376
+ return r
1377
+ r = _numeric(expr, var, domain)
1378
+ if r is not None:
1379
+ return r
1380
+ return RootIsolationResult(complete=False, method="unknown")