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.
- funcprops/__init__.py +234 -0
- funcprops/_semialg.py +246 -0
- funcprops/api.py +70 -0
- funcprops/branchcuts/__init__.py +141 -0
- funcprops/branchcuts/computation.py +664 -0
- funcprops/branchcuts/core.py +1204 -0
- funcprops/branchcuts/post_simplify.py +461 -0
- funcprops/conditions.py +124 -0
- funcprops/data/catalogue.json +127449 -0
- funcprops/data/dlmf_audit.json +3382 -0
- funcprops/dlmf.py +29 -0
- funcprops/engine.py +324 -0
- funcprops/exceptions.py +29 -0
- funcprops/proof_cache.py +373 -0
- funcprops/real_analysis/__init__.py +52 -0
- funcprops/real_analysis/advanced_convexity.py +217 -0
- funcprops/real_analysis/boundedness.py +168 -0
- funcprops/real_analysis/common.py +244 -0
- funcprops/real_analysis/convexity.py +698 -0
- funcprops/real_analysis/differentiability.py +112 -0
- funcprops/real_analysis/domain.py +188 -0
- funcprops/real_analysis/mapping.py +374 -0
- funcprops/real_analysis/monotonic_certificates.py +867 -0
- funcprops/real_analysis/monotonicity.py +673 -0
- funcprops/real_analysis/oscillation.py +151 -0
- funcprops/real_analysis/piecewise.py +174 -0
- funcprops/real_analysis/range.py +552 -0
- funcprops/real_analysis/sign.py +561 -0
- funcprops/real_analysis/smoothness.py +268 -0
- funcprops/real_analysis/special_functions.py +515 -0
- funcprops/real_analysis/structural_properties.py +558 -0
- funcprops/real_analysis/zeros.py +327 -0
- funcprops/registry.py +274 -0
- funcprops/results.py +239 -0
- funcprops/singularities.py +1767 -0
- funcprops/stratification.py +494 -0
- funcprops/sympy_compare.py +63 -0
- funcprops/transcendental/__init__.py +140 -0
- funcprops/transcendental/algebraize.py +201 -0
- funcprops/transcendental/analytic.py +261 -0
- funcprops/transcendental/complex_roots.py +542 -0
- funcprops/transcendental/exp_fourier.py +221 -0
- funcprops/transcendental/extrema.py +821 -0
- funcprops/transcendental/periodic.py +222 -0
- funcprops/transcendental/proof.py +104 -0
- funcprops/transcendental/real_interval.py +128 -0
- funcprops/transcendental/real_sign.py +117 -0
- funcprops/transcendental/reduce.py +288 -0
- funcprops/transcendental/roots.py +1380 -0
- funcprops/transcendental/zero_geometry.py +1197 -0
- funcprops/verification.py +410 -0
- funcprops-0.1.0.dist-info/METADATA +208 -0
- funcprops-0.1.0.dist-info/RECORD +56 -0
- funcprops-0.1.0.dist-info/WHEEL +5 -0
- funcprops-0.1.0.dist-info/licenses/LICENSE +674 -0
- funcprops-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable
|
|
4
|
+
from itertools import product
|
|
5
|
+
|
|
6
|
+
from sympy import (
|
|
7
|
+
Add,
|
|
8
|
+
Derivative,
|
|
9
|
+
I,
|
|
10
|
+
Mul,
|
|
11
|
+
Piecewise,
|
|
12
|
+
S,
|
|
13
|
+
Symbol,
|
|
14
|
+
diff,
|
|
15
|
+
log,
|
|
16
|
+
pi,
|
|
17
|
+
simplify,
|
|
18
|
+
sympify,
|
|
19
|
+
)
|
|
20
|
+
from sympy.logic.boolalg import And, Boolean
|
|
21
|
+
from sympy.logic.inference import satisfiable
|
|
22
|
+
from sympy.sets.contains import Contains
|
|
23
|
+
|
|
24
|
+
from ..exceptions import SYMBOLIC_QUERY_ERRORS
|
|
25
|
+
from .core import RestrictedExpr, Undefined, get_branch_rule
|
|
26
|
+
|
|
27
|
+
Pair = tuple[object, object]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def unsatisfiable_q(cond, symbols, assumptions=True) -> bool:
|
|
31
|
+
"""Best-effort unsatisfiability check using SymPy simplification and SAT search."""
|
|
32
|
+
cond = sympify(cond)
|
|
33
|
+
symbols = tuple(symbols) if isinstance(symbols, (list, tuple, set)) else (symbols,)
|
|
34
|
+
ass = sympify(assumptions)
|
|
35
|
+
try:
|
|
36
|
+
simp = simplify(And(cond, ass))
|
|
37
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
38
|
+
simp = And(cond, ass)
|
|
39
|
+
if simp is S.false:
|
|
40
|
+
return True
|
|
41
|
+
if simp is S.true:
|
|
42
|
+
return False
|
|
43
|
+
try:
|
|
44
|
+
return not bool(satisfiable(simp))
|
|
45
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def condition_simplify(cond, symbols, assumptions=True):
|
|
50
|
+
"""Simplify a Boolean condition and drop it to False if provably unsatisfiable."""
|
|
51
|
+
cond = sympify(cond)
|
|
52
|
+
try:
|
|
53
|
+
simp = simplify(cond)
|
|
54
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
55
|
+
simp = cond
|
|
56
|
+
if simp is S.false or simp is False:
|
|
57
|
+
return S.false
|
|
58
|
+
if simp is S.true or simp is True:
|
|
59
|
+
return S.true
|
|
60
|
+
if unsatisfiable_q(simp, symbols, assumptions=assumptions):
|
|
61
|
+
return S.false
|
|
62
|
+
return simp
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def condition_false_q(cond, symbols, assumptions=True) -> bool:
|
|
66
|
+
return condition_simplify(cond, symbols, assumptions=assumptions) is S.false
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def clean_pairs(pairs: Iterable[Pair]) -> list[Pair]:
|
|
70
|
+
out: list[Pair] = []
|
|
71
|
+
for pair in pairs:
|
|
72
|
+
if not isinstance(pair, (tuple, list)) or len(pair) != 2:
|
|
73
|
+
continue
|
|
74
|
+
expr, cond = pair
|
|
75
|
+
cond = sympify(cond)
|
|
76
|
+
if cond is S.false or cond is False:
|
|
77
|
+
continue
|
|
78
|
+
out.append((sympify(expr), cond))
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def map_pairs(f: Callable, pairs: Iterable[Pair]) -> list[Pair]:
|
|
83
|
+
return [(f(expr), cond) for expr, cond in clean_pairs(pairs)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def differentiate_pairs(pairs: Iterable[Pair], *vars_and_counts) -> list[Pair]:
|
|
87
|
+
"""Differentiate only the expression part of each pair, preserving the condition.
|
|
88
|
+
|
|
89
|
+
This implements the standard jump-propagation heuristic
|
|
90
|
+
J[D[f,z], z] = D[J[f,z], z] away from branch points/endpoints.
|
|
91
|
+
"""
|
|
92
|
+
out: list[Pair] = []
|
|
93
|
+
for expr, cond in clean_pairs(pairs):
|
|
94
|
+
dexpr = expr
|
|
95
|
+
for var, count in vars_and_counts:
|
|
96
|
+
dexpr = diff(dexpr, var, count)
|
|
97
|
+
out.append((simplify(dexpr), cond))
|
|
98
|
+
return clean_pairs(out)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def gate_pairs(
|
|
102
|
+
g: Callable, pairs: Iterable[Pair], symbols, assumptions=True
|
|
103
|
+
) -> list[Pair]:
|
|
104
|
+
out = []
|
|
105
|
+
for expr, cond in clean_pairs(pairs):
|
|
106
|
+
gated = condition_simplify(And(cond, g(expr)), symbols, assumptions=assumptions)
|
|
107
|
+
if gated is not S.false:
|
|
108
|
+
out.append((expr, gated))
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cross_diff_pairs(
|
|
113
|
+
f_plus: Callable, f_minus: Callable, plus: Iterable[Pair], minus: Iterable[Pair]
|
|
114
|
+
) -> list[Pair]:
|
|
115
|
+
out = []
|
|
116
|
+
for p_expr, p_cond in clean_pairs(plus):
|
|
117
|
+
for m_expr, m_cond in clean_pairs(minus):
|
|
118
|
+
out.append((f_plus(p_expr) - f_minus(m_expr), And(p_cond, m_cond)))
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _combine_head(head, args):
|
|
123
|
+
if head is Add:
|
|
124
|
+
return Add(*args)
|
|
125
|
+
if head is Mul:
|
|
126
|
+
return Mul(*args)
|
|
127
|
+
return head(*args)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def combine_general_capped(
|
|
131
|
+
head,
|
|
132
|
+
pair_lists: list[list[Pair]],
|
|
133
|
+
cap: Callable[[list[Pair]], list[Pair]] | None = None,
|
|
134
|
+
) -> list[Pair]:
|
|
135
|
+
pair_lists = [clean_pairs(lst) for lst in pair_lists]
|
|
136
|
+
if not pair_lists:
|
|
137
|
+
return []
|
|
138
|
+
out: list[Pair] = []
|
|
139
|
+
for combo in product(*pair_lists):
|
|
140
|
+
exprs = [expr for expr, _ in combo]
|
|
141
|
+
conds = [cond for _, cond in combo]
|
|
142
|
+
out.append(
|
|
143
|
+
(_combine_head(head, exprs), And(*conds) if len(conds) > 1 else conds[0])
|
|
144
|
+
)
|
|
145
|
+
out = clean_pairs(out)
|
|
146
|
+
return cap(out) if cap else out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cap_pairs(
|
|
150
|
+
pairs: Iterable[Pair], max_per_expr: int = 2, max_total: int = 128
|
|
151
|
+
) -> list[Pair]:
|
|
152
|
+
grouped = {}
|
|
153
|
+
for expr, cond in clean_pairs(pairs):
|
|
154
|
+
grouped.setdefault(expr, []).append((expr, cond))
|
|
155
|
+
out: list[Pair] = []
|
|
156
|
+
for expr_pairs in grouped.values():
|
|
157
|
+
out.extend(expr_pairs[:max_per_expr])
|
|
158
|
+
return out[:max_total]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def ensure_neutral_pairs(pairs: Iterable[Pair]) -> list[Pair]:
|
|
162
|
+
pairs = clean_pairs(pairs)
|
|
163
|
+
if any(expr == 0 and cond is S.true for expr, cond in pairs):
|
|
164
|
+
return pairs
|
|
165
|
+
return [*pairs, (S.Zero, S.true)]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def simplify_pairs(pairs: Iterable[Pair], symbols, assumptions=True) -> list[Pair]:
|
|
169
|
+
out = []
|
|
170
|
+
for expr, cond in clean_pairs(pairs):
|
|
171
|
+
new_cond = condition_simplify(cond, symbols, assumptions=assumptions)
|
|
172
|
+
if new_cond is not S.false:
|
|
173
|
+
out.append((simplify(expr), new_cond))
|
|
174
|
+
return clean_pairs(out)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def pairs_to_restricted(pairs: Iterable[Pair]):
|
|
178
|
+
from .core import restrict
|
|
179
|
+
|
|
180
|
+
return tuple(restrict(expr, cond) for expr, cond in clean_pairs(pairs))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def pairs_to_piecewise_list(pairs: Iterable[Pair], default=None):
|
|
184
|
+
if default is None:
|
|
185
|
+
default = Undefined()
|
|
186
|
+
return tuple(
|
|
187
|
+
Piecewise((expr, cond), (default, True)) for expr, cond in clean_pairs(pairs)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def jump_pairs_from_table(
|
|
192
|
+
expr,
|
|
193
|
+
arg,
|
|
194
|
+
z,
|
|
195
|
+
assumptions=True,
|
|
196
|
+
cap: Callable[[list[Pair]], list[Pair]] | None = None,
|
|
197
|
+
) -> list[Pair]:
|
|
198
|
+
"""Instantiate table-driven jumps using the current branch-rule registry."""
|
|
199
|
+
try:
|
|
200
|
+
rule = get_branch_rule(sympify(expr), sympify(arg) if arg is not None else z)
|
|
201
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
202
|
+
return []
|
|
203
|
+
pairs = [(term.expr, term.cond) for term in rule.jump_terms]
|
|
204
|
+
pairs = simplify_pairs(pairs, z, assumptions=assumptions)
|
|
205
|
+
return cap(pairs) if cap else pairs
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _expr_is_z_free(expr, z: Symbol) -> bool:
|
|
209
|
+
expr = sympify(expr)
|
|
210
|
+
return expr.is_Atom or not expr.has(z)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def jump_compose(
|
|
214
|
+
head,
|
|
215
|
+
u,
|
|
216
|
+
inner_assoc,
|
|
217
|
+
z,
|
|
218
|
+
assumptions=True,
|
|
219
|
+
cap: Callable[[list[Pair]], list[Pair]] | None = None,
|
|
220
|
+
):
|
|
221
|
+
expr = head(u)
|
|
222
|
+
plus_pairs = map_pairs(head, inner_assoc["Plus"])
|
|
223
|
+
minus_pairs = map_pairs(head, inner_assoc["Minus"])
|
|
224
|
+
if cap:
|
|
225
|
+
plus_pairs = cap(plus_pairs)
|
|
226
|
+
minus_pairs = cap(minus_pairs)
|
|
227
|
+
self_jumps = jump_pairs_from_table(expr, u, z, assumptions=assumptions, cap=cap)
|
|
228
|
+
inner_jumps = cross_diff_pairs(lambda x: x, lambda x: x, plus_pairs, minus_pairs)
|
|
229
|
+
jumps = self_jumps + inner_jumps
|
|
230
|
+
if cap:
|
|
231
|
+
jumps = cap(jumps)
|
|
232
|
+
return {
|
|
233
|
+
"Plus": plus_pairs,
|
|
234
|
+
"Minus": minus_pairs,
|
|
235
|
+
"Jump": ensure_neutral_pairs(simplify_pairs(jumps, z, assumptions=assumptions)),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _expanded_variable_counts(deriv: Derivative):
|
|
240
|
+
return list(deriv.variable_count)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def branch_cut_jumps_pairs(
|
|
244
|
+
expr, z: Symbol, assumptions=True, max_per_expr: int = 2, max_total: int = 128
|
|
245
|
+
):
|
|
246
|
+
# RestrictedExpr support is handled recursively inside go().
|
|
247
|
+
"""Recursively compute plus/minus/jump pair representations for an expression.
|
|
248
|
+
|
|
249
|
+
Each entry is a list of ``(expr, condition)`` pairs describing boundary values on
|
|
250
|
+
the two sides of cuts and the resulting jump contributions.
|
|
251
|
+
|
|
252
|
+
Derivatives are propagated by differentiating the existing plus/minus/jump
|
|
253
|
+
representatives and preserving their conditions. This follows the usual
|
|
254
|
+
jump rule away from branch points/endpoints.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
expr = sympify(expr)
|
|
258
|
+
|
|
259
|
+
def cap(lst: list[Pair]) -> list[Pair]:
|
|
260
|
+
return cap_pairs(
|
|
261
|
+
clean_pairs(lst), max_per_expr=max_per_expr, max_total=max_total
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
cache = {}
|
|
265
|
+
|
|
266
|
+
def go(e):
|
|
267
|
+
if _is_restricted_expr_instance(e):
|
|
268
|
+
inner_expr, inner_cond = e.args
|
|
269
|
+
inner = go(inner_expr)
|
|
270
|
+
return _restricted_assoc(
|
|
271
|
+
inner, inner_cond, z, opts={"assumptions": assumptions}
|
|
272
|
+
)
|
|
273
|
+
e = sympify(e)
|
|
274
|
+
# Boolean conditions are not analytic expressions and should not be recursed through.
|
|
275
|
+
# They can appear only as metadata inside RestrictedExpr conditions.
|
|
276
|
+
if isinstance(e, Boolean) or getattr(e, "is_Boolean", False):
|
|
277
|
+
out = {
|
|
278
|
+
"Plus": [(e, S.true)],
|
|
279
|
+
"Minus": [(e, S.true)],
|
|
280
|
+
"Jump": [(S.Zero, S.true)],
|
|
281
|
+
}
|
|
282
|
+
cache[e] = out
|
|
283
|
+
return out
|
|
284
|
+
key = e
|
|
285
|
+
if key in cache:
|
|
286
|
+
return cache[key]
|
|
287
|
+
|
|
288
|
+
if _expr_is_z_free(e, z):
|
|
289
|
+
out = {
|
|
290
|
+
"Plus": [(e, S.true)],
|
|
291
|
+
"Minus": [(e, S.true)],
|
|
292
|
+
"Jump": [(S.Zero, S.true)],
|
|
293
|
+
}
|
|
294
|
+
cache[key] = out
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
# Derivative-aware propagation.
|
|
298
|
+
if isinstance(e, Derivative):
|
|
299
|
+
base = e.expr
|
|
300
|
+
inner = go(base)
|
|
301
|
+
var_counts = _expanded_variable_counts(e)
|
|
302
|
+
P = cap(differentiate_pairs(inner["Plus"], *var_counts))
|
|
303
|
+
M = cap(differentiate_pairs(inner["Minus"], *var_counts))
|
|
304
|
+
J = cap(differentiate_pairs(inner["Jump"], *var_counts))
|
|
305
|
+
out = {
|
|
306
|
+
"Plus": P,
|
|
307
|
+
"Minus": M,
|
|
308
|
+
"Jump": ensure_neutral_pairs(
|
|
309
|
+
simplify_pairs(J, z, assumptions=assumptions)
|
|
310
|
+
),
|
|
311
|
+
}
|
|
312
|
+
cache[key] = out
|
|
313
|
+
return out
|
|
314
|
+
|
|
315
|
+
# Special handling for Log and simple power gives cleaner side values.
|
|
316
|
+
if e.func == log and len(e.args) == 1:
|
|
317
|
+
u = e.args[0]
|
|
318
|
+
inner = go(u)
|
|
319
|
+
|
|
320
|
+
def cutq(val):
|
|
321
|
+
return Contains(val, S.Reals) & (val < 0)
|
|
322
|
+
|
|
323
|
+
plus_cut = map_pairs(
|
|
324
|
+
lambda val: log(-val) + I * S.Pi,
|
|
325
|
+
gate_pairs(cutq, inner["Plus"], z, assumptions=assumptions),
|
|
326
|
+
)
|
|
327
|
+
minus_cut = map_pairs(
|
|
328
|
+
lambda val: log(-val) - I * S.Pi,
|
|
329
|
+
gate_pairs(cutq, inner["Minus"], z, assumptions=assumptions),
|
|
330
|
+
)
|
|
331
|
+
plus_reg = map_pairs(
|
|
332
|
+
log,
|
|
333
|
+
gate_pairs(
|
|
334
|
+
lambda val: ~cutq(val), inner["Plus"], z, assumptions=assumptions
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
minus_reg = map_pairs(
|
|
338
|
+
log,
|
|
339
|
+
gate_pairs(
|
|
340
|
+
lambda val: ~cutq(val), inner["Minus"], z, assumptions=assumptions
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
P = cap(plus_cut + plus_reg)
|
|
344
|
+
M = cap(minus_cut + minus_reg)
|
|
345
|
+
J = ensure_neutral_pairs(
|
|
346
|
+
simplify_pairs(
|
|
347
|
+
cross_diff_pairs(lambda x: x, lambda x: x, P, M),
|
|
348
|
+
z,
|
|
349
|
+
assumptions=assumptions,
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
out = {"Plus": P, "Minus": M, "Jump": J}
|
|
353
|
+
cache[key] = out
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
if e.is_Pow and len(e.args) == 2 and not e.exp.has(z):
|
|
357
|
+
u, a = e.base, e.exp
|
|
358
|
+
inner = go(u)
|
|
359
|
+
|
|
360
|
+
def cutq(val):
|
|
361
|
+
return Contains(val, S.Reals) & (val < 0) & ~Contains(a, S.Integers)
|
|
362
|
+
|
|
363
|
+
plus_cut = map_pairs(
|
|
364
|
+
lambda val: (-val) ** a * (S.Exp1 ** (I * S.Pi * a)),
|
|
365
|
+
gate_pairs(cutq, inner["Plus"], z, assumptions=assumptions),
|
|
366
|
+
)
|
|
367
|
+
minus_cut = map_pairs(
|
|
368
|
+
lambda val: (-val) ** a * (S.Exp1 ** (-I * S.Pi * a)),
|
|
369
|
+
gate_pairs(cutq, inner["Minus"], z, assumptions=assumptions),
|
|
370
|
+
)
|
|
371
|
+
plus_reg = map_pairs(
|
|
372
|
+
lambda val: val**a,
|
|
373
|
+
gate_pairs(
|
|
374
|
+
lambda val: ~cutq(val), inner["Plus"], z, assumptions=assumptions
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
minus_reg = map_pairs(
|
|
378
|
+
lambda val: val**a,
|
|
379
|
+
gate_pairs(
|
|
380
|
+
lambda val: ~cutq(val), inner["Minus"], z, assumptions=assumptions
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
P = cap(plus_cut + plus_reg)
|
|
384
|
+
M = cap(minus_cut + minus_reg)
|
|
385
|
+
J = ensure_neutral_pairs(
|
|
386
|
+
simplify_pairs(
|
|
387
|
+
cross_diff_pairs(lambda x: x, lambda x: x, P, M),
|
|
388
|
+
z,
|
|
389
|
+
assumptions=assumptions,
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
out = {"Plus": P, "Minus": M, "Jump": J}
|
|
393
|
+
cache[key] = out
|
|
394
|
+
return out
|
|
395
|
+
|
|
396
|
+
if e.func == Add:
|
|
397
|
+
parts = [go(arg) for arg in e.args]
|
|
398
|
+
P = cap(combine_general_capped(Add, [p["Plus"] for p in parts], cap=cap))
|
|
399
|
+
M = cap(combine_general_capped(Add, [p["Minus"] for p in parts], cap=cap))
|
|
400
|
+
J = cap(combine_general_capped(Add, [p["Jump"] for p in parts], cap=cap))
|
|
401
|
+
out = {
|
|
402
|
+
"Plus": P,
|
|
403
|
+
"Minus": M,
|
|
404
|
+
"Jump": ensure_neutral_pairs(
|
|
405
|
+
simplify_pairs(J, z, assumptions=assumptions)
|
|
406
|
+
),
|
|
407
|
+
}
|
|
408
|
+
cache[key] = out
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
if e.func == Mul:
|
|
412
|
+
parts = [go(arg) for arg in e.args]
|
|
413
|
+
P = cap(combine_general_capped(Mul, [p["Plus"] for p in parts], cap=cap))
|
|
414
|
+
M = cap(combine_general_capped(Mul, [p["Minus"] for p in parts], cap=cap))
|
|
415
|
+
J = cap(cross_diff_pairs(lambda x: x, lambda x: x, P, M))
|
|
416
|
+
out = {
|
|
417
|
+
"Plus": P,
|
|
418
|
+
"Minus": M,
|
|
419
|
+
"Jump": ensure_neutral_pairs(
|
|
420
|
+
simplify_pairs(J, z, assumptions=assumptions)
|
|
421
|
+
),
|
|
422
|
+
}
|
|
423
|
+
cache[key] = out
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
if len(e.args) == 1:
|
|
427
|
+
u = e.args[0]
|
|
428
|
+
out = jump_compose(e.func, u, go(u), z, assumptions=assumptions, cap=cap)
|
|
429
|
+
cache[key] = out
|
|
430
|
+
return out
|
|
431
|
+
|
|
432
|
+
parts = [go(arg) for arg in e.args]
|
|
433
|
+
P = cap(combine_general_capped(e.func, [p["Plus"] for p in parts], cap=cap))
|
|
434
|
+
M = cap(combine_general_capped(e.func, [p["Minus"] for p in parts], cap=cap))
|
|
435
|
+
table_jumps: list[Pair] = []
|
|
436
|
+
for arg in e.args:
|
|
437
|
+
table_jumps.extend(
|
|
438
|
+
jump_pairs_from_table(e, arg, z, assumptions=assumptions, cap=cap)
|
|
439
|
+
)
|
|
440
|
+
if table_jumps:
|
|
441
|
+
J = cap(table_jumps)
|
|
442
|
+
else:
|
|
443
|
+
J = cap(cross_diff_pairs(lambda x: x, lambda x: x, P, M))
|
|
444
|
+
out = {
|
|
445
|
+
"Plus": P,
|
|
446
|
+
"Minus": M,
|
|
447
|
+
"Jump": ensure_neutral_pairs(simplify_pairs(J, z, assumptions=assumptions)),
|
|
448
|
+
}
|
|
449
|
+
cache[key] = out
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
return go(expr)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def branch_cut_jumps(
|
|
456
|
+
expr,
|
|
457
|
+
z: Symbol,
|
|
458
|
+
mode: str = "restricted",
|
|
459
|
+
assumptions=True,
|
|
460
|
+
max_per_expr: int = 2,
|
|
461
|
+
max_total: int = 128,
|
|
462
|
+
):
|
|
463
|
+
"""Convert ``branch_cut_jumps_pairs`` output into restricted expressions or Piecewise objects."""
|
|
464
|
+
if mode not in {"pairs", "restricted", "piecewise"}:
|
|
465
|
+
raise ValueError(
|
|
466
|
+
"branch_cut_jumps mode must be one of: 'pairs', 'restricted', 'piecewise'"
|
|
467
|
+
)
|
|
468
|
+
pairs = branch_cut_jumps_pairs(
|
|
469
|
+
expr, z, assumptions=assumptions, max_per_expr=max_per_expr, max_total=max_total
|
|
470
|
+
)
|
|
471
|
+
if mode == "pairs":
|
|
472
|
+
return pairs
|
|
473
|
+
if mode == "restricted":
|
|
474
|
+
return {k: pairs_to_restricted(v) for k, v in pairs.items()}
|
|
475
|
+
if mode == "piecewise":
|
|
476
|
+
return {
|
|
477
|
+
"Plus": pairs_to_piecewise_list(pairs["Plus"]),
|
|
478
|
+
"Minus": pairs_to_piecewise_list(pairs["Minus"]),
|
|
479
|
+
"Jump": pairs_to_piecewise_list(pairs["Jump"], default=S.Zero),
|
|
480
|
+
}
|
|
481
|
+
raise ValueError("mode must be one of: 'pairs', 'restricted', 'piecewise'")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _condition_holds_numeric(cond, z: Symbol, point) -> bool:
|
|
485
|
+
try:
|
|
486
|
+
sub = simplify(sympify(cond).subs(z, point))
|
|
487
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
488
|
+
return False
|
|
489
|
+
if sub is S.true or sub is True:
|
|
490
|
+
return True
|
|
491
|
+
if sub is S.false or sub is False:
|
|
492
|
+
return False
|
|
493
|
+
try:
|
|
494
|
+
return bool(sub)
|
|
495
|
+
except SYMBOLIC_QUERY_ERRORS:
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _condition_text_sample(cond, z: Symbol):
|
|
500
|
+
text = str(cond)
|
|
501
|
+
# Real-axis samples
|
|
502
|
+
if "Contains(" in text and "Reals" in text:
|
|
503
|
+
if f"{z} < -1" in text:
|
|
504
|
+
return -2, I
|
|
505
|
+
if f"{z} < 0" in text and f"{z} > -1" in text:
|
|
506
|
+
return S(-1) / 2, I
|
|
507
|
+
if f"{z} > 1" in text:
|
|
508
|
+
return 2, I
|
|
509
|
+
if f"{z} > 0" in text and f"{z} < 1" in text:
|
|
510
|
+
return S(1) / 2, I
|
|
511
|
+
if f"{z} > -1" in text and f"{z} < 1" in text:
|
|
512
|
+
return S.Zero, I
|
|
513
|
+
if f"{z} < 0" in text:
|
|
514
|
+
return -2, I
|
|
515
|
+
if f"{z} > 0" in text:
|
|
516
|
+
return 2, I
|
|
517
|
+
# Imaginary-axis samples
|
|
518
|
+
if f"Eq(re({z}), 0)" in text or f"re({z}) == 0" in text:
|
|
519
|
+
if f"im({z}) > 1" in text:
|
|
520
|
+
return 2 * I, S.One
|
|
521
|
+
if f"im({z}) < -1" in text:
|
|
522
|
+
return -2 * I, S.One
|
|
523
|
+
if "Abs(im(" in text and "< 1" in text:
|
|
524
|
+
return S.Half * I, S.One
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _candidate_samples_for_condition(cond, z: Symbol):
|
|
529
|
+
"""Construct deterministic seam-crossing samples for a branch condition.
|
|
530
|
+
|
|
531
|
+
Candidates come from recognizable geometric forms first, followed by a
|
|
532
|
+
small fixed set of symbolic probes. They are validation samples only and do
|
|
533
|
+
not determine the branch condition itself.
|
|
534
|
+
"""
|
|
535
|
+
text_sample = _condition_text_sample(cond, z)
|
|
536
|
+
candidates = []
|
|
537
|
+
if text_sample is not None:
|
|
538
|
+
candidates.append(text_sample)
|
|
539
|
+
|
|
540
|
+
text = str(cond)
|
|
541
|
+
# Elliptic amplitude seams and related phi-seams are typically crossed by changing Re(z)
|
|
542
|
+
# near pi/2 while holding Im(z) fixed.
|
|
543
|
+
if ("cos(" in text and f"{z}" in text) or ("sin(" in text and f"{z}" in text):
|
|
544
|
+
for yy in (S(1) / 4, S.Half, S.One, S(3) / 2):
|
|
545
|
+
candidates.extend(
|
|
546
|
+
[
|
|
547
|
+
(pi / 2 + I * yy, S.One),
|
|
548
|
+
(pi / 2 - I * yy, S.One),
|
|
549
|
+
]
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Generic fallback candidates for common cut geometries.
|
|
553
|
+
candidates.extend(
|
|
554
|
+
[
|
|
555
|
+
(-2, I),
|
|
556
|
+
(-S.Half, I),
|
|
557
|
+
(S.Half, I),
|
|
558
|
+
(2, I),
|
|
559
|
+
(2 * I, S.One),
|
|
560
|
+
(-2 * I, S.One),
|
|
561
|
+
(S.Half * I, S.One),
|
|
562
|
+
(-S.Half * I, S.One),
|
|
563
|
+
]
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
seen = set()
|
|
567
|
+
uniq = []
|
|
568
|
+
for point, normal in candidates:
|
|
569
|
+
key = (str(point), str(normal))
|
|
570
|
+
if key not in seen:
|
|
571
|
+
seen.add(key)
|
|
572
|
+
uniq.append((point, normal))
|
|
573
|
+
return uniq
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _sample_from_condition(cond, z: Symbol):
|
|
577
|
+
"""Find a representative point and normal for a branch condition.
|
|
578
|
+
|
|
579
|
+
This is heuristic. It now includes explicit support for the more complicated
|
|
580
|
+
seam predicates that appear in elliptic amplitude cases.
|
|
581
|
+
"""
|
|
582
|
+
for point, normal in _candidate_samples_for_condition(cond, z):
|
|
583
|
+
if _condition_holds_numeric(cond, z, point):
|
|
584
|
+
return point, normal
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def numeric_branch_cut_jumps(
|
|
589
|
+
expr, vars_list, wprec: int = 30, step_size=None, assumptions=True
|
|
590
|
+
):
|
|
591
|
+
"""Numerically sample jumps across branch cuts inferred from ``branch_cut_jumps_pairs``.
|
|
592
|
+
|
|
593
|
+
Currently this supports one complex variable. It handles common real-axis and
|
|
594
|
+
imaginary-axis cuts, and now also supports the more complicated seam predicates
|
|
595
|
+
that arise in elliptic amplitude cases by searching for representative sample
|
|
596
|
+
points near ``pi/2 + i y`` and crossing them in the real direction.
|
|
597
|
+
"""
|
|
598
|
+
if isinstance(vars_list, Symbol):
|
|
599
|
+
vars_list = [vars_list]
|
|
600
|
+
if len(vars_list) != 1:
|
|
601
|
+
raise NotImplementedError(
|
|
602
|
+
"numeric_branch_cut_jumps currently supports exactly one variable"
|
|
603
|
+
)
|
|
604
|
+
z = vars_list[0]
|
|
605
|
+
expr = sympify(expr)
|
|
606
|
+
step = sympify(step_size) if step_size is not None else S("1e-8")
|
|
607
|
+
|
|
608
|
+
pairs = branch_cut_jumps_pairs(expr, z, assumptions=assumptions)["Jump"]
|
|
609
|
+
out = []
|
|
610
|
+
for jump_expr, cond in clean_pairs(pairs):
|
|
611
|
+
if jump_expr == 0:
|
|
612
|
+
continue
|
|
613
|
+
sample = _sample_from_condition(cond, z)
|
|
614
|
+
if sample is None:
|
|
615
|
+
continue
|
|
616
|
+
point, normal = sample
|
|
617
|
+
f_plus = (expr.subs(z, point + step * normal)).evalf(wprec)
|
|
618
|
+
f_minus = (expr.subs(z, point - step * normal)).evalf(wprec)
|
|
619
|
+
out.append(
|
|
620
|
+
{
|
|
621
|
+
"BranchCondition": cond,
|
|
622
|
+
"SamplePoint": point,
|
|
623
|
+
"Normal": normal,
|
|
624
|
+
"fPlus": f_plus,
|
|
625
|
+
"fMinus": f_minus,
|
|
626
|
+
"Jump": (f_plus - f_minus).evalf(wprec),
|
|
627
|
+
"ExpectedJump": jump_expr.subs(z, point).evalf(wprec)
|
|
628
|
+
if jump_expr.has(z)
|
|
629
|
+
else jump_expr.evalf(wprec),
|
|
630
|
+
}
|
|
631
|
+
)
|
|
632
|
+
return out
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _restrict_pairs(pairs, cond, z, opts=None):
|
|
636
|
+
out = []
|
|
637
|
+
for expr, c in clean_pairs(pairs):
|
|
638
|
+
new_c = condition_simplify(And(c, cond), z, **(opts or {}))
|
|
639
|
+
if new_c is not False:
|
|
640
|
+
out.append((expr, new_c))
|
|
641
|
+
return clean_pairs(out)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _restricted_assoc(inner, cond, z, opts=None):
|
|
645
|
+
plus = _restrict_pairs(inner["Plus"], cond, z, opts=opts)
|
|
646
|
+
minus = _restrict_pairs(inner["Minus"], cond, z, opts=opts)
|
|
647
|
+
jump = _restrict_pairs(inner["Jump"], cond, z, opts=opts)
|
|
648
|
+
if not jump:
|
|
649
|
+
jump = [(0, cond)]
|
|
650
|
+
return ensure_neutral_assoc({"Plus": plus, "Minus": minus, "Jump": jump})
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _is_restricted_expr_instance(e):
|
|
654
|
+
return isinstance(e, RestrictedExpr) or (
|
|
655
|
+
getattr(getattr(e, "func", None), "__name__", "") == "RestrictedExpr"
|
|
656
|
+
and getattr(e, "args", None)
|
|
657
|
+
and len(e.args) == 2
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def ensure_neutral_assoc(assoc):
|
|
662
|
+
a = dict(assoc)
|
|
663
|
+
a["Jump"] = ensure_neutral_pairs(a.get("Jump", []))
|
|
664
|
+
return a
|