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,552 @@
1
+ from __future__ import annotations
2
+
3
+ import sympy as sp
4
+ from sympy import S
5
+
6
+ from .. import _semialg
7
+ from ..engine import evaluate_property
8
+ from ..exceptions import POLYNOMIAL_ERRORS, SYMBOLIC_QUERY_ERRORS
9
+ from ..results import ConditionalValue, PropertyResult
10
+ from ..stratification import (
11
+ evaluate_parameter_strata,
12
+ normalize_cases,
13
+ semantic_condition,
14
+ )
15
+ from ..transcendental.algebraize import algebraize
16
+ from .common import (
17
+ combine_natural_domain_condition,
18
+ condition_to_set,
19
+ is_full_natural_domain,
20
+ set_to_condition,
21
+ )
22
+ from .domain import function_domain
23
+ from .monotonic_certificates import monotonic_partition, range_from_monotonic_partition
24
+ from .special_functions import (
25
+ parameter_guard,
26
+ positive_axis_range_strata,
27
+ special_function_range_strata,
28
+ )
29
+
30
+
31
+ def _lambda_condition(value, y):
32
+ """Instantiate a one-argument catalogue condition at ``y``."""
33
+ if isinstance(value, sp.Lambda) and len(value.variables) == 1:
34
+ return sp.sympify(value.expr).xreplace({value.variables[0]: y})
35
+ return None
36
+
37
+
38
+ def _refine_interval_attainment(expr, var, cond, y):
39
+ s = condition_to_set(cond, y)
40
+ if not isinstance(s, sp.Interval):
41
+ return cond
42
+ natural = function_domain(expr, var)
43
+ ds = condition_to_set(natural, var)
44
+ if ds is None:
45
+ return cond
46
+ left_open = s.left_open
47
+ right_open = s.right_open
48
+ if s.start not in (S.NegativeInfinity, S.Infinity):
49
+ try:
50
+ sol = sp.solveset(sp.Eq(expr, s.start), var, domain=ds)
51
+ if sol is S.EmptySet:
52
+ left_open = True
53
+ elif isinstance(sol, (sp.FiniteSet, sp.ImageSet, sp.Union)):
54
+ left_open = False
55
+ except SYMBOLIC_QUERY_ERRORS:
56
+ pass
57
+ if s.end not in (S.NegativeInfinity, S.Infinity):
58
+ try:
59
+ sol = sp.solveset(sp.Eq(expr, s.end), var, domain=ds)
60
+ if sol is S.EmptySet:
61
+ right_open = True
62
+ elif isinstance(sol, (sp.FiniteSet, sp.ImageSet, sp.Union)):
63
+ right_open = False
64
+ except SYMBOLIC_QUERY_ERRORS:
65
+ pass
66
+ return set_to_condition(
67
+ sp.Interval(s.start, s.end, left_open=left_open, right_open=right_open), y
68
+ )
69
+
70
+
71
+ def _registry_range(expr, var, y):
72
+ r = evaluate_property(expr, "Range", var=var, mode="real")
73
+ pguard = parameter_guard(expr, var, "Range")
74
+ if r.primary is not None:
75
+ cond = _lambda_condition(r.primary, y)
76
+ if cond is None:
77
+ return None
78
+ cond = _refine_interval_attainment(expr, var, cond, y)
79
+ if pguard is S.true:
80
+ return cond
81
+ return normalize_cases(
82
+ (ConditionalValue(cond, pguard),), source="registry-range-stratification"
83
+ )
84
+ if r.cases:
85
+ cases = []
86
+ for c in r.cases:
87
+ cond = _lambda_condition(c.value, y)
88
+ if cond is not None:
89
+ cond = _refine_interval_attainment(expr, var, cond, y)
90
+ cases.append(
91
+ ConditionalValue(
92
+ cond, sp.And(semantic_condition(c.condition), pguard)
93
+ )
94
+ )
95
+ if cases:
96
+ return normalize_cases(cases, source="registry-range-stratification")
97
+ return None
98
+
99
+
100
+ def _monotone_image_interval(outer, X, inner_set):
101
+ from ..results import Monotonicity
102
+ from .monotonicity import function_monotonicity
103
+
104
+ dom = set_to_condition(inner_set, X)
105
+ m = function_monotonicity(outer, X, domain=dom)
106
+ inc = m in (Monotonicity.STRICTLY_INCREASING, Monotonicity.NONDECREASING)
107
+ dec = m in (Monotonicity.STRICTLY_DECREASING, Monotonicity.NONINCREASING)
108
+ if not (inc or dec):
109
+ return None
110
+
111
+ def image_endpoint(v, is_left, is_open):
112
+ if v is S.NegativeInfinity:
113
+ return sp.limit(outer, X, -sp.oo)
114
+ if v is S.Infinity:
115
+ return sp.limit(outer, X, sp.oo)
116
+ if is_open:
117
+ try:
118
+ return sp.limit(outer, X, v, dir="+" if is_left else "-")
119
+ except SYMBOLIC_QUERY_ERRORS:
120
+ pass
121
+ return sp.simplify(outer.subs(X, v))
122
+
123
+ a = image_endpoint(inner_set.start, True, inner_set.left_open)
124
+ b = image_endpoint(inner_set.end, False, inner_set.right_open)
125
+ if inc:
126
+ lo, hi = a, b
127
+ lo_open = inner_set.left_open or inner_set.start in (
128
+ S.NegativeInfinity,
129
+ S.Infinity,
130
+ )
131
+ hi_open = inner_set.right_open or inner_set.end in (
132
+ S.NegativeInfinity,
133
+ S.Infinity,
134
+ )
135
+ else:
136
+ lo, hi = b, a
137
+ lo_open = inner_set.right_open or inner_set.end in (
138
+ S.NegativeInfinity,
139
+ S.Infinity,
140
+ )
141
+ hi_open = inner_set.left_open or inner_set.start in (
142
+ S.NegativeInfinity,
143
+ S.Infinity,
144
+ )
145
+ if lo is S.NegativeInfinity:
146
+ lo_open = True
147
+ if hi is S.Infinity:
148
+ hi_open = True
149
+ return sp.Interval(lo, hi, left_open=lo_open, right_open=hi_open)
150
+
151
+
152
+ def _composition_range(expr, var, y, domain_condition):
153
+ varying = [(i, a) for i, a in enumerate(getattr(expr, "args", ())) if a.has(var)]
154
+ if len(varying) != 1 or expr.func in (sp.Add, sp.Mul, sp.Pow, sp.Piecewise):
155
+ return None
156
+ i, inner = varying[0]
157
+ # A primitive f(x) is not a nontrivial composition. Recursing through
158
+ # function_range(x, ...) and then asking monotonicity of the same primitive
159
+ # can manufacture bogus empty/False images for oscillatory functions.
160
+ if inner == var:
161
+ return None
162
+ u = sp.Dummy("u", real=True)
163
+ rc = function_range(inner, var, u, constraints=domain_condition)
164
+ if isinstance(rc, PropertyResult):
165
+ return None
166
+ inner_set = condition_to_set(rc, u)
167
+ if not isinstance(inner_set, sp.Interval):
168
+ return None
169
+ X = sp.Dummy("X", real=True)
170
+ args = list(expr.args)
171
+ args[i] = X
172
+ outer = expr.func(*args)
173
+ out = _monotone_image_interval(outer, X, inner_set)
174
+ return set_to_condition(out, y) if out is not None else None
175
+
176
+
177
+ def _scale_interval(s, A, B, sign):
178
+ def finite_image(v):
179
+ if v is S.NegativeInfinity:
180
+ return S.NegativeInfinity if sign > 0 else S.Infinity
181
+ if v is S.Infinity:
182
+ return S.Infinity if sign > 0 else S.NegativeInfinity
183
+ return sp.simplify(A * v + B)
184
+
185
+ if sign > 0:
186
+ return sp.Interval(
187
+ finite_image(s.start),
188
+ finite_image(s.end),
189
+ left_open=s.left_open,
190
+ right_open=s.right_open,
191
+ )
192
+ return sp.Interval(
193
+ finite_image(s.end),
194
+ finite_image(s.start),
195
+ left_open=s.right_open,
196
+ right_open=s.left_open,
197
+ )
198
+
199
+
200
+ def _affine_transform(expr, var, y, domain):
201
+ """Transform an exact inner range through ``A*g(x) + B``.
202
+
203
+ Parameter-dependent coefficient signs are preserved as conditional strata
204
+ instead of choosing an orientation heuristically.
205
+ """
206
+ # Identify A*g(x)+B where A and B are independent of the input variable.
207
+ terms = sp.Add.make_args(sp.expand(expr))
208
+ const = sum((t for t in terms if not t.has(var)), S.Zero)
209
+ dep = sp.simplify(expr - const)
210
+ coeff, rest = dep.as_independent(var, as_Add=False)
211
+ if coeff.has(var) or not rest.has(var) or rest == expr:
212
+ return None
213
+ u = sp.Dummy("u", real=True)
214
+ inner = function_range(rest, var, u, constraints=domain)
215
+
216
+ def transform(cond, extra=S.true):
217
+ ss = condition_to_set(cond, u)
218
+ if not isinstance(ss, sp.Interval):
219
+ return None
220
+ A = sp.sympify(coeff)
221
+ B = sp.sympify(const)
222
+ if A.is_positive:
223
+ return set_to_condition(_scale_interval(ss, A, B, 1), y)
224
+ if A.is_negative:
225
+ return set_to_condition(_scale_interval(ss, A, B, -1), y)
226
+ if A.is_zero:
227
+ return sp.Eq(y, B)
228
+ return normalize_cases(
229
+ (
230
+ ConditionalValue(
231
+ set_to_condition(_scale_interval(ss, A, B, 1), y),
232
+ sp.And(extra, A > 0),
233
+ ),
234
+ ConditionalValue(sp.Eq(y, B), sp.And(extra, sp.Eq(A, 0))),
235
+ ConditionalValue(
236
+ set_to_condition(_scale_interval(ss, A, B, -1), y),
237
+ sp.And(extra, A < 0),
238
+ ),
239
+ ),
240
+ source="affine-range-stratification",
241
+ )
242
+
243
+ if isinstance(inner, PropertyResult):
244
+ cases = []
245
+ for c in inner.cases:
246
+ tr = transform(c.value, c.condition)
247
+ if isinstance(tr, PropertyResult):
248
+ cases.extend(tr.cases)
249
+ elif tr is not None:
250
+ cases.append(ConditionalValue(tr, c.condition))
251
+ return (
252
+ normalize_cases(cases, source="affine-range-stratification")
253
+ if cases
254
+ else None
255
+ )
256
+ return transform(inner)
257
+
258
+
259
+ def _parameterized_elementary_range(expr, var, y, domain_condition):
260
+ """High-value exact parameter strata not handled by generic calculus."""
261
+ ds = condition_to_set(domain_condition, var)
262
+ try:
263
+ positive_axis = (
264
+ isinstance(ds, sp.Interval)
265
+ and ds.start == 0
266
+ and ds.left_open
267
+ and ds.end is S.Infinity
268
+ ) or sp.simplify_logic(
269
+ sp.Implies(semantic_condition(domain_condition), var > 0)
270
+ ) is S.true
271
+ except SYMBOLIC_QUERY_ERRORS:
272
+ positive_axis = False
273
+ # x**a on x>0: every nonzero real exponent maps onto (0,oo), while
274
+ # a=0 is the singleton 1. This must precede global catalogue ranges.
275
+ if positive_axis and expr.func is sp.Pow and expr.base == var:
276
+ A = expr.exp
277
+ if A.free_symbols:
278
+ real_guard = S.true if A.is_real is True else sp.Contains(A, S.Reals)
279
+ return normalize_cases(
280
+ (
281
+ ConditionalValue(y > 0, sp.And(real_guard, sp.Ne(A, 0))),
282
+ ConditionalValue(sp.Eq(y, 1), sp.Eq(A, 0)),
283
+ ),
284
+ source="positive-power-range-stratification",
285
+ )
286
+ if ds is not S.Reals:
287
+ return None
288
+ # exp(a*x+b): nonzero real slope maps R bijectively to (0,oo); zero
289
+ # slope is the singleton exp(b).
290
+ if expr.func is sp.exp:
291
+ try:
292
+ p = sp.Poly(sp.expand(expr.args[0]), var)
293
+ except POLYNOMIAL_ERRORS:
294
+ p = None
295
+ if p is not None and p.degree() <= 1:
296
+ A = p.coeff_monomial(var)
297
+ B = p.nth(0)
298
+ if A.free_symbols and A.is_real is True and B.is_real is True:
299
+ return normalize_cases(
300
+ (
301
+ ConditionalValue(y > 0, sp.Ne(A, 0)),
302
+ ConditionalValue(sp.Eq(y, sp.exp(B)), sp.Eq(A, 0)),
303
+ ),
304
+ source="exp-affine-range-stratification",
305
+ )
306
+ return None
307
+
308
+
309
+ def _sympy_range(expr, var, y, domain_condition):
310
+ ds = condition_to_set(domain_condition, var)
311
+ if ds is None:
312
+ return None
313
+ try:
314
+ rs = sp.calculus.util.function_range(expr, var, ds)
315
+ except SYMBOLIC_QUERY_ERRORS:
316
+ return None
317
+ if isinstance(rs, sp.Set) and not rs.has(var):
318
+ return set_to_condition(rs, y)
319
+ return None
320
+
321
+
322
+ def _early_range_stage(expr, var, y, domain_condition, constraints):
323
+ """Run cheap parameter, catalogue, and positive-axis range rules."""
324
+ pr = _parameterized_elementary_range(expr, var, y, domain_condition)
325
+ if pr is not None:
326
+ return pr
327
+ sf_domain = domain_condition if constraints is True else sp.sympify(constraints)
328
+ sfacts = special_function_range_strata(expr, var, y, sf_domain)
329
+ if sfacts:
330
+ out = normalize_cases(
331
+ (ConditionalValue(v, g) for v, g, *_ in sfacts),
332
+ source="dlmf-special-function-range-stratification",
333
+ evidence=tuple(src for *_, src in sfacts),
334
+ )
335
+ return out.primary if out.primary is not None else out
336
+ ds0 = condition_to_set(domain_condition, var)
337
+ try:
338
+ positive_axis = (
339
+ isinstance(ds0, sp.Interval)
340
+ and ds0.start == 0
341
+ and ds0.left_open
342
+ and ds0.end is S.Infinity
343
+ ) or sp.simplify_logic(
344
+ sp.Implies(semantic_condition(domain_condition), var > 0)
345
+ ) is S.true
346
+ except SYMBOLIC_QUERY_ERRORS:
347
+ positive_axis = False
348
+ if positive_axis:
349
+ facts = positive_axis_range_strata(expr, var, y)
350
+ if facts:
351
+ out = normalize_cases(
352
+ (ConditionalValue(v, g) for v, g, *_ in facts),
353
+ source="special-function-range-stratification",
354
+ evidence=tuple(src for *_, src in facts),
355
+ )
356
+ return out.primary if out.primary is not None else out
357
+ return None
358
+
359
+
360
+ def _semialgebraic_range_stage(expr, var, y, domain_condition, method, kwargs):
361
+ """Run structural then exact semialgebraic range analysis."""
362
+ if not _semialg.eligible_problem(expr, var, domain=domain_condition):
363
+ return None
364
+ structural = _affine_transform(expr, var, y, domain_condition)
365
+ if structural is not None:
366
+ return (
367
+ structural
368
+ if isinstance(structural, PropertyResult)
369
+ else sp.simplify(structural)
370
+ )
371
+ try:
372
+ return _semialg.function_range(
373
+ expr,
374
+ var,
375
+ y,
376
+ constraints=domain_condition,
377
+ method="qe" if method == "auto" else method,
378
+ **kwargs,
379
+ )
380
+ except SYMBOLIC_QUERY_ERRORS:
381
+ return None
382
+
383
+
384
+ def function_range(
385
+ expr,
386
+ var,
387
+ y,
388
+ *,
389
+ constraints=True,
390
+ method="auto",
391
+ partition=None,
392
+ _domain_ready=False,
393
+ **kwargs,
394
+ ):
395
+ """Return the real range as a Boolean condition in ``y``.
396
+
397
+ Strategy: primitive lookup, structural transformations, SymPy's univariate
398
+ calculus algorithms, and semialg for the semialgebraic subset.
399
+ """
400
+ expr = sp.sympify(expr)
401
+ var = sp.sympify(var)
402
+ y = sp.sympify(y)
403
+ natural = function_domain(expr, var)
404
+ domain_condition = (
405
+ sp.sympify(constraints)
406
+ if _domain_ready
407
+ else combine_natural_domain_condition(natural, constraints, var)
408
+ )
409
+ if not _domain_ready:
410
+ split = evaluate_parameter_strata(
411
+ domain_condition,
412
+ var,
413
+ lambda branch_domain: function_range(
414
+ expr,
415
+ var,
416
+ y,
417
+ constraints=branch_domain,
418
+ method=method,
419
+ partition=None,
420
+ _domain_ready=True,
421
+ **kwargs,
422
+ ),
423
+ source="range-parameter-stratification",
424
+ )
425
+ if split is not None:
426
+ return split
427
+ if not expr.has(var):
428
+ return sp.Eq(y, expr)
429
+
430
+ early = _early_range_stage(expr, var, y, domain_condition, constraints)
431
+ if early is not None:
432
+ return early
433
+
434
+ # Preserve parameter strata before delegating semialgebraic elimination.
435
+ # A backend QE call may quantify/treat free coefficients in a way that
436
+ # collapses guarded image cases (for example a*x on x>0) to a tautology.
437
+ backend = _semialgebraic_range_stage(expr, var, y, domain_condition, method, kwargs)
438
+ if backend is not None:
439
+ return backend
440
+
441
+ # Exact transcendental algebraization can turn the range problem into a
442
+ # semialgebraic one without changing the output variable. For example,
443
+ # exp(2*x)-3*exp(x)+2 becomes u**2-3*u+2 on u>0.
444
+ ar = algebraize(expr, var)
445
+ if ar.changed and ar.exact and len(ar.substitutions) == 1:
446
+ u, _ = ar.substitutions[0]
447
+ # Preserve openness of the auxiliary image (notably u=exp(x)>0).
448
+ # SymPy's univariate calculus is tried first because it represents
449
+ # endpoint attainment explicitly; semialg remains the exact fallback.
450
+ try:
451
+ us = condition_to_set(ar.constraints, u)
452
+ if us is not None:
453
+ rs = sp.calculus.util.function_range(ar.expression, u, us)
454
+ if isinstance(rs, sp.Set):
455
+ return set_to_condition(rs, y)
456
+ except SYMBOLIC_QUERY_ERRORS:
457
+ pass
458
+ try:
459
+ raw = _semialg.function_range(
460
+ ar.expression,
461
+ u,
462
+ y,
463
+ constraints=ar.constraints,
464
+ method="qe" if method == "auto" else method,
465
+ **kwargs,
466
+ )
467
+ # Refine finite interval endpoints against the actual auxiliary
468
+ # domain so a backend closure approximation cannot turn > into >=.
469
+ rs = condition_to_set(raw, y)
470
+ us = condition_to_set(ar.constraints, u)
471
+ if isinstance(rs, sp.Interval) and us is not None:
472
+ lo_open, hi_open = rs.left_open, rs.right_open
473
+ if rs.start not in (S.NegativeInfinity, S.Infinity):
474
+ sol = sp.solveset(sp.Eq(ar.expression, rs.start), u, domain=us)
475
+ if sol is S.EmptySet:
476
+ lo_open = True
477
+ if rs.end not in (S.NegativeInfinity, S.Infinity):
478
+ sol = sp.solveset(sp.Eq(ar.expression, rs.end), u, domain=us)
479
+ if sol is S.EmptySet:
480
+ hi_open = True
481
+ return set_to_condition(
482
+ sp.Interval(
483
+ rs.start, rs.end, left_open=lo_open, right_open=hi_open
484
+ ),
485
+ y,
486
+ )
487
+ return raw
488
+ except SYMBOLIC_QUERY_ERRORS:
489
+ pass
490
+
491
+ # Monotone composition can turn a known inner interval into the exact
492
+ # outer range (e.g. exp(sin(x)) -> [exp(-1), exp(1)]).
493
+ r = _composition_range(expr, var, y, domain_condition)
494
+ if r is not None:
495
+ return r if isinstance(r, PropertyResult) else sp.simplify(r)
496
+
497
+ # Primitive catalogue ranges are exact and cheapest.
498
+ if constraints is True or is_full_natural_domain(domain_condition, natural):
499
+ r = _registry_range(expr, var, y)
500
+ if r is not None:
501
+ return r if isinstance(r, PropertyResult) else sp.simplify(r)
502
+
503
+ ds = condition_to_set(domain_condition, var)
504
+ part = (
505
+ partition
506
+ if partition is not None and getattr(partition, "domain", None) == ds
507
+ else None
508
+ )
509
+ # Indexed special-function geometry is cheap to recognize and expensive to
510
+ # rediscover through generic calculus, so build the shared partition first
511
+ # for those families. For generic expressions keep SymPy's successful fast
512
+ # path ahead of a potentially redundant derivative-root solve.
513
+ if isinstance(ds, sp.Interval):
514
+ try:
515
+ from ..transcendental.zero_geometry import zero_geometry
516
+
517
+ if part is None and zero_geometry(expr, var, derivative_order=1, domain=ds):
518
+ part = monotonic_partition(expr, var, domain=ds)
519
+ except SYMBOLIC_QUERY_ERRORS:
520
+ pass
521
+ if part is not None:
522
+ prange = range_from_monotonic_partition(expr, var, part)
523
+ if prange.complete and isinstance(prange.image, sp.Set):
524
+ return set_to_condition(prange.image, y)
525
+
526
+ r = _sympy_range(expr, var, y, domain_condition)
527
+ if r is not None:
528
+ return sp.simplify(r)
529
+
530
+ if part is None and isinstance(ds, sp.Interval):
531
+ part = monotonic_partition(expr, var, domain=ds)
532
+ prange = range_from_monotonic_partition(expr, var, part)
533
+ if prange.complete and isinstance(prange.image, sp.Set):
534
+ return set_to_condition(prange.image, y)
535
+
536
+ # Indexed extrema consume the same partition certificate, avoiding a second
537
+ # derivative-zero geometry pass.
538
+ try:
539
+ from ..transcendental.extrema import symbolic_extremum_range
540
+
541
+ er = symbolic_extremum_range(
542
+ expr,
543
+ var,
544
+ ds if isinstance(ds, sp.Interval) else domain_condition,
545
+ partition=part,
546
+ )
547
+ if er is not None and er.complete:
548
+ cond = er.as_condition(y)
549
+ return cond if cond is not None else er
550
+ except SYMBOLIC_QUERY_ERRORS:
551
+ pass
552
+ return sp.Function("UnknownRange")(expr, var, y)