codecalc 0.1.0__py3-none-win_amd64.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.
- codecalc/SKILL.md +87 -0
- codecalc/__init__.py +3 -0
- codecalc/__main__.py +19 -0
- codecalc/_worker_bootstrap.py +211 -0
- codecalc/bin/codecalc-exec.exe +0 -0
- codecalc/complexity.py +162 -0
- codecalc/contract.py +624 -0
- codecalc/doctor.py +428 -0
- codecalc/errors.py +214 -0
- codecalc/exact.py +969 -0
- codecalc/execution_service.py +302 -0
- codecalc/executor.py +1324 -0
- codecalc/guarded.py +300 -0
- codecalc/landlock.py +231 -0
- codecalc/logic.py +500 -0
- codecalc/mcp_middleware.py +135 -0
- codecalc/optimization.py +296 -0
- codecalc/optional.py +107 -0
- codecalc/packages.py +360 -0
- codecalc/parsing.py +266 -0
- codecalc/provider_adapters/__init__.py +1 -0
- codecalc/provider_adapters/piston.py +34 -0
- codecalc/providers.py +802 -0
- codecalc/py.typed +0 -0
- codecalc/registry.py +254 -0
- codecalc/run_supervisor.py +178 -0
- codecalc/runtimes.py +542 -0
- codecalc/safe_expr.py +346 -0
- codecalc/server.py +1103 -0
- codecalc/sessions.py +1060 -0
- codecalc/strict_runtime.py +232 -0
- codecalc/tools.py +326 -0
- codecalc/translation.py +286 -0
- codecalc/units.py +410 -0
- codecalc-0.1.0.dist-info/METADATA +838 -0
- codecalc-0.1.0.dist-info/RECORD +39 -0
- codecalc-0.1.0.dist-info/WHEEL +4 -0
- codecalc-0.1.0.dist-info/entry_points.txt +2 -0
- codecalc-0.1.0.dist-info/licenses/LICENSE +201 -0
codecalc/exact.py
ADDED
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
"""Exact arithmetic and programmer-mode tools, ported from the Claude `calc`
|
|
2
|
+
skill (~/.claude/skills/calc/calc.py) into codecalc as structured (JSON)
|
|
3
|
+
MCP tools.
|
|
4
|
+
|
|
5
|
+
Everything numeric is exact by default (Fraction / sympy), mirrors the
|
|
6
|
+
original's design: print the exact value first, the decimal approximation
|
|
7
|
+
second, because the two disagreeing is usually the bug.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import math
|
|
14
|
+
import statistics
|
|
15
|
+
import struct
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from decimal import Decimal, getcontext
|
|
18
|
+
from fractions import Fraction
|
|
19
|
+
|
|
20
|
+
from .guarded import guarded_call
|
|
21
|
+
from .optional import require
|
|
22
|
+
from .safe_expr import reject_unsafe
|
|
23
|
+
|
|
24
|
+
getcontext().prec = 50
|
|
25
|
+
|
|
26
|
+
# ─────────────────────────── exact AST evaluator ───────────────────────────
|
|
27
|
+
|
|
28
|
+
# Whitelisted callables for the AST evaluator. Deliberately NOT eval(): parsing
|
|
29
|
+
# to an AST and walking a closed set of node types means no attribute access,
|
|
30
|
+
# imports, comprehensions, or lambdas are reachable — same safety design as the
|
|
31
|
+
# original calc.py.
|
|
32
|
+
_FUNCS = {k: getattr(math, k) for k in dir(math)
|
|
33
|
+
if not k.startswith("_") and callable(getattr(math, k))}
|
|
34
|
+
_FUNCS.update({"abs": abs, "min": min, "max": max, "round": round,
|
|
35
|
+
"int": int, "float": float})
|
|
36
|
+
|
|
37
|
+
#: Functions that are EXACT over rationals and must never see a float.
|
|
38
|
+
#: math.* has to go through float — that is inherent — but these do not, and
|
|
39
|
+
#: routing them through float silently destroyed exactness in the one module
|
|
40
|
+
#: whose entire purpose is exactness: abs(-1/3) returned
|
|
41
|
+
#: 3333333333333333/10000000000000000. Worse, the loss was data-dependent:
|
|
42
|
+
#: max(1/3, 1/2) came back as exactly 1/2 because 1/2 is binary-representable,
|
|
43
|
+
#: so the bug looked absent about half the time.
|
|
44
|
+
_EXACT_FUNCS = {"abs", "min", "max", "int"}
|
|
45
|
+
#: pi/e/tau are IRRATIONAL — no Fraction is equal to them. These are the exact
|
|
46
|
+
#: rational value of the binary64 approximation, which is the best a rational
|
|
47
|
+
#: evaluator can do, but a result computed from one is not exact and must not be
|
|
48
|
+
#: labelled as such. `_uses_approx_const` tracks that so eval_exact can report it.
|
|
49
|
+
_CONSTS = {"pi": Fraction(math.pi), "e": Fraction(math.e),
|
|
50
|
+
"tau": Fraction(math.tau)}
|
|
51
|
+
|
|
52
|
+
_BIN = {
|
|
53
|
+
ast.Add: lambda a, b: a + b,
|
|
54
|
+
ast.Sub: lambda a, b: a - b,
|
|
55
|
+
ast.Mult: lambda a, b: a * b,
|
|
56
|
+
ast.Div: lambda a, b: a / b,
|
|
57
|
+
ast.FloorDiv: lambda a, b: a // b,
|
|
58
|
+
ast.Mod: lambda a, b: a % b,
|
|
59
|
+
ast.Pow: lambda a, b: _pow_exact(a, b),
|
|
60
|
+
ast.BitAnd: lambda a, b: _int_op(a, b, "&"),
|
|
61
|
+
ast.BitOr: lambda a, b: _int_op(a, b, "|"),
|
|
62
|
+
ast.BitXor: lambda a, b: _int_op(a, b, "^"),
|
|
63
|
+
ast.LShift: lambda a, b: _int_op(a, b, "<<"),
|
|
64
|
+
ast.RShift: lambda a, b: _int_op(a, b, ">>"),
|
|
65
|
+
}
|
|
66
|
+
_CMP = {
|
|
67
|
+
ast.Gt: lambda a, b: a > b,
|
|
68
|
+
ast.GtE: lambda a, b: a >= b,
|
|
69
|
+
ast.Lt: lambda a, b: a < b,
|
|
70
|
+
ast.LtE: lambda a, b: a <= b,
|
|
71
|
+
ast.Eq: lambda a, b: a == b,
|
|
72
|
+
ast.NotEq: lambda a, b: a != b,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _int_op(a: Fraction, b: Fraction, op: str) -> Fraction:
|
|
77
|
+
"""Bitwise on exact rationals: legal only when both sides are integers."""
|
|
78
|
+
for side, v in (("left", a), ("right", b)):
|
|
79
|
+
if v.denominator != 1:
|
|
80
|
+
raise ValueError(f"bitwise {op} needs integers; {side} operand is {v}")
|
|
81
|
+
ai, bi = int(a), int(b)
|
|
82
|
+
if op in ("<<", ">>") and bi < 0:
|
|
83
|
+
raise ValueError(f"negative shift count {bi}")
|
|
84
|
+
return Fraction({"<<": ai << bi, ">>": ai >> bi,
|
|
85
|
+
"&": ai & bi, "|": ai | bi, "^": ai ^ bi}[op])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: Exponent magnitude and predicted-result-size guards for `**`. Both are
|
|
89
|
+
#: checked BEFORE the exponentiation runs, not after: `a ** b` on Fraction has
|
|
90
|
+
#: no magnitude guard of its own, so `2**10**6` (the inner `10**6` evaluates
|
|
91
|
+
#: first, then `2**1000000`) takes seconds and `2**10**7` does not return in
|
|
92
|
+
#: any reasonable time, both reachable from ordinary expression syntax with no
|
|
93
|
+
#: big-integer literals involved.
|
|
94
|
+
_MAX_POW_EXPONENT = 10_000
|
|
95
|
+
_MAX_POW_RESULT_BITS = 50_000
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _pow_exact(a: Fraction, b: Fraction) -> Fraction:
|
|
99
|
+
"""a ** b, bounded before the computation runs rather than after.
|
|
100
|
+
|
|
101
|
+
The exponent's own magnitude is checked first (catches `2**10**7` for
|
|
102
|
+
free, since the huge exponent itself is rejected before any power is
|
|
103
|
+
taken). For an integer exponent, the result's bit length is then
|
|
104
|
+
predicted from the base's bit length — O(1) work — and rejected if it
|
|
105
|
+
would blow the budget, which catches a huge BASE with a modest exponent
|
|
106
|
+
too.
|
|
107
|
+
"""
|
|
108
|
+
if abs(b) > _MAX_POW_EXPONENT:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"exponent {b} exceeds the limit ({_MAX_POW_EXPONENT}) for exact exponentiation"
|
|
111
|
+
)
|
|
112
|
+
if b.denominator == 1 and a != 0:
|
|
113
|
+
base_bits = max(abs(a.numerator).bit_length(), abs(a.denominator).bit_length())
|
|
114
|
+
predicted_bits = base_bits * abs(int(b))
|
|
115
|
+
if predicted_bits > _MAX_POW_RESULT_BITS:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"{a}**{b} would need ~{predicted_bits} bits to represent, "
|
|
118
|
+
f"exceeding the limit ({_MAX_POW_RESULT_BITS}) for exact exponentiation"
|
|
119
|
+
)
|
|
120
|
+
return a ** b
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: Names consulted during the current eval_exact() call, reset per call.
|
|
124
|
+
_APPROX_CONSTS_USED: list[str] = []
|
|
125
|
+
#: math.* functions called during this eval. They must go through float — there
|
|
126
|
+
#: is no exact rational sqrt — so a NON-INTEGER result from one is an
|
|
127
|
+
#: approximation however exactly the Fraction prints. sqrt(144) -> 12 is still
|
|
128
|
+
#: the true answer; sqrt(2) -> 1414.../1000... is not.
|
|
129
|
+
_FLOAT_FUNCS_USED: list[str] = []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _ev(node):
|
|
133
|
+
"""Walk a closed set of AST nodes; anything else is refused."""
|
|
134
|
+
if isinstance(node, ast.Expression):
|
|
135
|
+
return _ev(node.body)
|
|
136
|
+
if isinstance(node, ast.Constant):
|
|
137
|
+
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
|
138
|
+
raise TypeError(f"unsupported literal {node.value!r}")
|
|
139
|
+
return Fraction(str(node.value))
|
|
140
|
+
if isinstance(node, ast.Name):
|
|
141
|
+
if node.id in _CONSTS:
|
|
142
|
+
_APPROX_CONSTS_USED.append(node.id)
|
|
143
|
+
return _CONSTS[node.id]
|
|
144
|
+
raise ValueError(f"unknown name {node.id!r}")
|
|
145
|
+
if isinstance(node, ast.UnaryOp):
|
|
146
|
+
if isinstance(node.op, ast.UAdd):
|
|
147
|
+
return _ev(node.operand)
|
|
148
|
+
if isinstance(node.op, ast.USub):
|
|
149
|
+
return -_ev(node.operand)
|
|
150
|
+
if isinstance(node.op, ast.Invert):
|
|
151
|
+
return Fraction(~int(_ev(node.operand)))
|
|
152
|
+
raise ValueError("unsupported unary op")
|
|
153
|
+
if isinstance(node, ast.BinOp):
|
|
154
|
+
fn = _BIN.get(type(node.op))
|
|
155
|
+
if fn is None:
|
|
156
|
+
raise ValueError(f"unsupported operator {type(node.op).__name__}")
|
|
157
|
+
return fn(_ev(node.left), _ev(node.right))
|
|
158
|
+
if isinstance(node, ast.Compare):
|
|
159
|
+
if len(node.ops) != 1:
|
|
160
|
+
raise ValueError("chained comparisons not supported")
|
|
161
|
+
fn = _CMP.get(type(node.ops[0]))
|
|
162
|
+
if fn is None:
|
|
163
|
+
raise ValueError(f"unsupported comparison {type(node.ops[0]).__name__}")
|
|
164
|
+
return fn(_ev(node.left), _ev(node.comparators[0]))
|
|
165
|
+
if isinstance(node, ast.Call):
|
|
166
|
+
if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCS:
|
|
167
|
+
raise ValueError("only whitelisted math functions are callable")
|
|
168
|
+
args = [_ev(a) for a in node.args]
|
|
169
|
+
fn = _FUNCS[node.func.id]
|
|
170
|
+
if node.func.id == "round" and len(args) == 2:
|
|
171
|
+
return Fraction(round(args[0] * 10 ** args[1]) / 10 ** args[1])
|
|
172
|
+
if node.func.id in _EXACT_FUNCS:
|
|
173
|
+
# Fraction supports these directly; keep the exact value.
|
|
174
|
+
return Fraction(fn(*args))
|
|
175
|
+
_FLOAT_FUNCS_USED.append(node.func.id)
|
|
176
|
+
try:
|
|
177
|
+
result = fn(*[float(a) for a in args])
|
|
178
|
+
except (TypeError, ValueError, OverflowError):
|
|
179
|
+
# functions needing exact ints (comb, factorial, ...): coerce
|
|
180
|
+
coerced = []
|
|
181
|
+
for a in args:
|
|
182
|
+
if isinstance(a, Fraction) and a.denominator == 1:
|
|
183
|
+
coerced.append(int(a))
|
|
184
|
+
else:
|
|
185
|
+
coerced.append(a)
|
|
186
|
+
result = fn(*coerced)
|
|
187
|
+
if isinstance(result, float):
|
|
188
|
+
if not math.isfinite(result):
|
|
189
|
+
raise ValueError(f"{node.func.id} produced non-finite result")
|
|
190
|
+
return Fraction(str(result))
|
|
191
|
+
return Fraction(result)
|
|
192
|
+
raise ValueError(f"unsupported syntax: {type(node).__name__}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
#: DoS guard on input size, consistent with logic.py's _MAX_EXPR_LEN.
|
|
196
|
+
_MAX_EXPR_LEN = 2000
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def eval_exact(expr: str) -> dict:
|
|
200
|
+
"""Evaluate an expression with EXACT rational arithmetic.
|
|
201
|
+
|
|
202
|
+
`0.1 + 0.2 == 0.3` is True here (False in plain Python). Supports
|
|
203
|
+
arithmetic, comparisons, bitwise ops on integers, math.* functions,
|
|
204
|
+
pi/e/tau. Returns the exact value plus decimal approximation.
|
|
205
|
+
|
|
206
|
+
GUARDED, and it was the last symbolic tool that was not. #78 put the six
|
|
207
|
+
SymPy tools in a killable child; this one has its own AST evaluator over
|
|
208
|
+
math.*, so it was not in that set and nobody noticed. It bounded `**`
|
|
209
|
+
through _MAX_POW_EXPONENT and bounded nothing about function arguments —
|
|
210
|
+
`factorial(10000000)` ran until the process was killed from outside.
|
|
211
|
+
|
|
212
|
+
Two layers, same as everywhere else here. reject_unsafe screens the string
|
|
213
|
+
(it carries the heavy-argument cap); the guard bounds whatever the screen
|
|
214
|
+
did not anticipate, which for this tool is every one of the ~50 callables
|
|
215
|
+
dir(math) exposes.
|
|
216
|
+
"""
|
|
217
|
+
_warm_exact()
|
|
218
|
+
return guarded_call(_eval_exact, expr)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _eval_exact(expr: str) -> dict:
|
|
222
|
+
"""The evaluation itself. Runs inside the guard, never called directly."""
|
|
223
|
+
if len(expr) > _MAX_EXPR_LEN:
|
|
224
|
+
return {"ok": False, "error": f"expression too long (max {_MAX_EXPR_LEN} chars)"}
|
|
225
|
+
# The screen the other four functions in this file already call, and this
|
|
226
|
+
# one never did. It carries the heavy-argument cap, which is what stops
|
|
227
|
+
# factorial(<huge>) before the evaluator reaches math.factorial.
|
|
228
|
+
_bad = reject_unsafe(expr)
|
|
229
|
+
if _bad:
|
|
230
|
+
return {"ok": False, "error": _bad}
|
|
231
|
+
try:
|
|
232
|
+
tree = ast.parse(expr, mode="eval")
|
|
233
|
+
except SyntaxError as exc:
|
|
234
|
+
return {"ok": False, "error": f"parse error: {exc.msg}"}
|
|
235
|
+
_APPROX_CONSTS_USED.clear()
|
|
236
|
+
_FLOAT_FUNCS_USED.clear()
|
|
237
|
+
try:
|
|
238
|
+
result = _ev(tree)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
return {"ok": False, "error": str(exc)}
|
|
241
|
+
approx_consts = sorted(set(_APPROX_CONSTS_USED))
|
|
242
|
+
float_funcs = sorted(set(_FLOAT_FUNCS_USED))
|
|
243
|
+
|
|
244
|
+
if isinstance(result, bool):
|
|
245
|
+
return {"ok": True, "value": result, "type": "bool"}
|
|
246
|
+
if isinstance(result, Fraction):
|
|
247
|
+
# The pow guard above bounds the CPU cost of exponentiation, but its
|
|
248
|
+
# budget is looser than Python's own int->str conversion ceiling
|
|
249
|
+
# (4300 digits by default), so a result can still be too big to
|
|
250
|
+
# FORMAT even though it was cheap to compute. Catch that here rather
|
|
251
|
+
# than let it escape as a bare ValueError with no tool-level context.
|
|
252
|
+
try:
|
|
253
|
+
d = Decimal(result.numerator) / Decimal(result.denominator)
|
|
254
|
+
approx = f"{d:.12f}".rstrip("0").rstrip(".")
|
|
255
|
+
exact = str(result) if result.denominator != 1 else str(result.numerator)
|
|
256
|
+
except ValueError as exc:
|
|
257
|
+
return {"ok": False, "error": f"result too large to format: {exc}"}
|
|
258
|
+
out = {"ok": True, "value": exact, "approx": approx,
|
|
259
|
+
# `exact` previously meant "the exact form differs from the
|
|
260
|
+
# 12-dp decimal", which is a different claim from the one the
|
|
261
|
+
# name makes. It now means what it says: is this value the true
|
|
262
|
+
# answer? An expression touching pi/e/tau is not, because those
|
|
263
|
+
# are irrational and were substituted with the rational value of
|
|
264
|
+
# their binary64 approximation.
|
|
265
|
+
# A float-path function that produced a non-integer gives a
|
|
266
|
+
# rational that merely PRINTS exactly. Integer results (sqrt(144))
|
|
267
|
+
# are still the true answer, so they stay exact.
|
|
268
|
+
"exact": not approx_consts and not (float_funcs and result.denominator != 1),
|
|
269
|
+
"differs_from_decimal": exact != approx,
|
|
270
|
+
"type": "rational"}
|
|
271
|
+
if float_funcs and result.denominator != 1:
|
|
272
|
+
out.setdefault("approximated", []).extend(float_funcs)
|
|
273
|
+
out["note"] = (f"{', '.join(float_funcs)} has no exact rational form; "
|
|
274
|
+
"the value shown is the exact rational of a binary64 "
|
|
275
|
+
"approximation, so this result is NOT exact")
|
|
276
|
+
if approx_consts:
|
|
277
|
+
out.setdefault("approximated", []).extend(approx_consts)
|
|
278
|
+
out["note"] = (f"{', '.join(approx_consts)} is irrational; the value "
|
|
279
|
+
"used is the exact rational form of its binary64 "
|
|
280
|
+
"approximation, so this result is NOT exact")
|
|
281
|
+
return out
|
|
282
|
+
return {"ok": True, "value": str(result), "type": type(result).__name__}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ────────────────────────────── threshold cmp ──────────────────────────────
|
|
286
|
+
|
|
287
|
+
_CMP_SYMS = {">": _CMP[ast.Gt], ">=": _CMP[ast.GtE], "<": _CMP[ast.Lt],
|
|
288
|
+
"<=": _CMP[ast.LtE], "==": _CMP[ast.Eq], "=": _CMP[ast.Eq],
|
|
289
|
+
"!=": _CMP[ast.NotEq]}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def compare_threshold(a: str, op: str, b: str) -> dict:
|
|
293
|
+
"""Exact threshold comparison: does `a OP b` hold?
|
|
294
|
+
|
|
295
|
+
Prints both sides as exact fractions plus the verdict and the shortfall,
|
|
296
|
+
so a threshold comparison written out cannot be gotten backwards.
|
|
297
|
+
`op` is one of ==, !=, >, >=, <, <= (= accepted for ==).
|
|
298
|
+
"""
|
|
299
|
+
op = op.strip()
|
|
300
|
+
if op not in _CMP_SYMS:
|
|
301
|
+
return {"ok": False, "error": f"bad operator {op!r}; use ==, !=, >, >=, <, <="}
|
|
302
|
+
ra = eval_exact(a)
|
|
303
|
+
rb = eval_exact(b)
|
|
304
|
+
if not (ra.get("ok") and rb.get("ok")):
|
|
305
|
+
return {"ok": False, "error": f"left: {ra.get('error')}; right: {rb.get('error')}"}
|
|
306
|
+
try:
|
|
307
|
+
fa = Fraction(ra["value"] if ra["type"] == "rational" else str(ra["value"]))
|
|
308
|
+
fb = Fraction(rb["value"] if rb["type"] == "rational" else str(rb["value"]))
|
|
309
|
+
except (ValueError, ZeroDivisionError):
|
|
310
|
+
return {"ok": False, "error": "operands must be numeric"}
|
|
311
|
+
holds = _CMP_SYMS[op](fa, fb)
|
|
312
|
+
diff = fa - fb
|
|
313
|
+
return {"ok": True, "a": str(fa), "b": str(fb), "op": op,
|
|
314
|
+
"holds": bool(holds), "difference": str(diff),
|
|
315
|
+
"shortfall": None if holds else str(-diff)}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def percentage(part: str, total: str) -> dict:
|
|
319
|
+
"""Exact share and percentage of PART / TOTAL."""
|
|
320
|
+
try:
|
|
321
|
+
p, t = Fraction(part), Fraction(total)
|
|
322
|
+
except (ValueError, ZeroDivisionError):
|
|
323
|
+
return {"ok": False, "error": "part and total must be numbers"}
|
|
324
|
+
if t == 0:
|
|
325
|
+
return {"ok": False, "error": "total is zero"}
|
|
326
|
+
share = p / t
|
|
327
|
+
d = Decimal(share.numerator) / Decimal(share.denominator)
|
|
328
|
+
return {"ok": True, "part": str(p), "total": str(t),
|
|
329
|
+
"share": f"{share.numerator}/{share.denominator}",
|
|
330
|
+
"percent": round(float(d) * 100, 6)}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ─────────────────────────────── stats / pctl ──────────────────────────────
|
|
334
|
+
|
|
335
|
+
def _first_non_finite(nums: list[float]) -> int | None:
|
|
336
|
+
"""Index of the first nan/inf in NUMS, or None if every value is finite.
|
|
337
|
+
|
|
338
|
+
nan/inf reach these tools as ordinary floats and used to raise out of
|
|
339
|
+
them uncaught — Fraction()/int() conversions elsewhere in this module
|
|
340
|
+
assume a finite value and blow up on the ones that are not
|
|
341
|
+
(AttributeError from statistics.stdev on a NaN-poisoned sum, ValueError
|
|
342
|
+
converting NaN to int, ...). Naming the offending index turns that into
|
|
343
|
+
an answerable question about the caller's input.
|
|
344
|
+
"""
|
|
345
|
+
for i, n in enumerate(nums):
|
|
346
|
+
if not math.isfinite(float(n)):
|
|
347
|
+
return i
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def stats(nums: list[float]) -> dict:
|
|
352
|
+
"""mean, median, stdev (sample), and coefficient of variation (CV)."""
|
|
353
|
+
if len(nums) < 2:
|
|
354
|
+
return {"ok": False, "error": "need at least 2 numbers"}
|
|
355
|
+
idx = _first_non_finite(nums)
|
|
356
|
+
if idx is not None:
|
|
357
|
+
return {"ok": False,
|
|
358
|
+
"error": f"nums[{idx}] = {nums[idx]!r} is not finite; stats needs finite numbers"}
|
|
359
|
+
vals = [float(n) for n in nums]
|
|
360
|
+
mean = statistics.fmean(vals)
|
|
361
|
+
stdev = statistics.stdev(vals) if len(vals) > 1 else 0.0
|
|
362
|
+
# CV is undefined when the mean is 0. It used to be float("nan"), which is
|
|
363
|
+
# (a) not valid JSON — json.dumps emits a bare NaN that strict parsers
|
|
364
|
+
# reject — and (b) silently classified as fine, because `nan > 0.2` is
|
|
365
|
+
# False, so the note read "within noise budget" for a number that does not
|
|
366
|
+
# exist. None serialises as null and says what it means.
|
|
367
|
+
cv = stdev / mean if mean else None
|
|
368
|
+
if cv is None:
|
|
369
|
+
note = "CV undefined: the mean is 0, so relative variation has no meaning"
|
|
370
|
+
elif cv > 0.2:
|
|
371
|
+
note = "run-to-run noise swamps the effect (CV > 0.2)"
|
|
372
|
+
else:
|
|
373
|
+
note = "within noise budget"
|
|
374
|
+
return {"ok": True, "n": len(vals), "mean": mean,
|
|
375
|
+
"median": statistics.median(vals),
|
|
376
|
+
"stdev": stdev, "cv": cv, "cv_note": note}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def percentiles(nums: list[float]) -> dict:
|
|
380
|
+
"""p50/p90/p95/p99 by nearest-rank AND linear interpolation."""
|
|
381
|
+
if not nums:
|
|
382
|
+
return {"ok": False, "error": "need at least 1 number"}
|
|
383
|
+
idx = _first_non_finite(nums)
|
|
384
|
+
if idx is not None:
|
|
385
|
+
return {"ok": False,
|
|
386
|
+
"error": f"nums[{idx}] = {nums[idx]!r} is not finite; percentiles needs finite numbers"}
|
|
387
|
+
vals = sorted(float(n) for n in nums)
|
|
388
|
+
n = len(vals)
|
|
389
|
+
out = {}
|
|
390
|
+
for p in (50, 90, 95, 99):
|
|
391
|
+
# nearest-rank: ceil(p/100 * n), 1-indexed
|
|
392
|
+
rank = max(1, math.ceil(p / 100 * n))
|
|
393
|
+
nr = vals[rank - 1]
|
|
394
|
+
# linear interpolation (numpy-style)
|
|
395
|
+
idx = p / 100 * (n - 1)
|
|
396
|
+
lo, hi = math.floor(idx), math.ceil(idx)
|
|
397
|
+
interp = vals[lo] + (vals[hi] - vals[lo]) * (idx - lo)
|
|
398
|
+
out[f"p{p}"] = {"nearest_rank": nr, "interpolated": round(interp, 6)}
|
|
399
|
+
warn = "n < 100: p99 is just the maximum wearing a label" if n < 100 else None
|
|
400
|
+
return {"ok": True, "n": n, "percentiles": out, "warning": warn}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def collision_prob(items: int, bits: int) -> dict:
|
|
404
|
+
"""Birthday-bound hash collision probability: 1 - exp(-n^2 / (2*2^b))."""
|
|
405
|
+
n, b = int(items), int(bits)
|
|
406
|
+
if b <= 0 or n < 0:
|
|
407
|
+
return {"ok": False, "error": "items >= 0 and bits >= 1"}
|
|
408
|
+
if n < 2:
|
|
409
|
+
return {"ok": True, "probability": 0.0, "items": n, "bits": b}
|
|
410
|
+
p = 1.0 - math.exp(-(n * n) / (2.0 * (2 ** b)))
|
|
411
|
+
return {"ok": True, "items": n, "bits": b, "probability": p,
|
|
412
|
+
"percent": round(p * 100, 6)}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ────────────────────────────── bytes / dur / time ─────────────────────────
|
|
416
|
+
|
|
417
|
+
def data_sizes(n: int) -> dict:
|
|
418
|
+
"""Byte sizes both ways: binary (KiB/MiB) and decimal (KB/MB)."""
|
|
419
|
+
n = int(n)
|
|
420
|
+
return {"ok": True, "bytes": n,
|
|
421
|
+
"binary": {"KiB": n / 1024, "MiB": n / 1024 ** 2,
|
|
422
|
+
"GiB": n / 1024 ** 3, "TiB": n / 1024 ** 4},
|
|
423
|
+
"decimal": {"KB": n / 1000, "MB": n / 1000 ** 2,
|
|
424
|
+
"GB": n / 1000 ** 3, "TB": n / 1000 ** 4}}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def human_duration(seconds: float) -> dict:
|
|
428
|
+
"""Humanised duration plus per-day and per-30d rates."""
|
|
429
|
+
s = float(seconds)
|
|
430
|
+
if not math.isfinite(s):
|
|
431
|
+
return {"ok": False, "error": f"seconds = {s!r} is not finite"}
|
|
432
|
+
if s < 0:
|
|
433
|
+
return {"ok": False, "error": "negative duration"}
|
|
434
|
+
units = [("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
|
|
435
|
+
parts = []
|
|
436
|
+
for name, size in units:
|
|
437
|
+
if s >= size or name == "s":
|
|
438
|
+
q = int(s // size)
|
|
439
|
+
parts.append(f"{q}{name}")
|
|
440
|
+
s -= q * size
|
|
441
|
+
return {"ok": True, "seconds": float(seconds),
|
|
442
|
+
"human": " ".join(parts),
|
|
443
|
+
"per_day": round(float(seconds) / 86400, 6) if seconds else 0.0,
|
|
444
|
+
"per_30d": round(float(seconds) / 2592000, 6) if seconds else 0.0}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def epoch_time(n: str) -> dict:
|
|
448
|
+
"""Epoch seconds/millis/micros/nanos to ISO 8601 UTC."""
|
|
449
|
+
try:
|
|
450
|
+
v = int(n)
|
|
451
|
+
except ValueError:
|
|
452
|
+
return {"ok": False, "error": "epoch must be an integer"}
|
|
453
|
+
if v < 0:
|
|
454
|
+
return {"ok": False, "error": "negative epoch"}
|
|
455
|
+
# 10**17 was below a real nanosecond timestamp (now ~1.8e18), so the
|
|
456
|
+
# advertised ns support was unreachable: every ns value was rejected as
|
|
457
|
+
# "implausibly large". The true ceiling is the largest value that is
|
|
458
|
+
# plausible in the COARSEST unit still accepted — nanoseconds — which is
|
|
459
|
+
# ~3e20 for the year 11000. Anything beyond that is not a timestamp.
|
|
460
|
+
if v > 10 ** 21:
|
|
461
|
+
return {"ok": False, "error": "implausibly large epoch"}
|
|
462
|
+
results = {}
|
|
463
|
+
for name, unit in (("seconds", 10 ** 0), ("millis", 10 ** 3),
|
|
464
|
+
("micros", 10 ** 6), ("nanos", 10 ** 9)):
|
|
465
|
+
secs = v / unit
|
|
466
|
+
# Lower bound raised off zero: interpreting a microsecond timestamp as
|
|
467
|
+
# nanoseconds put it in 1970, and that 1970 reading was reported
|
|
468
|
+
# alongside the real one. "Implausible readings suppressed" has to mean
|
|
469
|
+
# both ends. 10**8 ~ 1973-03; anything earlier is a unit mismatch, not a
|
|
470
|
+
# date someone meant.
|
|
471
|
+
if 10 ** 8 <= secs <= 10 ** 10: # plausible range ~1973..2286
|
|
472
|
+
try:
|
|
473
|
+
results[name] = datetime.fromtimestamp(secs, tz=UTC).isoformat()
|
|
474
|
+
except (OverflowError, OSError, ValueError):
|
|
475
|
+
pass
|
|
476
|
+
if not results:
|
|
477
|
+
return {"ok": False, "error": "no plausible unit found"}
|
|
478
|
+
return {"ok": True, "input": v, "interpretations": results}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ────────────────────────────── base / radix ───────────────────────────────
|
|
482
|
+
|
|
483
|
+
def base_repr(n: int, width: int | None = None) -> dict:
|
|
484
|
+
"""hex/oct/bin, two's complement at WIDTH, and signed-overflow detection."""
|
|
485
|
+
n = int(n)
|
|
486
|
+
w = width if width is not None else None
|
|
487
|
+
out = {"ok": True, "value": n,
|
|
488
|
+
"hex": f"{n:x}", "oct": f"{n:o}", "bin": f"{n:b}"}
|
|
489
|
+
if w is not None:
|
|
490
|
+
w = int(w)
|
|
491
|
+
if w <= 0:
|
|
492
|
+
return {"ok": False, "error": "width must be positive"}
|
|
493
|
+
lo, hi = -(2 ** (w - 1)), 2 ** (w - 1) - 1
|
|
494
|
+
uhi = 2 ** w - 1
|
|
495
|
+
if not (lo <= n <= uhi):
|
|
496
|
+
out["overflow"] = (f"does not fit in signed i{w} "
|
|
497
|
+
f"(range {lo}..{hi}) nor unsigned u{w}")
|
|
498
|
+
elif lo <= n <= hi:
|
|
499
|
+
out["signed_i" + str(w)] = n
|
|
500
|
+
out["unsigned_u" + str(w)] = n if n >= 0 else n + 2 ** w
|
|
501
|
+
out["two_complement_bin"] = f"{n & (2 ** w - 1):0{w}b}"
|
|
502
|
+
else: # only fits unsigned
|
|
503
|
+
out["signed_i" + str(w)] = n - 2 ** w
|
|
504
|
+
out["unsigned_u" + str(w)] = n
|
|
505
|
+
out["two_complement_bin"] = f"{n:0{w}b}"
|
|
506
|
+
out["wrapped_signed"] = n - 2 ** w
|
|
507
|
+
out["signed_overflow"] = (f"does not fit in signed i{w} "
|
|
508
|
+
f"(range {lo}..{hi}); fits u{w} only")
|
|
509
|
+
return out
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
_RADIX_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def radix_convert(value: str, from_base: int = 10, to_base: int = 10) -> dict:
|
|
516
|
+
"""Convert a value between any bases 2..36, fractions included.
|
|
517
|
+
|
|
518
|
+
Non-terminating results (e.g. 0.1 in base 2) are flagged.
|
|
519
|
+
"""
|
|
520
|
+
fb, tb = int(from_base), int(to_base)
|
|
521
|
+
if not (2 <= fb <= 36 and 2 <= tb <= 36):
|
|
522
|
+
return {"ok": False, "error": "bases must be 2..36"}
|
|
523
|
+
s = value.strip().lower()
|
|
524
|
+
neg = s.startswith("-")
|
|
525
|
+
if neg:
|
|
526
|
+
s = s[1:]
|
|
527
|
+
|
|
528
|
+
def _parse_digit(c):
|
|
529
|
+
v = _RADIX_DIGITS.index(c) if c in _RADIX_DIGITS else -1
|
|
530
|
+
if v < 0 or v >= fb:
|
|
531
|
+
raise ValueError(f"invalid digit {c!r} for base {fb}")
|
|
532
|
+
return v
|
|
533
|
+
|
|
534
|
+
if "." in s:
|
|
535
|
+
ip, fp = s.split(".", 1)
|
|
536
|
+
else:
|
|
537
|
+
ip, fp = s, ""
|
|
538
|
+
try:
|
|
539
|
+
whole = 0
|
|
540
|
+
for c in ip:
|
|
541
|
+
whole = whole * fb + _parse_digit(c)
|
|
542
|
+
frac_num, frac_den = 0, 1
|
|
543
|
+
for c in fp:
|
|
544
|
+
frac_num = frac_num * fb + _parse_digit(c)
|
|
545
|
+
frac_den *= fb
|
|
546
|
+
num = whole * frac_den + frac_num
|
|
547
|
+
den = frac_den
|
|
548
|
+
except ValueError as exc:
|
|
549
|
+
return {"ok": False, "error": str(exc)}
|
|
550
|
+
if den == 0:
|
|
551
|
+
return {"ok": True, "value": "0", "non_terminating": False}
|
|
552
|
+
|
|
553
|
+
# integer part to target base
|
|
554
|
+
whole = num // den
|
|
555
|
+
rem = num % den
|
|
556
|
+
int_digits = []
|
|
557
|
+
if whole == 0:
|
|
558
|
+
int_digits = ["0"]
|
|
559
|
+
while whole:
|
|
560
|
+
int_digits.append(_RADIX_DIGITS[whole % tb])
|
|
561
|
+
whole //= tb
|
|
562
|
+
int_str = "".join(reversed(int_digits))
|
|
563
|
+
|
|
564
|
+
# fractional part; terminates iff denominator (reduced) has only prime
|
|
565
|
+
# factors that also divide tb
|
|
566
|
+
from math import gcd
|
|
567
|
+
g = gcd(rem, den)
|
|
568
|
+
rn, rd = rem // g, den // g
|
|
569
|
+
# Reduce a SEPARATE copy of rd's prime factors to those in tb to decide
|
|
570
|
+
# `terminates`. This must not mutate rd itself: rd (paired with rn) is the
|
|
571
|
+
# denominator digits get generated from below, and rn/rd == rem/den (the
|
|
572
|
+
# true value) only while rd stays the reduced denominator. Stripping rd
|
|
573
|
+
# down to 1 in-place — the previous bug — left digit generation dividing
|
|
574
|
+
# the reduced numerator rn by the UNREDUCED den, i.e. computing rn/den,
|
|
575
|
+
# which is rem/den divided by g an extra time and is not the input at all.
|
|
576
|
+
check = rd
|
|
577
|
+
t = tb
|
|
578
|
+
while check > 1:
|
|
579
|
+
d = math.gcd(check, t)
|
|
580
|
+
if d == 1:
|
|
581
|
+
break
|
|
582
|
+
check //= d
|
|
583
|
+
terminates = check == 1
|
|
584
|
+
|
|
585
|
+
frac_digits = []
|
|
586
|
+
r = rn
|
|
587
|
+
seen = {}
|
|
588
|
+
while r and not terminates and len(frac_digits) < 64:
|
|
589
|
+
if r in seen:
|
|
590
|
+
break
|
|
591
|
+
seen[r] = len(frac_digits)
|
|
592
|
+
r *= tb
|
|
593
|
+
frac_digits.append(_RADIX_DIGITS[r // rd])
|
|
594
|
+
r %= rd
|
|
595
|
+
if terminates:
|
|
596
|
+
guard = 0
|
|
597
|
+
while r and guard < 100:
|
|
598
|
+
r *= tb
|
|
599
|
+
frac_digits.append(_RADIX_DIGITS[r // rd])
|
|
600
|
+
r %= rd
|
|
601
|
+
guard += 1
|
|
602
|
+
frac_str = "".join(frac_digits) if frac_digits else "0"
|
|
603
|
+
else:
|
|
604
|
+
# finite-length truncation marked with ellipsis
|
|
605
|
+
frac_str = "".join(frac_digits) + "…"
|
|
606
|
+
|
|
607
|
+
sign = "-" if neg else ""
|
|
608
|
+
if frac_digits:
|
|
609
|
+
return {"ok": True, "value": f"{sign}{int_str}.{frac_str}",
|
|
610
|
+
"non_terminating": not terminates}
|
|
611
|
+
return {"ok": True, "value": f"{sign}{int_str}", "non_terminating": False}
|
|
612
|
+
|
|
613
|
+
# ────────────────────────────── float / int / bits ─────────────────────────
|
|
614
|
+
|
|
615
|
+
def float_repr(x: float) -> dict:
|
|
616
|
+
"""What binary64 actually stores for x: exact value, bits, ULP, neighbours."""
|
|
617
|
+
x = float(x)
|
|
618
|
+
if math.isnan(x) or math.isinf(x):
|
|
619
|
+
return {"ok": True, "literal": repr(x), "stored": repr(x),
|
|
620
|
+
"bits": "nan" if math.isnan(x) else ("inf" if x > 0 else "-inf"),
|
|
621
|
+
"exact": True, "note": "non-finite value"}
|
|
622
|
+
bits = struct.unpack(">Q", struct.pack(">d", x))[0]
|
|
623
|
+
sign = bits >> 63
|
|
624
|
+
exp = (bits >> 52) & 0x7FF
|
|
625
|
+
frac = bits & 0xFFFFFFFFFFFFF
|
|
626
|
+
# exact rational value
|
|
627
|
+
if exp == 0: # subnormal
|
|
628
|
+
value = Fraction(frac, 2 ** 52) * Fraction(1, 2 ** 1022)
|
|
629
|
+
else:
|
|
630
|
+
mant = Fraction(2 ** 52 + frac, 2 ** 52)
|
|
631
|
+
e = exp - 1023
|
|
632
|
+
value = mant * (Fraction(2) ** e if e >= 0 else Fraction(1, 2 ** (-e)))
|
|
633
|
+
if sign:
|
|
634
|
+
value = -value
|
|
635
|
+
# Neighbours and ULP: math.nextafter, not the raw bit pattern. The bit
|
|
636
|
+
# pattern for -0.0 is 0x8000000000000000; decrementing that (the old
|
|
637
|
+
# "move away from zero" step for a negative sign) underflows to
|
|
638
|
+
# 0x7fffffffffffffff, which is a NaN encoding, not a neighbour. -0.0 is
|
|
639
|
+
# also the likeliest negative-zero input to a float-inspection tool.
|
|
640
|
+
# math.nextafter has no such special case: it is defined in terms of the
|
|
641
|
+
# real number line, so ±0.0 (which compare equal) both correctly step to
|
|
642
|
+
# ±5e-324. The bit-level path stays for bits_hex/exact/stored, which are
|
|
643
|
+
# legitimately about the stored encoding rather than the value's
|
|
644
|
+
# neighbours.
|
|
645
|
+
#
|
|
646
|
+
# This deliberately CHANGES +0.0 too, whose `prev` was -0.0 and is now
|
|
647
|
+
# -5e-324. The old special case reasoned that the adjacent bit pattern
|
|
648
|
+
# below +0.0 is -0.0, which is true of the ENCODING and false of the
|
|
649
|
+
# VALUE: -0.0 == 0.0, so it is not a neighbour on the number line. Mixing
|
|
650
|
+
# those two questions in one field is what produced the -0.0 bug. The
|
|
651
|
+
# encoding question is still answered, by bits_hex/sign/stored.
|
|
652
|
+
next_val = math.nextafter(x, math.inf)
|
|
653
|
+
prev_val = math.nextafter(x, -math.inf)
|
|
654
|
+
ulp = abs(next_val - x)
|
|
655
|
+
# is the literal representable? (compare exact rational of literal vs stored)
|
|
656
|
+
exact = (Decimal(str(x)) == Decimal(value.numerator) / Decimal(value.denominator))
|
|
657
|
+
d = Decimal(value.numerator) / Decimal(value.denominator)
|
|
658
|
+
stored = f"{d:.40f}".rstrip("0").rstrip(".")
|
|
659
|
+
err = value - Fraction(str(x))
|
|
660
|
+
rel = abs(float(err) / float(value)) if value else 0.0
|
|
661
|
+
return {"ok": True, "literal": repr(x), "stored": stored,
|
|
662
|
+
"bits_hex": f"0x{bits:016x}", "sign": sign, "exponent": exp,
|
|
663
|
+
"fraction_hex": f"0x{frac:013x}",
|
|
664
|
+
"exact": bool(exact),
|
|
665
|
+
"error": str(err), "relative_error": rel,
|
|
666
|
+
"ulp": ulp, "prev": prev_val, "next": next_val,
|
|
667
|
+
"note": (">2^53: consecutive integers no longer distinguishable"
|
|
668
|
+
if abs(x) > 2 ** 53 else None)}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def int_widths(n: int) -> dict:
|
|
672
|
+
"""Which widths hold n, and the wrapped value where they do not."""
|
|
673
|
+
n = int(n)
|
|
674
|
+
out = {"ok": True, "value": n}
|
|
675
|
+
fits = []
|
|
676
|
+
wraps = []
|
|
677
|
+
for w in (8, 16, 32, 64):
|
|
678
|
+
lo, hi = -(2 ** (w - 1)), 2 ** (w - 1) - 1
|
|
679
|
+
uhi = 2 ** w - 1
|
|
680
|
+
if lo <= n <= hi:
|
|
681
|
+
fits.append(f"i{w}")
|
|
682
|
+
if n >= 0:
|
|
683
|
+
fits.append(f"u{w}")
|
|
684
|
+
elif 0 <= n <= uhi:
|
|
685
|
+
# Fits unsigned only. The `n >= 0` guard is the fix: the test used
|
|
686
|
+
# to be `n <= uhi`, which is true for EVERY negative number, so
|
|
687
|
+
# int_widths(-200) claimed u8 and int_widths(-2**70) claimed
|
|
688
|
+
# u8/u16/u32/u64. A negative value fits no unsigned width at all.
|
|
689
|
+
fits.append(f"u{w}")
|
|
690
|
+
wraps.append(f"i{w}: wraps to {n - 2 ** w}")
|
|
691
|
+
elif n < lo:
|
|
692
|
+
wraps.append(f"i{w}/u{w}: too negative (below {lo})")
|
|
693
|
+
else:
|
|
694
|
+
wraps.append(f"i{w}/u{w}: too large (above {uhi})")
|
|
695
|
+
out["fits"] = fits
|
|
696
|
+
out["wraps"] = wraps
|
|
697
|
+
out["note"] = (">2^53 cannot round-trip through a JS number or JSON float"
|
|
698
|
+
if abs(n) > 2 ** 53 else None)
|
|
699
|
+
return out
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def bit_analysis(n: int, align: int | None = None) -> dict:
|
|
703
|
+
"""popcount, trailing zeros, next pow2, alignment padding."""
|
|
704
|
+
n = int(n)
|
|
705
|
+
out = {"ok": True, "value": n,
|
|
706
|
+
"popcount": n.bit_count(),
|
|
707
|
+
"bit_length": n.bit_length() if n >= 0 else None,
|
|
708
|
+
"trailing_zeros": (n & -n).bit_length() - 1 if n else None,
|
|
709
|
+
"is_power_of_two": n > 0 and (n & (n - 1)) == 0}
|
|
710
|
+
if n > 0:
|
|
711
|
+
out["next_power_of_two"] = 1 << (n - 1).bit_length()
|
|
712
|
+
if align is not None:
|
|
713
|
+
a = int(align)
|
|
714
|
+
if a <= 0:
|
|
715
|
+
return {"ok": False, "error": "alignment must be positive"}
|
|
716
|
+
pad = (-n) % a
|
|
717
|
+
out["aligned_to"] = a
|
|
718
|
+
out["padding_needed"] = pad
|
|
719
|
+
out["aligned_value"] = n + pad
|
|
720
|
+
return out
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# ────────────────────────────── bitop (programmer mode) ────────────────────
|
|
724
|
+
|
|
725
|
+
_BITOP_OPS = ("and", "or", "xor", "nand", "nor", "xnor", "not",
|
|
726
|
+
"shl", "shr", "sar", "rol", "ror")
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def bitop(a: int, op: str, b: int | None = None, width: int = 64) -> dict:
|
|
730
|
+
"""Programmer-mode bit operations at a fixed width.
|
|
731
|
+
|
|
732
|
+
Every result prints unsigned, signed, hex, octal and nibble-grouped
|
|
733
|
+
binary at the declared width (8/16/32/64). shr = logical (zero-fill),
|
|
734
|
+
sar = arithmetic (sign-propagating). A left shift that drops bits is
|
|
735
|
+
flagged OVERFLOW with the unbounded answer shown.
|
|
736
|
+
"""
|
|
737
|
+
op = op.lower()
|
|
738
|
+
w = int(width)
|
|
739
|
+
if w not in (8, 16, 32, 64):
|
|
740
|
+
return {"ok": False, "error": "width must be 8/16/32/64"}
|
|
741
|
+
mask = (1 << w) - 1
|
|
742
|
+
sign_bit = 1 << (w - 1)
|
|
743
|
+
a = int(a) & mask
|
|
744
|
+
|
|
745
|
+
def to_signed(v):
|
|
746
|
+
return v - (1 << w) if v & sign_bit else v
|
|
747
|
+
|
|
748
|
+
def show(v):
|
|
749
|
+
u = v & mask
|
|
750
|
+
return {"unsigned": u, "signed": to_signed(u),
|
|
751
|
+
"hex": f"0x{u:0{w // 4}x}",
|
|
752
|
+
"oct": f"0o{u:0{(w + 2) // 3}o}",
|
|
753
|
+
"bin": f"{u:0{w}b}"}
|
|
754
|
+
|
|
755
|
+
if op == "not":
|
|
756
|
+
r = ~a & mask
|
|
757
|
+
return {"ok": True, "op": op, "width": w, **show(r)}
|
|
758
|
+
if b is None:
|
|
759
|
+
return {"ok": False, "error": f"{op} needs two operands"}
|
|
760
|
+
# For shl/shr/sar/rol/ror, `b` is a SHIFT COUNT, not a value at this
|
|
761
|
+
# width — masking it with the width mask truncated the count itself:
|
|
762
|
+
# bitop(1, "shl", 256, 8) masked 256 to 0 and shifted by 0, silently
|
|
763
|
+
# turning "shift far past the width" into "don't shift at all". Keep the
|
|
764
|
+
# count as a real (unmasked) integer and reject a negative one outright;
|
|
765
|
+
# only and/or/xor/nand/nor/xnor treat `b` as a VALUE, which does get
|
|
766
|
+
# masked to the declared width like `a` above.
|
|
767
|
+
if op in ("shl", "shr", "sar", "rol", "ror"):
|
|
768
|
+
b = int(b)
|
|
769
|
+
if b < 0:
|
|
770
|
+
return {"ok": False, "error": f"negative shift/rotate count {b}"}
|
|
771
|
+
else:
|
|
772
|
+
b = int(b) & mask
|
|
773
|
+
if op == "and":
|
|
774
|
+
return {"ok": True, "op": op, "width": w, **show(a & b)}
|
|
775
|
+
if op == "or":
|
|
776
|
+
return {"ok": True, "op": op, "width": w, **show(a | b)}
|
|
777
|
+
if op == "xor":
|
|
778
|
+
return {"ok": True, "op": op, "width": w, **show(a ^ b)}
|
|
779
|
+
if op == "nand":
|
|
780
|
+
return {"ok": True, "op": op, "width": w, **show(~(a & b))}
|
|
781
|
+
if op == "nor":
|
|
782
|
+
return {"ok": True, "op": op, "width": w, **show(~(a | b))}
|
|
783
|
+
if op == "xnor":
|
|
784
|
+
return {"ok": True, "op": op, "width": w, **show(~(a ^ b))}
|
|
785
|
+
if op == "shl": # logical left
|
|
786
|
+
if b >= w:
|
|
787
|
+
return {"ok": True, "op": op, "width": w, **show(0),
|
|
788
|
+
"overflow": f"shift {b} >= width {w}"}
|
|
789
|
+
r = a << b
|
|
790
|
+
if r > mask:
|
|
791
|
+
return {"ok": True, "op": op, "width": w, **show(r),
|
|
792
|
+
"overflow": True, "unbounded": r}
|
|
793
|
+
return {"ok": True, "op": op, "width": w, **show(r)}
|
|
794
|
+
if op == "shr": # logical right: zero-fill
|
|
795
|
+
if b >= w:
|
|
796
|
+
return {"ok": True, "op": op, "width": w, "unsigned": 0,
|
|
797
|
+
"signed": 0, "hex": "0x0", "oct": "0o0",
|
|
798
|
+
"bin": "0" * w}
|
|
799
|
+
return {"ok": True, "op": op, "width": w, **show(a >> b)}
|
|
800
|
+
if op == "sar": # arithmetic right: sign-propagating
|
|
801
|
+
sa = to_signed(a)
|
|
802
|
+
r = sa >> b if b < w else (-1 if sa < 0 else 0)
|
|
803
|
+
return {"ok": True, "op": op, "width": w, **show(r & mask)}
|
|
804
|
+
if op == "rol":
|
|
805
|
+
b %= w
|
|
806
|
+
r = ((a << b) | (a >> (w - b))) & mask if b else a
|
|
807
|
+
return {"ok": True, "op": op, "width": w, **show(r)}
|
|
808
|
+
if op == "ror":
|
|
809
|
+
b %= w
|
|
810
|
+
r = ((a >> b) | (a << (w - b))) & mask if b else a
|
|
811
|
+
return {"ok": True, "op": op, "width": w, **show(r)}
|
|
812
|
+
return {"ok": False, "error": f"unknown op {op!r}; use {', '.join(_BITOP_OPS)}"}
|
|
813
|
+
|
|
814
|
+
# ────────────────────────────── symbolic (sympy) ───────────────────────────
|
|
815
|
+
|
|
816
|
+
def _sympy():
|
|
817
|
+
sp = require("sympy")
|
|
818
|
+
return sp
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _algebraic_equiv(a: str, b: str) -> dict:
|
|
822
|
+
"""Are two expressions algebraically identical? (refactor verification)
|
|
823
|
+
|
|
824
|
+
Identity over symbolic reals says nothing about float rounding, integer
|
|
825
|
+
truncation or modular overflow — usually where the real difference lives.
|
|
826
|
+
"""
|
|
827
|
+
# SymPy evaluates what it parses (see codecalc/safe_expr.py). Screen the
|
|
828
|
+
# caller's string BEFORE it reaches sympify, not after.
|
|
829
|
+
for _name, _val in (("a", a), ("b", b)):
|
|
830
|
+
_bad = reject_unsafe(_val)
|
|
831
|
+
if _bad:
|
|
832
|
+
return {"ok": False, "error": f"{_name}: {_bad}"}
|
|
833
|
+
sp = _sympy()
|
|
834
|
+
try:
|
|
835
|
+
ea = sp.sympify(a)
|
|
836
|
+
eb = sp.sympify(b)
|
|
837
|
+
except Exception as exc:
|
|
838
|
+
return {"ok": False, "error": f"parse: {exc}"}
|
|
839
|
+
diff = sp.simplify(ea - eb)
|
|
840
|
+
identical = diff == 0
|
|
841
|
+
return {"ok": True, "a": str(ea), "b": str(eb),
|
|
842
|
+
"identical": bool(identical), "difference": str(diff),
|
|
843
|
+
"caveat": ("identity over symbolic reals says nothing about float "
|
|
844
|
+
"rounding, integer truncation or modular overflow") if not identical else None}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _solve_expression(expr: str, var: str = "x") -> dict:
|
|
848
|
+
"""Solve for a root or crossover: `x**2 - 4 = 0`, `2*x + 1 = 7`."""
|
|
849
|
+
# SymPy evaluates what it parses (see codecalc/safe_expr.py). Screen the
|
|
850
|
+
# caller's string BEFORE it reaches sympify, not after.
|
|
851
|
+
# Screened per SIDE, because this splits on '=' before sympify and the
|
|
852
|
+
# screen refuses '=' — the parts are what reach the parser, so the parts
|
|
853
|
+
# are what must be checked.
|
|
854
|
+
for _part in (expr.split("=", 1) if ("=" in expr and "==" not in expr) else [expr]):
|
|
855
|
+
_bad = reject_unsafe(_part)
|
|
856
|
+
if _bad:
|
|
857
|
+
return {"ok": False, "error": _bad}
|
|
858
|
+
sp = _sympy()
|
|
859
|
+
try:
|
|
860
|
+
if "=" in expr and "==" not in expr:
|
|
861
|
+
lhs, rhs = expr.split("=", 1)
|
|
862
|
+
eq = sp.Eq(sp.sympify(lhs), sp.sympify(rhs))
|
|
863
|
+
else:
|
|
864
|
+
eq = sp.Eq(sp.sympify(expr), 0)
|
|
865
|
+
sym = sp.Symbol(var.strip())
|
|
866
|
+
solutions = sp.solve(eq, sym)
|
|
867
|
+
except Exception as exc:
|
|
868
|
+
return {"ok": False, "error": str(exc)}
|
|
869
|
+
return {"ok": True, "equation": str(eq), "variable": var,
|
|
870
|
+
"solutions": [str(s) for s in solutions]}
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _limit_expression(expr: str, var: str = "x", point: str = "oo") -> dict:
|
|
874
|
+
"""Asymptotic behaviour: limit of EXPR as var -> point (default oo)."""
|
|
875
|
+
# SymPy evaluates what it parses (see codecalc/safe_expr.py). Screen the
|
|
876
|
+
# caller's string BEFORE it reaches sympify, not after.
|
|
877
|
+
for _name, _val in (("expr", expr), ("point", point)):
|
|
878
|
+
_bad = reject_unsafe(_val)
|
|
879
|
+
if _bad:
|
|
880
|
+
return {"ok": False, "error": f"{_name}: {_bad}"}
|
|
881
|
+
sp = _sympy()
|
|
882
|
+
try:
|
|
883
|
+
e = sp.sympify(expr)
|
|
884
|
+
sym = sp.Symbol(var.strip())
|
|
885
|
+
pt = sp.oo if point.strip() == "oo" else sp.sympify(point.strip())
|
|
886
|
+
result = sp.limit(e, sym, pt)
|
|
887
|
+
except Exception as exc:
|
|
888
|
+
return {"ok": False, "error": str(exc)}
|
|
889
|
+
return {"ok": True, "expression": str(e), "variable": var,
|
|
890
|
+
"point": str(pt), "limit": str(result)}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _simplify_expression(expr: str) -> dict:
|
|
894
|
+
"""Simplified, factored and expanded forms of an expression."""
|
|
895
|
+
# SymPy evaluates what it parses (see codecalc/safe_expr.py). Screen the
|
|
896
|
+
# caller's string BEFORE it reaches sympify, not after.
|
|
897
|
+
_bad = reject_unsafe(expr)
|
|
898
|
+
if _bad:
|
|
899
|
+
return {"ok": False, "error": _bad}
|
|
900
|
+
sp = _sympy()
|
|
901
|
+
try:
|
|
902
|
+
e = sp.sympify(expr)
|
|
903
|
+
return {"ok": True, "original": str(e),
|
|
904
|
+
"simplified": str(sp.simplify(e)),
|
|
905
|
+
"factored": str(sp.factor(e)),
|
|
906
|
+
"expanded": str(sp.expand(e))}
|
|
907
|
+
except Exception as exc:
|
|
908
|
+
return {"ok": False, "error": str(exc)}
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
# ── the bound (#84) ────────────────────────────────────────────────────────
|
|
912
|
+
# Each tool above reaches SymPy's parser, which evaluates what it parses. The
|
|
913
|
+
# screen in safe_expr runs first and still does the work it always did; these
|
|
914
|
+
# wrappers add the part a denylist cannot provide, which is a ceiling that did
|
|
915
|
+
# not have to anticipate the expression.
|
|
916
|
+
#
|
|
917
|
+
# The bodies are untouched — renamed to _name and called through guarded_call,
|
|
918
|
+
# so the result shapes a caller already depends on are unchanged except for an
|
|
919
|
+
# `unenforced` entry where the platform has no fork.
|
|
920
|
+
|
|
921
|
+
_EXACT_WARMED = False
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _warm_exact() -> None:
|
|
925
|
+
"""Warm the parent for the paths THESE tools take, once.
|
|
926
|
+
|
|
927
|
+
Not shared with logic.py's warm set, and not merged into it: measured,
|
|
928
|
+
solve/limit/simplify reach different SymPy submodules than
|
|
929
|
+
evaluate_expression does, and warming the wrong ones costs the same startup
|
|
930
|
+
while leaving the fork to re-import the right ones on every call. #78
|
|
931
|
+
measured that mistake at +196ms on a single expression.
|
|
932
|
+
"""
|
|
933
|
+
global _EXACT_WARMED
|
|
934
|
+
if _EXACT_WARMED:
|
|
935
|
+
return
|
|
936
|
+
_EXACT_WARMED = True
|
|
937
|
+
try:
|
|
938
|
+
_simplify_expression("x**2 + 2*x + 1")
|
|
939
|
+
_solve_expression("x**2 - 4 = 0", "x")
|
|
940
|
+
_limit_expression("1/x", "x", "oo")
|
|
941
|
+
_algebraic_equiv("(x+1)**2", "x**2 + 2*x + 1")
|
|
942
|
+
except Exception:
|
|
943
|
+
# Warming is an optimisation; a failure here must not take down the
|
|
944
|
+
# call the caller actually made.
|
|
945
|
+
pass
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def algebraic_equiv(a: str, b: str) -> dict:
|
|
949
|
+
"""Are two expressions algebraically identical? (refactor verification)"""
|
|
950
|
+
_warm_exact()
|
|
951
|
+
return guarded_call(_algebraic_equiv, a, b)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def solve_expression(expr: str, var: str = "x") -> dict:
|
|
955
|
+
"""Solve for a root or crossover: `x**2 - 4 = 0`, `2*x + 1 = 7`."""
|
|
956
|
+
_warm_exact()
|
|
957
|
+
return guarded_call(_solve_expression, expr, var)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def limit_expression(expr: str, var: str = "x", point: str = "oo") -> dict:
|
|
961
|
+
"""Asymptotic behaviour: limit of EXPR as var -> point (default oo)."""
|
|
962
|
+
_warm_exact()
|
|
963
|
+
return guarded_call(_limit_expression, expr, var, point)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def simplify_expression(expr: str) -> dict:
|
|
967
|
+
"""Simplified, factored and expanded forms of an expression."""
|
|
968
|
+
_warm_exact()
|
|
969
|
+
return guarded_call(_simplify_expression, expr)
|