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,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