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,673 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+
5
+ import sympy as sp
6
+ from sympy import S
7
+
8
+ from .. import _semialg
9
+ from ..engine import evaluate_property
10
+ from ..exceptions import SYMBOLIC_QUERY_ERRORS
11
+ from ..results import ConditionalValue, Monotonicity, PropertyResult, Sign
12
+ from ..stratification import (
13
+ evaluate_parameter_strata,
14
+ normalize_cases,
15
+ semantic_condition,
16
+ )
17
+ from .common import (
18
+ combine_natural_domain_condition,
19
+ condition_to_set,
20
+ is_full_natural_domain,
21
+ )
22
+ from .domain import function_domain
23
+ from .monotonic_certificates import (
24
+ monotonic_decomposition,
25
+ monotonic_partition,
26
+ quick_nonmonotonicity_witness,
27
+ )
28
+ from .piecewise import (
29
+ combine_piecewise_monotonicity,
30
+ piecewise_branches,
31
+ piecewise_interface_limits,
32
+ )
33
+ from .sign import function_sign
34
+ from .special_functions import parameter_guard, positive_axis_monotonicity_strata
35
+
36
+
37
+ def _from_code(value):
38
+ try:
39
+ code = int(str(value))
40
+ except SYMBOLIC_QUERY_ERRORS:
41
+ return Monotonicity.UNKNOWN
42
+ return {
43
+ 1: Monotonicity.STRICTLY_INCREASING,
44
+ 2: Monotonicity.NONDECREASING,
45
+ -1: Monotonicity.STRICTLY_DECREASING,
46
+ -2: Monotonicity.NONINCREASING,
47
+ 0: Monotonicity.NOT_MONOTONE,
48
+ }.get(code, Monotonicity.UNKNOWN)
49
+
50
+
51
+ def _direct_registry(expr, var):
52
+ r = evaluate_property(expr, "GlobalMonotonicity", var=var, mode="real")
53
+ pguard = parameter_guard(expr, var, "GlobalMonotonicity")
54
+ if r.primary is not None:
55
+ v = _from_code(r.primary)
56
+ if pguard is S.true:
57
+ return v
58
+ return normalize_cases(
59
+ (ConditionalValue(v, pguard),),
60
+ source="registry-monotonicity-stratification",
61
+ )
62
+ if r.cases:
63
+ out = normalize_cases(
64
+ (
65
+ ConditionalValue(
66
+ _from_code(c.value), sp.And(semantic_condition(c.condition), pguard)
67
+ )
68
+ for c in r.cases
69
+ ),
70
+ source="registry-monotonicity-stratification",
71
+ )
72
+ if out.cases:
73
+ return out
74
+ return Monotonicity.UNKNOWN
75
+
76
+
77
+ def _compose_monotonicity(expr, var, domain):
78
+ # g(h(x)) with one varying argument: combine outer and inner order.
79
+ varying = [
80
+ (i, a)
81
+ for i, a in enumerate(getattr(expr, "args", ()))
82
+ if getattr(a, "has", lambda *_: False)(var)
83
+ ]
84
+ if len(varying) != 1 or expr.func in (sp.Add, sp.Mul, sp.Pow, sp.Piecewise):
85
+ return Monotonicity.UNKNOWN
86
+ i, inner = varying[0]
87
+ # A primitive f(x) is not a composition f(g(x)); treating it as f∘id can
88
+ # re-import an overbroad global catalogue fact on a restricted domain.
89
+ if inner == var:
90
+ return Monotonicity.UNKNOWN
91
+ X = sp.Dummy("X", real=True)
92
+ args = list(expr.args)
93
+ args[i] = X
94
+ outer = expr.func(*args)
95
+ om = _direct_registry(outer, X)
96
+ if om is Monotonicity.UNKNOWN and outer.func is sp.exp:
97
+ om = Monotonicity.STRICTLY_INCREASING
98
+ im = function_monotonicity(inner, var, domain=domain)
99
+ if isinstance(om, PropertyResult) or isinstance(im, PropertyResult):
100
+ return Monotonicity.UNKNOWN
101
+ inc = (Monotonicity.STRICTLY_INCREASING, Monotonicity.NONDECREASING)
102
+ dec = (Monotonicity.STRICTLY_DECREASING, Monotonicity.NONINCREASING)
103
+ if om in inc and im in inc:
104
+ return (
105
+ Monotonicity.STRICTLY_INCREASING
106
+ if om is inc[0] and im is inc[0]
107
+ else Monotonicity.NONDECREASING
108
+ )
109
+ if om in inc and im in dec:
110
+ return (
111
+ Monotonicity.STRICTLY_DECREASING
112
+ if om is inc[0] and im is dec[0]
113
+ else Monotonicity.NONINCREASING
114
+ )
115
+ if om in dec and im in inc:
116
+ return (
117
+ Monotonicity.STRICTLY_DECREASING
118
+ if om is dec[0] and im is inc[0]
119
+ else Monotonicity.NONINCREASING
120
+ )
121
+ if om in dec and im in dec:
122
+ return (
123
+ Monotonicity.STRICTLY_INCREASING
124
+ if om is dec[0] and im is dec[0]
125
+ else Monotonicity.NONDECREASING
126
+ )
127
+ return Monotonicity.UNKNOWN
128
+
129
+
130
+ def _closure_monotonicity(expr, var, domain):
131
+ """Cheap sign-aware closure rules for sums, products and Min/Max."""
132
+ inc = (Monotonicity.STRICTLY_INCREASING, Monotonicity.NONDECREASING)
133
+ dec = (Monotonicity.STRICTLY_DECREASING, Monotonicity.NONINCREASING)
134
+ if expr.is_Add:
135
+ parts = [a for a in expr.args if a.has(var)]
136
+ if not parts:
137
+ return Monotonicity.CONSTANT
138
+ vals = [function_monotonicity(a, var, domain=domain) for a in parts]
139
+ if any(isinstance(v, PropertyResult) for v in vals):
140
+ return Monotonicity.UNKNOWN
141
+ if all(v in inc for v in vals):
142
+ return (
143
+ Monotonicity.STRICTLY_INCREASING
144
+ if any(v is inc[0] for v in vals)
145
+ else Monotonicity.NONDECREASING
146
+ )
147
+ if all(v in dec for v in vals):
148
+ return (
149
+ Monotonicity.STRICTLY_DECREASING
150
+ if any(v is dec[0] for v in vals)
151
+ else Monotonicity.NONINCREASING
152
+ )
153
+ if expr.func in (sp.Min, sp.Max):
154
+ vals = [
155
+ function_monotonicity(a, var, domain=domain)
156
+ for a in expr.args
157
+ if a.has(var)
158
+ ]
159
+ if vals and not any(isinstance(v, PropertyResult) for v in vals):
160
+ if all(v in inc for v in vals):
161
+ return Monotonicity.NONDECREASING
162
+ if all(v in dec for v in vals):
163
+ return Monotonicity.NONINCREASING
164
+ if expr.is_Mul:
165
+ const, dep = expr.as_independent(var, as_Add=False)
166
+ parts = list(sp.Mul.make_args(dep))
167
+ if const.is_zero is True:
168
+ return Monotonicity.CONSTANT
169
+ # Product of positive factors moving in the same direction.
170
+ if parts:
171
+ vals = []
172
+ signs = []
173
+ for a in parts:
174
+ vals.append(function_monotonicity(a, var, domain=domain))
175
+ signs.append(function_sign(a, var, domain=domain))
176
+ if not any(isinstance(v, PropertyResult) for v in vals):
177
+ nonneg = all(q in (Sign.POSITIVE, Sign.NONNEGATIVE) for q in signs)
178
+ strictly_pos = all(q is Sign.POSITIVE for q in signs)
179
+ if nonneg and all(v in inc for v in vals):
180
+ # A merely nonnegative factor can vanish on an interval;
181
+ # then the product may have a plateau even if another
182
+ # factor is strictly increasing. Strictness is safe only
183
+ # when every factor is strictly positive.
184
+ out = (
185
+ Monotonicity.STRICTLY_INCREASING
186
+ if strictly_pos and any(v is inc[0] for v in vals)
187
+ else Monotonicity.NONDECREASING
188
+ )
189
+ if const.is_negative is True:
190
+ return (
191
+ Monotonicity.STRICTLY_DECREASING
192
+ if out is Monotonicity.STRICTLY_INCREASING
193
+ else Monotonicity.NONINCREASING
194
+ )
195
+ if const.is_positive is True:
196
+ return out
197
+ if nonneg and all(v in dec for v in vals):
198
+ out = (
199
+ Monotonicity.STRICTLY_DECREASING
200
+ if strictly_pos and any(v is dec[0] for v in vals)
201
+ else Monotonicity.NONINCREASING
202
+ )
203
+ if const.is_negative is True:
204
+ return (
205
+ Monotonicity.STRICTLY_INCREASING
206
+ if out is Monotonicity.STRICTLY_DECREASING
207
+ else Monotonicity.NONDECREASING
208
+ )
209
+ if const.is_positive is True:
210
+ return out
211
+ return Monotonicity.UNKNOWN
212
+
213
+
214
+ def _piecewise_monotonicity(expr, var, domain):
215
+ """Classify finite interval branches and verify ordering at interfaces."""
216
+ if not isinstance(expr, sp.Piecewise):
217
+ return Monotonicity.UNKNOWN
218
+ branches = piecewise_branches(expr, var, domain)
219
+ if not branches:
220
+ return Monotonicity.UNKNOWN
221
+ values = [
222
+ function_monotonicity(b.expression, var, domain=b.condition) for b in branches
223
+ ]
224
+ direction = combine_piecewise_monotonicity(values)
225
+ if direction in (Monotonicity.UNKNOWN, Monotonicity.NOT_MONOTONE):
226
+ return direction
227
+ interfaces = piecewise_interface_limits(branches, var)
228
+ if interfaces is None:
229
+ return direction if len(branches) == 1 else Monotonicity.UNKNOWN
230
+ for _, left_value, right_value in interfaces:
231
+ jump = sp.simplify(right_value - left_value)
232
+ if direction in (
233
+ Monotonicity.NONDECREASING,
234
+ Monotonicity.STRICTLY_INCREASING,
235
+ ):
236
+ if jump.is_nonnegative is not True:
237
+ return (
238
+ Monotonicity.NOT_MONOTONE
239
+ if jump.is_negative is True
240
+ else Monotonicity.UNKNOWN
241
+ )
242
+ elif direction in (
243
+ Monotonicity.NONINCREASING,
244
+ Monotonicity.STRICTLY_DECREASING,
245
+ ):
246
+ if jump.is_nonpositive is not True:
247
+ return (
248
+ Monotonicity.NOT_MONOTONE
249
+ if jump.is_positive is True
250
+ else Monotonicity.UNKNOWN
251
+ )
252
+ return direction
253
+
254
+
255
+ def _parameter_strata(expr, var, domain):
256
+ if expr.func is sp.Pow and expr.base == var:
257
+ a = expr.exp
258
+ if a.free_symbols and domain == (var > 0):
259
+ return normalize_cases(
260
+ (
261
+ ConditionalValue(Monotonicity.STRICTLY_INCREASING, a > 0),
262
+ ConditionalValue(Monotonicity.CONSTANT, sp.Eq(a, 0)),
263
+ ConditionalValue(Monotonicity.STRICTLY_DECREASING, a < 0),
264
+ ),
265
+ source="power-parameter-stratification",
266
+ )
267
+ if expr.func is sp.exp:
268
+ g = sp.expand(expr.args[0])
269
+ try:
270
+ p = sp.Poly(g, var)
271
+ except SYMBOLIC_QUERY_ERRORS:
272
+ p = None
273
+ if p is not None and p.degree() == 1:
274
+ a = p.coeff_monomial(var)
275
+ if a.free_symbols:
276
+ return normalize_cases(
277
+ (
278
+ ConditionalValue(Monotonicity.STRICTLY_INCREASING, a > 0),
279
+ ConditionalValue(Monotonicity.CONSTANT, sp.Eq(a, 0)),
280
+ ConditionalValue(Monotonicity.STRICTLY_DECREASING, a < 0),
281
+ ),
282
+ source="exp-affine-parameter-stratification",
283
+ )
284
+ return None
285
+
286
+
287
+ def _semialgebraic_monotonicity_stage(expr, var, assumptions, *, details, intervals):
288
+ """Run the exact semialgebraic monotonicity stage, if eligible.
289
+
290
+ Interval requests deliberately continue to funcprops' shared partition
291
+ engine because callers rely on its boundary/zero-geometry certificates.
292
+ """
293
+ if intervals or not _semialg.eligible_problem(expr, var, domain=assumptions):
294
+ return None
295
+ try:
296
+ raw = _semialg.function_monotonicity(
297
+ expr, var, domain=assumptions, return_result=bool(intervals or details)
298
+ )
299
+ except (_semialg.SemialgUnavailable, *SYMBOLIC_QUERY_ERRORS):
300
+ return None
301
+ value = _semialg.normalize_monotonicity(raw)
302
+ if value is Monotonicity.UNKNOWN:
303
+ return None
304
+ classification = getattr(raw, "classification", raw)
305
+ evidence = (raw,) if raw is not classification else ()
306
+ return value, evidence
307
+
308
+
309
+ def _classify_monotonicity(
310
+ expr,
311
+ var,
312
+ *,
313
+ domain=True,
314
+ intervals=False,
315
+ details=False,
316
+ partition=None,
317
+ _domain_ready=False,
318
+ ):
319
+ """Classify global monotonicity on the natural/requested real domain.
320
+
321
+ Expensive critical-point decomposition is deliberately delayed until cheap
322
+ registry, closure, and composition proofs have failed. When needed, the
323
+ shared :func:`monotonic_partition` certificate is reused by range,
324
+ injectivity, inverse, oscillation, and extrema consumers.
325
+ """
326
+ expr = sp.sympify(expr)
327
+ var = sp.sympify(var)
328
+ if not expr.has(var):
329
+ return Monotonicity.CONSTANT
330
+ natural = function_domain(expr, var)
331
+ assumptions = (
332
+ sp.sympify(domain)
333
+ if _domain_ready
334
+ else combine_natural_domain_condition(natural, domain, var)
335
+ )
336
+ with contextlib.suppress(SYMBOLIC_QUERY_ERRORS):
337
+ assumptions = sp.simplify(assumptions)
338
+ if not _domain_ready:
339
+ split = evaluate_parameter_strata(
340
+ assumptions,
341
+ var,
342
+ lambda branch_domain: _classify_monotonicity(
343
+ expr,
344
+ var,
345
+ domain=branch_domain,
346
+ intervals=intervals,
347
+ details=details,
348
+ partition=None,
349
+ _domain_ready=True,
350
+ ),
351
+ source="monotonicity-parameter-stratification",
352
+ )
353
+ if split is not None:
354
+ return split
355
+ ds = condition_to_set(assumptions, var)
356
+ geom_domain = ds if isinstance(ds, sp.Set) else S.Reals
357
+
358
+ def finish(value, source=None, evidence=()):
359
+ if intervals or details:
360
+ return PropertyResult(
361
+ value, source=source or "monotonicity", evidence=tuple(evidence)
362
+ )
363
+ return value
364
+
365
+ strata = _parameter_strata(expr, var, assumptions)
366
+ if strata is not None:
367
+ return strata
368
+ piecewise = _piecewise_monotonicity(expr, var, assumptions)
369
+ if piecewise is not Monotonicity.UNKNOWN:
370
+ return finish(piecewise, "piecewise-analysis")
371
+ try:
372
+ positive_axis = (
373
+ isinstance(ds, sp.Interval)
374
+ and ds.start == 0
375
+ and ds.left_open
376
+ and ds.end is S.Infinity
377
+ ) or sp.simplify_logic(
378
+ sp.Implies(semantic_condition(assumptions), var > 0)
379
+ ) is S.true
380
+ except SYMBOLIC_QUERY_ERRORS:
381
+ positive_axis = False
382
+ if positive_axis:
383
+ facts = positive_axis_monotonicity_strata(expr, var)
384
+ if facts:
385
+ out = normalize_cases(
386
+ (ConditionalValue(v, g) for v, g, *_ in facts),
387
+ source="special-function-monotonicity-stratification",
388
+ )
389
+ if out.primary is not None:
390
+ return finish(
391
+ out.primary, "special-function-monotonicity-stratification"
392
+ )
393
+ return out
394
+
395
+ # Stored GlobalMonotonicity describes the whole natural domain only.
396
+ if domain is True or is_full_natural_domain(assumptions, natural):
397
+ m = _direct_registry(expr, var)
398
+ if m is not Monotonicity.UNKNOWN:
399
+ if intervals or details:
400
+ part = (
401
+ partition
402
+ if partition is not None
403
+ and getattr(partition, "domain", None) == geom_domain
404
+ else monotonic_partition(expr, var, domain=geom_domain)
405
+ )
406
+ ev = (
407
+ (part.property_decomposition,)
408
+ if part.property_decomposition is not None
409
+ else ()
410
+ ) + ((part,) if part.complete else ())
411
+ return finish(m, "registry-monotonicity", ev)
412
+ return m
413
+
414
+ closure = _closure_monotonicity(expr, var, assumptions)
415
+ if closure is not Monotonicity.UNKNOWN:
416
+ return finish(closure, "closure-properties")
417
+
418
+ backend = _semialgebraic_monotonicity_stage(
419
+ expr, var, assumptions, details=details, intervals=intervals
420
+ )
421
+ if backend is not None:
422
+ value, backend_evidence = backend
423
+ return finish(value, "semialg", backend_evidence)
424
+
425
+ dec = monotonic_decomposition(expr, var, strict=True, continuous=True)
426
+ if dec is not None and dec.inner != expr and dec.inner.has(var):
427
+ try:
428
+ valid = sp.simplify_logic(sp.Implies(assumptions, dec.condition))
429
+ if valid is S.true:
430
+ im = function_monotonicity(dec.inner, var, domain=assumptions)
431
+ else:
432
+ im = PropertyResult(None)
433
+ if not isinstance(im, PropertyResult):
434
+ inc = (Monotonicity.STRICTLY_INCREASING, Monotonicity.NONDECREASING)
435
+ decm = (Monotonicity.STRICTLY_DECREASING, Monotonicity.NONINCREASING)
436
+ if im in inc + decm:
437
+ increasing = im in inc
438
+ strict = im in (inc[0], decm[0]) and dec.strict
439
+ if dec.direction < 0:
440
+ increasing = not increasing
441
+ out = (
442
+ Monotonicity.STRICTLY_INCREASING
443
+ if increasing and strict
444
+ else Monotonicity.NONDECREASING
445
+ if increasing
446
+ else Monotonicity.STRICTLY_DECREASING
447
+ if strict
448
+ else Monotonicity.NONINCREASING
449
+ )
450
+ return finish(out, "monotonic-decomposition", (dec,))
451
+ except SYMBOLIC_QUERY_ERRORS:
452
+ pass
453
+
454
+ m = _compose_monotonicity(expr, var, assumptions)
455
+ if m is not Monotonicity.UNKNOWN:
456
+ return finish(m, "composition")
457
+
458
+ def from_sign(v):
459
+ if v is Sign.POSITIVE:
460
+ return Monotonicity.STRICTLY_INCREASING
461
+ if v is Sign.NEGATIVE:
462
+ return Monotonicity.STRICTLY_DECREASING
463
+ if v is Sign.NONNEGATIVE:
464
+ return Monotonicity.NONDECREASING
465
+ if v is Sign.NONPOSITIVE:
466
+ return Monotonicity.NONINCREASING
467
+ if v is Sign.ZERO:
468
+ return Monotonicity.CONSTANT
469
+ if v is Sign.SIGN_CHANGING:
470
+ return Monotonicity.NOT_MONOTONE
471
+ return Monotonicity.UNKNOWN
472
+
473
+ def from_partition(part):
474
+ ev = (
475
+ (part.property_decomposition,)
476
+ if part.property_decomposition is not None
477
+ else ()
478
+ ) + (part,)
479
+ if part.indexed is not None and part.indexed.boundary_geometry:
480
+ # Completeness of the entire requested domain is not needed to
481
+ # disprove monotonicity: an infinite alternating critical family
482
+ # contained inside the domain is already a certificate.
483
+ try:
484
+ from ..transcendental.zero_geometry import (
485
+ geometry_support_inside_domain,
486
+ )
487
+
488
+ if any(
489
+ geometry_support_inside_domain(g, part.domain)
490
+ for g in part.indexed.boundary_geometry
491
+ ):
492
+ return finish(
493
+ Monotonicity.NOT_MONOTONE, "indexed-monotonic-partition", ev
494
+ )
495
+ except SYMBOLIC_QUERY_ERRORS:
496
+ pass
497
+ if not part.complete:
498
+ return None
499
+ if part.cells:
500
+ dirs = {c.direction for c in part.cells}
501
+ if dirs == {Monotonicity.STRICTLY_INCREASING}:
502
+ return finish(
503
+ Monotonicity.STRICTLY_INCREASING, "monotonic-partition", ev
504
+ )
505
+ if dirs == {Monotonicity.STRICTLY_DECREASING}:
506
+ return finish(
507
+ Monotonicity.STRICTLY_DECREASING, "monotonic-partition", ev
508
+ )
509
+ if len(dirs) > 1:
510
+ return finish(Monotonicity.NOT_MONOTONE, "monotonic-partition", ev)
511
+ return None
512
+
513
+ # Indexed zero geometry is cheap to recognize and gives stronger evidence
514
+ # than a generic derivative-sign attempt. Details/interval requests also
515
+ # explicitly ask for the partition certificate.
516
+ need_part = bool(intervals or details)
517
+ if not need_part:
518
+ try:
519
+ from ..transcendental.zero_geometry import zero_geometry
520
+
521
+ need_part = bool(
522
+ zero_geometry(expr, var, derivative_order=1, domain=geom_domain)
523
+ )
524
+ except SYMBOLIC_QUERY_ERRORS:
525
+ need_part = False
526
+ part = (
527
+ partition
528
+ if partition is not None and getattr(partition, "domain", None) == geom_domain
529
+ else None
530
+ )
531
+ if need_part and part is None:
532
+ part = monotonic_partition(expr, var, domain=geom_domain)
533
+ ans = from_partition(part)
534
+ if ans is not None:
535
+ return ans
536
+
537
+ # For generic expressions, derivative sign is often cheaper than solving
538
+ # all critical points. Strong signs finish immediately. Weak signs are
539
+ # retained as a fallback but a partition may upgrade them to strictness.
540
+ try:
541
+ from ..proof_cache import cached_derivative
542
+
543
+ derivative = (
544
+ part.derivative
545
+ if part is not None and part.derivative is not None
546
+ else cached_derivative(expr, var, order=1)
547
+ )
548
+ except SYMBOLIC_QUERY_ERRORS:
549
+ return finish(
550
+ Monotonicity.UNKNOWN,
551
+ "unknown",
552
+ (part,) if part is not None and (details or intervals) else (),
553
+ )
554
+ if derivative == 0:
555
+ return finish(
556
+ Monotonicity.CONSTANT,
557
+ "zero-derivative",
558
+ (part,) if part is not None else (),
559
+ )
560
+ s = function_sign(derivative, var, domain=assumptions, strict_inequalities=True)
561
+ if isinstance(s, PropertyResult):
562
+ out = normalize_cases(
563
+ (ConditionalValue(from_sign(c.value), c.condition) for c in s.cases),
564
+ source="derivative-sign-stratification",
565
+ )
566
+ return out if out.cases else Monotonicity.UNKNOWN
567
+ direct = from_sign(s)
568
+ if direct in (
569
+ Monotonicity.STRICTLY_INCREASING,
570
+ Monotonicity.STRICTLY_DECREASING,
571
+ Monotonicity.CONSTANT,
572
+ Monotonicity.NOT_MONOTONE,
573
+ ):
574
+ return finish(
575
+ direct,
576
+ "derivative-sign",
577
+ (part,) if part is not None and (details or intervals) else (),
578
+ )
579
+
580
+ if part is None:
581
+ part = monotonic_partition(expr, var, domain=geom_domain)
582
+ ans = from_partition(part)
583
+ if ans is not None:
584
+ return ans
585
+
586
+ witness = quick_nonmonotonicity_witness(expr, var, geom_domain)
587
+ if witness is not None:
588
+ return finish(
589
+ Monotonicity.NOT_MONOTONE, "exact-ordering-witness", (witness, part)
590
+ )
591
+ return finish(direct, "derivative-sign", (part,) if (details or intervals) else ())
592
+
593
+
594
+ def _weaken_monotonicity(value):
595
+ """Map strict monotonicity classes to their weak counterparts."""
596
+ if value is Monotonicity.STRICTLY_INCREASING:
597
+ return Monotonicity.NONDECREASING
598
+ if value is Monotonicity.STRICTLY_DECREASING:
599
+ return Monotonicity.NONINCREASING
600
+ return value
601
+
602
+
603
+ def _monotonicity_implications(value):
604
+ """Return ordinary monotonicity properties implied by ``value``."""
605
+ if value is Monotonicity.STRICTLY_INCREASING:
606
+ return (Monotonicity.NONDECREASING,)
607
+ if value is Monotonicity.STRICTLY_DECREASING:
608
+ return (Monotonicity.NONINCREASING,)
609
+ if value is Monotonicity.CONSTANT:
610
+ return (Monotonicity.NONDECREASING, Monotonicity.NONINCREASING)
611
+ return ()
612
+
613
+
614
+ def _format_monotonicity(result, *, strict_inequalities, all_properties):
615
+ """Apply public strictness and implication options to a classification."""
616
+ weaken = (lambda value: value) if strict_inequalities else _weaken_monotonicity
617
+ if isinstance(result, PropertyResult):
618
+ primary = weaken(result.primary) if result.primary is not None else None
619
+ cases = tuple(
620
+ ConditionalValue(weaken(case.value), case.condition)
621
+ for case in result.cases
622
+ )
623
+ implied = (
624
+ _monotonicity_implications(primary)
625
+ if all_properties
626
+ else result.all_properties
627
+ )
628
+ return PropertyResult(
629
+ primary,
630
+ cases=cases,
631
+ all_properties=implied,
632
+ source=result.source,
633
+ exceptions=result.exceptions,
634
+ evidence=result.evidence,
635
+ )
636
+ primary = weaken(result)
637
+ if all_properties:
638
+ return PropertyResult(
639
+ primary,
640
+ all_properties=_monotonicity_implications(primary),
641
+ source="monotonicity",
642
+ )
643
+ return primary
644
+
645
+
646
+ def function_monotonicity(
647
+ expr,
648
+ var,
649
+ *,
650
+ domain=True,
651
+ strict_inequalities=True,
652
+ all_properties=False,
653
+ intervals=False,
654
+ details=False,
655
+ partition=None,
656
+ ):
657
+ """Classify global monotonicity on the natural/requested real domain.
658
+
659
+ ``strict_inequalities=False`` weakens strict increasing/decreasing results.
660
+ ``all_properties=True`` returns the primary class together with its ordinary
661
+ monotonicity implications. Detail and interval requests retain certificates.
662
+ """
663
+ result = _classify_monotonicity(
664
+ expr,
665
+ var,
666
+ domain=domain,
667
+ intervals=intervals,
668
+ details=details,
669
+ partition=partition,
670
+ )
671
+ return _format_monotonicity(
672
+ result, strict_inequalities=strict_inequalities, all_properties=all_properties
673
+ )