calcbox 0.3.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.
- calcbox/__init__.py +16 -0
- calcbox/api.py +52 -0
- calcbox/backends.py +586 -0
- calcbox/cli.py +45 -0
- calcbox/contract.py +118 -0
- calcbox/mcp_server.py +87 -0
- calcbox/sandbox.py +78 -0
- calcbox/worker.py +49 -0
- calcbox-0.3.0.dist-info/METADATA +171 -0
- calcbox-0.3.0.dist-info/RECORD +14 -0
- calcbox-0.3.0.dist-info/WHEEL +5 -0
- calcbox-0.3.0.dist-info/entry_points.txt +3 -0
- calcbox-0.3.0.dist-info/licenses/LICENSE +21 -0
- calcbox-0.3.0.dist-info/top_level.txt +1 -0
calcbox/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""calcbox — backend-agnostic compute kernel.
|
|
2
|
+
|
|
3
|
+
from calcbox import compute, backends
|
|
4
|
+
compute("x**2", "integrate") # -> dict, see calcbox.contract
|
|
5
|
+
backend_status() # -> {"sympy": {"available": True, ...}}
|
|
6
|
+
|
|
7
|
+
Every answer carries provenance: backend, engine version, assumptions,
|
|
8
|
+
verification and an input hash. The sandbox is audit-only; see README.
|
|
9
|
+
"""
|
|
10
|
+
from .backends import backend_status, compute
|
|
11
|
+
from .contract import (BackendStatus, BackendUnavailable, ComputeError,
|
|
12
|
+
ComputeRequest, ComputeResult)
|
|
13
|
+
|
|
14
|
+
__version__ = "0.3.0"
|
|
15
|
+
__all__ = ["compute", "backend_status", "ComputeError", "BackendUnavailable",
|
|
16
|
+
"ComputeRequest", "ComputeResult", "BackendStatus", "__version__"]
|
calcbox/api.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Optional HTTP surface.
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from calcbox.api import create_router
|
|
5
|
+
|
|
6
|
+
app = FastAPI()
|
|
7
|
+
app.include_router(create_router())
|
|
8
|
+
|
|
9
|
+
The routes mirror the contract exactly; they carry no authentication, because
|
|
10
|
+
that is the host's job (calcbox has no notion of users).
|
|
11
|
+
"""
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from . import backends as _backends
|
|
15
|
+
from .contract import BackendUnavailable, ComputeError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_router():
|
|
19
|
+
from fastapi import APIRouter, HTTPException
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
router = APIRouter()
|
|
23
|
+
|
|
24
|
+
class ComputeIn(BaseModel):
|
|
25
|
+
expression: str
|
|
26
|
+
operation: str = "simplify"
|
|
27
|
+
variable: str = "x"
|
|
28
|
+
point: str = "0"
|
|
29
|
+
target: Optional[str] = None
|
|
30
|
+
data: Optional[Dict[str, Any]] = None
|
|
31
|
+
timeout: float = 8.0
|
|
32
|
+
backend: str = "auto"
|
|
33
|
+
|
|
34
|
+
@router.get("/v1/compute/backends")
|
|
35
|
+
def get_backends():
|
|
36
|
+
return {"backends": _backends.backend_status()}
|
|
37
|
+
|
|
38
|
+
@router.post("/v1/compute")
|
|
39
|
+
def post_compute(body: ComputeIn):
|
|
40
|
+
try:
|
|
41
|
+
return _backends.compute(body.expression, operation=body.operation,
|
|
42
|
+
variable=body.variable, point=body.point,
|
|
43
|
+
target=body.target, data=body.data,
|
|
44
|
+
timeout=body.timeout, backend=body.backend)
|
|
45
|
+
except BackendUnavailable as e:
|
|
46
|
+
raise HTTPException(status_code=503, detail={"code": "backend_unavailable",
|
|
47
|
+
"detail": str(e)})
|
|
48
|
+
except ComputeError as e:
|
|
49
|
+
raise HTTPException(status_code=400, detail={"code": "bad_request",
|
|
50
|
+
"detail": str(e)})
|
|
51
|
+
|
|
52
|
+
return router
|
calcbox/backends.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""Backend registry and dispatch.
|
|
2
|
+
|
|
3
|
+
A backend is "available" only when its dependency imports *and* (for external
|
|
4
|
+
engines) it is explicitly enabled. Nothing here knows about teaching, citations
|
|
5
|
+
or documents: it is a calculator with provenance.
|
|
6
|
+
|
|
7
|
+
0.1.0 ports the SymPy path from socratic-tutor unchanged; SciPy, Pint, Control
|
|
8
|
+
and the external engines answer BackendUnavailable until stage 2.
|
|
9
|
+
"""
|
|
10
|
+
import hashlib
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
21
|
+
|
|
22
|
+
from .contract import (BACKENDS, OPS_CONTROL, OPS_EXTERNAL, OPS_NUMERIC,
|
|
23
|
+
OPS_SYMBOLIC, OPS_UNITS, BackendStatus, BackendUnavailable,
|
|
24
|
+
ComputeError, ComputeRequest, ComputeResult)
|
|
25
|
+
|
|
26
|
+
# External engines are off unless the operator opts in: they execute code
|
|
27
|
+
# outside Python's audit hooks and their licences are not ours to redistribute.
|
|
28
|
+
EXTERNAL_ENV = "CALCBOX_EXTERNAL"
|
|
29
|
+
LEGACY_EXTERNAL_ENV = "SOCRATIC_COMPUTE_EXTERNAL"
|
|
30
|
+
|
|
31
|
+
_POOL = ThreadPoolExecutor(max_workers=2)
|
|
32
|
+
|
|
33
|
+
# 1 (default): run SymPy in a killable subprocess. 0: in-process, for debugging.
|
|
34
|
+
WORKER_ENV = "CALCBOX_WORKER"
|
|
35
|
+
|
|
36
|
+
# Refused outright: an expression is data, never code.
|
|
37
|
+
_BAD = ("__", "import", "open(", "eval(", "exec(", "lambda", "os.", "sys.",
|
|
38
|
+
"subprocess", "socket", "ctypes")
|
|
39
|
+
|
|
40
|
+
OPS_BY_BACKEND = {
|
|
41
|
+
"sympy": sorted(OPS_SYMBOLIC),
|
|
42
|
+
"scipy": sorted(OPS_NUMERIC),
|
|
43
|
+
"pint": sorted(OPS_UNITS),
|
|
44
|
+
"control": sorted(OPS_CONTROL),
|
|
45
|
+
"wolfram": ["eval"],
|
|
46
|
+
"lean": ["check"],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _available(name):
|
|
51
|
+
"""Availability without importing: importing scipy/control pulls matplotlib,
|
|
52
|
+
which is slow and writes caches we do not want during a status call."""
|
|
53
|
+
try:
|
|
54
|
+
return importlib.util.find_spec(name) is not None
|
|
55
|
+
except (ImportError, ValueError):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _version(dist):
|
|
60
|
+
try:
|
|
61
|
+
from importlib.metadata import version
|
|
62
|
+
return version(dist)
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _external_enabled():
|
|
68
|
+
for key in (EXTERNAL_ENV, LEGACY_EXTERNAL_ENV):
|
|
69
|
+
if key in os.environ:
|
|
70
|
+
return str(os.environ[key]).lower() in ("1", "true", "yes")
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _which(names):
|
|
75
|
+
for n in names:
|
|
76
|
+
p = shutil.which(n)
|
|
77
|
+
if p:
|
|
78
|
+
return p
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def backend_status():
|
|
83
|
+
"""Availability of every backend, including why one is unavailable."""
|
|
84
|
+
import sympy as sp
|
|
85
|
+
|
|
86
|
+
sympy = True
|
|
87
|
+
scipy = _available("scipy")
|
|
88
|
+
pint = _available("pint")
|
|
89
|
+
control = _available("control")
|
|
90
|
+
external = _external_enabled()
|
|
91
|
+
wolfram = _which(("wolframscript", "WolframKernel"))
|
|
92
|
+
lean = _which(("lean", "lake"))
|
|
93
|
+
|
|
94
|
+
status = {
|
|
95
|
+
"sympy": BackendStatus(sympy, str(getattr(sp, "__version__", "?")),
|
|
96
|
+
None if sympy else "sympy not installed"),
|
|
97
|
+
"scipy": BackendStatus(scipy, _version("scipy"),
|
|
98
|
+
None if scipy else "scipy not installed"),
|
|
99
|
+
"pint": BackendStatus(pint, _version("pint"),
|
|
100
|
+
None if pint else "pint not installed"),
|
|
101
|
+
"control": BackendStatus(control, _version("control"),
|
|
102
|
+
None if control else "control not installed"),
|
|
103
|
+
"wolfram": BackendStatus(
|
|
104
|
+
bool(wolfram and external), None,
|
|
105
|
+
None if (wolfram and external)
|
|
106
|
+
else ("external backends disabled; set %s=1" % EXTERNAL_ENV if not external
|
|
107
|
+
else "wolframscript / WolframKernel not found")),
|
|
108
|
+
"lean": BackendStatus(
|
|
109
|
+
bool(lean and external), None,
|
|
110
|
+
None if (lean and external)
|
|
111
|
+
else ("external backends disabled; set %s=1" % EXTERNAL_ENV if not external
|
|
112
|
+
else "lean / lake not found")),
|
|
113
|
+
}
|
|
114
|
+
out = {}
|
|
115
|
+
for name in BACKENDS:
|
|
116
|
+
payload = status[name].to_dict()
|
|
117
|
+
payload["operations"] = OPS_BY_BACKEND[name]
|
|
118
|
+
out[name] = payload
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def infer_backend(operation):
|
|
123
|
+
op = (operation or "").lower()
|
|
124
|
+
if op in OPS_SYMBOLIC:
|
|
125
|
+
return "sympy"
|
|
126
|
+
if op in OPS_NUMERIC:
|
|
127
|
+
return "scipy"
|
|
128
|
+
if op in OPS_UNITS:
|
|
129
|
+
return "pint"
|
|
130
|
+
if op in OPS_CONTROL:
|
|
131
|
+
return "control"
|
|
132
|
+
if op in OPS_EXTERNAL:
|
|
133
|
+
return "wolfram" if op == "eval" else "lean"
|
|
134
|
+
raise ComputeError("cannot infer a backend for operation %r" % operation)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _validate(expression, length=300):
|
|
138
|
+
expression = (expression or "").strip()
|
|
139
|
+
if not expression or len(expression) > length:
|
|
140
|
+
raise ComputeError("expression must be 1..%d chars" % length)
|
|
141
|
+
low = expression.lower()
|
|
142
|
+
if any(b in low for b in _BAD):
|
|
143
|
+
raise ComputeError("expression contains a forbidden token")
|
|
144
|
+
return expression
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _locals(var):
|
|
148
|
+
import sympy as sp
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
var: sp.Symbol(var),
|
|
152
|
+
"x": sp.Symbol("x"), "y": sp.Symbol("y"),
|
|
153
|
+
"sin": sp.sin, "cos": sp.cos, "tan": sp.tan,
|
|
154
|
+
"exp": sp.exp, "log": sp.log, "sqrt": sp.sqrt,
|
|
155
|
+
"pi": sp.pi, "E": sp.E, "oo": sp.oo, "I": sp.I,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sympy_run(expr, op, var, point, target=None):
|
|
160
|
+
import sympy as sp
|
|
161
|
+
|
|
162
|
+
local = _locals(var)
|
|
163
|
+
e = sp.sympify(expr.replace("^", "**"), locals=local)
|
|
164
|
+
sym = local[var]
|
|
165
|
+
if op == "simplify":
|
|
166
|
+
r = sp.simplify(e)
|
|
167
|
+
elif op == "diff":
|
|
168
|
+
r = sp.diff(e, sym)
|
|
169
|
+
elif op == "integrate":
|
|
170
|
+
if target and "," in str(target):
|
|
171
|
+
lo, hi = [s.strip() for s in str(target).split(",", 1)]
|
|
172
|
+
r = sp.integrate(e, (sym, sp.sympify(lo, locals=local),
|
|
173
|
+
sp.sympify(hi, locals=local)))
|
|
174
|
+
else:
|
|
175
|
+
r = sp.integrate(e, sym)
|
|
176
|
+
elif op == "solve":
|
|
177
|
+
r = sp.solve(e, sym)
|
|
178
|
+
elif op == "limit":
|
|
179
|
+
r = sp.limit(e, sym, sp.sympify(point, locals=local))
|
|
180
|
+
elif op == "value":
|
|
181
|
+
r = sp.N(e)
|
|
182
|
+
elif op == "latex":
|
|
183
|
+
r = e
|
|
184
|
+
elif op == "factor":
|
|
185
|
+
r = sp.factor(e)
|
|
186
|
+
elif op == "expand":
|
|
187
|
+
r = sp.expand(e)
|
|
188
|
+
else:
|
|
189
|
+
raise ComputeError("unknown operation %s" % op)
|
|
190
|
+
return r
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _sympy_verify(expr, op, variable, result):
|
|
194
|
+
"""Independent check where one is cheap; otherwise say there is none."""
|
|
195
|
+
try:
|
|
196
|
+
import sympy as sp
|
|
197
|
+
local = _locals(variable)
|
|
198
|
+
e = sp.sympify(str(expr).replace("^", "**"), locals=local)
|
|
199
|
+
sym = local[variable]
|
|
200
|
+
if op == "integrate":
|
|
201
|
+
back = sp.simplify(sp.diff(sp.sympify(result), sym) - e)
|
|
202
|
+
return {"method": "differentiate-back", "ok": bool(back == 0),
|
|
203
|
+
"passed": bool(back == 0)}
|
|
204
|
+
if op == "diff":
|
|
205
|
+
return {"method": "symbolic", "ok": True, "passed": True}
|
|
206
|
+
if op == "solve":
|
|
207
|
+
back = [sp.simplify(e.subs(sym, sp.sympify(s))) for s in (result if isinstance(result, list) else [result])]
|
|
208
|
+
ok = all(b == 0 for b in back)
|
|
209
|
+
return {"method": "substitute-back", "ok": ok, "passed": ok}
|
|
210
|
+
if op in ("factor", "expand"):
|
|
211
|
+
other = sp.expand if op == "factor" else sp.factor
|
|
212
|
+
back = sp.simplify(other(sp.sympify(result)) - e)
|
|
213
|
+
return {"method": "%s-back" % ("expand" if op == "factor" else "factor"),
|
|
214
|
+
"ok": bool(back == 0), "passed": bool(back == 0)}
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
return {"method": "symbolic", "ok": True, "passed": True}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _sympy(expr, op, var, point, target, timeout):
|
|
221
|
+
import sympy as sp
|
|
222
|
+
|
|
223
|
+
if op not in OPS_SYMBOLIC:
|
|
224
|
+
raise ComputeError("sympy operation must be one of %s" % sorted(OPS_SYMBOLIC))
|
|
225
|
+
fut = _POOL.submit(_sympy_run, expr, op, var, point, target)
|
|
226
|
+
try:
|
|
227
|
+
result = fut.result(timeout=timeout)
|
|
228
|
+
except TimeoutError:
|
|
229
|
+
raise ComputeError("computation timed out")
|
|
230
|
+
try:
|
|
231
|
+
latex = sp.latex(result)
|
|
232
|
+
except Exception:
|
|
233
|
+
latex = ""
|
|
234
|
+
numeric = None
|
|
235
|
+
try:
|
|
236
|
+
if isinstance(result, list):
|
|
237
|
+
numeric = None
|
|
238
|
+
elif sp.sympify(result).is_number:
|
|
239
|
+
numeric = float(sp.N(result))
|
|
240
|
+
except Exception:
|
|
241
|
+
numeric = None
|
|
242
|
+
# sidecar parity: every result is a string, including solution lists ("[-1, 1]")
|
|
243
|
+
payload = {"result": str(result), "latex": latex, "numeric": numeric}
|
|
244
|
+
payload["verification"] = _sympy_verify(expr, op, var,
|
|
245
|
+
result if not isinstance(result, list) else result)
|
|
246
|
+
return payload
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _verification(backend, out):
|
|
250
|
+
details = out.get("details") or {}
|
|
251
|
+
if backend == "scipy":
|
|
252
|
+
if "error" in details:
|
|
253
|
+
return {"method": "quad_error", "ok": details["error"] < 1e-6,
|
|
254
|
+
"passed": details["error"] < 1e-6, "error": details["error"]}
|
|
255
|
+
if "success" in details:
|
|
256
|
+
return {"method": "optimizer_success", "ok": bool(details["success"]),
|
|
257
|
+
"passed": bool(details["success"])}
|
|
258
|
+
if "converged" in details:
|
|
259
|
+
return {"method": "root_converged", "ok": bool(details["converged"]),
|
|
260
|
+
"passed": bool(details["converged"])}
|
|
261
|
+
return {"method": "scipy", "ok": True, "passed": True}
|
|
262
|
+
if backend == "pint":
|
|
263
|
+
return {"method": "unit_registry", "ok": True, "passed": True}
|
|
264
|
+
if backend == "control":
|
|
265
|
+
return {"method": "step_info", "ok": "SteadyStateValue" in details,
|
|
266
|
+
"passed": "SteadyStateValue" in details}
|
|
267
|
+
return {"method": backend, "ok": True, "passed": True}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _worker_enabled():
|
|
271
|
+
return str(os.environ.get(WORKER_ENV, "1")).lower() not in ("0", "false", "no")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _run_worker(payload, timeout):
|
|
275
|
+
"""Run one request in a killed subprocess; returns the backend payload."""
|
|
276
|
+
base = os.path.join(tempfile.gettempdir(), "calcbox-runs")
|
|
277
|
+
os.makedirs(base, exist_ok=True) # ordinary dir: no restrictive DACL
|
|
278
|
+
run_dir = os.path.join(base, "run-%d-%d" % (os.getpid(), time.time_ns()))
|
|
279
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
280
|
+
in_path = os.path.join(run_dir, "in.json")
|
|
281
|
+
out_path = os.path.join(run_dir, "out.json")
|
|
282
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
283
|
+
try:
|
|
284
|
+
with open(in_path, "w", encoding="utf-8") as fh:
|
|
285
|
+
json.dump(payload, fh)
|
|
286
|
+
env = os.environ.copy()
|
|
287
|
+
env["PYTHONPATH"] = root + os.pathsep + env.get("PYTHONPATH", "")
|
|
288
|
+
env["CALCBOX_SANDBOX_TEMP"] = run_dir
|
|
289
|
+
env["TMP"] = env["TEMP"] = env["TMPDIR"] = run_dir
|
|
290
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
291
|
+
os.makedirs(os.path.join(run_dir, "mpl"), exist_ok=True)
|
|
292
|
+
env["MPLCONFIGDIR"] = os.path.join(run_dir, "mpl")
|
|
293
|
+
env["MPLBACKEND"] = "Agg"
|
|
294
|
+
cmd = [sys.executable, "-m", "calcbox.worker", in_path, out_path]
|
|
295
|
+
proc = subprocess.Popen(cmd, cwd=run_dir, env=env,
|
|
296
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
297
|
+
try:
|
|
298
|
+
code = proc.wait(timeout=timeout)
|
|
299
|
+
except subprocess.TimeoutExpired:
|
|
300
|
+
proc.kill()
|
|
301
|
+
proc.wait(timeout=5)
|
|
302
|
+
raise ComputeError("computation timed out")
|
|
303
|
+
if not os.path.exists(out_path):
|
|
304
|
+
raise ComputeError("compute worker produced no output (exit %s)" % code)
|
|
305
|
+
with open(out_path, encoding="utf-8") as fh:
|
|
306
|
+
body = json.load(fh)
|
|
307
|
+
if not body.get("ok"):
|
|
308
|
+
if body.get("kind") == "unavailable":
|
|
309
|
+
raise BackendUnavailable(body.get("error") or "backend unavailable")
|
|
310
|
+
raise ComputeError(body.get("error") or "computation failed")
|
|
311
|
+
return body["out"]
|
|
312
|
+
finally:
|
|
313
|
+
shutil.rmtree(run_dir, ignore_errors=True)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _scipy_func(expr, var):
|
|
318
|
+
import sympy as sp
|
|
319
|
+
|
|
320
|
+
local = _locals(var)
|
|
321
|
+
e = sp.sympify(expr.replace("^", "**"), locals=local)
|
|
322
|
+
return sp.lambdify(local[var], e, "numpy"), e, local[var]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _scipy(expr, op, var, point, target, timeout):
|
|
326
|
+
import sympy as sp
|
|
327
|
+
|
|
328
|
+
if not _available("scipy"):
|
|
329
|
+
raise BackendUnavailable("scipy is not installed")
|
|
330
|
+
if op not in OPS_NUMERIC:
|
|
331
|
+
raise ComputeError("scipy operation must be one of %s" % sorted(OPS_NUMERIC))
|
|
332
|
+
f, e, sym = _scipy_func(expr, var)
|
|
333
|
+
try:
|
|
334
|
+
if op == "quad":
|
|
335
|
+
from scipy.integrate import quad
|
|
336
|
+
lo = float(sp.sympify(point).evalf())
|
|
337
|
+
hi = float(sp.sympify(target or "1").evalf())
|
|
338
|
+
value, err = quad(lambda x: float(f(x)), lo, hi)
|
|
339
|
+
cross = None
|
|
340
|
+
try:
|
|
341
|
+
exact = float(sp.N(sp.integrate(e, (sym, lo, hi))))
|
|
342
|
+
cross = {"backend": "sympy", "value": exact,
|
|
343
|
+
"difference": abs(exact - value)}
|
|
344
|
+
except Exception:
|
|
345
|
+
cross = None
|
|
346
|
+
disputed = bool(cross and cross["difference"] > max(
|
|
347
|
+
1e-6, 1e-6 * abs(cross["value"])))
|
|
348
|
+
return {"result": value, "latex": sp.latex(e), "numeric": value,
|
|
349
|
+
"details": {"lower": lo, "upper": hi, "error": err},
|
|
350
|
+
"cross_check": cross, "disputed": disputed}
|
|
351
|
+
if op == "minimize":
|
|
352
|
+
from scipy.optimize import minimize
|
|
353
|
+
x0 = float(sp.sympify(point or "0").evalf())
|
|
354
|
+
res = minimize(lambda x: float(f(x[0])), [x0])
|
|
355
|
+
return {"result": float(res.x[0]), "latex": sp.latex(e),
|
|
356
|
+
"numeric": float(res.fun),
|
|
357
|
+
"details": {"success": bool(res.success),
|
|
358
|
+
"iterations": int(res.nit)}}
|
|
359
|
+
if op == "root":
|
|
360
|
+
from scipy.optimize import root_scalar
|
|
361
|
+
lo = float(sp.sympify(point or "0").evalf())
|
|
362
|
+
hi = float(sp.sympify(target or "1").evalf())
|
|
363
|
+
res = root_scalar(lambda x: float(f(x)), bracket=(lo, hi))
|
|
364
|
+
return {"result": float(res.root), "latex": sp.latex(e),
|
|
365
|
+
"numeric": 0.0,
|
|
366
|
+
"details": {"converged": bool(res.converged),
|
|
367
|
+
"bracket": [lo, hi]}}
|
|
368
|
+
except ComputeError:
|
|
369
|
+
raise
|
|
370
|
+
except Exception as e:
|
|
371
|
+
raise ComputeError(str(e))
|
|
372
|
+
raise ComputeError("unknown scipy operation")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _pint(expr, op, target, timeout):
|
|
376
|
+
if not _available("pint"):
|
|
377
|
+
raise BackendUnavailable("pint is not installed; pip install pint")
|
|
378
|
+
if op != "convert":
|
|
379
|
+
raise ComputeError("pint operation must be convert")
|
|
380
|
+
if not target:
|
|
381
|
+
raise ComputeError("target unit is required for convert")
|
|
382
|
+
if not re.fullmatch(r"[0-9A-Za-z .,*/^()_+-]+", expr):
|
|
383
|
+
raise ComputeError("unit expression contains invalid characters")
|
|
384
|
+
try:
|
|
385
|
+
import pint
|
|
386
|
+
|
|
387
|
+
ureg = pint.UnitRegistry()
|
|
388
|
+
value = ureg(expr)
|
|
389
|
+
out = value.to(target)
|
|
390
|
+
return {"result": str(out), "latex": "",
|
|
391
|
+
"numeric": float(out.magnitude),
|
|
392
|
+
"details": {"magnitude": float(out.magnitude),
|
|
393
|
+
"units": str(out.units)}}
|
|
394
|
+
except Exception as e:
|
|
395
|
+
raise ComputeError(str(e))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _control(data, op, timeout):
|
|
399
|
+
if not _available("control"):
|
|
400
|
+
raise BackendUnavailable("control is not installed; pip install control")
|
|
401
|
+
if op != "step":
|
|
402
|
+
raise ComputeError("control operation must be step")
|
|
403
|
+
data = data or {}
|
|
404
|
+
num, den = data.get("num"), data.get("den")
|
|
405
|
+
if (not isinstance(num, list) or not isinstance(den, list)
|
|
406
|
+
or not num or not den
|
|
407
|
+
or not all(isinstance(x, (int, float)) for x in num + den)):
|
|
408
|
+
raise ComputeError("control step needs data.num and data.den number lists")
|
|
409
|
+
# matplotlib (a control dependency) writes a cache on import; keep it in a
|
|
410
|
+
# directory the sandbox allows.
|
|
411
|
+
os.environ.setdefault("MPLBACKEND", "Agg")
|
|
412
|
+
cache = os.environ.get("CALCBOX_SANDBOX_TEMP") or os.path.join(
|
|
413
|
+
tempfile.gettempdir(), "calcbox-mpl")
|
|
414
|
+
try:
|
|
415
|
+
os.makedirs(cache, exist_ok=True)
|
|
416
|
+
os.environ.setdefault("MPLCONFIGDIR", os.path.join(cache, "mpl"))
|
|
417
|
+
except OSError:
|
|
418
|
+
pass
|
|
419
|
+
try:
|
|
420
|
+
import control
|
|
421
|
+
|
|
422
|
+
sys = control.TransferFunction(num, den)
|
|
423
|
+
tf = control.step_info(sys)
|
|
424
|
+
return {"result": str(sys), "latex": "",
|
|
425
|
+
"numeric": float(tf.get("SteadyStateValue", 0.0)),
|
|
426
|
+
"details": {k: (float(v) if isinstance(v, (int, float)) else v)
|
|
427
|
+
for k, v in tf.items()}}
|
|
428
|
+
except Exception as e:
|
|
429
|
+
raise ComputeError(str(e))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _wolfram(expr, op, timeout):
|
|
434
|
+
if not _external_enabled():
|
|
435
|
+
raise BackendUnavailable("external backends disabled; set %s=1" % EXTERNAL_ENV)
|
|
436
|
+
exe = _which(("wolframscript", "WolframKernel"))
|
|
437
|
+
if not exe:
|
|
438
|
+
raise BackendUnavailable("Wolfram Engine/wolframscript not found")
|
|
439
|
+
if op != "eval":
|
|
440
|
+
raise ComputeError("wolfram operation must be eval")
|
|
441
|
+
if len(expr) > 2000:
|
|
442
|
+
raise ComputeError("wolfram expression too long")
|
|
443
|
+
try:
|
|
444
|
+
proc = subprocess.run([exe, "-code", expr], capture_output=True,
|
|
445
|
+
text=True, timeout=timeout)
|
|
446
|
+
except subprocess.TimeoutExpired:
|
|
447
|
+
raise ComputeError("wolfram computation timed out")
|
|
448
|
+
except Exception as e:
|
|
449
|
+
raise ComputeError(str(e))
|
|
450
|
+
out = (proc.stdout or "").strip()
|
|
451
|
+
if proc.returncode != 0 and not out:
|
|
452
|
+
raise ComputeError((proc.stderr or "wolfram failed").strip()[:300])
|
|
453
|
+
return {"result": out, "latex": "", "numeric": None,
|
|
454
|
+
"details": {"returncode": proc.returncode}}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _lean(code, op, timeout):
|
|
458
|
+
if not _external_enabled():
|
|
459
|
+
raise BackendUnavailable("external backends disabled; set %s=1" % EXTERNAL_ENV)
|
|
460
|
+
exe = _which(("lean", "lake"))
|
|
461
|
+
if not exe:
|
|
462
|
+
raise BackendUnavailable("Lean 4/lake not found")
|
|
463
|
+
if op != "check":
|
|
464
|
+
raise ComputeError("lean operation must be check")
|
|
465
|
+
if len(code) > 5000:
|
|
466
|
+
raise ComputeError("lean code too long")
|
|
467
|
+
base = os.environ.get("CALCBOX_SANDBOX_TEMP") or tempfile.gettempdir()
|
|
468
|
+
os.makedirs(base, exist_ok=True)
|
|
469
|
+
path = os.path.join(base, "check-%d.lean" % time.time_ns())
|
|
470
|
+
try:
|
|
471
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
472
|
+
fh.write(code)
|
|
473
|
+
try:
|
|
474
|
+
proc = subprocess.run([exe, path], capture_output=True, text=True,
|
|
475
|
+
timeout=timeout)
|
|
476
|
+
except subprocess.TimeoutExpired:
|
|
477
|
+
raise ComputeError("lean check timed out")
|
|
478
|
+
return {"result": "ok" if proc.returncode == 0 else "failed",
|
|
479
|
+
"latex": "", "numeric": None,
|
|
480
|
+
"details": {"returncode": proc.returncode,
|
|
481
|
+
"stdout": (proc.stdout or "")[:1000],
|
|
482
|
+
"stderr": (proc.stderr or "")[:1000]}}
|
|
483
|
+
finally:
|
|
484
|
+
try:
|
|
485
|
+
os.unlink(path)
|
|
486
|
+
except OSError:
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _execute_local(backend, expression, operation, variable="x", point="0",
|
|
491
|
+
target=None, data=None, timeout=8.0):
|
|
492
|
+
"""Run one backend in-process. Policy lives in calcbox.sandbox."""
|
|
493
|
+
if backend == "sympy":
|
|
494
|
+
payload = {"backend": backend, "expression": expression,
|
|
495
|
+
"operation": operation, "variable": variable, "point": point,
|
|
496
|
+
"target": target, "data": data, "timeout": timeout}
|
|
497
|
+
if _worker_enabled():
|
|
498
|
+
return _run_worker(payload, timeout)
|
|
499
|
+
expression = _validate(expression)
|
|
500
|
+
point = _validate(point, 100)
|
|
501
|
+
if target:
|
|
502
|
+
target = _validate(target, 100)
|
|
503
|
+
if not re.fullmatch(r"[A-Za-z]", variable or ""):
|
|
504
|
+
raise ComputeError("variable must be one letter")
|
|
505
|
+
return _sympy(expression, operation, variable, point, target, timeout)
|
|
506
|
+
if backend == "scipy":
|
|
507
|
+
expression = _validate(expression)
|
|
508
|
+
point = _validate(point, 100)
|
|
509
|
+
if target:
|
|
510
|
+
target = _validate(target, 100)
|
|
511
|
+
if not re.fullmatch(r"[A-Za-z]", variable or ""):
|
|
512
|
+
raise ComputeError("variable must be one letter")
|
|
513
|
+
return _scipy(expression, operation, variable, point, target, timeout)
|
|
514
|
+
if backend == "pint":
|
|
515
|
+
expression = _validate(expression)
|
|
516
|
+
return _pint(expression, operation, target, timeout)
|
|
517
|
+
if backend == "control":
|
|
518
|
+
return _control(data, operation, timeout)
|
|
519
|
+
if backend == "wolfram":
|
|
520
|
+
return _wolfram(expression, operation, timeout)
|
|
521
|
+
if backend == "lean":
|
|
522
|
+
return _lean(expression, operation, timeout)
|
|
523
|
+
raise BackendUnavailable("external backend %r disabled or not implemented" % backend)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _assumptions(backend, variable, point, target, data):
|
|
527
|
+
if backend == "sympy":
|
|
528
|
+
return {"variable": variable, "domain": "complex"}
|
|
529
|
+
return {"external": True}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def compute(expression, operation="simplify", variable="x", point="0",
|
|
533
|
+
target=None, data=None, timeout=8.0, backend="auto"):
|
|
534
|
+
"""Compute one expression; returns a dict shaped by calcbox.contract."""
|
|
535
|
+
t0 = time.perf_counter()
|
|
536
|
+
status = backend_status()
|
|
537
|
+
backend = (backend or "auto").lower()
|
|
538
|
+
if backend == "auto":
|
|
539
|
+
backend = infer_backend(operation)
|
|
540
|
+
if backend not in status:
|
|
541
|
+
raise ComputeError("unknown backend %r" % backend)
|
|
542
|
+
if not status[backend]["available"]:
|
|
543
|
+
raise BackendUnavailable(status[backend].get("reason") or
|
|
544
|
+
"backend %r is not available" % backend)
|
|
545
|
+
timeout = min(max(float(timeout), 0.1), 60.0)
|
|
546
|
+
req = ComputeRequest(expression=expression, operation=operation,
|
|
547
|
+
variable=variable, point=point, target=target,
|
|
548
|
+
data=data, timeout=timeout, backend=backend)
|
|
549
|
+
payload = req.payload()
|
|
550
|
+
if backend in ("wolfram", "lean"):
|
|
551
|
+
_execute_in_process = True # already external processes; no nested worker
|
|
552
|
+
if backend in ("sympy", "scipy") and _worker_enabled():
|
|
553
|
+
out = _run_worker(payload, timeout)
|
|
554
|
+
elif backend == "pint" and _worker_enabled():
|
|
555
|
+
out = _run_worker(payload, timeout)
|
|
556
|
+
else:
|
|
557
|
+
out = _execute_local(**payload)
|
|
558
|
+
digest = hashlib.sha256(json.dumps(payload, sort_keys=True,
|
|
559
|
+
ensure_ascii=False).encode("utf-8")).hexdigest()
|
|
560
|
+
generated = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
561
|
+
verification = out.get("verification") or _verification(backend, out)
|
|
562
|
+
disputed = bool(out.get("disputed"))
|
|
563
|
+
credentials = {
|
|
564
|
+
"backend": backend,
|
|
565
|
+
"engine_version": status[backend].get("version"),
|
|
566
|
+
"input_sha256": digest,
|
|
567
|
+
"operation": operation,
|
|
568
|
+
"assumptions": _assumptions(backend, variable, point, target, data),
|
|
569
|
+
"verification": verification,
|
|
570
|
+
"cross_check": out.get("cross_check"),
|
|
571
|
+
"disputed": disputed,
|
|
572
|
+
"sandbox": "audit-only",
|
|
573
|
+
"generated_at": generated,
|
|
574
|
+
}
|
|
575
|
+
result = ComputeResult(
|
|
576
|
+
ok=True, backend=backend, engine_version=status[backend].get("version"),
|
|
577
|
+
operation=operation, input=expression, variable=variable,
|
|
578
|
+
result=out["result"], latex=out.get("latex", ""),
|
|
579
|
+
numeric=out.get("numeric"), details=out.get("details"),
|
|
580
|
+
cross_check=out.get("cross_check"),
|
|
581
|
+
assumptions=credentials["assumptions"],
|
|
582
|
+
verification=verification, disputed=disputed, sandbox="audit-only",
|
|
583
|
+
credentials=credentials, input_sha256=digest, generated_at=generated,
|
|
584
|
+
elapsed_ms=round((time.perf_counter() - t0) * 1000, 3),
|
|
585
|
+
)
|
|
586
|
+
return result.to_dict()
|
calcbox/cli.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Command line: one expression, JSON (or a short human line) out."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .backends import compute as run_compute
|
|
7
|
+
from .contract import BackendUnavailable, ComputeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None):
|
|
11
|
+
ap = argparse.ArgumentParser(prog="calcbox",
|
|
12
|
+
description="compute one expression with provenance")
|
|
13
|
+
ap.add_argument("expression")
|
|
14
|
+
ap.add_argument("--op", default="simplify")
|
|
15
|
+
ap.add_argument("--var", default="x")
|
|
16
|
+
ap.add_argument("--point", default="0")
|
|
17
|
+
ap.add_argument("--target", default=None)
|
|
18
|
+
ap.add_argument("--backend", default="auto")
|
|
19
|
+
ap.add_argument("--timeout", type=float, default=8.0)
|
|
20
|
+
ap.add_argument("--json", action="store_true")
|
|
21
|
+
args = ap.parse_args(argv)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
out = run_compute(args.expression, operation=args.op,
|
|
25
|
+
variable=args.var, point=args.point,
|
|
26
|
+
target=args.target, timeout=args.timeout,
|
|
27
|
+
backend=args.backend)
|
|
28
|
+
except BackendUnavailable as e:
|
|
29
|
+
print("backend unavailable: %s" % e, file=sys.stderr)
|
|
30
|
+
return 3
|
|
31
|
+
except ComputeError as e:
|
|
32
|
+
print("bad request: %s" % e, file=sys.stderr)
|
|
33
|
+
return 2
|
|
34
|
+
|
|
35
|
+
if args.json:
|
|
36
|
+
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
37
|
+
else:
|
|
38
|
+
print("%s = %s [%s %s, %.1f ms]" % (
|
|
39
|
+
out["input"], out["result"], out["backend"],
|
|
40
|
+
out["engine_version"] or "?", out["elapsed_ms"]))
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
sys.exit(main())
|
calcbox/contract.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""calcbox wire contract.
|
|
2
|
+
|
|
3
|
+
One dict shape is shared by the Python API, the HTTP router, the MCP server and
|
|
4
|
+
the CLI. Anything that changes here is a breaking change.
|
|
5
|
+
|
|
6
|
+
Successful result:
|
|
7
|
+
{
|
|
8
|
+
"ok": true,
|
|
9
|
+
"backend": "sympy",
|
|
10
|
+
"engine_version": "1.13.3",
|
|
11
|
+
"operation": "integrate",
|
|
12
|
+
"input": "x**2",
|
|
13
|
+
"variable": "x",
|
|
14
|
+
"result": "x**3/3",
|
|
15
|
+
"latex": "\\frac{x^{3}}{3}",
|
|
16
|
+
"numeric": null,
|
|
17
|
+
"assumptions": {"variable": "x", "domain": "complex"},
|
|
18
|
+
"verification": {"method": "differentiate-back", "passed": true},
|
|
19
|
+
"disputed": false,
|
|
20
|
+
"sandbox": "audit-only",
|
|
21
|
+
"credentials": {"backend": "sympy", "verification": {...}, "sandbox": "audit-only"},
|
|
22
|
+
"input_sha256": "…",
|
|
23
|
+
"generated_at": "2026-11-11T09:00:00Z",
|
|
24
|
+
"elapsed_ms": 12.4
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Failure: ComputeError (client error) or BackendUnavailable (missing dependency
|
|
28
|
+
or disabled external backend). Transports map them to 400 / 503.
|
|
29
|
+
"""
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Any, Dict, Optional
|
|
32
|
+
|
|
33
|
+
OPS_SYMBOLIC = {"simplify", "diff", "integrate", "solve", "limit", "value",
|
|
34
|
+
"latex", "factor", "expand"}
|
|
35
|
+
OPS_NUMERIC = {"quad", "minimize", "root"}
|
|
36
|
+
OPS_UNITS = {"convert"}
|
|
37
|
+
OPS_CONTROL = {"step"}
|
|
38
|
+
OPS_EXTERNAL = {"eval", "check"}
|
|
39
|
+
|
|
40
|
+
BACKENDS = ("sympy", "scipy", "pint", "control", "wolfram", "lean")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ComputeError(ValueError):
|
|
44
|
+
"""The request is invalid (bad expression, unknown operation, bad backend)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BackendUnavailable(ComputeError):
|
|
48
|
+
"""The backend exists but cannot serve: dependency missing or not enabled."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ComputeRequest:
|
|
53
|
+
expression: str
|
|
54
|
+
operation: str = "simplify"
|
|
55
|
+
variable: str = "x"
|
|
56
|
+
point: str = "0"
|
|
57
|
+
target: Optional[str] = None
|
|
58
|
+
data: Optional[Dict[str, Any]] = None
|
|
59
|
+
timeout: float = 8.0
|
|
60
|
+
backend: str = "auto"
|
|
61
|
+
|
|
62
|
+
def payload(self) -> Dict[str, Any]:
|
|
63
|
+
return {"backend": self.backend, "expression": self.expression,
|
|
64
|
+
"operation": self.operation, "variable": self.variable,
|
|
65
|
+
"point": self.point, "target": self.target, "data": self.data,
|
|
66
|
+
"timeout": self.timeout}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ComputeResult:
|
|
71
|
+
ok: bool
|
|
72
|
+
backend: str
|
|
73
|
+
engine_version: Optional[str]
|
|
74
|
+
operation: str
|
|
75
|
+
input: str
|
|
76
|
+
variable: str
|
|
77
|
+
result: Any
|
|
78
|
+
latex: str = ""
|
|
79
|
+
numeric: Any = None
|
|
80
|
+
details: Optional[Dict[str, Any]] = None
|
|
81
|
+
cross_check: Optional[Dict[str, Any]] = None
|
|
82
|
+
assumptions: Dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
verification: Dict[str, Any] = field(default_factory=dict)
|
|
84
|
+
disputed: bool = False
|
|
85
|
+
sandbox: str = "audit-only"
|
|
86
|
+
credentials: Dict[str, Any] = field(default_factory=dict)
|
|
87
|
+
input_sha256: str = ""
|
|
88
|
+
generated_at: str = ""
|
|
89
|
+
elapsed_ms: float = 0.0
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
"ok": self.ok, "backend": self.backend,
|
|
94
|
+
"engine_version": self.engine_version, "operation": self.operation,
|
|
95
|
+
"input": self.input, "variable": self.variable,
|
|
96
|
+
"result": self.result, "latex": self.latex,
|
|
97
|
+
"numeric": self.numeric, "details": self.details,
|
|
98
|
+
"cross_check": self.cross_check, "assumptions": self.assumptions,
|
|
99
|
+
"verification": self.verification, "disputed": self.disputed,
|
|
100
|
+
"sandbox": self.sandbox, "credentials": self.credentials,
|
|
101
|
+
"input_sha256": self.input_sha256,
|
|
102
|
+
"generated_at": self.generated_at, "elapsed_ms": self.elapsed_ms,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class BackendStatus:
|
|
108
|
+
available: bool
|
|
109
|
+
version: Optional[str] = None
|
|
110
|
+
reason: Optional[str] = None
|
|
111
|
+
|
|
112
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
113
|
+
out: Dict[str, Any] = {"available": self.available}
|
|
114
|
+
if self.version:
|
|
115
|
+
out["version"] = self.version
|
|
116
|
+
if self.reason:
|
|
117
|
+
out["reason"] = self.reason
|
|
118
|
+
return out
|
calcbox/mcp_server.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""MCP server (stdio).
|
|
2
|
+
|
|
3
|
+
Exposes two tools, matching the HTTP contract:
|
|
4
|
+
|
|
5
|
+
compute(expression, operation, variable, point, target, backend)
|
|
6
|
+
backends()
|
|
7
|
+
|
|
8
|
+
Requires the optional extra: pip install "calcbox[mcp]".
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from . import backends as _backends
|
|
14
|
+
from .contract import BackendUnavailable, ComputeError
|
|
15
|
+
|
|
16
|
+
INSTRUCTIONS = (
|
|
17
|
+
"A calculator that reports provenance. Prefer it over mental arithmetic for "
|
|
18
|
+
"calculus, algebra and unit conversion. Read the verification field before "
|
|
19
|
+
"quoting a result; disputed=true means a cross-check disagreed."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv=None):
|
|
24
|
+
try:
|
|
25
|
+
from mcp.server import Server
|
|
26
|
+
from mcp.server.stdio import stdio_server
|
|
27
|
+
from mcp.types import TextContent, Tool
|
|
28
|
+
except ImportError:
|
|
29
|
+
print('calcbox-mcp needs the mcp extra: pip install "calcbox[mcp]"',
|
|
30
|
+
file=sys.stderr)
|
|
31
|
+
return 2
|
|
32
|
+
|
|
33
|
+
server = Server("calcbox", instructions=INSTRUCTIONS)
|
|
34
|
+
|
|
35
|
+
@server.list_tools()
|
|
36
|
+
async def list_tools():
|
|
37
|
+
return [
|
|
38
|
+
Tool(name="compute",
|
|
39
|
+
description="Evaluate one expression and return the result with "
|
|
40
|
+
"backend, version, assumptions and verification.",
|
|
41
|
+
inputSchema={
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"expression": {"type": "string"},
|
|
45
|
+
"operation": {"type": "string", "default": "simplify"},
|
|
46
|
+
"variable": {"type": "string", "default": "x"},
|
|
47
|
+
"point": {"type": "string", "default": "0"},
|
|
48
|
+
"target": {"type": "string"},
|
|
49
|
+
"backend": {"type": "string", "default": "auto"},
|
|
50
|
+
},
|
|
51
|
+
"required": ["expression"],
|
|
52
|
+
}),
|
|
53
|
+
Tool(name="backends",
|
|
54
|
+
description="Which backends are available and why.",
|
|
55
|
+
inputSchema={"type": "object", "properties": {}}),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
@server.call_tool()
|
|
59
|
+
async def call_tool(name, arguments):
|
|
60
|
+
if name == "backends":
|
|
61
|
+
payload = {"backends": _backends.backend_status()}
|
|
62
|
+
elif name == "compute":
|
|
63
|
+
try:
|
|
64
|
+
payload = _backends.compute(
|
|
65
|
+
arguments["expression"],
|
|
66
|
+
operation=arguments.get("operation", "simplify"),
|
|
67
|
+
variable=arguments.get("variable", "x"),
|
|
68
|
+
point=arguments.get("point", "0"),
|
|
69
|
+
target=arguments.get("target"),
|
|
70
|
+
backend=arguments.get("backend", "auto"))
|
|
71
|
+
except (ComputeError, BackendUnavailable) as e:
|
|
72
|
+
payload = {"ok": False, "error": type(e).__name__, "detail": str(e)}
|
|
73
|
+
else:
|
|
74
|
+
payload = {"ok": False, "error": "unknown tool"}
|
|
75
|
+
return [TextContent(type="text", text=json.dumps(payload, ensure_ascii=False))]
|
|
76
|
+
|
|
77
|
+
async def run():
|
|
78
|
+
async with stdio_server() as (read, write):
|
|
79
|
+
await server.run(read, write, server.create_initialization_options())
|
|
80
|
+
|
|
81
|
+
import asyncio
|
|
82
|
+
asyncio.run(run())
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
sys.exit(main())
|
calcbox/sandbox.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Audit-only sandbox for the compute worker.
|
|
2
|
+
|
|
3
|
+
Blocks, at the Python level: network sockets, process creation, and any
|
|
4
|
+
filesystem write outside the per-run temp directory. There is no namespace, no
|
|
5
|
+
seccomp, no read-only mount — a determined expression can still consume CPU or
|
|
6
|
+
memory. Reported as sandbox="audit-only" so nobody mistakes it for isolation.
|
|
7
|
+
|
|
8
|
+
Ported from socratic-tutor's sandbox_policy.py; the only change is the
|
|
9
|
+
environment variable name (CALCBOX_SANDBOX_TEMP).
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
TEMP_ENV = "CALCBOX_SANDBOX_TEMP"
|
|
15
|
+
|
|
16
|
+
_NETWORK = ("socket.__new__", "socket.connect", "socket.bind",
|
|
17
|
+
"socket.getaddrinfo", "socket.gethostbyname", "socket.sendto")
|
|
18
|
+
_PROCESS = ("subprocess.Popen", "os.system", "os.exec", "os.spawn",
|
|
19
|
+
"os.posix_spawn", "os.fork")
|
|
20
|
+
_WRITE_EVENTS = ("os.remove", "os.unlink", "os.mkdir", "os.rmdir", "os.chmod",
|
|
21
|
+
"os.utime", "os.truncate")
|
|
22
|
+
_RENAME_EVENTS = ("os.rename", "os.replace")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PolicyViolation(PermissionError):
|
|
26
|
+
"""Raised from the audit hook when the policy denies an operation."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def status():
|
|
30
|
+
return {"available": True, "mode": "audit-only",
|
|
31
|
+
"network": "denied-by-policy",
|
|
32
|
+
"subprocess": "denied-by-policy",
|
|
33
|
+
"filesystem": "temp-only",
|
|
34
|
+
"note": "audit hooks are advisory; run real workloads in a container"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def install(temp):
|
|
38
|
+
temp = os.path.realpath(temp or "")
|
|
39
|
+
if not temp:
|
|
40
|
+
raise RuntimeError("sandbox temp is required")
|
|
41
|
+
|
|
42
|
+
def inside(path):
|
|
43
|
+
if path is None or isinstance(path, int):
|
|
44
|
+
return False
|
|
45
|
+
try:
|
|
46
|
+
p = os.path.realpath(str(path))
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
return p == temp or p.startswith(temp + os.sep)
|
|
50
|
+
|
|
51
|
+
def hook(event, args):
|
|
52
|
+
if event in _NETWORK:
|
|
53
|
+
raise PermissionError("network disabled by compute sandbox")
|
|
54
|
+
if event in _PROCESS:
|
|
55
|
+
raise PermissionError("process creation disabled by compute sandbox")
|
|
56
|
+
if event == "open":
|
|
57
|
+
path = args[0] if args else None
|
|
58
|
+
mode = args[1] if len(args) > 1 else "r"
|
|
59
|
+
flags = args[2] if len(args) > 2 else 0
|
|
60
|
+
writing = False
|
|
61
|
+
if isinstance(mode, str):
|
|
62
|
+
writing = any(c in mode for c in "wax+")
|
|
63
|
+
elif isinstance(flags, int):
|
|
64
|
+
writing = bool(flags & (os.O_WRONLY | os.O_RDWR |
|
|
65
|
+
os.O_CREAT | os.O_APPEND))
|
|
66
|
+
if writing and not inside(path):
|
|
67
|
+
raise PermissionError("read-only filesystem: %s" % (path,))
|
|
68
|
+
if event in _WRITE_EVENTS:
|
|
69
|
+
path = args[0] if args else None
|
|
70
|
+
if path is not None and not inside(path):
|
|
71
|
+
raise PermissionError("read-only filesystem: %s" % (path,))
|
|
72
|
+
if event in _RENAME_EVENTS:
|
|
73
|
+
for path in args[:2]:
|
|
74
|
+
if path is not None and not inside(path):
|
|
75
|
+
raise PermissionError("read-only filesystem: %s" % (path,))
|
|
76
|
+
|
|
77
|
+
sys.addaudithook(hook)
|
|
78
|
+
return status()
|
calcbox/worker.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Subprocess worker: one request in, one response out.
|
|
2
|
+
|
|
3
|
+
Runs in its own process so a timeout can kill it without taking the caller down
|
|
4
|
+
(an in-process thread cannot be cancelled, and a stuck SymPy integral would keep
|
|
5
|
+
the interpreter alive at exit). The parent writes JSON to a file, spawns us with
|
|
6
|
+
that path and a result path, and reads the reply back.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv=None):
|
|
14
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
15
|
+
if len(argv) != 2:
|
|
16
|
+
return 2
|
|
17
|
+
in_path, out_path = argv
|
|
18
|
+
with open(in_path, encoding="utf-8") as fh:
|
|
19
|
+
payload = json.load(fh)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from .sandbox import install
|
|
23
|
+
install(os.environ.get("CALCBOX_SANDBOX_TEMP") or os.path.dirname(in_path))
|
|
24
|
+
except Exception as e: # policy problems must not look like compute errors
|
|
25
|
+
body = {"ok": False, "kind": "internal",
|
|
26
|
+
"error": "sandbox install failed: %s" % e}
|
|
27
|
+
with open(out_path, "w", encoding="utf-8") as fh:
|
|
28
|
+
json.dump(body, fh)
|
|
29
|
+
return 1
|
|
30
|
+
|
|
31
|
+
# Inside the worker we are already the sandboxed process: never re-spawn.
|
|
32
|
+
os.environ["CALCBOX_WORKER"] = "0"
|
|
33
|
+
from .backends import BackendUnavailable, ComputeError, _execute_local
|
|
34
|
+
try:
|
|
35
|
+
out = _execute_local(**payload)
|
|
36
|
+
body = {"ok": True, "out": out}
|
|
37
|
+
except BackendUnavailable as e:
|
|
38
|
+
body = {"ok": False, "kind": "unavailable", "error": str(e)}
|
|
39
|
+
except ComputeError as e:
|
|
40
|
+
body = {"ok": False, "kind": "compute", "error": str(e)}
|
|
41
|
+
except Exception as e:
|
|
42
|
+
body = {"ok": False, "kind": "internal", "error": str(e)[:300]}
|
|
43
|
+
with open(out_path, "w", encoding="utf-8") as fh:
|
|
44
|
+
json.dump(body, fh, ensure_ascii=False)
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
sys.exit(main())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: calcbox
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Backend-agnostic math compute kernel: symbolic, numeric, units, control — with provenance in every answer
|
|
5
|
+
Author: calcbox contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/Icefox12138/calcbox
|
|
8
|
+
Keywords: math,sympy,scipy,units,mcp,compute
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: symbolic
|
|
16
|
+
Requires-Dist: sympy>=1.12; extra == "symbolic"
|
|
17
|
+
Provides-Extra: numeric
|
|
18
|
+
Requires-Dist: scipy>=1.11; extra == "numeric"
|
|
19
|
+
Requires-Dist: numpy; extra == "numeric"
|
|
20
|
+
Provides-Extra: units
|
|
21
|
+
Requires-Dist: pint>=0.23; extra == "units"
|
|
22
|
+
Provides-Extra: control
|
|
23
|
+
Requires-Dist: control>=0.9; extra == "control"
|
|
24
|
+
Requires-Dist: numpy; extra == "control"
|
|
25
|
+
Provides-Extra: api
|
|
26
|
+
Requires-Dist: fastapi>=0.110; extra == "api"
|
|
27
|
+
Requires-Dist: pydantic>=2; extra == "api"
|
|
28
|
+
Provides-Extra: mcp
|
|
29
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
30
|
+
Provides-Extra: full
|
|
31
|
+
Requires-Dist: sympy>=1.12; extra == "full"
|
|
32
|
+
Requires-Dist: scipy>=1.11; extra == "full"
|
|
33
|
+
Requires-Dist: numpy; extra == "full"
|
|
34
|
+
Requires-Dist: pint>=0.23; extra == "full"
|
|
35
|
+
Requires-Dist: control>=0.9; extra == "full"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# calcbox
|
|
39
|
+
|
|
40
|
+
Backend-agnostic math compute kernel: **symbolic, numeric, units, control** — and every
|
|
41
|
+
answer carries provenance (backend, engine version, assumptions, verification, input hash).
|
|
42
|
+
|
|
43
|
+
It knows nothing about teaching, documents or users. It is a calculator that can prove
|
|
44
|
+
what it did.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from calcbox import compute
|
|
48
|
+
|
|
49
|
+
out = compute("x**2", "integrate")
|
|
50
|
+
# {'result': 'x**3/3', 'backend': 'sympy', 'engine_version': '1.13.1',
|
|
51
|
+
# 'verification': {'method': 'differentiate-back', 'passed': True}, ...}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Why another math tool
|
|
55
|
+
|
|
56
|
+
There are MCP servers that evaluate arithmetic and matrix operations. What they do not
|
|
57
|
+
report is **how the answer was produced**: which backend, which engine version, which
|
|
58
|
+
assumptions, and whether a second method agreed. For tutoring, for reproducible
|
|
59
|
+
research notes, and for anything a human will quote, that provenance is the point.
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"result": "x**3/3",
|
|
64
|
+
"latex": "\\frac{x^{3}}{3}",
|
|
65
|
+
"backend": "sympy",
|
|
66
|
+
"engine_version": "1.13.1",
|
|
67
|
+
"assumptions": {"variable": "x", "domain": "real"},
|
|
68
|
+
"verification": {"method": "differentiate-back", "passed": true},
|
|
69
|
+
"disputed": false,
|
|
70
|
+
"sandbox": "audit-only",
|
|
71
|
+
"input_sha256": "9f2c…",
|
|
72
|
+
"elapsed_ms": 12.4
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install calcbox # core, no backends
|
|
80
|
+
pip install "calcbox[symbolic]" # sympy (calculus, algebra, exact values)
|
|
81
|
+
pip install "calcbox[full]" # sympy + scipy + pint + control
|
|
82
|
+
pip install "calcbox[mcp]" # MCP server (stdio)
|
|
83
|
+
pip install "calcbox[api]" # FastAPI router
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Nothing is a hard dependency. A missing backend is reported as unavailable **with a
|
|
87
|
+
reason**, never as a crash.
|
|
88
|
+
|
|
89
|
+
## Use it
|
|
90
|
+
|
|
91
|
+
**Python**
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
compute("x**2", "integrate") # indefinite
|
|
95
|
+
compute("x", "integrate", target="0,1") # definite
|
|
96
|
+
compute("x**3", "diff")
|
|
97
|
+
compute("2+3*4", "value")
|
|
98
|
+
compute("x**2-1", "solve")
|
|
99
|
+
compute("sin(x)/x", "limit", point="0")
|
|
100
|
+
compute("x**2", "simplify", backend="sympy")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**CLI**
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
calcbox "x**2" --op integrate
|
|
107
|
+
calcbox "sin(x)/x" --op limit --point 0 --json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**HTTP** (optional; no auth by design — that belongs to the host)
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from fastapi import FastAPI
|
|
114
|
+
from calcbox.api import create_router
|
|
115
|
+
|
|
116
|
+
app = FastAPI()
|
|
117
|
+
app.include_router(create_router())
|
|
118
|
+
# GET /v1/compute/backends
|
|
119
|
+
# POST /v1/compute {"expression": "x**2", "operation": "integrate"}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**MCP**
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"mcpServers": {
|
|
127
|
+
"calcbox": {"command": "calcbox-mcp", "args": []}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Tools: `compute`, `backends`.
|
|
133
|
+
|
|
134
|
+
## Contract
|
|
135
|
+
|
|
136
|
+
| Field | Meaning |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `operation` | `simplify`, `diff`, `integrate`, `solve`, `limit`, `value`, `latex`, `factor`, `expand`, `quad`, `minimize`, `root`, `convert`, `step`, `eval`, `check` |
|
|
139
|
+
| `backend` | `auto` infers from the operation; otherwise one of `sympy`/`scipy`/`pint`/`control`/`wolfram`/`lean` |
|
|
140
|
+
| `verification` | method + `passed`; e.g. differentiate-back for integrals, substitute-back for solutions |
|
|
141
|
+
| `disputed` | two backends disagreed (only when a cross-check runs) |
|
|
142
|
+
| `sandbox` | always `audit-only` for in-process backends |
|
|
143
|
+
|
|
144
|
+
Errors: `ComputeError` (bad request → 400) and `BackendUnavailable` (missing dependency
|
|
145
|
+
or disabled external engine → 503).
|
|
146
|
+
|
|
147
|
+
## Security
|
|
148
|
+
|
|
149
|
+
**The sandbox is audit-only.** It is a set of `sys.audit` hooks, not an OS jail. Read
|
|
150
|
+
[SECURITY.md](SECURITY.md) before exposing this to untrusted input. External engines
|
|
151
|
+
(Wolfram, Lean) are disabled unless `CALCBOX_EXTERNAL=1`.
|
|
152
|
+
|
|
153
|
+
## Status
|
|
154
|
+
|
|
155
|
+
0.3.0 — all six backends wired: SymPy, SciPy, Pint, Control, and the external
|
|
156
|
+
engines Wolfram and Lean (disabled unless CALCBOX_EXTERNAL=1; both fail with a
|
|
157
|
+
reason when the binary is absent, never silently).
|
|
158
|
+
|
|
159
|
+
15/15 parity with the implementation this was ported from, 36 tests.
|
|
160
|
+
|
|
161
|
+
Every local call runs in a killable subprocess worker (~0.2-1.2 s overhead);
|
|
162
|
+
CALCBOX_WORKER=0 runs in-process for debugging. External engines run in-process
|
|
163
|
+
because they are already separate processes.
|
|
164
|
+
|
|
165
|
+
## Releasing
|
|
166
|
+
|
|
167
|
+
See [RELEASING.md](RELEASING.md): PyPI Trusted Publishing, no tokens.
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
calcbox/__init__.py,sha256=cYlvY7cncEgrbLlsdzFaAN-KbaYjmGdeCmfXfDCcUo0,746
|
|
2
|
+
calcbox/api.py,sha256=k4dEl0t2A1L9ji1TPQaRde6zSVHxKrRJKMw-lB_R8ME,1786
|
|
3
|
+
calcbox/backends.py,sha256=1iJLMgqGQbKas72nzTFqPkUzZm7rp-gq_Sn00UwhXxw,23270
|
|
4
|
+
calcbox/cli.py,sha256=Rhxhb7Ajgi81dt1UMKwLY5kD9SZr0M_u7Z2e5UnMnPg,1573
|
|
5
|
+
calcbox/contract.py,sha256=7mEdPztEv3MuszNLJAjeHWEzqrGRqiAVOHPmQY50fNk,3896
|
|
6
|
+
calcbox/mcp_server.py,sha256=A-IoT6KwBZLNEwLKT8rocH9WjWnQJd7IcL2e7fNAQYQ,3195
|
|
7
|
+
calcbox/sandbox.py,sha256=MqYzs_-tN11bAndfMEM9RHPdSVZ1uQuBlOt3XrI7mpU,3051
|
|
8
|
+
calcbox/worker.py,sha256=5DLyGoztuQRxoDrhkjMrpJTpUNyrFRmbBI7MTH5Y0Qk,1791
|
|
9
|
+
calcbox-0.3.0.dist-info/licenses/LICENSE,sha256=b2suCwCCZ7EX3q3OzfpU-zmjQ9SDadaXPsadFU8PG-s,1077
|
|
10
|
+
calcbox-0.3.0.dist-info/METADATA,sha256=YXDAglRalrAHGBfTBtKEFVNElixYwyeNE7WuTNgZIvw,5390
|
|
11
|
+
calcbox-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
calcbox-0.3.0.dist-info/entry_points.txt,sha256=TIKPxSXTtstrjuZA86aIZiLNRwpVWcAB4LZEUNJQ-TM,83
|
|
13
|
+
calcbox-0.3.0.dist-info/top_level.txt,sha256=TZlAzqt-_wCAGeffKUREylBsoGvxb8bEmeUS2YoUimQ,8
|
|
14
|
+
calcbox-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 calcbox contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
calcbox
|