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,867 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ from dataclasses import dataclass
5
+ from functools import lru_cache
6
+
7
+ import sympy as sp
8
+ from sympy import S
9
+
10
+ from ..exceptions import SYMBOLIC_QUERY_ERRORS
11
+ from ..results import Monotonicity, TruthValue
12
+ from .common import condition_to_set, exact_order_real_points
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class MonotonicDecomposition:
17
+ expression: sp.Basic
18
+ variable: sp.Symbol
19
+ inner: sp.Basic
20
+ outer: sp.Lambda
21
+ direction: int
22
+ condition: sp.Basic = S.true
23
+ inverse: sp.Lambda | None = None
24
+ strict: bool = True
25
+ continuous: bool = True
26
+ source: str = "structural-monotonic-decomposition"
27
+
28
+ def reconstruct(self):
29
+ return sp.simplify(self.outer(self.inner))
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class MonotonicInterval:
34
+ interval: sp.Set
35
+ direction: Monotonicity
36
+ left_limit: sp.Basic | None = None
37
+ right_limit: sp.Basic | None = None
38
+ strict: bool = True
39
+ continuous: bool = True
40
+ left_attained: bool | None = None
41
+ right_attained: bool | None = None
42
+ evidence: tuple = ()
43
+
44
+ @property
45
+ def increasing(self):
46
+ return self.direction in (
47
+ Monotonicity.STRICTLY_INCREASING,
48
+ Monotonicity.NONDECREASING,
49
+ )
50
+
51
+ @property
52
+ def decreasing(self):
53
+ return self.direction in (
54
+ Monotonicity.STRICTLY_DECREASING,
55
+ Monotonicity.NONINCREASING,
56
+ )
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class IndexedMonotonicPartition:
61
+ domain: sp.Set
62
+ boundary_geometry: tuple
63
+ # Empty means the geometry proves alternation but not its phase. This is
64
+ # deliberately different from assuming that the leftmost cell increases.
65
+ direction_pattern: tuple[Monotonicity, ...] = ()
66
+ complete: bool = False
67
+ first_direction: Monotonicity | None = None
68
+ source: str = "indexed-derivative-zero-geometry"
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class MonotonicPartition:
73
+ domain: sp.Set
74
+ cells: tuple[MonotonicInterval, ...] = ()
75
+ indexed: IndexedMonotonicPartition | None = None
76
+ complete: bool = False
77
+ source: str = "monotonic-partition"
78
+ derivative: sp.Basic | None = None
79
+ critical_points: tuple = ()
80
+ property_decomposition: object = None
81
+ critical_points_complete: bool = False
82
+
83
+ @property
84
+ def finite(self):
85
+ return self.indexed is None
86
+
87
+ @property
88
+ def direction_changes(self):
89
+ vals = [c.direction for c in self.cells]
90
+ n = 0
91
+ for a, b in itertools.pairwise(vals):
92
+ if a != b:
93
+ n += 1
94
+ return n
95
+
96
+ @property
97
+ def has_indexed_oscillation(self):
98
+ return bool(
99
+ self.indexed is not None
100
+ and self.indexed.complete
101
+ and self.indexed.boundary_geometry
102
+ )
103
+
104
+ def same_strict_direction(self):
105
+ if not self.complete or not self.cells:
106
+ return None
107
+ dirs = {c.direction for c in self.cells}
108
+ if dirs == {Monotonicity.STRICTLY_INCREASING}:
109
+ return Monotonicity.STRICTLY_INCREASING
110
+ if dirs == {Monotonicity.STRICTLY_DECREASING}:
111
+ return Monotonicity.STRICTLY_DECREASING
112
+ return None
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class NonMonotonicityWitness:
117
+ points: tuple
118
+ values: tuple
119
+ exact: bool
120
+ source: str = "exact-sample-ordering-witness"
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class InverseSolutionCertificate:
125
+ equation: sp.Basic
126
+ domain: sp.Set
127
+ solutions: sp.Set
128
+ complete: bool
129
+ partition: MonotonicPartition | None = None
130
+ source: str = "monotonic-partition-inverse"
131
+ evidence: tuple = ()
132
+ isolated_intervals: tuple = ()
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class PartitionRange:
137
+ image: sp.Set | None
138
+ complete: bool
139
+ partition: MonotonicPartition
140
+ source: str = "monotonic-partition-range"
141
+
142
+
143
+ def _monotone_head(expr, X):
144
+ """Return (direction, condition, inverse) for high-value primitive heads."""
145
+ f = expr.func
146
+ args = expr.args
147
+ slot = sp.Dummy("u", real=True)
148
+ if len(args) != 1 or args[0] != X:
149
+ return None
150
+ if f in (sp.exp, sp.log, sp.sinh, sp.tanh, sp.atan, sp.asinh, sp.erf, sp.erfi):
151
+ cond = X > 0 if f is sp.log else S.true
152
+ inv = {
153
+ sp.exp: sp.log,
154
+ sp.log: sp.exp,
155
+ sp.sinh: sp.asinh,
156
+ sp.tanh: sp.atanh,
157
+ sp.atan: sp.tan,
158
+ sp.asinh: sp.sinh,
159
+ sp.erf: sp.erfinv,
160
+ }.get(f)
161
+ invlam = sp.Lambda(slot, inv(slot)) if inv is not None else None
162
+ return 1, cond, invlam
163
+ if f in (sp.acos, sp.erfc):
164
+ cond = sp.And(X >= -1, X <= 1) if f is sp.acos else S.true
165
+ inv = sp.cos if f is sp.acos else getattr(sp, "erfcinv", None)
166
+ return -1, cond, (sp.Lambda(slot, inv(slot)) if inv else None)
167
+ if f is sp.asin:
168
+ return 1, sp.And(X >= -1, X <= 1), sp.Lambda(slot, sp.sin(slot))
169
+ if f is sp.acosh:
170
+ return 1, X >= 1, sp.Lambda(slot, sp.cosh(slot))
171
+ if f is sp.atanh:
172
+ return 1, sp.And(X > -1, X < 1), sp.Lambda(slot, sp.tanh(slot))
173
+ if f is sp.LambertW and len(args) == 1:
174
+ return 1, X >= -1 / sp.E, sp.Lambda(slot, slot * sp.exp(slot))
175
+ return None
176
+
177
+
178
+ def monotonic_decomposition(expr, var, *, strict=True, continuous=True):
179
+ """Find ``expr = F(inner)`` with a certified monotone outer ``F``."""
180
+ expr = sp.sympify(expr)
181
+ var = sp.sympify(var)
182
+ if not expr.has(var):
183
+ return MonotonicDecomposition(
184
+ expr,
185
+ var,
186
+ S.Zero,
187
+ sp.Lambda(sp.Dummy("u"), expr),
188
+ 1,
189
+ S.true,
190
+ None,
191
+ False,
192
+ True,
193
+ "constant",
194
+ )
195
+ u = sp.Dummy("u", real=True)
196
+ if expr.is_Add:
197
+ c, dep = expr.as_independent(var, as_Add=True)
198
+ if c != 0 and dep != expr:
199
+ return MonotonicDecomposition(
200
+ expr,
201
+ var,
202
+ dep,
203
+ sp.Lambda(u, u + c),
204
+ 1,
205
+ S.true,
206
+ sp.Lambda(u, u - c),
207
+ strict,
208
+ continuous,
209
+ "affine-shift",
210
+ )
211
+ if expr.is_Mul:
212
+ c, dep = expr.as_independent(var, as_Add=False)
213
+ if dep != expr and c.is_real is True and c.is_zero is not True:
214
+ if c.is_positive is True:
215
+ return MonotonicDecomposition(
216
+ expr,
217
+ var,
218
+ dep,
219
+ sp.Lambda(u, c * u),
220
+ 1,
221
+ S.true,
222
+ sp.Lambda(u, u / c),
223
+ strict,
224
+ continuous,
225
+ "positive-scale",
226
+ )
227
+ if c.is_negative is True:
228
+ return MonotonicDecomposition(
229
+ expr,
230
+ var,
231
+ dep,
232
+ sp.Lambda(u, c * u),
233
+ -1,
234
+ S.true,
235
+ sp.Lambda(u, u / c),
236
+ strict,
237
+ continuous,
238
+ "negative-scale",
239
+ )
240
+ if isinstance(expr, sp.Pow):
241
+ base, exponent = expr.args
242
+ if base.has(var) and not exponent.has(var):
243
+ if exponent.is_integer and exponent.is_positive:
244
+ if exponent.is_odd:
245
+ return MonotonicDecomposition(
246
+ expr,
247
+ var,
248
+ base,
249
+ sp.Lambda(u, u**exponent),
250
+ 1,
251
+ S.true,
252
+ sp.Lambda(u, sp.real_root(u, exponent)),
253
+ strict,
254
+ continuous,
255
+ "odd-positive-power",
256
+ )
257
+ if exponent.is_even and exponent != 2:
258
+ return MonotonicDecomposition(
259
+ expr,
260
+ var,
261
+ base**2,
262
+ sp.Lambda(u, u ** (exponent / 2)),
263
+ 1,
264
+ S.true,
265
+ None,
266
+ strict,
267
+ continuous,
268
+ "even-power-square-reduction",
269
+ )
270
+ if exponent.is_Rational:
271
+ d = 1 if exponent.is_positive else -1
272
+ cond = base >= 0 if exponent.is_positive else base > 0
273
+ return MonotonicDecomposition(
274
+ expr,
275
+ var,
276
+ base,
277
+ sp.Lambda(u, u**exponent),
278
+ d,
279
+ cond,
280
+ None,
281
+ strict,
282
+ continuous,
283
+ "rational-power",
284
+ )
285
+ if (
286
+ not base.has(var)
287
+ and exponent.has(var)
288
+ and base.is_positive is True
289
+ and base != 1
290
+ ):
291
+ d = 1 if (base - 1).is_positive else (-1 if (base - 1).is_negative else 0)
292
+ if d:
293
+ return MonotonicDecomposition(
294
+ expr,
295
+ var,
296
+ exponent,
297
+ sp.Lambda(u, base**u),
298
+ d,
299
+ S.true,
300
+ sp.Lambda(u, sp.log(u, base)),
301
+ strict,
302
+ continuous,
303
+ "positive-base-exponential",
304
+ )
305
+ varying = [
306
+ (i, a)
307
+ for i, a in enumerate(getattr(expr, "args", ()))
308
+ if getattr(a, "has", lambda *_: False)(var)
309
+ ]
310
+ if len(varying) == 1 and expr.func not in (sp.Add, sp.Mul, sp.Pow, sp.Piecewise):
311
+ i, inner = varying[0]
312
+ if inner != var:
313
+ X = sp.Dummy("X", real=True)
314
+ aa = list(expr.args)
315
+ aa[i] = X
316
+ outer_expr = expr.func(*aa)
317
+ info = _monotone_head(outer_expr, X)
318
+ if info:
319
+ d, cond, inv = info
320
+ return MonotonicDecomposition(
321
+ expr,
322
+ var,
323
+ inner,
324
+ sp.Lambda(X, outer_expr),
325
+ d,
326
+ cond.xreplace({X: inner}),
327
+ inv,
328
+ strict,
329
+ continuous,
330
+ "elementary-monotone-head",
331
+ )
332
+ try:
333
+ from .monotonicity import _direct_registry
334
+
335
+ m = _direct_registry(outer_expr, X)
336
+ inc = (Monotonicity.STRICTLY_INCREASING, Monotonicity.NONDECREASING)
337
+ dec = (Monotonicity.STRICTLY_DECREASING, Monotonicity.NONINCREASING)
338
+ if m in inc + dec:
339
+ d = 1 if m in inc else -1
340
+ from .domain import function_domain
341
+
342
+ cond = function_domain(outer_expr, X)
343
+ cond = (
344
+ cond.xreplace({X: inner})
345
+ if isinstance(cond, sp.Basic)
346
+ else S.true
347
+ )
348
+ return MonotonicDecomposition(
349
+ expr,
350
+ var,
351
+ inner,
352
+ sp.Lambda(X, outer_expr),
353
+ d,
354
+ cond,
355
+ None,
356
+ m in (inc[0], dec[0]),
357
+ continuous,
358
+ "registry-monotone-head",
359
+ )
360
+ except SYMBOLIC_QUERY_ERRORS:
361
+ pass
362
+ return None
363
+
364
+
365
+ def _endpoint_limit(expr, var, v, left, open_, domain=S.true, assumptions=S.true):
366
+ from ..proof_cache import property_proof_context
367
+
368
+ return property_proof_context(
369
+ expr, var, domain=domain, assumptions=assumptions
370
+ ).endpoint_limit(v, direction=("+" if left else "-"), attained=not open_)
371
+
372
+
373
+ def _phase_for_indexed(expr, var, ds, pd, derivative=None):
374
+ """Determine an indexed alternating phase only when an exact theorem/sample does."""
375
+ # For an infinite family the zero geometry itself proves alternation but
376
+ # usually does not identify which sign occurs on the leftmost unbounded
377
+ # cell. A finite endpoint cell can sometimes be sampled exactly.
378
+ if not isinstance(ds, sp.Interval) or not pd.boundary_geometry:
379
+ return None
380
+ try:
381
+ from ..proof_cache import cached_derivative
382
+
383
+ d = (
384
+ sp.factor(derivative)
385
+ if derivative is not None
386
+ else cached_derivative(expr, var, order=1, factored=True)
387
+ )
388
+ except SYMBOLIC_QUERY_ERRORS:
389
+ return None
390
+ # Airy phase is known at x=0 exactly. The cell immediately left of zero
391
+ # therefore fixes the parity without numerical root evaluation.
392
+ if expr.func in (sp.airyai, sp.airybi) and ds.end == 0:
393
+ val = sp.simplify(d.subs(var, 0))
394
+ if val.is_positive is True:
395
+ return Monotonicity.STRICTLY_INCREASING
396
+ if val.is_negative is True:
397
+ return Monotonicity.STRICTLY_DECREASING
398
+ # Otherwise do not guess a phase from structural zero ordering.
399
+ return None
400
+
401
+
402
+ @lru_cache(maxsize=512)
403
+ def _monotonic_partition_on_set(expr, var, domain_set):
404
+ """Build an exact monotonic partition on an already-normalized real set.
405
+
406
+ The routine prefers derivative zero geometry, then falls back to finite
407
+ critical-point analysis. Completeness is retained explicitly so consumers do
408
+ not mistake a partial decomposition for a global proof.
409
+ """
410
+ ds = domain_set
411
+ expr = sp.sympify(expr)
412
+ var = sp.sympify(var)
413
+ if not isinstance(ds, sp.Set):
414
+ return MonotonicPartition(S.Reals, complete=False, source="unknown-domain")
415
+ derivative = None
416
+ try:
417
+ from ..proof_cache import property_proof_context
418
+
419
+ derivative = property_proof_context(expr, var, domain=ds).derivative(
420
+ 1, factored=True
421
+ )
422
+ except SYMBOLIC_QUERY_ERRORS:
423
+ pass
424
+ from ..transcendental.zero_geometry import property_interval_decomposition
425
+
426
+ pd = property_interval_decomposition(
427
+ expr,
428
+ var,
429
+ derivative_order=1,
430
+ domain=ds,
431
+ positive_value=Monotonicity.STRICTLY_INCREASING,
432
+ negative_value=Monotonicity.STRICTLY_DECREASING,
433
+ derivative=derivative,
434
+ )
435
+ if pd.cells:
436
+ cells = []
437
+ crit = ()
438
+ for g in pd.boundary_geometry:
439
+ if getattr(g, "family", None) == "finite-derivative-zeros" and isinstance(
440
+ getattr(g, "support", None), sp.FiniteSet
441
+ ):
442
+ q = exact_order_real_points(tuple(g.support))
443
+ if q is not None:
444
+ crit = tuple(
445
+ z
446
+ for z in q
447
+ if not (isinstance(ds, sp.Interval) and z in (ds.start, ds.end))
448
+ )
449
+ break
450
+ for j, c in enumerate(pd.cells):
451
+ it = getattr(c, "interval", None) or getattr(c, "domain", None)
452
+ value = getattr(c, "value", None) or getattr(c, "property", None)
453
+ if not isinstance(it, sp.Interval) or value is None:
454
+ continue
455
+ left_outer = j == 0 and isinstance(ds, sp.Interval) and it.start == ds.start
456
+ right_outer = (
457
+ j == len(pd.cells) - 1
458
+ and isinstance(ds, sp.Interval)
459
+ and it.end == ds.end
460
+ )
461
+ la = (
462
+ (not ds.left_open)
463
+ if left_outer and isinstance(ds, sp.Interval)
464
+ else True
465
+ )
466
+ ra = (
467
+ (not ds.right_open)
468
+ if right_outer and isinstance(ds, sp.Interval)
469
+ else True
470
+ )
471
+ cells.append(
472
+ MonotonicInterval(
473
+ it,
474
+ value,
475
+ _endpoint_limit(expr, var, it.start, True, not la, ds),
476
+ _endpoint_limit(expr, var, it.end, False, not ra, ds),
477
+ True,
478
+ True,
479
+ la,
480
+ ra,
481
+ (c,),
482
+ )
483
+ )
484
+ crit_complete = bool(
485
+ pd.boundary_geometry
486
+ and any(
487
+ getattr(g, "family", None) == "finite-derivative-zeros"
488
+ and getattr(g, "complete_on_support", False)
489
+ for g in pd.boundary_geometry
490
+ )
491
+ )
492
+ return MonotonicPartition(
493
+ ds,
494
+ tuple(cells),
495
+ complete=bool(pd.complete and len(cells) == len(pd.cells)),
496
+ source="finite-derivative-zero-partition",
497
+ derivative=derivative,
498
+ critical_points=crit,
499
+ property_decomposition=pd,
500
+ critical_points_complete=crit_complete,
501
+ )
502
+ if pd.boundary_geometry and pd.alternating:
503
+ phase = _phase_for_indexed(expr, var, ds, pd, derivative)
504
+ pattern = (
505
+ ()
506
+ if phase is None
507
+ else (
508
+ phase,
509
+ Monotonicity.STRICTLY_DECREASING
510
+ if phase is Monotonicity.STRICTLY_INCREASING
511
+ else Monotonicity.STRICTLY_INCREASING,
512
+ )
513
+ )
514
+ idx = IndexedMonotonicPartition(
515
+ ds, pd.boundary_geometry, pattern, pd.complete, phase
516
+ )
517
+ return MonotonicPartition(
518
+ ds,
519
+ indexed=idx,
520
+ complete=pd.complete,
521
+ source="indexed-derivative-zero-geometry",
522
+ derivative=derivative,
523
+ property_decomposition=pd,
524
+ critical_points_complete=False,
525
+ )
526
+ return MonotonicPartition(
527
+ ds,
528
+ complete=False,
529
+ derivative=derivative,
530
+ property_decomposition=pd,
531
+ critical_points_complete=False,
532
+ )
533
+
534
+
535
+ def monotonic_partition(expr, var, *, domain=True):
536
+ """Return a cached exact finite or indexed partition into monotone cells."""
537
+ expr = sp.sympify(expr)
538
+ var = sp.sympify(var)
539
+ if isinstance(domain, sp.Set):
540
+ ds = domain
541
+ else:
542
+ if domain is True:
543
+ from .domain import function_domain
544
+
545
+ cond = function_domain(expr, var)
546
+ else:
547
+ cond = sp.sympify(domain)
548
+ ds = condition_to_set(cond, var)
549
+ if not isinstance(ds, sp.Set):
550
+ return MonotonicPartition(S.Reals, complete=False, source="unknown-domain")
551
+ try:
552
+ return _monotonic_partition_on_set(expr, var, ds)
553
+ except TypeError:
554
+ return _monotonic_partition_on_set.__wrapped__(expr, var, ds)
555
+
556
+
557
+ def clear_monotonic_partition_cache():
558
+ # Preserve the public spelling while clearing all proof primitives that can
559
+ # feed a partition; otherwise a "cleared" partition cache can immediately
560
+ # repopulate from stale lower-level proofs.
561
+ from ..proof_cache import clear_property_proof_cache
562
+
563
+ clear_property_proof_cache()
564
+
565
+
566
+ def _cell_image(cell: MonotonicInterval):
567
+ if not isinstance(cell.interval, sp.Interval):
568
+ return None
569
+ a, b = cell.left_limit, cell.right_limit
570
+ if a is None or b is None:
571
+ return None
572
+ if cell.increasing:
573
+ lo, hi = a, b
574
+ la, ua = cell.left_attained, cell.right_attained
575
+ elif cell.decreasing:
576
+ lo, hi = b, a
577
+ la, ua = cell.right_attained, cell.left_attained
578
+ else:
579
+ return None
580
+ if la is None or ua is None:
581
+ return None
582
+ if lo is S.NegativeInfinity:
583
+ la = False
584
+ if hi is S.Infinity:
585
+ ua = False
586
+ try:
587
+ return sp.Interval(lo, hi, left_open=not la, right_open=not ua)
588
+ except SYMBOLIC_QUERY_ERRORS:
589
+ return None
590
+
591
+
592
+ def range_from_monotonic_partition(expr, var, partition=None, *, domain=True):
593
+ """Exact image set when a finite monotonic partition is complete."""
594
+ part = partition or monotonic_partition(expr, var, domain=domain)
595
+ if not part.complete or part.indexed is not None or not part.cells:
596
+ return PartitionRange(None, False, part)
597
+ images = []
598
+ for c in part.cells:
599
+ im = _cell_image(c)
600
+ if im is None:
601
+ return PartitionRange(None, False, part)
602
+ images.append(im)
603
+ try:
604
+ image = sp.Union(*images)
605
+ except SYMBOLIC_QUERY_ERRORS:
606
+ return PartitionRange(None, False, part)
607
+ return PartitionRange(image, True, part)
608
+
609
+
610
+ def injectivity_from_monotonic_partition(partition):
611
+ """Three-valued injectivity conclusion derived only from a complete partition."""
612
+ if not isinstance(partition, MonotonicPartition) or not partition.complete:
613
+ return TruthValue.UNKNOWN
614
+ if partition.indexed is not None:
615
+ # Infinite simple alternating extrema force collisions on an interval.
616
+ return (
617
+ TruthValue.FALSE
618
+ if partition.indexed.boundary_geometry
619
+ else TruthValue.UNKNOWN
620
+ )
621
+ if not partition.cells:
622
+ return TruthValue.UNKNOWN
623
+ same = partition.same_strict_direction()
624
+ if same is not None:
625
+ return TruthValue.TRUE
626
+ # Direction reversal at an attained continuous interior critical point
627
+ # creates a strict local extremum and hence duplicate nearby values.
628
+ for a, b in zip(partition.cells, partition.cells[1:], strict=False):
629
+ if (
630
+ a.direction != b.direction
631
+ and a.continuous
632
+ and b.continuous
633
+ and a.right_attained
634
+ and b.left_attained
635
+ ):
636
+ return TruthValue.FALSE
637
+ return TruthValue.UNKNOWN
638
+
639
+
640
+ def _certified_inverse_cell(expr, var, value, cell):
641
+ """Resolve one strict monotonic cell exactly or by a rational isolating interval."""
642
+ from ..transcendental.real_sign import robust_real_sign
643
+ from ..transcendental.roots import CertifiedRootInterval, RootCertificate
644
+
645
+ interval = cell.interval
646
+ if not isinstance(interval, sp.Interval) or not cell.strict:
647
+ return None
648
+ left = None if cell.left_limit is None else sp.simplify(cell.left_limit - value)
649
+ right = None if cell.right_limit is None else sp.simplify(cell.right_limit - value)
650
+ if left is None or right is None:
651
+ return None
652
+ sl = robust_real_sign(left)
653
+ sr = robust_real_sign(right)
654
+ if sl is None or sr is None:
655
+ return None
656
+ exact = []
657
+ if sl == 0 and cell.left_attained:
658
+ exact.append(interval.start)
659
+ if sr == 0 and cell.right_attained:
660
+ exact.append(interval.end)
661
+ if exact:
662
+ return sp.FiniteSet(*exact), (), True
663
+ if sl == sr and sl in (-1, 1):
664
+ return S.EmptySet, (), True
665
+ if sl * sr != -1:
666
+ return None
667
+ if interval.start in (S.NegativeInfinity, S.Infinity) or interval.end in (
668
+ S.NegativeInfinity,
669
+ S.Infinity,
670
+ ):
671
+ return None
672
+
673
+ h = sp.simplify(expr - value)
674
+ a, b = interval.start, interval.end
675
+ # Move to exact interior dyadic points when an endpoint is open or non-rational.
676
+ aa = a
677
+ bb = b
678
+ left_sign = sl
679
+ right_sign = sr
680
+ for level in range(4, 28):
681
+ if robust_real_sign(sp.simplify(h.subs(var, aa))) != left_sign:
682
+ aa = sp.simplify(a + (b - a) / (2**level))
683
+ if robust_real_sign(sp.simplify(h.subs(var, bb))) != right_sign:
684
+ bb = sp.simplify(b - (b - a) / (2**level))
685
+ if (
686
+ robust_real_sign(sp.simplify(h.subs(var, aa))) == left_sign
687
+ and robust_real_sign(sp.simplify(h.subs(var, bb))) == right_sign
688
+ ):
689
+ break
690
+ else:
691
+ return None
692
+ for _ in range(32):
693
+ m = sp.simplify((aa + bb) / 2)
694
+ sm = robust_real_sign(sp.simplify(h.subs(var, m)))
695
+ if sm == 0:
696
+ return sp.FiniteSet(m), (), True
697
+ if sm not in (-1, 1):
698
+ break
699
+ if sm == left_sign:
700
+ aa = m
701
+ else:
702
+ bb = m
703
+ iv = sp.Interval(aa, bb)
704
+ cert = RootCertificate(
705
+ interval,
706
+ 1,
707
+ "certified_monotonic_inverse",
708
+ (("target", value), ("endpoint_signs", sl, sr), ("isolating_interval", iv)),
709
+ True,
710
+ 1,
711
+ )
712
+ return S.EmptySet, (CertifiedRootInterval(iv, cert),), True
713
+
714
+
715
+ def solve_monotonic_inverse(expr, var, value, *, domain=True, partition=None):
716
+ """Solve ``expr == value`` using a shared monotonic partition certificate.
717
+
718
+ Finite cells are solved independently, with uniqueness guaranteed on each
719
+ strict cell. Indexed partitions remain exact ``ConditionSet`` objects;
720
+ this routine never numerically truncates an infinite family.
721
+ """
722
+ expr = sp.sympify(expr)
723
+ var = sp.sympify(var)
724
+ value = sp.sympify(value)
725
+ part = partition or monotonic_partition(expr, var, domain=domain)
726
+ eq = sp.Eq(expr, value)
727
+ if not part.complete:
728
+ ds = part.domain if isinstance(part.domain, sp.Set) else S.Reals
729
+ return InverseSolutionCertificate(
730
+ eq,
731
+ ds,
732
+ sp.ConditionSet(var, eq, ds),
733
+ False,
734
+ part,
735
+ source="incomplete-monotonic-partition",
736
+ )
737
+ if part.indexed is not None:
738
+ return InverseSolutionCertificate(
739
+ eq,
740
+ part.domain,
741
+ sp.ConditionSet(var, eq, part.domain),
742
+ False,
743
+ part,
744
+ source="indexed-monotonic-partition",
745
+ )
746
+ # Prefer one global exact solve; splitting by monotonic cells is a fallback,
747
+ # not a reason to multiply solver work when SymPy already has a closed form.
748
+ try:
749
+ global_sol = sp.solveset(eq, var, domain=part.domain)
750
+ except SYMBOLIC_QUERY_ERRORS:
751
+ global_sol = sp.ConditionSet(var, eq, part.domain)
752
+ if not isinstance(global_sol, sp.ConditionSet) and not getattr(
753
+ global_sol, "has", lambda *_: False
754
+ )(sp.ConditionSet):
755
+ return InverseSolutionCertificate(
756
+ eq,
757
+ part.domain,
758
+ global_sol,
759
+ True,
760
+ part,
761
+ evidence=(part,),
762
+ source="global-solve+monotonic-partition",
763
+ )
764
+ sols = []
765
+ isolated = []
766
+ complete = True
767
+ for cell in part.cells:
768
+ if not isinstance(cell.interval, sp.Interval):
769
+ complete = False
770
+ continue
771
+ image = _cell_image(cell)
772
+ if image is not None:
773
+ try:
774
+ contained = image.contains(value)
775
+ if contained is S.false or contained is False:
776
+ continue
777
+ except SYMBOLIC_QUERY_ERRORS:
778
+ pass
779
+ try:
780
+ s = sp.solveset(eq, var, domain=cell.interval)
781
+ except SYMBOLIC_QUERY_ERRORS:
782
+ s = sp.ConditionSet(var, eq, cell.interval)
783
+ if isinstance(s, sp.ConditionSet) or getattr(s, "has", lambda *_: False)(
784
+ sp.ConditionSet
785
+ ):
786
+ certified = _certified_inverse_cell(expr, var, value, cell)
787
+ if certified is None:
788
+ complete = False
789
+ sols.append(s)
790
+ else:
791
+ exact_part, isolated_part, cell_complete = certified
792
+ sols.append(exact_part)
793
+ isolated.extend(isolated_part)
794
+ complete = complete and cell_complete
795
+ else:
796
+ sols.append(s)
797
+ # Include attained shared/end boundaries exactly once; solveset on open
798
+ # cells intentionally excludes them.
799
+ boundaries = []
800
+ if isinstance(part.domain, sp.Interval):
801
+ if not part.domain.left_open:
802
+ boundaries.append(part.domain.start)
803
+ if not part.domain.right_open:
804
+ boundaries.append(part.domain.end)
805
+ boundaries.extend(part.critical_points)
806
+ for q in boundaries:
807
+ try:
808
+ if sp.simplify(expr.subs(var, q) - value).is_zero is True:
809
+ sols.append(sp.FiniteSet(q))
810
+ except SYMBOLIC_QUERY_ERRORS:
811
+ pass
812
+ sol = sp.Union(*sols) if sols else S.EmptySet
813
+ return InverseSolutionCertificate(
814
+ eq,
815
+ part.domain,
816
+ sol,
817
+ complete,
818
+ part,
819
+ evidence=(part,),
820
+ isolated_intervals=tuple(isolated),
821
+ )
822
+
823
+
824
+ def certified_monotonic_inverse(expr, var, value, *, domain=True, partition=None):
825
+ """Certified reusable inverse primitive for a monotonic real function.
826
+
827
+ This is the explicit certification-oriented spelling of
828
+ :func:`solve_monotonic_inverse`; the latter is retained for compatibility.
829
+ """
830
+ return solve_monotonic_inverse(expr, var, value, domain=domain, partition=partition)
831
+
832
+
833
+ def quick_nonmonotonicity_witness(expr, var, domain):
834
+ """Try exact rational points and return only an exactly verified violation."""
835
+ ds = domain if isinstance(domain, sp.Set) else condition_to_set(domain, var)
836
+ if (
837
+ not isinstance(ds, sp.Interval)
838
+ or ds.start in (S.NegativeInfinity, S.Infinity)
839
+ or ds.end in (S.NegativeInfinity, S.Infinity)
840
+ ):
841
+ return None
842
+ a, b = ds.start, ds.end
843
+ pts = tuple(
844
+ sp.simplify(a + (b - a) * q)
845
+ for q in (
846
+ sp.Rational(1, 5),
847
+ sp.Rational(2, 5),
848
+ sp.Rational(3, 5),
849
+ sp.Rational(4, 5),
850
+ )
851
+ )
852
+ vals = tuple(sp.simplify(expr.subs(var, p)) for p in pts)
853
+ signs = []
854
+ for u, v in itertools.pairwise(vals):
855
+ d = sp.simplify(v - u)
856
+ if d.is_positive is True:
857
+ signs.append(1)
858
+ elif d.is_negative is True:
859
+ signs.append(-1)
860
+ elif d.is_zero is True:
861
+ signs.append(0)
862
+ else:
863
+ return None
864
+ nz = {s for s in signs if s}
865
+ if len(nz) > 1:
866
+ return NonMonotonicityWitness(pts, vals, True)
867
+ return None