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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ calcbox = calcbox.cli:main
3
+ calcbox-mcp = calcbox.mcp_server:main
@@ -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