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,1767 @@
1
+ """Structural singularity, branch, analyticity and continuity analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+
8
+ import sympy as sp
9
+ from sympy import S
10
+ from sympy.calculus.util import continuous_domain
11
+ from sympy.core.relational import Relational
12
+ from sympy.logic.boolalg import And, Boolean, Or
13
+
14
+ from .engine import evaluate_property, simplify_condition
15
+ from .exceptions import SYMBOLIC_QUERY_ERRORS
16
+ from .results import (
17
+ Analyticity,
18
+ ConditionalValue,
19
+ Continuity,
20
+ Meromorphicity,
21
+ PropertyResult,
22
+ Singularity,
23
+ SingularityKind,
24
+ )
25
+ from .stratification import evaluate_parameter_strata
26
+
27
+
28
+ def _is_empty(value) -> bool:
29
+ return value in ({}, (), [], S.EmptySet, sp.Tuple())
30
+
31
+
32
+ def _replace_helper_heads(expr):
33
+ """Normalize a few helper heads used only by the catalogue property dump."""
34
+ if not isinstance(expr, sp.Basic):
35
+ return expr
36
+ name = getattr(expr.func, "__name__", "")
37
+ if name == "RemoveQuadratics" and len(expr.args) == 1:
38
+ a = expr.args[0]
39
+ if getattr(a, "func", None) is sp.Abs:
40
+ return _replace_helper_heads(a.args[0])
41
+ if isinstance(a, sp.Pow):
42
+ return _replace_helper_heads(a.args[0])
43
+ return _replace_helper_heads(a)
44
+ if name == "If" and len(expr.args) == 3:
45
+ cond = _replace_helper_heads(expr.args[0])
46
+ if cond in (S.true, True):
47
+ return _replace_helper_heads(expr.args[1])
48
+ if cond in (S.false, False):
49
+ return _replace_helper_heads(expr.args[2])
50
+ args = tuple(_replace_helper_heads(a) for a in expr.args)
51
+ try:
52
+ return expr.func(*args)
53
+ except SYMBOLIC_QUERY_ERRORS:
54
+ return expr
55
+
56
+
57
+ def _combine_guard(a, b, assumptions=S.true):
58
+ return simplify_condition(And(a, b), assumptions=assumptions)
59
+
60
+
61
+ def _split_locus_guard(condition, var, *, assumptions=S.true):
62
+ """Split a condition into variable-locus and parameter-only parts."""
63
+ c = sp.sympify(condition)
64
+ if c in (S.true, S.false):
65
+ return c, S.true
66
+ if c.func is And:
67
+ locus = []
68
+ guard = []
69
+ for atom in c.args:
70
+ (locus if atom.has(var) else guard).append(atom)
71
+ lc = And(*locus) if locus else S.true
72
+ gd = And(*guard) if guard else S.true
73
+ return simplify_condition(lc, assumptions=assumptions), simplify_condition(
74
+ gd, assumptions=assumptions
75
+ )
76
+ if c.has(var):
77
+ return c, S.true
78
+ return S.true, simplify_condition(c, assumptions=assumptions)
79
+
80
+
81
+ def _registry_loci(
82
+ expr,
83
+ prop: str,
84
+ kind: SingularityKind,
85
+ *,
86
+ mode="complex",
87
+ var=None,
88
+ assumptions=S.true,
89
+ ):
90
+ result = evaluate_property(expr, prop, var=var, mode=mode, assumptions=assumptions)
91
+ out = []
92
+ for case in result.cases:
93
+ value = _replace_helper_heads(case.value)
94
+ if _is_empty(value):
95
+ continue
96
+ entries = (
97
+ tuple(value) if isinstance(value, (sp.Tuple, tuple, list)) else (value,)
98
+ )
99
+ for entry in entries:
100
+ entry = _replace_helper_heads(entry)
101
+ if isinstance(entry, (sp.Tuple, tuple, list)) and len(entry) >= 2:
102
+ locus, local_condition = entry[0], entry[1]
103
+ local_locus, local_guard = _split_locus_guard(
104
+ local_condition, var, assumptions=assumptions
105
+ )
106
+ locus = _combine_guard(locus, local_locus, assumptions=assumptions)
107
+ guard = _combine_guard(
108
+ case.condition, local_guard, assumptions=assumptions
109
+ )
110
+ else:
111
+ locus, guard = entry, case.condition
112
+ if guard is S.false or locus is S.false:
113
+ continue
114
+ out.append(
115
+ Singularity(
116
+ kind, sp.sympify(locus), sp.sympify(guard), source="registry"
117
+ )
118
+ )
119
+ return tuple(out)
120
+
121
+
122
+ def _unique(items: Iterable[Singularity]):
123
+ out = []
124
+ seen = set()
125
+ for item in items:
126
+ key = (
127
+ item.kind,
128
+ sp.srepr(item.condition),
129
+ sp.srepr(item.guard),
130
+ str(item.multiplicity),
131
+ )
132
+ if key in seen:
133
+ continue
134
+ seen.add(key)
135
+ out.append(item)
136
+ return tuple(out)
137
+
138
+
139
+ def function_order_at(expr, var, point):
140
+ """Return the local integer/Puiseux order when SymPy can certify it.
141
+
142
+ Positive means a zero, negative a pole, and zero a finite nonzero value.
143
+ ``None`` means the order could not be established symbolically.
144
+ """
145
+ expr = sp.sympify(expr)
146
+ var = sp.sympify(var)
147
+ point = sp.sympify(point)
148
+ t = sp.Dummy("funcprops_local")
149
+ try:
150
+ shifted = expr.subs(var, point + t)
151
+ lead = shifted.as_leading_term(t)
152
+ pd = lead.as_powers_dict()
153
+ order = sp.simplify(pd.get(t, S.Zero))
154
+ if order.is_number:
155
+ # ``as_leading_term`` can return an unresolved special function
156
+ # such as zeta(1+t); treating the absence of an explicit t power
157
+ # as order zero is not a certificate.
158
+ if order == 0 and lead.has(t):
159
+ pass
160
+ else:
161
+ return order
162
+ except SYMBOLIC_QUERY_ERRORS:
163
+ pass
164
+ # A short series fallback catches cancellation such as sin(x)/x**3.
165
+ try:
166
+ ser = sp.series(expr, var, point, 8).removeO().expand()
167
+ shifted = ser.subs(var, point + t).expand()
168
+ terms = sp.Add.make_args(shifted)
169
+ orders = []
170
+ for term in terms:
171
+ power = term.as_powers_dict().get(t, S.Zero)
172
+ if power.is_number:
173
+ orders.append(power)
174
+ if orders:
175
+ return min(orders, key=lambda q: float(q))
176
+ except SYMBOLIC_QUERY_ERRORS:
177
+ pass
178
+ return None
179
+
180
+
181
+ def _explicit_denominator_poles(expr, var):
182
+ """Extract exact or conditional poles from a denominator without solving generally."""
183
+ expr = sp.sympify(expr)
184
+ try:
185
+ _num, den = sp.fraction(sp.together(expr))
186
+ except SYMBOLIC_QUERY_ERRORS:
187
+ return ()
188
+ if den == 1 or not den.has(var):
189
+ return ()
190
+ out = []
191
+ # Polynomial denominators get exact factor multiplicities and local
192
+ # cancellation checks at explicitly solvable roots.
193
+ try:
194
+ poly = sp.Poly(den, var)
195
+ _, factors = sp.factor_list(poly.as_expr(), var)
196
+ for factor, mult in factors:
197
+ roots = []
198
+ try:
199
+ roots = sp.solve(sp.Eq(factor, 0), var)
200
+ except SYMBOLIC_QUERY_ERRORS:
201
+ roots = []
202
+ if roots and all(not r.has(var) for r in roots):
203
+ for r in roots:
204
+ order = function_order_at(expr, var, r)
205
+ if order is not None and order.is_negative:
206
+ out.append(
207
+ Singularity(
208
+ SingularityKind.POLE,
209
+ sp.Eq(var, r),
210
+ S.true,
211
+ int(-order) if (-order).is_Integer else -order,
212
+ source="local-order",
213
+ )
214
+ )
215
+ elif order is not None and order.is_nonnegative:
216
+ # Removable or regular: cancellation is certified, so do
217
+ # not retain the denominator candidate as a pole.
218
+ continue
219
+ else:
220
+ out.append(
221
+ Singularity(
222
+ SingularityKind.POLE,
223
+ sp.Eq(factor, 0),
224
+ S.true,
225
+ mult,
226
+ source="denominator",
227
+ )
228
+ )
229
+ else:
230
+ out.append(
231
+ Singularity(
232
+ SingularityKind.POLE,
233
+ sp.Eq(factor, 0),
234
+ S.true,
235
+ mult,
236
+ source="denominator",
237
+ )
238
+ )
239
+ return _unique(out)
240
+ except SYMBOLIC_QUERY_ERRORS:
241
+ pass
242
+ # Generic denominator: deliberately leave the condition unsolved.
243
+ return (
244
+ Singularity(
245
+ SingularityKind.POLE, sp.Eq(den, 0), S.true, None, source="denominator"
246
+ ),
247
+ )
248
+
249
+
250
+ def _recursive_argument_singularities(expr, var, kind, assumptions=S.true):
251
+ out = []
252
+ for arg in getattr(expr, "args", ()):
253
+ if not getattr(arg, "has", lambda *_: False)(var):
254
+ continue
255
+ if kind is SingularityKind.POLE:
256
+ out.extend(function_poles(arg, var, assumptions=assumptions))
257
+ elif kind is SingularityKind.BRANCH_POINT:
258
+ out.extend(function_branchpoints(arg, var, assumptions=assumptions))
259
+ elif kind is SingularityKind.ESSENTIAL:
260
+ out.extend(
261
+ function_essential_singularities(arg, var, assumptions=assumptions)
262
+ )
263
+ return out
264
+
265
+
266
+ def _validate_pole_candidates(expr, var, candidates):
267
+ out = []
268
+ for p in candidates:
269
+ # Primitive registry/DLMF poles are already exact facts. Local-order
270
+ # probing is for algebraic denominator candidates and must not erase a
271
+ # special-function pole merely because SymPy cannot expand locally.
272
+ if p.source == "registry":
273
+ out.append(p)
274
+ continue
275
+ cond = p.condition
276
+ point = None
277
+ if isinstance(cond, sp.Equality):
278
+ if cond.lhs == var and not cond.rhs.has(var):
279
+ point = cond.rhs
280
+ elif cond.rhs == var and not cond.lhs.has(var):
281
+ point = cond.lhs
282
+ if point is not None:
283
+ order = function_order_at(expr, var, point)
284
+ if order is not None:
285
+ if order.is_negative:
286
+ mult = -order
287
+ out.append(
288
+ Singularity(
289
+ SingularityKind.POLE,
290
+ cond,
291
+ p.guard,
292
+ int(mult) if mult.is_Integer else mult,
293
+ source="local-order",
294
+ )
295
+ )
296
+ # nonnegative means cancellation/regularity, drop candidate
297
+ continue
298
+ out.append(p)
299
+ return _unique(out)
300
+
301
+
302
+ def function_removable_singularities(expr, var):
303
+ """Return denominator singularities whose local order is nonnegative.
304
+
305
+ This is intentionally internal for now; ``function_singularities`` exposes
306
+ the records under its ``removable`` key without adding another top-level
307
+ public function.
308
+ """
309
+ expr = sp.sympify(expr)
310
+ var = sp.sympify(var)
311
+ try:
312
+ _, den = sp.fraction(sp.together(expr))
313
+ if den == 1 or not den.has(var):
314
+ return ()
315
+ poly = sp.Poly(den, var)
316
+ roots = sp.solve(sp.Eq(poly.as_expr(), 0), var)
317
+ except SYMBOLIC_QUERY_ERRORS:
318
+ return ()
319
+ out = []
320
+ for point in roots:
321
+ if point.has(var):
322
+ continue
323
+ order = function_order_at(expr, var, point)
324
+ if order is not None and order.is_nonnegative:
325
+ out.append(
326
+ Singularity(
327
+ SingularityKind.REMOVABLE,
328
+ sp.Eq(var, point),
329
+ S.true,
330
+ source="local-order",
331
+ )
332
+ )
333
+ return _unique(out)
334
+
335
+
336
+ def function_poles(expr, var, *, assumptions=S.true):
337
+ expr = sp.sympify(expr)
338
+ var = sp.sympify(var)
339
+ out = []
340
+ # Exact algebraic/rational structure is preferable to table data because it
341
+ # sees cancellation in the concrete expression.
342
+ out.extend(_explicit_denominator_poles(expr, var))
343
+ if expr.is_Function:
344
+ out.extend(
345
+ _registry_loci(
346
+ expr,
347
+ "Poles",
348
+ SingularityKind.POLE,
349
+ mode="complex",
350
+ var=var,
351
+ assumptions=assumptions,
352
+ )
353
+ )
354
+ # A pole of an argument is not normally a pole of a transcendental outer
355
+ # function, so recurse only through algebraic Add/Mul/Pow containers.
356
+ if expr.is_Add or expr.is_Mul or isinstance(expr, sp.Pow):
357
+ out.extend(
358
+ _recursive_argument_singularities(
359
+ expr, var, SingularityKind.POLE, assumptions
360
+ )
361
+ )
362
+ return _validate_pole_candidates(expr, var, _unique(out))
363
+
364
+
365
+ def function_branchpoints(expr, var, *, assumptions=S.true):
366
+ expr = sp.sympify(expr)
367
+ var = sp.sympify(var)
368
+ out = []
369
+ if expr.is_Function:
370
+ out.extend(
371
+ _registry_loci(
372
+ expr,
373
+ "BranchPoints",
374
+ SingularityKind.BRANCH_POINT,
375
+ mode="complex",
376
+ var=var,
377
+ assumptions=assumptions,
378
+ )
379
+ )
380
+ if isinstance(expr, sp.Pow):
381
+ base, exponent = expr.args
382
+ if exponent.is_integer is not True and base.has(var):
383
+ out.append(
384
+ Singularity(
385
+ SingularityKind.BRANCH_POINT, sp.Eq(base, 0), S.true, source="power"
386
+ )
387
+ )
388
+ out.extend(
389
+ _recursive_argument_singularities(
390
+ expr, var, SingularityKind.BRANCH_POINT, assumptions
391
+ )
392
+ )
393
+ return _unique(out)
394
+
395
+
396
+ def function_definition_cuts(expr, var, *, mode="real", assumptions=S.true):
397
+ expr = sp.sympify(expr)
398
+ var = sp.sympify(var)
399
+ out = []
400
+ if expr.is_Function:
401
+ out.extend(
402
+ _registry_loci(
403
+ expr,
404
+ "DefCuts",
405
+ SingularityKind.DEFINITION_CUT,
406
+ mode=mode,
407
+ var=var,
408
+ assumptions=assumptions,
409
+ )
410
+ )
411
+ # Elementary nonsmooth heads have incomplete DefCuts in parts of the source
412
+ # database, so make their real definition boundaries explicit.
413
+ if mode == "real":
414
+ if expr.func in (sp.Abs, sp.sign) and expr.args and expr.args[0].has(var):
415
+ out.append(
416
+ Singularity(
417
+ SingularityKind.DEFINITION_CUT,
418
+ sp.Eq(expr.args[0], 0),
419
+ S.true,
420
+ source="structural",
421
+ )
422
+ )
423
+ if expr.func in (sp.floor, sp.ceiling) and expr.args and expr.args[0].has(var):
424
+ a = expr.args[0]
425
+ out.append(
426
+ Singularity(
427
+ SingularityKind.DEFINITION_CUT,
428
+ sp.Contains(a, S.Integers),
429
+ S.true,
430
+ source="structural",
431
+ )
432
+ )
433
+ if isinstance(expr, sp.Pow):
434
+ base, exponent = expr.args
435
+ if exponent.is_integer is not True and base.has(var):
436
+ out.append(
437
+ Singularity(
438
+ SingularityKind.DEFINITION_CUT,
439
+ sp.Eq(base, 0),
440
+ S.true,
441
+ source="power",
442
+ )
443
+ )
444
+ for arg in getattr(expr, "args", ()):
445
+ if getattr(arg, "has", lambda *_: False)(var):
446
+ out.extend(
447
+ function_definition_cuts(arg, var, mode=mode, assumptions=assumptions)
448
+ )
449
+ return _unique(out)
450
+
451
+
452
+ def function_essential_singularities(expr, var, *, assumptions=S.true):
453
+ expr = sp.sympify(expr)
454
+ var = sp.sympify(var)
455
+ out = []
456
+ if expr.is_Function:
457
+ out.extend(
458
+ _registry_loci(
459
+ expr,
460
+ "Essential",
461
+ SingularityKind.ESSENTIAL,
462
+ mode="complex",
463
+ var=var,
464
+ assumptions=assumptions,
465
+ )
466
+ )
467
+ # A non-polynomial entire transcendental function composed with a pole
468
+ # has an essential singularity at that pole (or, for periodic
469
+ # meromorphic heads, a non-meromorphic accumulation). These common
470
+ # heads cover the elementary cases that matter for composition.
471
+ transcendental_entire = {
472
+ sp.exp,
473
+ sp.sin,
474
+ sp.cos,
475
+ sp.sinh,
476
+ sp.cosh,
477
+ sp.erf,
478
+ sp.erfc,
479
+ }
480
+ if expr.func in transcendental_entire:
481
+ for arg in expr.args:
482
+ if arg.has(var):
483
+ for pole in function_poles(arg, var, assumptions=assumptions):
484
+ out.append(
485
+ Singularity(
486
+ SingularityKind.ESSENTIAL,
487
+ pole.condition,
488
+ pole.guard,
489
+ source="composition",
490
+ )
491
+ )
492
+ if expr.is_Add or expr.is_Mul or isinstance(expr, sp.Pow):
493
+ out.extend(
494
+ _recursive_argument_singularities(
495
+ expr, var, SingularityKind.ESSENTIAL, assumptions
496
+ )
497
+ )
498
+ return _unique(out)
499
+
500
+
501
+ def function_branchcuts(expr, var, *, assumptions=S.true, mode="conditions"):
502
+ """Return principal branch-cut loci, unsolved by default."""
503
+ from .branchcuts import (
504
+ IMPLEMENTED_RULE_NAMES,
505
+ branch_cut_conditions,
506
+ branch_cut_jumps,
507
+ )
508
+
509
+ expr = sp.sympify(expr)
510
+ var = sp.sympify(var)
511
+ if mode == "jumps":
512
+ return branch_cut_jumps(expr, var, assumptions=assumptions)
513
+ out = []
514
+ for cond in branch_cut_conditions(expr, var, assumptions=assumptions):
515
+ locus, guard = _split_locus_guard(cond, var, assumptions=assumptions)
516
+ if locus is not S.false and guard is not S.false:
517
+ out.append(
518
+ Singularity(
519
+ SingularityKind.BRANCH_CUT, locus, guard, source="branch-rules"
520
+ )
521
+ )
522
+ # Structural jump rules cover the primitive heads for which funcprops can
523
+ # compute boundary values. Catalogue loci supplement other function heads
524
+ # without duplicating equivalent structural cuts.
525
+ head = getattr(expr.func, "__name__", "").lower()
526
+ if isinstance(expr, sp.Pow):
527
+ head = "power"
528
+ if expr.is_Function and head not in IMPLEMENTED_RULE_NAMES:
529
+ out.extend(
530
+ _registry_loci(
531
+ expr,
532
+ "BranchCuts",
533
+ SingularityKind.BRANCH_CUT,
534
+ mode="complex",
535
+ var=var,
536
+ assumptions=assumptions,
537
+ )
538
+ )
539
+
540
+ # Formula-level DLMF structural cuts broader than the available exact jump
541
+ # formulas. Record the locus without inventing a jump formula at resonant
542
+ # parameter values.
543
+ if expr.func is sp.hyper and len(expr.args) == 3 and expr.args[2] == var:
544
+ aa = tuple(expr.args[0])
545
+ bb = tuple(expr.args[1])
546
+ if len(aa) == len(bb) + 1:
547
+ terminating = (
548
+ Or(*[And(sp.Contains(a, S.Integers), a <= 0) for a in aa])
549
+ if aa
550
+ else S.false
551
+ )
552
+ bottom_bad = (
553
+ Or(*[And(sp.Contains(b, S.Integers), b <= 0) for b in bb])
554
+ if bb
555
+ else S.false
556
+ )
557
+ guard = simplify_condition(
558
+ And(sp.Not(terminating), sp.Not(bottom_bad)), assumptions=assumptions
559
+ )
560
+ if guard is not S.false:
561
+ out.append(
562
+ Singularity(
563
+ SingularityKind.BRANCH_CUT,
564
+ And(sp.Contains(var, S.Reals), var >= 1),
565
+ guard,
566
+ source="DLMF 16.2(iii)",
567
+ )
568
+ )
569
+ if expr.func is sp.assoc_legendre and len(expr.args) == 3 and expr.args[2] == var:
570
+ nu, mu = expr.args[:2]
571
+ polynomial_exception = And(
572
+ sp.Contains(nu, S.Naturals0),
573
+ sp.Contains(mu, S.Integers),
574
+ sp.Abs(mu) <= nu,
575
+ sp.Eq(sp.Mod(mu, 2), 0),
576
+ )
577
+ zero_exception = And(
578
+ sp.Contains(nu, S.Naturals0), sp.Contains(mu, S.Naturals), mu > nu
579
+ )
580
+ guard = simplify_condition(
581
+ And(sp.Not(polynomial_exception), sp.Not(zero_exception)),
582
+ assumptions=assumptions,
583
+ )
584
+ if guard is not S.false:
585
+ out.append(
586
+ Singularity(
587
+ SingularityKind.BRANCH_CUT,
588
+ And(sp.Contains(var, S.Reals), var <= 1),
589
+ guard,
590
+ source="DLMF 14.21",
591
+ )
592
+ )
593
+ if (
594
+ expr.func in (sp.elliptic_f, sp.elliptic_e)
595
+ and len(expr.args) == 2
596
+ and expr.args[1] == var
597
+ ):
598
+ phi = expr.args[0]
599
+ q = 1 - var * sp.sin(phi) ** 2
600
+ out.append(
601
+ Singularity(
602
+ SingularityKind.BRANCH_CUT,
603
+ And(sp.Eq(sp.im(q), 0), sp.re(q) <= 0),
604
+ S.true,
605
+ source="DLMF 19.2.4-19.2.5",
606
+ )
607
+ )
608
+ if expr.func is sp.elliptic_pi and len(expr.args) == 3:
609
+ _n, phi, m = expr.args
610
+ if m == var:
611
+ q = 1 - var * sp.sin(phi) ** 2
612
+ out.append(
613
+ Singularity(
614
+ SingularityKind.BRANCH_CUT,
615
+ And(sp.Eq(sp.im(q), 0), sp.re(q) <= 0),
616
+ S.true,
617
+ source="DLMF 19.2.7",
618
+ )
619
+ )
620
+ out = _unique(out)
621
+ conds = tuple(
622
+ s.condition if s.guard is S.true else And(s.condition, s.guard) for s in out
623
+ )
624
+ if mode == "conditions":
625
+ return conds
626
+ if mode == "records":
627
+ return out
628
+ if mode == "union":
629
+ return Or(*conds) if conds else S.false
630
+ raise ValueError("mode must be 'conditions', 'records', 'union', or 'jumps'")
631
+
632
+
633
+ def _piecewise_boundaries(expr):
634
+ out = []
635
+ if not isinstance(expr, sp.Piecewise):
636
+ return out
637
+ for _, cond in expr.args[:-1]:
638
+ for rel in cond.atoms(Relational) if hasattr(cond, "atoms") else ():
639
+ out.append(sp.Eq(rel.lhs, rel.rhs))
640
+ return out
641
+
642
+
643
+ def _piecewise_branch_for_side(expr, var, point, side):
644
+ t = sp.Dummy("funcprops_side", positive=True)
645
+ probe = point + t if side == "+" else point - t
646
+ for value, cond in expr.args:
647
+ if cond in (S.true, True):
648
+ return value
649
+ try:
650
+ test = sp.simplify(sp.refine(cond.subs(var, probe), sp.Q.positive(t)))
651
+ except SYMBOLIC_QUERY_ERRORS:
652
+ test = cond.subs(var, probe)
653
+ if test is S.true or test is True:
654
+ return value
655
+ if test is S.false or test is False:
656
+ continue
657
+ # reduce simple one-variable inequalities in the positive probe.
658
+ try:
659
+ red = sp.reduce_inequalities(test, t)
660
+ if red is S.true:
661
+ return value
662
+ if red is S.false:
663
+ continue
664
+ except SYMBOLIC_QUERY_ERRORS:
665
+ pass
666
+ return None
667
+ return None
668
+
669
+
670
+ def _piecewise_discontinuous_at(expr, var, condition):
671
+ """Return True/False/None for a simple explicitly solvable boundary."""
672
+ try:
673
+ sols = sp.solve(condition, var)
674
+ except SYMBOLIC_QUERY_ERRORS:
675
+ return None
676
+ if not sols or any(s.has(var) for s in sols):
677
+ return None
678
+ any_unknown = False
679
+ for p in sols:
680
+ left_branch = _piecewise_branch_for_side(expr, var, p, "-")
681
+ right_branch = _piecewise_branch_for_side(expr, var, p, "+")
682
+ if left_branch is None or right_branch is None:
683
+ any_unknown = True
684
+ continue
685
+ try:
686
+ left = (
687
+ sp.limit(left_branch, var, p, dir="-")
688
+ if left_branch.has(var)
689
+ else left_branch
690
+ )
691
+ right = (
692
+ sp.limit(right_branch, var, p, dir="+")
693
+ if right_branch.has(var)
694
+ else right_branch
695
+ )
696
+ value = expr.subs(var, p)
697
+ if sp.simplify(left - right) != 0 or sp.simplify(value - left) != 0:
698
+ return True
699
+ except SYMBOLIC_QUERY_ERRORS:
700
+ any_unknown = True
701
+ return None if any_unknown else False
702
+
703
+
704
+ def function_discontinuities(expr, var, *, assumptions=S.true):
705
+ """Return unsolved real discontinuity conditions."""
706
+ expr = sp.sympify(expr)
707
+ var = sp.sympify(var)
708
+ out = []
709
+ if expr.is_Function:
710
+ r = evaluate_property(expr, "Discontinuities", var=var, assumptions=assumptions)
711
+ for case in r.cases:
712
+ value = case.value
713
+ if _is_empty(value):
714
+ continue
715
+ entries = (
716
+ tuple(value) if isinstance(value, (sp.Tuple, tuple, list)) else (value,)
717
+ )
718
+ for entry in entries:
719
+ cond = (
720
+ entry[0]
721
+ if isinstance(entry, (sp.Tuple, tuple, list)) and entry
722
+ else entry
723
+ )
724
+ out.append(
725
+ Singularity(
726
+ SingularityKind.DISCONTINUITY,
727
+ sp.sympify(cond),
728
+ case.condition,
729
+ source="registry",
730
+ )
731
+ )
732
+ if isinstance(expr, sp.Piecewise):
733
+ for boundary in _piecewise_boundaries(expr):
734
+ verdict = _piecewise_discontinuous_at(expr, var, boundary)
735
+ if verdict is not False:
736
+ out.append(
737
+ Singularity(
738
+ SingularityKind.DISCONTINUITY,
739
+ boundary,
740
+ S.true,
741
+ source="piecewise",
742
+ )
743
+ )
744
+ # Discontinuities of inner arguments propagate through ordinary composition
745
+ # unless an outer rule proves a cancellation; conservatively retain them.
746
+ recursive_args = (
747
+ [v for v, _ in expr.args]
748
+ if isinstance(expr, sp.Piecewise)
749
+ else getattr(expr, "args", ())
750
+ )
751
+ for arg in recursive_args:
752
+ if getattr(arg, "has", lambda *_: False)(var):
753
+ out.extend(function_discontinuities(arg, var, assumptions=assumptions))
754
+ return _unique(out)
755
+
756
+
757
+ def function_singularities(expr, var, *, domain=S.Complexes, assumptions=S.true):
758
+ """Return all known singularity loci without solving their equations."""
759
+ complex_mode = domain in (S.Complexes, "complex", "complexes", "C", "CC")
760
+ result = {
761
+ "poles": function_poles(expr, var, assumptions=assumptions),
762
+ "removable": function_removable_singularities(expr, var),
763
+ "essential": function_essential_singularities(
764
+ expr, var, assumptions=assumptions
765
+ ),
766
+ "branch_points": function_branchpoints(expr, var, assumptions=assumptions),
767
+ "branch_cuts": function_branchcuts(
768
+ expr, var, assumptions=assumptions, mode="records"
769
+ )
770
+ if complex_mode
771
+ else (),
772
+ "definition_cuts": function_definition_cuts(
773
+ expr,
774
+ var,
775
+ mode="complex" if complex_mode else "real",
776
+ assumptions=assumptions,
777
+ ),
778
+ }
779
+ if not complex_mode:
780
+ result["discontinuities"] = function_discontinuities(
781
+ expr, var, assumptions=assumptions
782
+ )
783
+ return result
784
+
785
+
786
+ def _real_domain_condition(expr, var):
787
+ from .api import function_domain
788
+
789
+ try:
790
+ d = function_domain(expr, var, domain=S.Reals)
791
+ if isinstance(d, PropertyResult):
792
+ return None
793
+ return sp.sympify(d)
794
+ except SYMBOLIC_QUERY_ERRORS:
795
+ return None
796
+
797
+
798
+ def _condition_intersects_domain(cond, domain_cond, assumptions=S.true):
799
+ if domain_cond is None:
800
+ return None
801
+ c = simplify_condition(And(cond, domain_cond), assumptions=assumptions)
802
+ if c is S.false:
803
+ return False
804
+ if c is S.true:
805
+ return True
806
+ # For one-dimensional explicit relational conditions SymPy often resolves
807
+ # conjunction consistency via reduce_inequalities.
808
+ syms = tuple(c.free_symbols)
809
+ if len(syms) == 1:
810
+ try:
811
+ red = sp.reduce_inequalities(c, syms[0])
812
+ return red is not S.false
813
+ except SYMBOLIC_QUERY_ERRORS:
814
+ pass
815
+ return None
816
+
817
+
818
+ def _conditional_boolean_status(result):
819
+ """Return True/False when conditional Boolean cases cover all possibilities."""
820
+ if result.primary is S.true:
821
+ return True
822
+ if result.primary is S.false:
823
+ return False
824
+ if not result.cases:
825
+ return None
826
+ if all(c.value is S.true for c in result.cases):
827
+ try:
828
+ if sp.simplify_logic(Or(*[c.condition for c in result.cases])) is S.true:
829
+ return True
830
+ except SYMBOLIC_QUERY_ERRORS:
831
+ pass
832
+ if all(c.value is S.false for c in result.cases):
833
+ try:
834
+ if sp.simplify_logic(Or(*[c.condition for c in result.cases])) is S.true:
835
+ return False
836
+ except SYMBOLIC_QUERY_ERRORS:
837
+ pass
838
+ return None
839
+
840
+
841
+ def _variable_tuple(var):
842
+ if isinstance(var, (tuple, list, sp.Tuple)):
843
+ vv = tuple(sp.sympify(v) for v in var)
844
+ else:
845
+ vv = (sp.sympify(var),)
846
+ if not vv:
847
+ raise ValueError("at least one variable is required")
848
+ return vv
849
+
850
+
851
+ def _multivariate_structural_analytic(expr, variables, *, complex_mode=False):
852
+ """Conservative joint analyticity classifier for several variables."""
853
+ vv = tuple(variables)
854
+ expr = sp.sympify(expr)
855
+ if not any(expr.has(v) for v in vv):
856
+ return Analyticity.ANALYTIC
857
+ if expr.func in (sp.Abs, sp.sign, sp.floor, sp.ceiling) or isinstance(
858
+ expr, sp.Piecewise
859
+ ):
860
+ return Analyticity.NOT_ANALYTIC
861
+ try:
862
+ if expr.is_polynomial(*vv) or expr.is_rational_function(*vv):
863
+ return Analyticity.ANALYTIC
864
+ except SYMBOLIC_QUERY_ERRORS:
865
+ pass
866
+ if expr.is_Add or expr.is_Mul or (isinstance(expr, sp.Pow) and expr.exp.is_integer):
867
+ vals = [
868
+ _multivariate_structural_analytic(a, vv, complex_mode=complex_mode)
869
+ for a in expr.args
870
+ ]
871
+ if any(v is Analyticity.NOT_ANALYTIC for v in vals):
872
+ return Analyticity.NOT_ANALYTIC
873
+ if all(v is Analyticity.ANALYTIC for v in vals):
874
+ return Analyticity.ANALYTIC
875
+ return Analyticity.UNKNOWN
876
+ analytic_heads = (
877
+ sp.exp,
878
+ sp.sin,
879
+ sp.cos,
880
+ sp.sinh,
881
+ sp.cosh,
882
+ sp.tanh,
883
+ sp.erf,
884
+ sp.erfc,
885
+ )
886
+ if expr.func in analytic_heads:
887
+ vals = [
888
+ _multivariate_structural_analytic(a, vv, complex_mode=complex_mode)
889
+ for a in expr.args
890
+ if any(a.has(v) for v in vv)
891
+ ]
892
+ if all(v is Analyticity.ANALYTIC for v in vals):
893
+ return Analyticity.ANALYTIC
894
+ if any(v is Analyticity.NOT_ANALYTIC for v in vals):
895
+ return Analyticity.NOT_ANALYTIC
896
+ return Analyticity.UNKNOWN
897
+
898
+
899
+ def _multivariate_structural_continuity(expr, variables):
900
+ """Conservative joint continuity classifier on the natural real domain."""
901
+ vv = tuple(variables)
902
+ expr = sp.sympify(expr)
903
+ if not any(expr.has(v) for v in vv):
904
+ return Continuity.CONTINUOUS
905
+ if expr.func in (sp.floor, sp.ceiling, sp.sign):
906
+ return Continuity.DISCONTINUOUS
907
+ try:
908
+ if expr.is_polynomial(*vv) or expr.is_rational_function(*vv):
909
+ return Continuity.CONTINUOUS
910
+ except SYMBOLIC_QUERY_ERRORS:
911
+ pass
912
+ if expr.func is sp.Abs:
913
+ vals = [_multivariate_structural_continuity(a, vv) for a in expr.args]
914
+ return (
915
+ Continuity.CONTINUOUS
916
+ if all(v is Continuity.CONTINUOUS for v in vals)
917
+ else Continuity.UNKNOWN
918
+ )
919
+ if expr.is_Add or expr.is_Mul or isinstance(expr, sp.Pow):
920
+ vals = [
921
+ _multivariate_structural_continuity(a, vv)
922
+ for a in expr.args
923
+ if any(a.has(v) for v in vv)
924
+ ]
925
+ if any(v is Continuity.DISCONTINUOUS for v in vals):
926
+ return Continuity.DISCONTINUOUS
927
+ if all(v is Continuity.CONTINUOUS for v in vals):
928
+ return Continuity.CONTINUOUS
929
+ return Continuity.UNKNOWN
930
+ continuous_heads = (
931
+ sp.exp,
932
+ sp.log,
933
+ sp.sin,
934
+ sp.cos,
935
+ sp.tan,
936
+ sp.cot,
937
+ sp.sinh,
938
+ sp.cosh,
939
+ sp.tanh,
940
+ sp.asin,
941
+ sp.acos,
942
+ sp.atan,
943
+ sp.asinh,
944
+ sp.acosh,
945
+ sp.atanh,
946
+ sp.erf,
947
+ sp.erfc,
948
+ sp.gamma,
949
+ )
950
+ if expr.func in continuous_heads:
951
+ vals = [
952
+ _multivariate_structural_continuity(a, vv)
953
+ for a in expr.args
954
+ if any(a.has(v) for v in vv)
955
+ ]
956
+ if all(v is Continuity.CONTINUOUS for v in vals):
957
+ return Continuity.CONTINUOUS
958
+ return Continuity.UNKNOWN
959
+
960
+
961
+ def function_analytic(
962
+ expr,
963
+ var,
964
+ *,
965
+ domain=S.Reals,
966
+ assumptions=S.true,
967
+ details=False,
968
+ _domain_ready=False,
969
+ ):
970
+ """Classify real analyticity by default, holomorphy for Complexes."""
971
+ expr = sp.sympify(expr)
972
+ vv = _variable_tuple(var)
973
+ is_complex = domain in (S.Complexes, "complex", "complexes", "C", "CC")
974
+ if len(vv) > 1:
975
+ val = _multivariate_structural_analytic(expr, vv, complex_mode=is_complex)
976
+ result = PropertyResult(val, source="multivariate-structural-analyticity")
977
+ return result if details else val
978
+ var = vv[0]
979
+ if (
980
+ not is_complex
981
+ and domain not in (S.Reals, "real", "reals", "R", "RR")
982
+ and not isinstance(domain, (sp.Set, Boolean, Relational))
983
+ ):
984
+ raise ValueError(
985
+ "domain must be Reals, Complexes, a set, or a Boolean domain condition"
986
+ )
987
+
988
+ # Algebraic expressions are analytic on their natural domain except at
989
+ # branch/definition boundaries, handled below.
990
+ if is_complex:
991
+ # "Analytic" means holomorphic on the function's natural complex
992
+ # domain, matching the source LocallyAnalytic property.
993
+ if expr.func in (sp.Abs, sp.sign, sp.floor, sp.ceiling) or isinstance(
994
+ expr, sp.Piecewise
995
+ ):
996
+ result = PropertyResult(
997
+ Analyticity.NOT_ANALYTIC, source="nonholomorphic-head"
998
+ )
999
+ return result if details else result.primary
1000
+ # Generalized hypergeometric functions: DLMF 16.2 states that p<=q
1001
+ # is entire in z, while p=q+1 has a principal analytic branch off its
1002
+ # standard cut. A terminating top parameter makes the series a
1003
+ # polynomial. We require bottom parameters to be provably outside
1004
+ # the excluded nonpositive integers rather than assuming genericity.
1005
+ if expr.func is sp.hyper and len(expr.args) == 3 and expr.args[2] == var:
1006
+ aa = tuple(expr.args[0])
1007
+ bb = tuple(expr.args[1])
1008
+
1009
+ def _bottom_ok(b):
1010
+ return b.is_integer is False or b.is_positive is True
1011
+
1012
+ bottoms_ok = all(_bottom_ok(b) for b in bb)
1013
+ terminating = any(
1014
+ a.is_integer is True and a.is_nonpositive is True for a in aa
1015
+ )
1016
+ if bottoms_ok and (len(aa) <= len(bb) + 1 or terminating):
1017
+ src = (
1018
+ "DLMF 16.2(ii)"
1019
+ if len(aa) <= len(bb)
1020
+ else (
1021
+ "DLMF 16.2 terminating polynomial"
1022
+ if terminating
1023
+ else "DLMF 16.2(iii) principal branch"
1024
+ )
1025
+ )
1026
+ result = PropertyResult(Analyticity.ANALYTIC, source=src)
1027
+ return result if details else result.primary
1028
+ local = evaluate_property(
1029
+ expr, "LocallyAnalytic", mode="complex", var=var, assumptions=assumptions
1030
+ )
1031
+ local_status = _conditional_boolean_status(local)
1032
+ if local_status is False:
1033
+ return (
1034
+ PropertyResult(Analyticity.NOT_ANALYTIC, source="registry")
1035
+ if details
1036
+ else Analyticity.NOT_ANALYTIC
1037
+ )
1038
+ if local_status is True:
1039
+ return (
1040
+ PropertyResult(Analyticity.ANALYTIC, source="registry")
1041
+ if details
1042
+ else Analyticity.ANALYTIC
1043
+ )
1044
+ entire = evaluate_property(
1045
+ expr, "Entire", mode="complex", var=var, assumptions=assumptions
1046
+ )
1047
+ if _conditional_boolean_status(entire) is True or expr.is_polynomial(var):
1048
+ return (
1049
+ PropertyResult(Analyticity.ANALYTIC, source="analytic-primitive")
1050
+ if details
1051
+ else Analyticity.ANALYTIC
1052
+ )
1053
+ # Arithmetic closure is valid without needing a primitive function
1054
+ # theorem. Do not apply generic composition closure to an unknown head.
1055
+ if (
1056
+ expr.is_Add
1057
+ or expr.is_Mul
1058
+ or (isinstance(expr, sp.Pow) and expr.exp.is_integer)
1059
+ ):
1060
+ vals = [
1061
+ function_analytic(a, var, domain=S.Complexes, assumptions=assumptions)
1062
+ for a in expr.args
1063
+ if a.has(var)
1064
+ ]
1065
+ if vals and all(v == Analyticity.ANALYTIC for v in vals):
1066
+ return (
1067
+ PropertyResult(Analyticity.ANALYTIC, source="holomorphic-closure")
1068
+ if details
1069
+ else Analyticity.ANALYTIC
1070
+ )
1071
+ if any(v == Analyticity.NOT_ANALYTIC for v in vals):
1072
+ return (
1073
+ PropertyResult(
1074
+ Analyticity.NOT_ANALYTIC, source="holomorphic-closure"
1075
+ )
1076
+ if details
1077
+ else Analyticity.NOT_ANALYTIC
1078
+ )
1079
+ return (
1080
+ PropertyResult(Analyticity.UNKNOWN, source="insufficient-evidence")
1081
+ if details
1082
+ else Analyticity.UNKNOWN
1083
+ )
1084
+
1085
+ # Real analyticity on the requested/natural real domain.
1086
+ domain_cond = (
1087
+ sp.sympify(domain)
1088
+ if _domain_ready
1089
+ else (
1090
+ _real_domain_condition(expr, var)
1091
+ if domain in (S.Reals, "real", "reals", "R", "RR")
1092
+ else (
1093
+ domain.as_relational(var)
1094
+ if isinstance(domain, sp.Set)
1095
+ else sp.sympify(domain)
1096
+ )
1097
+ )
1098
+ )
1099
+ if not _domain_ready and domain_cond is not None:
1100
+ split = evaluate_parameter_strata(
1101
+ domain_cond,
1102
+ var,
1103
+ lambda branch_domain: function_analytic(
1104
+ expr,
1105
+ var,
1106
+ domain=branch_domain,
1107
+ assumptions=assumptions,
1108
+ details=details,
1109
+ _domain_ready=True,
1110
+ ),
1111
+ source="analyticity-parameter-stratification",
1112
+ )
1113
+ if split is not None:
1114
+ return split
1115
+ if expr.func in (sp.Abs, sp.sign, sp.floor, sp.ceiling):
1116
+ cuts = function_definition_cuts(expr, var, mode="real", assumptions=assumptions)
1117
+ bad = []
1118
+ unknown_cut = False
1119
+ for cut in cuts:
1120
+ hit = _condition_intersects_domain(
1121
+ And(cut.condition, cut.guard), domain_cond, assumptions
1122
+ )
1123
+ if hit is True:
1124
+ bad.append(cut)
1125
+ elif hit is None:
1126
+ unknown_cut = True
1127
+ if bad:
1128
+ result = PropertyResult(
1129
+ Analyticity.NOT_ANALYTIC, exceptions=tuple(bad), source="nonsmooth-head"
1130
+ )
1131
+ return result if details else result.primary
1132
+ if unknown_cut:
1133
+ result = PropertyResult(
1134
+ Analyticity.UNKNOWN,
1135
+ exceptions=cuts,
1136
+ source="conditional-nonsmooth-cuts",
1137
+ )
1138
+ return result if details else result.primary
1139
+ # Abs/sign/floor/ceiling are locally analytic on every real component
1140
+ # that excludes their kink/jump lattice. This matters for restricted
1141
+ # domains such as Abs(x) on x>0.
1142
+ result = PropertyResult(Analyticity.ANALYTIC, source="nonsmooth-head-off-cuts")
1143
+ return result if details else result.primary
1144
+ if isinstance(expr, sp.Piecewise):
1145
+ boundaries = _piecewise_boundaries(expr)
1146
+ bad = []
1147
+ unknown_boundary = False
1148
+ for b in boundaries:
1149
+ hit = _condition_intersects_domain(b, domain_cond, assumptions)
1150
+ if hit is True:
1151
+ bad.append(b)
1152
+ elif hit is None:
1153
+ unknown_boundary = True
1154
+ if bad:
1155
+ exc = tuple(
1156
+ Singularity(
1157
+ SingularityKind.DEFINITION_CUT, b, S.true, source="piecewise"
1158
+ )
1159
+ for b in bad
1160
+ )
1161
+ result = PropertyResult(
1162
+ Analyticity.NOT_ANALYTIC, exceptions=exc, source="piecewise"
1163
+ )
1164
+ return result if details else result.primary
1165
+ if unknown_boundary:
1166
+ return (
1167
+ PropertyResult(Analyticity.UNKNOWN, source="piecewise-boundary-unknown")
1168
+ if details
1169
+ else Analyticity.UNKNOWN
1170
+ )
1171
+ cuts = function_definition_cuts(expr, var, mode="real", assumptions=assumptions)
1172
+ bad = []
1173
+ unknown = False
1174
+ for cut in cuts:
1175
+ cond = And(cut.condition, cut.guard)
1176
+ intersects = _condition_intersects_domain(cond, domain_cond, assumptions)
1177
+ if intersects is True:
1178
+ bad.append(cut)
1179
+ elif intersects is None:
1180
+ unknown = True
1181
+ if bad:
1182
+ result = PropertyResult(
1183
+ Analyticity.NOT_ANALYTIC, exceptions=tuple(bad), source="definition-cuts"
1184
+ )
1185
+ return result if details else result.primary
1186
+ if unknown:
1187
+ return (
1188
+ PropertyResult(
1189
+ Analyticity.UNKNOWN,
1190
+ exceptions=cuts,
1191
+ source="conditional-definition-cuts",
1192
+ )
1193
+ if details
1194
+ else Analyticity.UNKNOWN
1195
+ )
1196
+
1197
+ # Composition theorem: analytic primitives composed with analytic arguments.
1198
+ for arg in getattr(expr, "args", ()):
1199
+ if arg.has(var) and arg != var:
1200
+ a = function_analytic(arg, var, domain=S.Reals, assumptions=assumptions)
1201
+ if a == Analyticity.NOT_ANALYTIC:
1202
+ return (
1203
+ PropertyResult(Analyticity.NOT_ANALYTIC, source="composition")
1204
+ if details
1205
+ else Analyticity.NOT_ANALYTIC
1206
+ )
1207
+ if a == Analyticity.UNKNOWN:
1208
+ unknown = True
1209
+ entire = evaluate_property(
1210
+ expr, "Entire", mode="complex", var=var, assumptions=assumptions
1211
+ )
1212
+ local = evaluate_property(
1213
+ expr, "LocallyAnalytic", mode="real", var=var, assumptions=assumptions
1214
+ )
1215
+ if expr.is_polynomial(var) or entire.primary is S.true or local.primary is S.true:
1216
+ val = Analyticity.UNKNOWN if unknown else Analyticity.ANALYTIC
1217
+ return PropertyResult(val, source="registry+composition") if details else val
1218
+ if expr.is_rational_function(var) and not cuts:
1219
+ return (
1220
+ PropertyResult(Analyticity.ANALYTIC, source="rational")
1221
+ if details
1222
+ else Analyticity.ANALYTIC
1223
+ )
1224
+ if expr.is_Add or expr.is_Mul or (isinstance(expr, sp.Pow) and expr.exp.is_integer):
1225
+ vals = [
1226
+ function_analytic(a, var, domain=S.Reals, assumptions=assumptions)
1227
+ for a in expr.args
1228
+ if a.has(var)
1229
+ ]
1230
+ if vals and all(v == Analyticity.ANALYTIC for v in vals):
1231
+ return (
1232
+ PropertyResult(Analyticity.ANALYTIC, source="analytic-closure")
1233
+ if details
1234
+ else Analyticity.ANALYTIC
1235
+ )
1236
+ if any(v == Analyticity.NOT_ANALYTIC for v in vals):
1237
+ return (
1238
+ PropertyResult(Analyticity.NOT_ANALYTIC, source="analytic-closure")
1239
+ if details
1240
+ else Analyticity.NOT_ANALYTIC
1241
+ )
1242
+ return (
1243
+ PropertyResult(Analyticity.UNKNOWN, source="insufficient-evidence")
1244
+ if details
1245
+ else Analyticity.UNKNOWN
1246
+ )
1247
+
1248
+
1249
+ def function_meromorphic(expr, var, *, assumptions=S.true, details=False):
1250
+ """Classify meromorphicity on the complex plane/natural complex domain."""
1251
+ expr = sp.sympify(expr)
1252
+ var = sp.sympify(var)
1253
+ if expr.is_rational_function(var):
1254
+ return (
1255
+ PropertyResult(Meromorphicity.MEROMORPHIC, source="rational")
1256
+ if details
1257
+ else Meromorphicity.MEROMORPHIC
1258
+ )
1259
+ branch = function_branchpoints(expr, var, assumptions=assumptions)
1260
+ cuts = function_branchcuts(expr, var, assumptions=assumptions, mode="records")
1261
+ essential = function_essential_singularities(expr, var, assumptions=assumptions)
1262
+ bad = branch + cuts + essential
1263
+ definite = []
1264
+ conditional = []
1265
+ for item in bad:
1266
+ g = simplify_condition(item.guard, assumptions=assumptions)
1267
+ if g is S.true:
1268
+ definite.append(item)
1269
+ elif g is not S.false:
1270
+ conditional.append(item)
1271
+ if definite:
1272
+ result = PropertyResult(
1273
+ Meromorphicity.NOT_MEROMORPHIC,
1274
+ exceptions=tuple(definite + conditional),
1275
+ source="singularity-analysis",
1276
+ )
1277
+ return result if details else result.primary
1278
+ if conditional:
1279
+ bad_guard = simplify_condition(
1280
+ Or(*[item.guard for item in conditional]), assumptions=assumptions
1281
+ )
1282
+ cases = (
1283
+ ConditionalValue(Meromorphicity.NOT_MEROMORPHIC, bad_guard),
1284
+ ConditionalValue(Meromorphicity.MEROMORPHIC, sp.Not(bad_guard)),
1285
+ )
1286
+ result = PropertyResult(
1287
+ Meromorphicity.UNKNOWN,
1288
+ cases=cases,
1289
+ exceptions=tuple(conditional),
1290
+ source="conditional-singularity-analysis",
1291
+ )
1292
+ return result
1293
+ reg = evaluate_property(
1294
+ expr, "Meromorphic", mode="complex", assumptions=assumptions
1295
+ )
1296
+ if reg.primary is S.true:
1297
+ return (
1298
+ PropertyResult(Meromorphicity.MEROMORPHIC, source="registry")
1299
+ if details
1300
+ else Meromorphicity.MEROMORPHIC
1301
+ )
1302
+ if reg.primary is S.false:
1303
+ return (
1304
+ PropertyResult(Meromorphicity.NOT_MEROMORPHIC, source="registry")
1305
+ if details
1306
+ else Meromorphicity.NOT_MEROMORPHIC
1307
+ )
1308
+ entire = evaluate_property(expr, "Entire", mode="complex", assumptions=assumptions)
1309
+ if entire.primary is S.true:
1310
+ return (
1311
+ PropertyResult(Meromorphicity.MEROMORPHIC, source="entire")
1312
+ if details
1313
+ else Meromorphicity.MEROMORPHIC
1314
+ )
1315
+ if (
1316
+ function_analytic(expr, var, domain=S.Complexes, assumptions=assumptions)
1317
+ == Analyticity.ANALYTIC
1318
+ ):
1319
+ # Holomorphic on its natural domain and with no branch/essential
1320
+ # obstruction above: any remaining isolated singularities are poles.
1321
+ return (
1322
+ PropertyResult(Meromorphicity.MEROMORPHIC, source="analytic-with-poles")
1323
+ if details
1324
+ else Meromorphicity.MEROMORPHIC
1325
+ )
1326
+ # Closure of meromorphic functions under arithmetic.
1327
+ if expr.is_Add or expr.is_Mul or (isinstance(expr, sp.Pow) and expr.exp.is_integer):
1328
+ vals = []
1329
+ for arg in expr.args:
1330
+ if arg.has(var):
1331
+ vals.append(function_meromorphic(arg, var, assumptions=assumptions))
1332
+ if vals and all(v == Meromorphicity.MEROMORPHIC for v in vals):
1333
+ return (
1334
+ PropertyResult(Meromorphicity.MEROMORPHIC, source="closure")
1335
+ if details
1336
+ else Meromorphicity.MEROMORPHIC
1337
+ )
1338
+ if any(v == Meromorphicity.NOT_MEROMORPHIC for v in vals):
1339
+ return (
1340
+ PropertyResult(Meromorphicity.NOT_MEROMORPHIC, source="closure")
1341
+ if details
1342
+ else Meromorphicity.NOT_MEROMORPHIC
1343
+ )
1344
+ return (
1345
+ PropertyResult(Meromorphicity.UNKNOWN, source="insufficient-evidence")
1346
+ if details
1347
+ else Meromorphicity.UNKNOWN
1348
+ )
1349
+
1350
+
1351
+ def _condition_to_set(condition, var):
1352
+ """Convert a real condition using the shared structural set converter."""
1353
+ if condition is None:
1354
+ return None
1355
+
1356
+ # Import lazily to avoid an import cycle through real_analysis.smoothness.
1357
+ from .real_analysis.common import condition_to_set
1358
+
1359
+ converted = condition_to_set(condition, var)
1360
+ if converted is not None:
1361
+ return converted
1362
+ try:
1363
+ return sp.solveset(condition, var, domain=S.Reals)
1364
+ except SYMBOLIC_QUERY_ERRORS:
1365
+ return None
1366
+
1367
+
1368
+ def function_continuous(
1369
+ expr,
1370
+ var,
1371
+ *,
1372
+ domain=S.Reals,
1373
+ assumptions=S.true,
1374
+ details=False,
1375
+ _domain_ready=False,
1376
+ ):
1377
+ """Test continuity on the function's natural real domain by default."""
1378
+ expr = sp.sympify(expr)
1379
+ vv = _variable_tuple(var)
1380
+ if len(vv) > 1:
1381
+ val = _multivariate_structural_continuity(expr, vv)
1382
+ result = PropertyResult(val, source="multivariate-structural-continuity")
1383
+ return result if details else val
1384
+ var = vv[0]
1385
+ domain_cond = (
1386
+ sp.sympify(domain)
1387
+ if _domain_ready
1388
+ else (
1389
+ _real_domain_condition(expr, var)
1390
+ if domain in (S.Reals, "real", "reals", "R", "RR")
1391
+ else (
1392
+ domain.as_relational(var)
1393
+ if isinstance(domain, sp.Set)
1394
+ else sp.sympify(domain)
1395
+ )
1396
+ )
1397
+ )
1398
+ if not _domain_ready and domain_cond is not None:
1399
+ split = evaluate_parameter_strata(
1400
+ domain_cond,
1401
+ var,
1402
+ lambda branch_domain: function_continuous(
1403
+ expr,
1404
+ var,
1405
+ domain=branch_domain,
1406
+ assumptions=assumptions,
1407
+ details=details,
1408
+ _domain_ready=True,
1409
+ ),
1410
+ source="continuity-parameter-stratification",
1411
+ )
1412
+ if split is not None:
1413
+ return split
1414
+ discontinuities = function_discontinuities(expr, var, assumptions=assumptions)
1415
+ actual = []
1416
+ uncertain = []
1417
+ for d in discontinuities:
1418
+ hit = _condition_intersects_domain(
1419
+ And(d.condition, d.guard), domain_cond, assumptions
1420
+ )
1421
+ if hit is True:
1422
+ actual.append(d)
1423
+ elif hit is None:
1424
+ uncertain.append(d)
1425
+ if actual:
1426
+ result = PropertyResult(
1427
+ Continuity.DISCONTINUOUS,
1428
+ exceptions=tuple(actual),
1429
+ source="discontinuity-analysis",
1430
+ )
1431
+ return result if details else result.primary
1432
+ if isinstance(expr, sp.Piecewise) and not uncertain:
1433
+ from .real_analysis.piecewise import piecewise_branches
1434
+
1435
+ branches = piecewise_branches(expr, var, domain_cond)
1436
+ branch_values = [
1437
+ function_continuous(
1438
+ branch.expression,
1439
+ var,
1440
+ domain=branch.condition,
1441
+ assumptions=assumptions,
1442
+ _domain_ready=True,
1443
+ )
1444
+ for branch in branches
1445
+ if branch.expression.has(var)
1446
+ ]
1447
+ if all(v == Continuity.CONTINUOUS for v in branch_values):
1448
+ return (
1449
+ PropertyResult(Continuity.CONTINUOUS, source="piecewise-analysis")
1450
+ if details
1451
+ else Continuity.CONTINUOUS
1452
+ )
1453
+ natural_set = _condition_to_set(domain_cond, var)
1454
+ if natural_set is not None:
1455
+ try:
1456
+ cset = continuous_domain(expr, var, S.Reals)
1457
+ if cset == natural_set:
1458
+ return (
1459
+ PropertyResult(
1460
+ Continuity.CONTINUOUS, source="sympy.continuous_domain"
1461
+ )
1462
+ if details
1463
+ else Continuity.CONTINUOUS
1464
+ )
1465
+ missing = natural_set - cset
1466
+ if missing is not S.EmptySet and missing != S.EmptySet:
1467
+ result = PropertyResult(
1468
+ Continuity.DISCONTINUOUS,
1469
+ exceptions=(missing,),
1470
+ source="sympy.continuous_domain",
1471
+ )
1472
+ return result if details else result.primary
1473
+ except NotImplementedError:
1474
+ pass
1475
+ except SYMBOLIC_QUERY_ERRORS:
1476
+ pass
1477
+
1478
+ # Analytic functions are continuous on their real analytic domain.
1479
+ analytic = function_analytic(expr, var, domain=domain, assumptions=assumptions)
1480
+ if analytic == Analyticity.ANALYTIC:
1481
+ return (
1482
+ PropertyResult(Continuity.CONTINUOUS, source="analyticity")
1483
+ if details
1484
+ else Continuity.CONTINUOUS
1485
+ )
1486
+ if expr.func is sp.Abs:
1487
+ return (
1488
+ PropertyResult(Continuity.CONTINUOUS, source="Abs")
1489
+ if details
1490
+ else Continuity.CONTINUOUS
1491
+ )
1492
+ return (
1493
+ PropertyResult(Continuity.UNKNOWN, source="insufficient-evidence")
1494
+ if details
1495
+ else Continuity.UNKNOWN
1496
+ )
1497
+
1498
+
1499
+ # ---------------------------------------------------------------------------
1500
+ # Structural singularity traversal
1501
+ # ---------------------------------------------------------------------------
1502
+
1503
+
1504
+ @dataclass(frozen=True)
1505
+ class SingularityAnalysis:
1506
+ branch_cuts: tuple[Singularity, ...] = ()
1507
+ definition_cuts: tuple[Singularity, ...] = ()
1508
+ poles: tuple[Singularity, ...] = ()
1509
+ essential: tuple[Singularity, ...] = ()
1510
+ branch_points: tuple[Singularity, ...] = ()
1511
+ removable: tuple[Singularity, ...] = ()
1512
+ discontinuities: tuple[Singularity, ...] = ()
1513
+ domain_exterior: sp.Basic = S.false
1514
+ complete: bool = False
1515
+ unknown_heads: tuple[str, ...] = ()
1516
+ source: str = "structural-singularity-traversal"
1517
+
1518
+ def all_records(self):
1519
+ return _unique(
1520
+ self.branch_cuts
1521
+ + self.definition_cuts
1522
+ + self.poles
1523
+ + self.essential
1524
+ + self.branch_points
1525
+ + self.removable
1526
+ + self.discontinuities
1527
+ )
1528
+
1529
+ def condition(self):
1530
+ conds = []
1531
+ if self.domain_exterior is not S.false:
1532
+ conds.append(self.domain_exterior)
1533
+ for s in self.all_records():
1534
+ conds.append(And(s.condition, s.guard))
1535
+ return Or(*conds) if conds else S.false
1536
+
1537
+
1538
+ def _conditioned(records, cond):
1539
+ if cond is S.true:
1540
+ return tuple(records)
1541
+ out = []
1542
+ for r in records:
1543
+ g = simplify_condition(And(r.guard, cond))
1544
+ if g is not S.false:
1545
+ out.append(Singularity(r.kind, r.condition, g, r.multiplicity, r.source))
1546
+ return tuple(out)
1547
+
1548
+
1549
+ def _domain_exterior_condition(expr, var, real=True):
1550
+ """Boundary/exterior contribution of the supported natural domain."""
1551
+ try:
1552
+ from .api import function_domain
1553
+
1554
+ d = function_domain(expr, var, domain=S.Reals if real else S.Complexes)
1555
+ except SYMBOLIC_QUERY_ERRORS:
1556
+ return None
1557
+ if isinstance(d, PropertyResult):
1558
+ return None
1559
+ d = sp.sympify(d)
1560
+ if d is S.true:
1561
+ return S.false
1562
+ if d is S.false:
1563
+ return S.true
1564
+ try:
1565
+ return sp.simplify_logic(sp.Not(d))
1566
+ except SYMBOLIC_QUERY_ERRORS:
1567
+ return sp.Not(d)
1568
+
1569
+
1570
+ def _primitive_known(expr, var):
1571
+ if expr.is_Atom:
1572
+ return True
1573
+ if expr.is_Add or expr.is_Mul or isinstance(expr, (sp.Pow, sp.Piecewise)):
1574
+ return True
1575
+ if expr.func in (
1576
+ sp.exp,
1577
+ sp.log,
1578
+ sp.sin,
1579
+ sp.cos,
1580
+ sp.tan,
1581
+ sp.cot,
1582
+ sp.sinh,
1583
+ sp.cosh,
1584
+ sp.tanh,
1585
+ sp.asin,
1586
+ sp.acos,
1587
+ sp.atan,
1588
+ sp.asinh,
1589
+ sp.acosh,
1590
+ sp.atanh,
1591
+ sp.Abs,
1592
+ sp.sign,
1593
+ sp.erf,
1594
+ sp.erfc,
1595
+ sp.LambertW,
1596
+ sp.besselj,
1597
+ sp.bessely,
1598
+ sp.gamma,
1599
+ ):
1600
+ return True
1601
+ try:
1602
+ fam = evaluate_property(expr, "LocallyAnalytic", var=var, mode="complex")
1603
+ if fam.primary is not None or fam.cases:
1604
+ return True
1605
+ except SYMBOLIC_QUERY_ERRORS:
1606
+ pass
1607
+ return False
1608
+
1609
+
1610
+ def _function_singularity_analysis_impl(
1611
+ expr, var, *, domain=S.Complexes, assumptions=S.true, ignore_unknown=False
1612
+ ):
1613
+ """Traverse an expression and return a typed, condition-aware singularity certificate.
1614
+
1615
+ Unlike :func:`function_singularities`, this records whether traversal was
1616
+ complete, preserves Piecewise branch guards, includes natural-domain
1617
+ exterior conditions, and promotes poles of an inner expression to
1618
+ essential singularities when composed through a transcendental entire
1619
+ function with an essential singularity at infinity.
1620
+ """
1621
+ expr = sp.sympify(expr)
1622
+ var = sp.sympify(var)
1623
+ complex_mode = domain in (S.Complexes, "complex", "complexes", "C", "CC")
1624
+ bc = []
1625
+ dc = []
1626
+ po = []
1627
+ es = []
1628
+ bp = []
1629
+ rm = []
1630
+ di = []
1631
+ unknown = []
1632
+ transcendental_entire = {sp.exp, sp.sin, sp.cos, sp.sinh, sp.cosh, sp.erf, sp.erfc}
1633
+
1634
+ def visit(e, guard=S.true, inside_essential=False):
1635
+ if not getattr(e, "has", lambda *_: False)(var):
1636
+ return
1637
+ # Piecewise values are traversed only under their active branch guard.
1638
+ if isinstance(e, sp.Piecewise):
1639
+ covered = S.false
1640
+ for value, cond in e.args:
1641
+ active = (
1642
+ sp.Not(covered) if cond is S.true else And(cond, sp.Not(covered))
1643
+ )
1644
+ active = simplify_condition(And(guard, active), assumptions=assumptions)
1645
+ if active is not S.false:
1646
+ visit(value, active, inside_essential)
1647
+ covered = Or(covered, cond)
1648
+ for boundary in _piecewise_boundaries(e):
1649
+ dc.append(
1650
+ Singularity(
1651
+ SingularityKind.DEFINITION_CUT,
1652
+ boundary,
1653
+ guard,
1654
+ source="piecewise-boundary",
1655
+ )
1656
+ )
1657
+ verdict = _piecewise_discontinuous_at(e, var, boundary)
1658
+ if verdict is not False:
1659
+ di.append(
1660
+ Singularity(
1661
+ SingularityKind.DISCONTINUITY,
1662
+ boundary,
1663
+ guard,
1664
+ source="piecewise-boundary",
1665
+ )
1666
+ )
1667
+ return
1668
+
1669
+ # Collect local information at this node, but condition it by traversal guard.
1670
+ if e.is_Function or isinstance(e, sp.Pow):
1671
+ if complex_mode:
1672
+ bc.extend(
1673
+ _conditioned(
1674
+ function_branchcuts(
1675
+ e, var, assumptions=assumptions, mode="records"
1676
+ ),
1677
+ guard,
1678
+ )
1679
+ )
1680
+ bp.extend(
1681
+ _conditioned(
1682
+ function_branchpoints(e, var, assumptions=assumptions), guard
1683
+ )
1684
+ )
1685
+ dc.extend(
1686
+ _conditioned(
1687
+ function_definition_cuts(
1688
+ e,
1689
+ var,
1690
+ mode="complex" if complex_mode else "real",
1691
+ assumptions=assumptions,
1692
+ ),
1693
+ guard,
1694
+ )
1695
+ )
1696
+ localp = _conditioned(
1697
+ function_poles(e, var, assumptions=assumptions), guard
1698
+ )
1699
+ if inside_essential:
1700
+ es.extend(
1701
+ Singularity(
1702
+ SingularityKind.ESSENTIAL,
1703
+ p.condition,
1704
+ p.guard,
1705
+ p.multiplicity,
1706
+ "outer-essential-at-infinity",
1707
+ )
1708
+ for p in localp
1709
+ )
1710
+ else:
1711
+ po.extend(localp)
1712
+ es.extend(
1713
+ _conditioned(
1714
+ function_essential_singularities(e, var, assumptions=assumptions),
1715
+ guard,
1716
+ )
1717
+ )
1718
+ rm.extend(_conditioned(function_removable_singularities(e, var), guard))
1719
+ if not complex_mode:
1720
+ di.extend(
1721
+ _conditioned(
1722
+ function_discontinuities(e, var, assumptions=assumptions), guard
1723
+ )
1724
+ )
1725
+ if not _primitive_known(e, var):
1726
+ unknown.append(getattr(e.func, "__name__", str(e.func)))
1727
+ if not ignore_unknown:
1728
+ return
1729
+
1730
+ outer_essential = inside_essential or (
1731
+ getattr(e, "func", None) in transcendental_entire
1732
+ )
1733
+ args = (
1734
+ [v for v, _ in e.args]
1735
+ if isinstance(e, sp.Piecewise)
1736
+ else getattr(e, "args", ())
1737
+ )
1738
+ for a in args:
1739
+ if getattr(a, "has", lambda *_: False)(var):
1740
+ visit(a, guard, outer_essential)
1741
+
1742
+ visit(expr)
1743
+ dex = _domain_exterior_condition(expr, var, not complex_mode)
1744
+ complete = dex is not None and (ignore_unknown or not unknown)
1745
+ return SingularityAnalysis(
1746
+ branch_cuts=_unique(bc),
1747
+ definition_cuts=_unique(dc),
1748
+ poles=_unique(po),
1749
+ essential=_unique(es),
1750
+ branch_points=_unique(bp),
1751
+ removable=_unique(rm),
1752
+ discontinuities=_unique(di),
1753
+ domain_exterior=S.false if dex is None else dex,
1754
+ complete=complete,
1755
+ unknown_heads=tuple(sorted(set(unknown))),
1756
+ )
1757
+
1758
+
1759
+ def function_singularity_analysis(
1760
+ expr, var, *, domain=S.Complexes, assumptions=S.true, ignore_unknown=False
1761
+ ):
1762
+ """Cached aggregate singularity analysis with assumptions in the cache key."""
1763
+ from .proof_cache import property_proof_context
1764
+
1765
+ return property_proof_context(expr, var, assumptions=assumptions).singularities(
1766
+ domain=domain, ignore_unknown=ignore_unknown
1767
+ )