pycodemath 0.2.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.
- pycodemath/__init__.py +91 -0
- pycodemath/__main__.py +10 -0
- pycodemath/bench/__init__.py +0 -0
- pycodemath/bench/benchmark.py +61 -0
- pycodemath/cli/__init__.py +0 -0
- pycodemath/cli/mcp_server.py +82 -0
- pycodemath/cli/repl.py +620 -0
- pycodemath/codegen/__init__.py +0 -0
- pycodemath/codegen/emit.py +554 -0
- pycodemath/codegen/pipeline.py +139 -0
- pycodemath/core/__init__.py +0 -0
- pycodemath/core/errors.py +15 -0
- pycodemath/core/ir.py +307 -0
- pycodemath/engine/__init__.py +0 -0
- pycodemath/engine/linalg.py +197 -0
- pycodemath/engine/numerics.py +973 -0
- pycodemath/engine/ode.py +1279 -0
- pycodemath/engine/symbolic.py +204 -0
- pycodemath/frontend/__init__.py +0 -0
- pycodemath/frontend/parser.py +299 -0
- pycodemath-0.2.0.dist-info/METADATA +327 -0
- pycodemath-0.2.0.dist-info/RECORD +26 -0
- pycodemath-0.2.0.dist-info/WHEEL +5 -0
- pycodemath-0.2.0.dist-info/entry_points.txt +3 -0
- pycodemath-0.2.0.dist-info/licenses/LICENSE +21 -0
- pycodemath-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Symbolic engine (module 1) — algebra + analysis.
|
|
2
|
+
|
|
3
|
+
Operates on IR (``Expr``). Delegates to SymPy, but the API belongs to Pycodemath.
|
|
4
|
+
MVP: simplify, expand, derivative, integral, solve.
|
|
5
|
+
Module P2: limit, series, summation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sympy as sp
|
|
11
|
+
|
|
12
|
+
from ..core.errors import PycodemathError
|
|
13
|
+
from ..core.ir import Expr
|
|
14
|
+
from ..frontend.parser import parse
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def simplify(expr: Expr) -> Expr:
|
|
18
|
+
return expr.simplify()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def expand(expr: Expr) -> Expr:
|
|
22
|
+
return expr.expand()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def diff(expr: Expr, var: str) -> Expr:
|
|
26
|
+
return expr.diff(var)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def integrate(expr: Expr, var: str) -> Expr:
|
|
30
|
+
return expr.integrate(var)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_real_value(s: sp.Basic) -> bool:
|
|
34
|
+
"""Is the root real? Filters out junk complex roots that ``sympy.solve``
|
|
35
|
+
returns for transcendental equations (e.g. ``cos(x) = 2``). Resolves
|
|
36
|
+
three-valued: explicitly complex → reject, explicitly real → keep. When
|
|
37
|
+
SymPy cannot decide (``is_real`` = None), we check the imaginary part; if
|
|
38
|
+
that too is ambiguous, we KEEP the root — we do not pretend to know more
|
|
39
|
+
than the engine, and the filter only cuts obvious junk."""
|
|
40
|
+
if s.is_real is True:
|
|
41
|
+
return True
|
|
42
|
+
if s.is_real is False:
|
|
43
|
+
return False
|
|
44
|
+
im = sp.simplify(sp.im(s))
|
|
45
|
+
if im.is_zero is True:
|
|
46
|
+
return True
|
|
47
|
+
if im.is_zero is False:
|
|
48
|
+
return False
|
|
49
|
+
return True # ambiguous — we do not discard
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def solve(expr: Expr, var: str, real: bool = False) -> list[Expr]:
|
|
53
|
+
"""Solve the equation ``expr = 0`` for ``var``. Returns a list of roots.
|
|
54
|
+
|
|
55
|
+
``sympy.solve`` can be inconsistent in the shape of its result: usually a
|
|
56
|
+
list of roots, but sometimes a dict ``{var: value}`` or a list of dicts
|
|
57
|
+
(parametric solutions). We normalize these shapes into a list of values
|
|
58
|
+
for ``var``; a shape that cannot be interpreted as a root with respect to
|
|
59
|
+
``var`` (e.g. a tuple from a system solution) we do NOT let through as a
|
|
60
|
+
raw ``SympifyError`` from the ``Expr`` constructor — we return a
|
|
61
|
+
``PycodemathError``.
|
|
62
|
+
|
|
63
|
+
``real=True`` filters out complex roots (``_is_real_value``). Defaults to
|
|
64
|
+
``False`` (the full API also returns complex ones — no regression); the
|
|
65
|
+
REPL calls with ``real=True``, because transcendental equations returned
|
|
66
|
+
junk complex roots there. We filter AFTER solving, on the same
|
|
67
|
+
(non-real) symbol as the expression — otherwise ``sp.solve`` would look
|
|
68
|
+
for a different symbol.
|
|
69
|
+
"""
|
|
70
|
+
sym = sp.Symbol(var)
|
|
71
|
+
try:
|
|
72
|
+
raw = sp.solve(expr.sy, sym)
|
|
73
|
+
except (
|
|
74
|
+
NotImplementedError,
|
|
75
|
+
sp.PolynomialError,
|
|
76
|
+
sp.SympifyError,
|
|
77
|
+
ValueError,
|
|
78
|
+
AttributeError,
|
|
79
|
+
) as exc:
|
|
80
|
+
raise PycodemathError(
|
|
81
|
+
f"solve: cannot solve the equation for '{var}': {exc}"
|
|
82
|
+
) from exc
|
|
83
|
+
if isinstance(raw, dict): # single solution as a map {sym: value}
|
|
84
|
+
raw = [raw]
|
|
85
|
+
|
|
86
|
+
roots: list[Expr] = []
|
|
87
|
+
for r in raw:
|
|
88
|
+
if isinstance(r, dict): # list of maps (parametric solutions)
|
|
89
|
+
if sym in r:
|
|
90
|
+
roots.append(Expr(r[sym]))
|
|
91
|
+
# a map without our variable carries no root for var — we skip it
|
|
92
|
+
elif isinstance(r, (tuple, list)): # system tuple — not for a single variable
|
|
93
|
+
raise PycodemathError(
|
|
94
|
+
f"solve: unexpected solution shape for '{var}' "
|
|
95
|
+
f"(parametric/system result) — use solve_nd for systems"
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
roots.append(Expr(r))
|
|
99
|
+
if real:
|
|
100
|
+
roots = [rt for rt in roots if _is_real_value(rt.sy)]
|
|
101
|
+
return roots
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- module P2: limit / series / summation ---------------------------------
|
|
105
|
+
def _point(value: "Expr | str | int | float") -> sp.Basic:
|
|
106
|
+
"""Point/interval bound: Expr, text (via the parser — the same whitelist
|
|
107
|
+
as the whole input) or a number. We let nothing else through, so as not to
|
|
108
|
+
open a side door into sympify with the full namespace."""
|
|
109
|
+
if isinstance(value, Expr):
|
|
110
|
+
return value.sy
|
|
111
|
+
if isinstance(value, str):
|
|
112
|
+
return parse(value).sy
|
|
113
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
114
|
+
return sp.sympify(value)
|
|
115
|
+
raise PycodemathError(f"invalid point/bound: {value!r}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def limit(
|
|
119
|
+
expr: Expr, var: str, to: "Expr | str | int | float", dir: str = "+"
|
|
120
|
+
) -> Expr:
|
|
121
|
+
"""Limit of ``expr`` as ``var → to`` (``to`` also accepts ``oo``/``-oo``).
|
|
122
|
+
|
|
123
|
+
``dir``: "+" (from the right, default) or "-" (from the left). A
|
|
124
|
+
nonexistent limit (oscillation → AccumBounds) or an undefined one (nan/zoo,
|
|
125
|
+
an unevaluated ``Limit``) is a clear refusal, not a raw SymPy object.
|
|
126
|
+
"""
|
|
127
|
+
if dir not in ("+", "-"):
|
|
128
|
+
raise PycodemathError(f"limit: dir must be '+' or '-', not {dir!r}")
|
|
129
|
+
sym = sp.Symbol(var)
|
|
130
|
+
point = _point(to)
|
|
131
|
+
try:
|
|
132
|
+
res = sp.limit(expr.sy, sym, point, dir)
|
|
133
|
+
except (sp.PoleError, NotImplementedError, ValueError) as exc:
|
|
134
|
+
raise PycodemathError(
|
|
135
|
+
f"limit: cannot determine the limit at {point}: {exc}"
|
|
136
|
+
) from exc
|
|
137
|
+
if isinstance(res, sp.AccumBounds):
|
|
138
|
+
raise PycodemathError(
|
|
139
|
+
f"limit: the limit at {point} does not exist (the expression oscillates)"
|
|
140
|
+
)
|
|
141
|
+
if res.has(sp.Limit) or res.has(sp.nan) or res.has(sp.zoo):
|
|
142
|
+
raise PycodemathError(f"limit: the limit at {point} is undefined")
|
|
143
|
+
return Expr(res)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def series(
|
|
147
|
+
expr: Expr, var: str, at: "Expr | str | int | float" = 0, n: int = 6
|
|
148
|
+
) -> Expr:
|
|
149
|
+
"""Series expansion (Taylor/Laurent) around ``at`` up to order ``n``.
|
|
150
|
+
|
|
151
|
+
Returns a polynomial WITHOUT the ``O(...)`` term (``removeO``) — a
|
|
152
|
+
deliberate decision: the result is an ordinary ``Expr`` that round-trips
|
|
153
|
+
through the parser (``O`` is not in the whitelist and makes no sense
|
|
154
|
+
outside the series context). The truncation order is documented by the
|
|
155
|
+
parameter ``n``, not the result.
|
|
156
|
+
"""
|
|
157
|
+
if not isinstance(n, int) or n < 1:
|
|
158
|
+
raise PycodemathError(
|
|
159
|
+
f"series: n must be a positive integer, not {n!r}"
|
|
160
|
+
)
|
|
161
|
+
sym = sp.Symbol(var)
|
|
162
|
+
point = _point(at)
|
|
163
|
+
try:
|
|
164
|
+
res = expr.sy.series(sym, point, n).removeO()
|
|
165
|
+
except (sp.PoleError, NotImplementedError, ValueError) as exc:
|
|
166
|
+
raise PycodemathError(
|
|
167
|
+
f"series: cannot expand around {point}: {exc}"
|
|
168
|
+
) from exc
|
|
169
|
+
return Expr(res)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def summation(
|
|
173
|
+
expr: Expr,
|
|
174
|
+
var: str,
|
|
175
|
+
lo: "Expr | str | int | float",
|
|
176
|
+
hi: "Expr | str | int | float",
|
|
177
|
+
) -> Expr:
|
|
178
|
+
"""Symbolic summation of ``expr`` over ``var`` from ``lo`` to ``hi``.
|
|
179
|
+
|
|
180
|
+
The bounds may be numbers, symbols (``n``) or ``oo``. A divergent sum or
|
|
181
|
+
one without a closed form is a ``PycodemathError`` with a hint, not
|
|
182
|
+
``oo``/an unevaluated ``Sum`` in the result (contract: zero junk).
|
|
183
|
+
"""
|
|
184
|
+
sym = sp.Symbol(var)
|
|
185
|
+
a, b = _point(lo), _point(hi)
|
|
186
|
+
try:
|
|
187
|
+
res = sp.summation(expr.sy, (sym, a, b))
|
|
188
|
+
except (NotImplementedError, ValueError) as exc:
|
|
189
|
+
raise PycodemathError(f"summation: cannot compute the sum: {exc}") from exc
|
|
190
|
+
# First the no-closed-form case: an unevaluated Sum with an oo bound
|
|
191
|
+
# CARRIES oo inside, so the divergence check would catch it with a
|
|
192
|
+
# misleading message (the sum may be convergent, just without a closed form).
|
|
193
|
+
if res.has(sp.Sum):
|
|
194
|
+
raise PycodemathError(
|
|
195
|
+
"summation: no closed form — try finite bounds "
|
|
196
|
+
"(partial sum) or compute numerically"
|
|
197
|
+
)
|
|
198
|
+
if isinstance(res, sp.AccumBounds) or res.has(
|
|
199
|
+
sp.oo, -sp.oo, sp.zoo, sp.nan
|
|
200
|
+
):
|
|
201
|
+
raise PycodemathError(
|
|
202
|
+
"summation: divergent sum — narrow the bounds to finite ones"
|
|
203
|
+
)
|
|
204
|
+
return Expr(res)
|
|
File without changes
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Parser — natural math notation → IR.
|
|
2
|
+
|
|
3
|
+
A thin layer over ``sympy.parsing``. Accepts standard mathematical
|
|
4
|
+
notation (``sin(x)*x + 2^3``) and returns an ``Expr`` from the IR core.
|
|
5
|
+
Guarantees round-trip: ``parse(str(parse(s))) == parse(s)``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import token as _token
|
|
12
|
+
from tokenize import TokenError
|
|
13
|
+
|
|
14
|
+
import sympy as sp
|
|
15
|
+
from sympy import SympifyError
|
|
16
|
+
from sympy.parsing.sympy_parser import (
|
|
17
|
+
convert_xor,
|
|
18
|
+
implicit_multiplication_application,
|
|
19
|
+
parse_expr,
|
|
20
|
+
standard_transformations,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from ..core.errors import PycodemathError
|
|
24
|
+
from ..core.ir import Expr
|
|
25
|
+
|
|
26
|
+
# Tokens that make no sense in math notation but open up an eval surface:
|
|
27
|
+
# STRING/f-string (a string result would land in sympify with the FULL namespace) and
|
|
28
|
+
# OP "." = attribute access (x.__class__ etc.; fractions like 1.5 are NUMBER,
|
|
29
|
+
# so a decimal point does not fall in here).
|
|
30
|
+
_DENIED_TOKEN_TYPES = frozenset(
|
|
31
|
+
t
|
|
32
|
+
for t in (
|
|
33
|
+
_token.STRING,
|
|
34
|
+
getattr(_token, "FSTRING_START", None), # Python 3.12+
|
|
35
|
+
)
|
|
36
|
+
if t is not None
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _deny_unsafe_tokens(tokens, local_dict, global_dict):
|
|
41
|
+
"""Guard transformation: rejects tokens outside math notation."""
|
|
42
|
+
for tok_type, tok_val in tokens:
|
|
43
|
+
if tok_type in _DENIED_TOKEN_TYPES:
|
|
44
|
+
raise PycodemathError(
|
|
45
|
+
f"text literals are not a mathematical expression: {tok_val!r}"
|
|
46
|
+
)
|
|
47
|
+
if tok_type == _token.OP and tok_val == ".":
|
|
48
|
+
raise PycodemathError(
|
|
49
|
+
"attribute access (dot) is not mathematical notation"
|
|
50
|
+
)
|
|
51
|
+
return tokens
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Token guard BEFORE everything, then ^ as exponentiation + implicit
|
|
55
|
+
# multiplication (2x -> 2*x), as in math notation.
|
|
56
|
+
_TRANSFORMS = (_deny_unsafe_tokens,) + standard_transformations + (
|
|
57
|
+
convert_xor,
|
|
58
|
+
implicit_multiplication_application,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Parser whitelist: the ONLY names that resolve to SymPy objects.
|
|
62
|
+
# Everything off the list becomes a Symbol/undefined function (auto_symbol),
|
|
63
|
+
# so __import__/eval/open are not reachable at all from the input text.
|
|
64
|
+
_ALLOWED_FUNCTIONS = (
|
|
65
|
+
# trigonometry + inverses
|
|
66
|
+
"sin", "cos", "tan", "cot", "sec", "csc",
|
|
67
|
+
"asin", "acos", "atan", "acot", "asec", "acsc", "atan2",
|
|
68
|
+
# hyperbolic + inverses
|
|
69
|
+
"sinh", "cosh", "tanh", "coth", "sech", "csch",
|
|
70
|
+
"asinh", "acosh", "atanh", "acoth",
|
|
71
|
+
# exponential / power
|
|
72
|
+
"exp", "log", "sqrt", "cbrt", "root",
|
|
73
|
+
# piecewise / discrete
|
|
74
|
+
"Abs", "sign", "floor", "ceiling",
|
|
75
|
+
"Min", "Max", "gamma", "factorial", "binomial",
|
|
76
|
+
)
|
|
77
|
+
_LOCAL_DICT: dict[str, object] = {
|
|
78
|
+
name: getattr(sp, name) for name in _ALLOWED_FUNCTIONS
|
|
79
|
+
}
|
|
80
|
+
_LOCAL_DICT.update(
|
|
81
|
+
{
|
|
82
|
+
# aliases for common notation (previously captured by Python builtins)
|
|
83
|
+
"ln": sp.log,
|
|
84
|
+
"abs": sp.Abs,
|
|
85
|
+
"min": sp.Min,
|
|
86
|
+
"max": sp.Max,
|
|
87
|
+
# constants
|
|
88
|
+
"pi": sp.pi,
|
|
89
|
+
"E": sp.E,
|
|
90
|
+
"I": sp.I,
|
|
91
|
+
"oo": sp.oo,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# global_dict passed EXPLICITLY: without it parse_expr does `from sympy import *`
|
|
96
|
+
# and adds Python builtins (including __import__). Here only the constructors
|
|
97
|
+
# that the token transformations emit (auto_symbol/auto_number/…), plus
|
|
98
|
+
# an empty __builtins__ so eval does not get the real builtins.
|
|
99
|
+
_GLOBAL_DICT: dict[str, object] = {
|
|
100
|
+
"Symbol": sp.Symbol,
|
|
101
|
+
"Function": sp.Function,
|
|
102
|
+
"Integer": sp.Integer,
|
|
103
|
+
"Float": sp.Float,
|
|
104
|
+
"Rational": sp.Rational,
|
|
105
|
+
"__builtins__": {},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# --- Evaluation cost guard (DoS) -------------------------------------
|
|
109
|
+
# The math whitelist allows exponentiation and factorial/gamma/binomial — and on
|
|
110
|
+
# CONCRETE numbers these can build a gigantic int: ``9**9**9`` is
|
|
111
|
+
# ~1.2 billion bits, ``factorial(10**8)`` likewise. This is reachable directly from the
|
|
112
|
+
# MCP server (``math_eval``), so a single line would hang/OOM the host. SymPy
|
|
113
|
+
# computes such things eagerly under ``evaluate=True`` — that is why we FIRST
|
|
114
|
+
# parse the same syntax WITHOUT evaluation and with "blind" functions (so the
|
|
115
|
+
# guard itself computes nothing), then estimate the cost and refuse in advance.
|
|
116
|
+
|
|
117
|
+
#: Upper bit limit for a CONCRETE integer produced by evaluation.
|
|
118
|
+
#: ~200k bits ≈ 60k digits — generous for real math, yet cuts off DoS.
|
|
119
|
+
_MAX_RESULT_BITS = 200_000
|
|
120
|
+
#: An exponent/argument with more bits than this WILL surely blow up the result.
|
|
121
|
+
_SAFE_EXPONENT_BITS = 64
|
|
122
|
+
#: The largest factorial/gamma/binomial argument (n! grows ~n·log2(n)).
|
|
123
|
+
_MAX_FACTORIAL_ARG = 10_000
|
|
124
|
+
#: Functions whose CONCRETE integer argument alone blows up the result.
|
|
125
|
+
_COST_FUNCS = frozenset({"factorial", "gamma", "binomial"})
|
|
126
|
+
_LN2 = math.log(2)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _CostExceeded(Exception):
|
|
130
|
+
"""Internal signal: evaluation would build a number that is too large."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _int_value_bounded(node: sp.Basic) -> int | None:
|
|
134
|
+
"""Exact integer value of a node built EXCLUSIVELY from numbers (powers,
|
|
135
|
+
products, sums, factorial/gamma/binomial), or ``None`` when the node contains
|
|
136
|
+
symbols/floats/other functions. Raises ``_CostExceeded`` BEFORE computing anything
|
|
137
|
+
whose bit count would exceed ``_MAX_RESULT_BITS`` — never materializes
|
|
138
|
+
a huge int.
|
|
139
|
+
"""
|
|
140
|
+
if node.is_Integer:
|
|
141
|
+
return int(node)
|
|
142
|
+
if isinstance(node, sp.Pow):
|
|
143
|
+
base = _int_value_bounded(node.base)
|
|
144
|
+
exp = _int_value_bounded(node.exp)
|
|
145
|
+
if base is None or exp is None or exp < 0:
|
|
146
|
+
return None # symbolic or negative exp. (Rational) — not a "big int"
|
|
147
|
+
if abs(base) <= 1:
|
|
148
|
+
return base**exp # 0/1/-1 to any power: negligible cost
|
|
149
|
+
# estimate the RESULT bits before computing base**exp
|
|
150
|
+
if exp > _MAX_RESULT_BITS or exp * abs(base).bit_length() > _MAX_RESULT_BITS:
|
|
151
|
+
raise _CostExceeded(f"power {node} exceeds {_MAX_RESULT_BITS} bits")
|
|
152
|
+
return base**exp
|
|
153
|
+
if isinstance(node, sp.Mul):
|
|
154
|
+
prod, bits = 1, 0
|
|
155
|
+
for arg in node.args:
|
|
156
|
+
val = _int_value_bounded(arg)
|
|
157
|
+
if val is None:
|
|
158
|
+
return None
|
|
159
|
+
bits += abs(val).bit_length() or 1
|
|
160
|
+
if bits > _MAX_RESULT_BITS:
|
|
161
|
+
raise _CostExceeded(f"product {node} exceeds {_MAX_RESULT_BITS} bits")
|
|
162
|
+
prod *= val
|
|
163
|
+
return prod
|
|
164
|
+
if isinstance(node, sp.Add):
|
|
165
|
+
total, bits = 0, 0
|
|
166
|
+
for arg in node.args:
|
|
167
|
+
val = _int_value_bounded(arg)
|
|
168
|
+
if val is None:
|
|
169
|
+
return None
|
|
170
|
+
bits = max(bits, abs(val).bit_length())
|
|
171
|
+
if bits > _MAX_RESULT_BITS:
|
|
172
|
+
raise _CostExceeded(f"sum {node} exceeds {_MAX_RESULT_BITS} bits")
|
|
173
|
+
total += val
|
|
174
|
+
return total
|
|
175
|
+
if isinstance(node, sp.Function):
|
|
176
|
+
name = type(node).__name__
|
|
177
|
+
if name in ("factorial", "gamma") and len(node.args) == 1:
|
|
178
|
+
arg = _int_value_bounded(node.args[0])
|
|
179
|
+
if arg is None:
|
|
180
|
+
return None
|
|
181
|
+
n = arg - 1 if name == "gamma" else arg # gamma(n) = (n-1)!
|
|
182
|
+
if n < 0:
|
|
183
|
+
return None
|
|
184
|
+
if n > _MAX_FACTORIAL_ARG:
|
|
185
|
+
raise _CostExceeded(f"{name}({arg}) — argument > {_MAX_FACTORIAL_ARG}")
|
|
186
|
+
return math.factorial(n)
|
|
187
|
+
if name == "binomial" and len(node.args) == 2:
|
|
188
|
+
top = _int_value_bounded(node.args[0])
|
|
189
|
+
bottom = _int_value_bounded(node.args[1])
|
|
190
|
+
if top is None or bottom is None:
|
|
191
|
+
return None
|
|
192
|
+
if abs(top) > _MAX_FACTORIAL_ARG:
|
|
193
|
+
raise _CostExceeded(f"binomial({top}, …) — argument > {_MAX_FACTORIAL_ARG}")
|
|
194
|
+
return math.comb(top, bottom) if 0 <= bottom <= top else None
|
|
195
|
+
return None # Symbol/Float/non-integer Rational/other function → not a "big int"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _check_cost(node: sp.Basic) -> None:
|
|
199
|
+
"""Walk the tree and reject any CONCRETE subexpression whose evaluation
|
|
200
|
+
would build a number that is too large. ``_int_value_bounded`` closes over the whole
|
|
201
|
+
subgraph built from numbers (raising ``_CostExceeded``); for subgraphs with symbols
|
|
202
|
+
we descend deeper to catch a dangerous nested fragment (e.g. in ``sin(9**9**9)``).
|
|
203
|
+
"""
|
|
204
|
+
if _int_value_bounded(node) is not None:
|
|
205
|
+
return
|
|
206
|
+
for arg in node.args:
|
|
207
|
+
_check_cost(arg)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# The guard parses the SAME syntax but without evaluation and with dummy functions
|
|
211
|
+
# (computes nothing). Constants become plain symbols (their value does not affect
|
|
212
|
+
# the cost). The Pow/Mul/Add constructors are needed because ``evaluate=False`` emits
|
|
213
|
+
# explicit constructor calls.
|
|
214
|
+
_GUARD_FUNC_NAMES = set(_ALLOWED_FUNCTIONS) | {"ln", "abs", "min", "max"}
|
|
215
|
+
_GUARD_LOCAL_DICT: dict[str, object] = {name: sp.Function(name) for name in _GUARD_FUNC_NAMES}
|
|
216
|
+
_GUARD_LOCAL_DICT.update({c: sp.Symbol(c) for c in ("pi", "E", "I", "oo")})
|
|
217
|
+
_GUARD_GLOBAL_DICT: dict[str, object] = {
|
|
218
|
+
**_GLOBAL_DICT,
|
|
219
|
+
"Pow": sp.Pow,
|
|
220
|
+
"Mul": sp.Mul,
|
|
221
|
+
"Add": sp.Add,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _guard_cost(source: str) -> None:
|
|
226
|
+
"""Refuse when evaluating ``source`` would build a gigantic number.
|
|
227
|
+
|
|
228
|
+
Parses without evaluation and with dummy functions, so the guard itself
|
|
229
|
+
computes nothing; the real (costly) ``parse_expr`` runs only after this check.
|
|
230
|
+
Structurally invalid input we let through — the real parse will report it.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
tree = parse_expr(
|
|
234
|
+
source,
|
|
235
|
+
local_dict=dict(_GUARD_LOCAL_DICT),
|
|
236
|
+
global_dict=dict(_GUARD_GLOBAL_DICT),
|
|
237
|
+
transformations=_TRANSFORMS,
|
|
238
|
+
evaluate=False,
|
|
239
|
+
)
|
|
240
|
+
except Exception: # noqa: BLE001 — syntax/name error: the real parse handles it
|
|
241
|
+
return
|
|
242
|
+
_check_cost(tree)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def parse(source: str) -> Expr:
|
|
246
|
+
"""Parse mathematical text into IR.
|
|
247
|
+
|
|
248
|
+
A syntax error yields a readable ``PycodemathError`` (not a raw
|
|
249
|
+
``SympifyError``/``SyntaxError``) — the REPL shows a concise message.
|
|
250
|
+
|
|
251
|
+
Security note: the parser resolves ONLY the explicit whitelist of math
|
|
252
|
+
functions and constants (``_LOCAL_DICT``); unknown names become
|
|
253
|
+
symbols, not Python code — ``__import__``/``eval``/``open`` are not
|
|
254
|
+
resolvable, text literals and attribute access are rejected
|
|
255
|
+
at the token level. Honest boundary: this raises the bar (no
|
|
256
|
+
builtin is reachable from text), but evaluation still runs
|
|
257
|
+
through SymPy in this process — this is not a whole-process sandbox.
|
|
258
|
+
|
|
259
|
+
Unknown names: single-letter ``f(x)`` reads as multiplication ``f*x``,
|
|
260
|
+
multi-letter ``foo(x)`` breaks up into a product of letters (``split_symbols``
|
|
261
|
+
from ``implicit_multiplication_application`` — the price of ``2x`` notation);
|
|
262
|
+
names with ``_`` (e.g. ``x_1``) stay a single symbol. None of these
|
|
263
|
+
paths calls Python code.
|
|
264
|
+
Building ``Expr`` is in the same ``try`` because ``sympify`` in
|
|
265
|
+
``Expr.__init__`` can also raise ``SympifyError`` on the parser's output.
|
|
266
|
+
|
|
267
|
+
Evaluation cost is bounded: expressions that would build a huge
|
|
268
|
+
number (``9**9**9``, ``factorial(10**8)``) are rejected BEFORE SymPy
|
|
269
|
+
computes them — otherwise a single line (reachable directly from the MCP server) could
|
|
270
|
+
hang or OOM the process.
|
|
271
|
+
"""
|
|
272
|
+
try:
|
|
273
|
+
_guard_cost(source)
|
|
274
|
+
except _CostExceeded as exc:
|
|
275
|
+
raise PycodemathError(
|
|
276
|
+
f"expression {source!r} is too costly to compute "
|
|
277
|
+
f"({exc}) — the result would exceed the safe size"
|
|
278
|
+
) from exc
|
|
279
|
+
try:
|
|
280
|
+
sy = parse_expr(
|
|
281
|
+
source,
|
|
282
|
+
local_dict=dict(_LOCAL_DICT),
|
|
283
|
+
global_dict=dict(_GLOBAL_DICT),
|
|
284
|
+
transformations=_TRANSFORMS,
|
|
285
|
+
evaluate=True,
|
|
286
|
+
)
|
|
287
|
+
return Expr(sy)
|
|
288
|
+
# NameError: notation emitting a name outside _GLOBAL_DICT (e.g. lambda);
|
|
289
|
+
# AttributeError: attribute access (x.y) — both are a refusal, not a crash.
|
|
290
|
+
except (
|
|
291
|
+
SympifyError,
|
|
292
|
+
SyntaxError,
|
|
293
|
+
TokenError,
|
|
294
|
+
TypeError,
|
|
295
|
+
ValueError,
|
|
296
|
+
NameError,
|
|
297
|
+
AttributeError,
|
|
298
|
+
) as exc:
|
|
299
|
+
raise PycodemathError(f"cannot understand expression {source!r}: {exc}") from exc
|