@cyanheads/calculator-mcp-server 0.4.3 → 0.5.0

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.
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * @fileoverview Hardened math.js wrapper for secure expression evaluation.
3
3
  * Creates a restricted math.js instance with dangerous functions disabled in
4
- * the expression scope, and wraps evaluation in a vm sandbox with timeout.
4
+ * the expression scope, parses each expression once and checks the tree before
5
+ * anything runs, and evaluates, inspects, and formats the result inside a vm
6
+ * sandbox with a timeout.
5
7
  * @module services/math/math-service
6
8
  */
7
9
  import vm from 'node:vm';
8
- import { timeout, validationError } from '@cyanheads/mcp-ts-core/errors';
9
- import { all, create } from 'mathjs';
10
+ import { McpError, timeout, validationError } from '@cyanheads/mcp-ts-core/errors';
11
+ import { all, create, isBigNumber, isComplex, isHelp, isMatrix, isSparseMatrix, isUnit, } from 'mathjs';
12
+ import { beginEvaluation, installSizeGuards, isPlainObject, meterNode, sizeLimitErrorIn, } from './size-guard.js';
10
13
  /**
11
14
  * Custom simplification rules extending math.js defaults.
12
15
  * math.js ships with algebraic rules only — these add common trig identities.
@@ -43,6 +46,7 @@ const CUSTOM_UNITS = {
43
46
  * Functions disabled in the expression scope for security.
44
47
  * These are overridden via math.import() — expressions cannot call them.
45
48
  * simplify/derivative are called programmatically by the tool handler, not from expressions.
49
+ * `config` is not listed: it gets a read-only guard instead (see createMathInstance).
46
50
  */
47
51
  const DISABLED_FUNCTIONS = [
48
52
  'import',
@@ -55,7 +59,6 @@ const DISABLED_FUNCTIONS = [
55
59
  'reviver',
56
60
  'compile',
57
61
  'chain',
58
- 'config',
59
62
  'parser',
60
63
  ];
61
64
  /**
@@ -81,17 +84,92 @@ const BLOCKED_RESULT_TYPES = new Set(['function', 'Function', 'ResultSet', 'Pars
81
84
  * accessor: `.toString()` / `.toLocaleString()` on a function-valued identifier
82
85
  * (e.g. `cos.toString()`) otherwise returns internal source as a plain string,
83
86
  * slipping past {@link BLOCKED_RESULT_TYPES} (which only sees the value after
84
- * stringification). See {@link MathService.validateNoFunctionStringification}.
87
+ * stringification). See {@link inspectTree}.
85
88
  */
86
89
  const STRINGIFYING_METHODS = new Set(['toString', 'toLocaleString']);
87
90
  /**
88
- * math.js error thrown when a Fraction-mode expression yields a value that has
89
- * no exact rational representation (`sqrt`, `sin`, `log`, …). math.js reports it
90
- * as an implicit type-conversion failure; paired with a `numericType === 'Fraction'`
91
- * guard it maps to the actionable `fraction_unsupported` error instead of the
92
- * misleading, syntax-oriented `parse_failed`. See {@link MathService.runWithTimeout}.
91
+ * The tool's `operation` values. Each is also a disabled function name, so a
92
+ * call such as `derivative("x^2", "x")` inside an expression is a caller asking
93
+ * for the operation the wrong way — rejected at parse time with a pointer to the
94
+ * `operation` parameter rather than the generic disabled-function message.
95
+ */
96
+ const OPERATION_FUNCTIONS = new Set(['evaluate', 'simplify', 'derivative']);
97
+ /**
98
+ * Standard-notation function names math.js does not define — natural log `ln`
99
+ * and the inverse-trig `arc*` family. Renamed on the parse tree (see
100
+ * {@link inspectTree}) to their math.js builtins.
101
+ */
102
+ const NOTATION_ALIAS = /^(?:ln|arc(?:sin|cos|tan|sec|csc|cot)h?)$/;
103
+ /**
104
+ * Node types that compute a new value — a call, an operator, a matrix or object
105
+ * literal, an indexed read, a range. Each is metered against the evaluation's
106
+ * element budget; the rest (symbols, constants, parentheses, assignments,
107
+ * conditionals) pass along a value some other node built.
108
+ */
109
+ const VALUE_BUILDING_NODES = new Set([
110
+ 'AccessorNode',
111
+ 'ArrayNode',
112
+ 'FunctionNode',
113
+ 'ObjectNode',
114
+ 'OperatorNode',
115
+ 'RangeNode',
116
+ ]);
117
+ /**
118
+ * Lower bound on the characters one element of a collection result renders to:
119
+ * at least one character plus the `, ` separator (a one-element matrix, `[0]`,
120
+ * is three). A result with more than `maxResultLength / 3` elements can never
121
+ * fit, so it is rejected before formatting.
122
+ */
123
+ const MIN_CHARS_PER_ELEMENT = 3;
124
+ /**
125
+ * math.js error thrown when a Fraction-mode expression calls a function with no
126
+ * Fraction implementation: irrational and transcendental ones (`sqrt`, `sin`,
127
+ * `log`, …) and some whose result can be rational (`sqrt(4)`, `5!`,
128
+ * `combinations`). math.js reports it as an implicit type-conversion failure that
129
+ * does not name the function; paired with a `numericType === 'Fraction'` guard it
130
+ * maps to the actionable `fraction_unsupported` error instead of the misleading,
131
+ * syntax-oriented `parse_failed`. See {@link MathService.classifyFailure}.
93
132
  */
94
133
  const FRACTION_CONVERSION_ERROR = /Cannot implicitly convert a Fraction/;
134
+ /**
135
+ * math.js error thrown when a 64-bit float meets a Fraction and has no Fraction
136
+ * equal to it — `pi * 2/3`, `pi^40`, `sin(pi) + 1/3`. Under numericType
137
+ * "Fraction" the float can only have come from a value Fraction mode holds
138
+ * approximately, so it maps to `fraction_unsupported`, not `type_mismatch`.
139
+ */
140
+ const FLOAT_TO_FRACTION_ERROR = /^Cannot implicitly convert a number to a Fraction when there will be a loss of precision/;
141
+ /** `fraction_unsupported` message for a function Fraction mode cannot compute. */
142
+ const FRACTION_FUNCTION_MESSAGE = 'numericType "Fraction" cannot compute this expression: either its result has no exact rational value (e.g. sqrt(2), sin(1), log(3)), or it calls a function Fraction mode cannot compute, even for a rational result (e.g. sqrt(4), 5!, combinations(5, 2)). Retry with numericType "number" or "BigNumber".';
143
+ /** `undefined_result` message: a non-finite value, or a Fraction division by zero. */
144
+ const UNDEFINED_RESULT_MESSAGE = 'Expression evaluated to a non-finite result (Infinity, -Infinity, or NaN): either the operation is mathematically undefined (division by zero, 0/0, log(0)), or a value overflowed its numeric range (under numericType "number", past about 1.8e308 — e.g. 171!, 2^1024, exp(1000)).';
145
+ /** `fraction_unsupported` message for a Fraction-mode evaluation that picked up a float. */
146
+ const FRACTION_FLOAT_MESSAGE = 'numericType "Fraction" cannot return this result exactly: the expression uses a value Fraction mode holds only as a rounded 64-bit float — an irrational constant (pi, e), a non-integer power (2^(1/2)), a complex number with a non-integer part, or a float from number() or random(). Retry with numericType "number" or "BigNumber".';
147
+ /** math.js derivative error for a function or operator it has no differentiation rule for. */
148
+ const NO_DERIVATIVE_RULE = /^Cannot process (function|operator) "([^"]+)" in derivative/;
149
+ /**
150
+ * Unit names math.js resolves to a function first — `5 min` multiplies by the
151
+ * minimum function — paired with a unit name to write instead.
152
+ */
153
+ const FUNCTION_SHADOWED_UNITS = { min: 'minute', sec: 's' };
154
+ /** Message of the vm timeout error (`ERR_SCRIPT_EXECUTION_TIMEOUT`), also when re-wrapped. */
155
+ const VM_TIMEOUT_ERROR = /Script execution timed out after/;
156
+ /**
157
+ * fraction.js throws this for any Fraction division by zero — reachable in every
158
+ * numericType through `fraction()`. Fractions never hold Infinity, so it is the
159
+ * Fraction form of a non-finite result and maps to `undefined_result`.
160
+ */
161
+ const FRACTION_DIVISION_BY_ZERO = /^Division by Zero$/;
162
+ /**
163
+ * Evaluation-time errors that are about the expression's names rather than its
164
+ * values: an undefined symbol, function, or unit, a disabled function, or a
165
+ * blocked property. They stay `parse_failed`, whose recovery is about names.
166
+ */
167
+ const NAME_ERROR = /^(?:Undefined (?:symbol|function) |Unit ".*" not found|No access to )|is disabled for security\.$/;
168
+ /**
169
+ * Operand type and unit errors that math.js raises as plain messages (typed-function
170
+ * `wrongType` errors are recognized by `data.category` instead).
171
+ */
172
+ const TYPE_MISMATCH_ERROR = /^(?:Units do not match|Cannot convert |Cannot implicitly convert )|is no angle$/;
95
173
  /**
96
174
  * Scope key names that could pollute the object prototype chain or shadow
97
175
  * critical Object.prototype methods. Validated before passing to math.js.
@@ -112,96 +190,262 @@ const BLOCKED_SCOPE_KEYS = new Set([
112
190
  'toLocaleString',
113
191
  ]);
114
192
  /**
115
- * Check for expression separators (`;` or newline) that split the input into
116
- * multiple statements. Three contexts are NOT separators and are skipped:
117
- * - `;` inside `[...]` — a matrix row separator (`[1, 2; 3, 4]`).
118
- * - `;` or a newline inside a double-quoted string literal — part of the data,
119
- * not a statement break (`"a;b"`, `concat("a;b", "c")`).
120
- * - `[` / `]` inside a string literal — they must not shift the bracket depth,
121
- * or a string such as `"]"` would let a later top-level `;` slip past.
193
+ * Walk a freshly parsed tree once: flag stringifier access and operation-named
194
+ * calls, collect the names used as values, meter every value-building node against the element budget (so a node
195
+ * in a loop body — a user-defined function or an inline `map` callback — is
196
+ * charged on each iteration), and rename standard-notation calls in place —
197
+ * `ln` → `log`, `arc<fn>` → `a<fn>` (`arcsin` → `asin`, `arctanh` → `atanh`, …).
122
198
  *
123
- * Single quotes are NOT string delimiters in math.js — `'` is the transpose
124
- * operator (`A'`) — so only `"` opens a string span, and a backslash escapes the
125
- * next character within it (`"\""` stays open). The scan is intentionally
126
- * lexical rather than a full parse: it only has to recognize quoted spans, and a
127
- * genuine multi-statement input (`1+2; 3+4`) is still caught at depth 0.
199
+ * The rename works on `FunctionNode`s whose callee is a `SymbolNode`, so string
200
+ * literals (`"ln("` is a `ConstantNode`) and scope variables named `ln` (a bare
201
+ * `SymbolNode`) are never touched, and nested calls are reached because the walk
202
+ * covers every node. It is name-level rather than a `math.import` alias because
203
+ * symbolic `derivative` matches builtin names — an imported alias is not in its
204
+ * differentiation table. The tree is evaluated as-is afterwards, never
205
+ * re-stringified: a Fraction-mode tree prints `0.1` as `1/10`.
128
206
  */
129
- function hasExpressionSeparator(expr) {
130
- let depth = 0;
131
- let inString = false;
132
- for (let i = 0; i < expr.length; i++) {
133
- const ch = expr[i];
134
- if (inString) {
135
- if (ch === '\\')
136
- i++;
137
- else if (ch === '"')
138
- inString = false;
139
- continue;
207
+ function inspectTree(tree) {
208
+ const inspection = {
209
+ stringifies: false,
210
+ operationCall: undefined,
211
+ valueNames: new Set(),
212
+ };
213
+ tree.traverse((node, path) => {
214
+ if (VALUE_BUILDING_NODES.has(node.type))
215
+ meterNode(node);
216
+ if (node.type === 'SymbolNode' && path !== 'fn') {
217
+ inspection.valueNames.add(node.name);
140
218
  }
141
- if (ch === '"')
142
- inString = true;
143
- else if (ch === '[')
144
- depth++;
145
- else if (ch === ']')
146
- depth--;
147
- else if (ch === '\n' || ch === '\r')
148
- return true;
149
- else if (ch === ';' && depth === 0)
150
- return true;
151
- }
219
+ if (node.type === 'IndexNode') {
220
+ const { dimensions } = node;
221
+ if (dimensions.some((dim) => dim.type === 'ConstantNode' &&
222
+ STRINGIFYING_METHODS.has(String(dim.value)))) {
223
+ inspection.stringifies = true;
224
+ }
225
+ return;
226
+ }
227
+ if (node.type !== 'FunctionNode')
228
+ return;
229
+ const callee = node.fn;
230
+ if (callee.type !== 'SymbolNode' || callee.name === undefined)
231
+ return;
232
+ if (OPERATION_FUNCTIONS.has(callee.name)) {
233
+ inspection.operationCall ??= callee.name;
234
+ }
235
+ else if (NOTATION_ALIAS.test(callee.name)) {
236
+ callee.name = callee.name === 'ln' ? 'log' : `a${callee.name.slice(3)}`;
237
+ }
238
+ });
239
+ return inspection;
240
+ }
241
+ /** Whether a scalar math.js value is Infinity, -Infinity, or NaN. */
242
+ function isNonFiniteScalar(value) {
243
+ if (typeof value === 'number')
244
+ return !Number.isFinite(value);
245
+ if (isBigNumber(value))
246
+ return !value.isFinite();
247
+ if (isComplex(value))
248
+ return isNonFiniteScalar(value.re) || isNonFiniteScalar(value.im);
152
249
  return false;
153
250
  }
154
251
  /**
155
- * Recursively test whether a math.js result holds any non-finite number
156
- * (Infinity, -Infinity, NaN). The `undefined_result` guard must reach inside
157
- * compound types: a `DenseMatrix`/`SparseMatrix` element or the real/imaginary
158
- * part of a `Complex` can be non-finite while the outer value is an object — a
159
- * scalar `typeof === 'number'` check would skip it and leak `Infinity` to clients.
252
+ * Whether a scalar is a 64-bit float that need not be the exact value: a
253
+ * `number`, or a `Complex` part, that is not a safe integer. Under numericType
254
+ * "Fraction" an exact value is a Fraction or a whole-number count (`count`,
255
+ * `size`), so such a float came from an irrational constant, a non-integer
256
+ * power, complex arithmetic, or an explicit float conversion. The test is
257
+ * safe-integer rather than integer because every double past 2^53 is an integer.
258
+ * A `BigNumber` only appears when an expression asks for one (`bignumber(x)`),
259
+ * and a `Unit` is judged by its magnitude.
160
260
  */
161
- function containsNonFinite(value) {
261
+ function isFloatApproximation(value) {
162
262
  if (typeof value === 'number')
163
- return !Number.isFinite(value);
164
- if (value === null || typeof value !== 'object')
165
- return false;
166
- if (Array.isArray(value))
167
- return value.some(containsNonFinite);
168
- // Complex: re/im are plain numbers.
169
- if ('re' in value && 'im' in value) {
170
- return containsNonFinite(value.re) || containsNonFinite(value.im);
263
+ return !Number.isSafeInteger(value);
264
+ if (isComplex(value))
265
+ return isFloatApproximation(value.re) || isFloatApproximation(value.im);
266
+ return false;
267
+ }
268
+ /** Record a scalar's non-finite and float-approximation findings on the scan. */
269
+ function scanScalar(value, scan) {
270
+ if (isNonFiniteScalar(value))
271
+ scan.nonFinite = true;
272
+ if (scan.exactOnly && isFloatApproximation(value))
273
+ scan.inexact = true;
274
+ }
275
+ /**
276
+ * Walk a result once, counting leaf elements (stopping past `limit`) and flagging
277
+ * `help()` objects, non-finite numbers, and — with `exactOnly` — float
278
+ * approximations. The number checks reach every container a result can nest a
279
+ * number in — matrix elements (dense data, or a sparse matrix's stored values,
280
+ * never densified), `Complex` parts, a `Unit`'s magnitude, `BigNumber`s, and
281
+ * object-literal values. `Fraction`s are always finite (fraction.js throws
282
+ * instead of producing Infinity) and always exact.
283
+ */
284
+ function scanResult(value, limit, scan) {
285
+ if (scan.elements > limit)
286
+ return;
287
+ if (value === null || typeof value !== 'object') {
288
+ scan.elements++;
289
+ scanScalar(value, scan);
290
+ return;
171
291
  }
172
- // DenseMatrix / SparseMatrix expose toArray() → nested JS arrays.
173
- if (typeof value.toArray === 'function') {
174
- return containsNonFinite(value.toArray());
292
+ if (isHelp(value)) {
293
+ scan.elements++;
294
+ scan.help = true;
295
+ return;
175
296
  }
176
- return false;
297
+ if (isSparseMatrix(value)) {
298
+ scanResult(value._values ?? [], limit, scan);
299
+ return;
300
+ }
301
+ if (isMatrix(value)) {
302
+ const size = value.size();
303
+ if (size.reduce((n, d) => n * d, 1) > limit) {
304
+ scan.elements = limit + 1;
305
+ return;
306
+ }
307
+ scanResult(value.valueOf(), limit, scan);
308
+ return;
309
+ }
310
+ if (Array.isArray(value)) {
311
+ for (const item of value) {
312
+ scanResult(item, limit, scan);
313
+ if (scan.elements > limit)
314
+ return;
315
+ }
316
+ return;
317
+ }
318
+ if (isUnit(value)) {
319
+ scan.elements++;
320
+ scanScalar(value.value, scan);
321
+ return;
322
+ }
323
+ // BigNumber, Complex, Fraction, and other class instances are scalars.
324
+ if (!isPlainObject(value)) {
325
+ scan.elements++;
326
+ scanScalar(value, scan);
327
+ return;
328
+ }
329
+ for (const item of Object.values(value)) {
330
+ scanResult(item, limit, scan);
331
+ if (scan.elements > limit)
332
+ return;
333
+ }
334
+ }
335
+ /** Script every timed call runs in the service's reused vm context. */
336
+ const TIMED_CALL = new vm.Script('result = fn()');
337
+ /**
338
+ * Largest decimal exponent a Fraction-mode literal may carry. `1e1000` already
339
+ * spells out a 1001-digit integer; past this, an exponent only builds numbers too
340
+ * large to return, so it fails fast instead of allocating.
341
+ */
342
+ const MAX_FRACTION_LITERAL_EXPONENT = 1000;
343
+ /** A decimal literal in exponent notation: mantissa digits, fraction digits, exponent. */
344
+ const EXPONENT_LITERAL = /^(\d*)(?:\.(\d*))?[eE]([+-]?\d+)$/;
345
+ /**
346
+ * Rewrite an exponent-notation literal as the plain decimal it denotes
347
+ * (`1.5e-3` → `0.0015`), which fraction.js reads exactly. Returns `undefined`
348
+ * for anything that is not exponent notation.
349
+ */
350
+ function expandExponentLiteral(literal) {
351
+ const match = EXPONENT_LITERAL.exec(literal);
352
+ if (!match)
353
+ return undefined;
354
+ const [, whole = '', fraction = '', exponentText = '0'] = match;
355
+ const exponent = Number(exponentText);
356
+ if (Math.abs(exponent) > MAX_FRACTION_LITERAL_EXPONENT) {
357
+ throw new SyntaxError(`Exponent literal "${literal}" is out of range for numericType "Fraction" (exponents up to ±${MAX_FRACTION_LITERAL_EXPONENT}); use numericType "BigNumber" for numbers this large or small.`);
358
+ }
359
+ const digits = whole + fraction;
360
+ const point = whole.length + exponent;
361
+ if (point <= 0)
362
+ return `0.${'0'.repeat(-point)}${digits}`;
363
+ if (point >= digits.length)
364
+ return digits + '0'.repeat(point - digits.length);
365
+ return `${digits.slice(0, point)}.${digits.slice(point)}`;
177
366
  }
178
367
  /**
179
- * Matches standard-notation function names math.js does not define — natural log
180
- * `ln` and the inverse-trig `arc*` family — only at call sites (word boundary +
181
- * `(` lookahead), so identifiers and scope variables are never rewritten.
368
+ * Let a Fraction instance read exponent-notation literals exactly. `numeric` is
369
+ * what the parser uses to turn a literal into a value; for a Fraction target, an
370
+ * exponent literal is expanded to plain decimal first. Every other conversion is
371
+ * passed through unchanged.
182
372
  */
183
- const NOTATION_ALIAS_PATTERN = /\b(ln|arc(?:sin|cos|tan|sec|csc|cot)h?)(?=\s*\()/g;
373
+ function installExponentLiterals(math, mathImport) {
374
+ const numeric = math.numeric.bind(math);
375
+ const exactNumeric = (value, outputType) => {
376
+ const expanded = outputType === 'Fraction' && typeof value === 'string'
377
+ ? expandExponentLiteral(value)
378
+ : undefined;
379
+ return numeric(expanded ?? value, outputType);
380
+ };
381
+ mathImport({ numeric: exactNumeric }, { override: true });
382
+ }
383
+ /**
384
+ * Read Fraction-mode scope values the way the instance's own arithmetic does:
385
+ * math.js converts a number to a Fraction implicitly when the Fraction equals it
386
+ * exactly (0.5 → 1/2, 0.1 → 1/10), so `x + 0` already reads 0.5 as 1/2. Applying
387
+ * that rule up front makes a bare `x` read the same. A number with no equal
388
+ * Fraction stays a float, which the result scan then rejects.
389
+ */
390
+ function fractionScopeValue(fraction) {
391
+ return (value) => {
392
+ const exact = fraction(value);
393
+ return exact.valueOf() === value ? exact : value;
394
+ };
395
+ }
184
396
  /**
185
- * Rewrite standard mathematical notation to math.js canonical names before
186
- * parsing: `ln` → `log` (natural log) and `arc<fn>` → `a<fn>` for inverse trig
187
- * (`arcsin` → `asin`, `arctanh` → `atanh`, …). All 12 `a*` targets are math.js
188
- * builtins, so every operation resolves them.
397
+ * Register {@link CUSTOM_UNITS} without changing how other results simplify, and
398
+ * return a function that resets the instance's auto unit system.
189
399
  *
190
- * The rewrite is name-level (not a `math.import` alias) deliberately: symbolic
191
- * `derivative` matches on builtin function names, and an imported alias is not in
192
- * its differentiation table — so an alias would fix evaluate/simplify but leave
193
- * `derivative('ln(x)')` throwing. A pre-parse rewrite covers all three operations.
400
+ * math.js simplifies a combined result (`10 m / 2 s`) through its "auto" unit
401
+ * system: one unit per base dimension, overwritten by every unit it parses. That
402
+ * state lives on the instance, so it leaked between calls, and `createUnit`
403
+ * disturbed it three ways. A unit whose dimensions match no base unit (a velocity)
404
+ * got a base of its own (`mph_STUFF`), so every velocity then simplified to mph or
405
+ * knot. A unit with a base enters the auto system whenever it is parsed, so a
406
+ * `lightyear` elsewhere turned length results into `ly`. And parsing the
407
+ * definitions re-seeded the system (`hour` as the time unit). So the new base is
408
+ * removed, the custom units lose their base (they keep their dimensions, so
409
+ * conversions and unit checks are unchanged), and the auto system is reset to its
410
+ * seed before every call (#37).
194
411
  */
195
- function normalizeNotation(expression) {
196
- return expression.replace(NOTATION_ALIAS_PATTERN, (name) => name === 'ln' ? 'log' : `a${name.slice(3)}`);
412
+ function registerCustomUnits(math) {
413
+ const registry = math.Unit;
414
+ const auto = registry.UNIT_SYSTEMS.auto;
415
+ const seed = { ...auto };
416
+ const bases = new Set(Object.keys(registry.BASE_UNITS));
417
+ const units = new Set(Object.keys(registry.UNITS));
418
+ math.createUnit(CUSTOM_UNITS);
419
+ for (const key of Object.keys(registry.BASE_UNITS)) {
420
+ if (bases.has(key))
421
+ continue;
422
+ delete registry.BASE_UNITS[key];
423
+ for (const system of Object.values(registry.UNIT_SYSTEMS))
424
+ delete system[key];
425
+ }
426
+ for (const [name, unit] of Object.entries(registry.UNITS)) {
427
+ if (!units.has(name))
428
+ delete unit.base;
429
+ }
430
+ return () => {
431
+ for (const key of Object.keys(auto))
432
+ delete auto[key];
433
+ Object.assign(auto, seed);
434
+ };
197
435
  }
198
436
  /** Create and harden a math.js instance with the given numeric type. */
199
437
  function createMathInstance(number) {
200
438
  // biome-ignore lint/style/noNonNullAssertion: math.js types declare `all` as potentially undefined, but it's always defined at runtime
201
439
  const math = create(all, { number });
440
+ // Capture math.import before it's disabled — needed for the imports and guards below.
441
+ const mathImport = math.import.bind(math);
442
+ // fraction.js cannot read exponent notation, so a Fraction-mode literal such as
443
+ // `2e5` fails to parse. The parser converts literals through `numeric`, which it
444
+ // captures when first created — so the override must precede the `parse` capture.
445
+ if (number === 'Fraction')
446
+ installExponentLiterals(math, mathImport);
202
447
  // Capture references BEFORE the override step — the import shim replaces these
203
448
  // on the instance, so binding after would capture the disabled stubs.
204
- const evaluate = math.evaluate.bind(math);
205
449
  const format = math.format.bind(math);
206
450
  const typeOf = math.typeOf.bind(math);
207
451
  // Symbolic operations are captured here too so a hardened instance can back
@@ -212,23 +456,24 @@ function createMathInstance(number) {
212
456
  const simplify = math.simplify.bind(math);
213
457
  const derivative = math.derivative.bind(math);
214
458
  const simplifyRules = [...math.simplify.rules];
215
- // Capture math.import before it's disabled — needed to install the config guard below.
216
- const mathImport = math.import.bind(math);
217
- // For BigNumber/Fraction instances, also capture config before the override.
218
- // math.js BigNumber arithmetic reads config() internally to get the Decimal.js precision.
219
- // Replacing config with a throwing stub breaks that and causes:
220
- // [DecimalError] Invalid argument: precision: NaN
221
- // Instead, we install a read-only guard after the disable step: reads pass through,
222
- // writes throw — blocking expression-scope config mutations while preserving internal access.
223
- const realConfig = number !== 'number' ? math.config.bind(math) : null;
459
+ const scopeValue = number === 'Fraction' ? fractionScopeValue(math.fraction.bind(math)) : (value) => value;
460
+ // Read the config before the override (an empty options object changes nothing).
461
+ // math.js injects `config` into every factory it instantiates lazily, and reads it
462
+ // both as a call (config()) and as properties (config.relTol, config.precision). A
463
+ // throwing stub breaks those readers — BigNumber arithmetic fails with
464
+ // `[DecimalError] precision: NaN`, and number-mode functions created after the
465
+ // override (eigs, schur, intersect, isPositive, compare, …) see `relTol`/`absTol` as
466
+ // undefined (#25). Every instance therefore gets a read-only guard (installed
467
+ // below): reads pass through, writes throw.
468
+ const currentConfig = math.config({});
224
469
  // Register custom units and function-name aliases (natural-language plus common
225
470
  // cross-ecosystem synonyms from Excel/NumPy/calculator notation). None are math.js
226
471
  // builtins, so the imports are purely additive — they turn an agent's natural guess
227
472
  // into a result instead of an "undefined function" error. Statistics/combinatorics
228
473
  // functions aren't differentiable, so a value-alias suffices (unlike ln/arc*, which
229
- // need the pre-parse rewrite to stay in the derivative table).
474
+ // are renamed on the parse tree to stay in the derivative table — see inspectTree).
230
475
  // Must run before createUnit/import are disabled below.
231
- math.createUnit(CUSTOM_UNITS);
476
+ const resetUnitSystem = registerCustomUnits(math);
232
477
  mathImport({
233
478
  average: math.mean,
234
479
  avg: math.mean,
@@ -241,55 +486,63 @@ function createMathInstance(number) {
241
486
  length: math.count,
242
487
  len: math.count,
243
488
  });
244
- // Disable dangerous functions in expression scope.
245
- // `config` is excluded here for BigNumber/Fraction instances — installed as a
246
- // read-only guard via mathImport after this block (import is also disabled here).
247
- const disabled = {};
248
- for (const fn of DISABLED_FUNCTIONS) {
249
- if (fn === 'config' && number !== 'number')
250
- continue; // read-only guard installed below
251
- disabled[fn] = () => {
252
- throw new Error(`Function "${fn}" is disabled for security.`);
253
- };
254
- }
255
- // Redact constants that leak implementation details
256
- for (const [key, value] of Object.entries(REDACTED_CONSTANTS)) {
257
- disabled[key] = value;
258
- }
259
- mathImport(disabled, { override: true });
260
- // For BigNumber/Fraction: install a read-only config guard using the pre-captured
261
- // mathImport reference (math.import is now disabled in the expression scope).
262
- //
263
- // Two access patterns must be preserved for math.js internals:
489
+ // Bound what an expression can build from a size argument, a product, a broadcast,
490
+ // a repeated input, an index, or a formatting precision. Installed on the instance,
491
+ // so simplify/derivative constant folding on this instance hits the same limits.
492
+ installSizeGuards(math, mathImport, all);
493
+ // Replace `config` with a read-only guard. Two access patterns must be preserved
494
+ // for math.js internals:
264
495
  // 1. config() — gamma/factorial call this to read the full config object
265
- // 2. config.precision — gamma accesses precision directly as a property on the function
496
+ // 2. config.precision — gamma accesses precision directly as a property on the
497
+ // function; eigs, intersect, compare, … read config.relTol / config.absTol
266
498
  //
267
499
  // Replacing config with a plain stub breaks both; the guard function below satisfies
268
500
  // both patterns while blocking write access (calls with a non-empty options object).
269
- if (realConfig !== null) {
270
- const currentConfig = realConfig({});
271
- const configGuard = Object.assign((options) => {
272
- if (options !== undefined && Object.keys(options).length > 0) {
273
- throw new Error('"config" is disabled for security.');
274
- }
275
- return currentConfig;
276
- }, currentConfig);
277
- mathImport({ config: configGuard }, { override: true });
501
+ // A bare `config()` from an expression therefore returns the config object — a fresh
502
+ // copy each call, as stock math.js does: the instance outlives the request, and an
503
+ // expression can assign into a plain object it holds (`c.k = …` after `c = config()`).
504
+ const configGuard = Object.assign((options) => {
505
+ if (options !== undefined && Object.keys(options).length > 0) {
506
+ throw new Error('"config" is disabled for security.');
507
+ }
508
+ return { ...currentConfig };
509
+ }, currentConfig);
510
+ // Disable dangerous functions and redact constants that leak implementation details,
511
+ // through the pre-captured import (this step disables math.import too).
512
+ const overrides = { ...REDACTED_CONSTANTS, config: configGuard };
513
+ for (const fn of DISABLED_FUNCTIONS) {
514
+ overrides[fn] = () => {
515
+ throw new Error(`Function "${fn}" is disabled for security.`);
516
+ };
278
517
  }
279
- return { evaluate, format, typeOf, parse, simplify, derivative, simplifyRules };
518
+ mathImport(overrides, { override: true });
519
+ // The namespace an expression's names resolve in (before units), transforms included.
520
+ const expressionNames = math.expression.mathWithTransform;
521
+ return {
522
+ format,
523
+ typeOf,
524
+ parse,
525
+ isFunction: (name) => Object.hasOwn(expressionNames, name) && typeof expressionNames[name] === 'function',
526
+ resetUnitSystem,
527
+ scopeValue,
528
+ simplify: simplify,
529
+ derivative: derivative,
530
+ simplifyRules,
531
+ };
280
532
  }
281
533
  export class MathService {
282
534
  /** Default IEEE 754 instance — used for the vast majority of evaluations. */
283
535
  defaultInstance;
284
- /** BigNumber instance — arbitrary precision; selected via numericType: "BigNumber". */
536
+ /** BigNumber instance — 64 significant digits; selected via numericType: "BigNumber". */
285
537
  bigNumberInstance;
286
538
  /** Fraction instance — exact rational arithmetic; selected via numericType: "Fraction". */
287
539
  fractionInstance;
288
- parse;
289
540
  simplify;
290
541
  derivative;
291
542
  simplifyRules;
292
543
  config;
544
+ /** vm context reused by every timed call; see {@link runWithTimeout}. */
545
+ sandbox = vm.createContext({});
293
546
  constructor(config) {
294
547
  this.config = config;
295
548
  // Pre-initialize one hardened instance per numeric type so numeric-type selection
@@ -303,7 +556,6 @@ export class MathService {
303
556
  // simplify/derivative resolves disabled functions to their throwing stubs and reads
304
557
  // the redacted `version` — closing the bypass where folded `evaluate("…")` ran on an
305
558
  // unhardened instance (#18).
306
- this.parse = this.defaultInstance.parse;
307
559
  this.simplify = this.defaultInstance.simplify;
308
560
  this.derivative = this.defaultInstance.derivative;
309
561
  this.simplifyRules = [...this.defaultInstance.simplifyRules, ...TRIG_SIMPLIFY_RULES];
@@ -324,56 +576,57 @@ export class MathService {
324
576
  this.validateInput(expression, ctx);
325
577
  if (scope)
326
578
  this.validateScope(scope, ctx);
327
- const normalized = normalizeNotation(expression);
328
- this.validateNoFunctionStringification(normalized, ctx);
329
579
  const inst = this.instanceFor(numericType);
330
- const raw = this.runWithTimeout(() => (scope ? inst.evaluate(normalized, scope) : inst.evaluate(normalized)), ctx, numericType);
331
- const resultType = inst.typeOf(raw);
332
- this.validateResultType(resultType, ctx);
333
- this.validateFinite(raw, ctx);
334
- // Match JS Number.toString thresholds — math.js defaults to exp ≥ 5,
335
- // which would render 83810205 as "8.3810205e+7".
336
- const result = inst.format(raw, {
337
- lowerExp: -6,
338
- upperExp: 21,
339
- ...(precision != null && { precision }),
580
+ // Parse with the instance for the requested numericType: constant nodes take
581
+ // their numeric type at parse time.
582
+ const { tree, functionValues } = this.parseExpression(expression, inst, ctx);
583
+ // Evaluation, result inspection, and formatting all run inside the timeout, so
584
+ // no result reaches formatting unchecked and no step runs unbounded (#31).
585
+ return this.runWithTimeout(() => {
586
+ // Evaluate against a copy: an assignment in the expression (`(y = 2) + x`)
587
+ // writes into the scope it is given, and the caller's object must not change (#34).
588
+ const variables = Object.entries(scope ?? {}).map(([name, value]) => [name, inst.scopeValue(value)]);
589
+ const raw = tree.compile().evaluate(new Map(variables));
590
+ const resultType = inst.typeOf(raw);
591
+ this.validateResultType(resultType, ctx);
592
+ this.validateResultValue(raw, ctx, numericType);
593
+ // Match JS Number.toString thresholds — math.js defaults to exp ≥ 5,
594
+ // which would render 83810205 as "8.3810205e+7".
595
+ const result = inst.format(raw, {
596
+ lowerExp: -6,
597
+ upperExp: 21,
598
+ ...(precision != null && { precision }),
599
+ });
600
+ this.validateResultSize(result, ctx);
601
+ return { result, resultType };
602
+ }, ctx, {
603
+ operation: 'evaluate',
604
+ numericType,
605
+ // A scope value shadows the function of the same name.
606
+ functionValues: functionValues.filter((name) => !(scope && Object.hasOwn(scope, name))),
340
607
  });
341
- this.validateResultSize(result, ctx);
342
- return { result, resultType };
343
608
  }
344
609
  /** Simplify an algebraic expression symbolically. */
345
610
  simplifyExpression(expression, ctx) {
346
611
  this.validateInput(expression, ctx);
347
- const normalized = normalizeNotation(expression);
348
- this.validateNoFunctionStringification(normalized, ctx);
349
- // Capture the AST-normalized form of the input before simplification so we
350
- // can detect whether the simplifier made any progress. String comparison is
351
- // not sufficient — formatting-only changes like `x+1` vs `x + 1` should not
352
- // count as progress. We parse both sides and compare their `.toString()` output,
353
- // which normalises whitespace and operator representation consistently.
354
- let inputNormalized;
355
- try {
356
- inputNormalized = this.parse(normalized).toString();
357
- }
358
- catch {
359
- // If the expression cannot be parsed, let the simplify step surface the error
360
- // with full math.js context. Set to the raw input so the unchanged flag is
361
- // false (we don't know, but a parse error is not a no-op simplification).
362
- inputNormalized = '';
363
- }
364
- const simplified = this.runWithTimeout(() => this.simplify(normalized, this.simplifyRules), ctx);
365
- const result = simplified.toString();
612
+ const { tree } = this.parseExpression(expression, this.defaultInstance, ctx);
613
+ // Capture the alias-normalized input before simplification so we can detect
614
+ // whether the simplifier made any progress. String comparison of the raw input
615
+ // is not sufficient — formatting-only changes like `x+1` vs `x + 1` should not
616
+ // count as progress — so both sides are compared as math.js renders them.
617
+ const inputNormalized = tree.toString();
618
+ const result = this.runWithTimeout(() => this.simplify(tree, this.simplifyRules).toString(), ctx, { operation: 'simplify' });
366
619
  this.validateResultSize(result, ctx);
367
- const unchanged = inputNormalized !== '' && inputNormalized === result;
368
- return { result, resultType: 'string', unchanged };
620
+ return { result, resultType: 'string', unchanged: inputNormalized === result };
369
621
  }
370
622
  /** Compute the symbolic derivative of an expression with respect to a variable. */
371
623
  differentiateExpression(expression, variable, ctx) {
372
624
  this.validateInput(expression, ctx);
373
- const normalized = normalizeNotation(expression);
374
- this.validateNoFunctionStringification(normalized, ctx);
375
- const derived = this.runWithTimeout(() => this.derivative(normalized, variable), ctx);
376
- const result = derived.toString();
625
+ const { tree } = this.parseExpression(expression, this.defaultInstance, ctx);
626
+ const result = this.runWithTimeout(() => this.derivative(tree, variable).toString(), ctx, {
627
+ operation: 'derivative',
628
+ variable,
629
+ });
377
630
  this.validateResultSize(result, ctx);
378
631
  return { result, resultType: 'string' };
379
632
  }
@@ -383,107 +636,273 @@ export class MathService {
383
636
  }
384
637
  validateInput(expression, ctx) {
385
638
  if (!expression.trim()) {
386
- throw validationError('Expression cannot be empty.', {
387
- reason: 'empty_expression',
388
- ...ctx.recoveryFor('empty_expression'),
389
- });
639
+ throw declared(ctx, 'empty_expression', 'Expression cannot be empty.');
390
640
  }
391
641
  if (expression.length > this.config.maxExpressionLength) {
392
- throw validationError(`Expression exceeds maximum length of ${this.config.maxExpressionLength} characters.`, { reason: 'expression_too_long', ...ctx.recoveryFor('expression_too_long') });
642
+ throw declared(ctx, 'expression_too_long', `Expression exceeds maximum length of ${this.config.maxExpressionLength} characters.`);
393
643
  }
394
- if (hasExpressionSeparator(expression)) {
395
- throw validationError('Multiple expressions are not allowed. Submit one expression per call.', { reason: 'multiple_expressions', ...ctx.recoveryFor('multiple_expressions') });
644
+ }
645
+ /**
646
+ * Parse once and check the tree before anything evaluates. A parse failure is
647
+ * `parse_failed`. Multiple statements are detected structurally — math.js parses
648
+ * `a; b` and a top-level newline into a `BlockNode` — so `;` inside a matrix or
649
+ * inside either quote style (`"a;b"`, `'a;b'`) is data, transpose `A'` stays an
650
+ * operator, and a real separator is rejected before either statement runs (#32).
651
+ * math.js treats a bare carriage return as a syntax error rather than a
652
+ * separator; when it is what broke the parse, the input is re-parsed with line
653
+ * feeds purely to report `multiple_expressions` — the rewritten text is never
654
+ * evaluated. The same tree then gets the stringifier check, the
655
+ * operation-as-function check (#27), and the notation rename (#24), and yields
656
+ * the names it uses as values that resolve to functions (#38). Every operation
657
+ * starts here, so it also resets the instance's auto unit system (#37).
658
+ */
659
+ parseExpression(expression, inst, ctx) {
660
+ inst.resetUnitSystem();
661
+ let tree;
662
+ try {
663
+ tree = inst.parse(expression);
396
664
  }
665
+ catch (err) {
666
+ if (expression.includes('\r') &&
667
+ this.parsesAsBlock(expression.replace(/\r\n?/g, '\n'), inst)) {
668
+ throw this.multipleExpressions(ctx);
669
+ }
670
+ throw declared(ctx, 'parse_failed', `Invalid expression: ${errorMessage(err)}`);
671
+ }
672
+ if (tree.type === 'BlockNode')
673
+ throw this.multipleExpressions(ctx);
674
+ const { stringifies, operationCall, valueNames } = inspectTree(tree);
675
+ // `.toString()` / `.toLocaleString()` on a function-valued identifier
676
+ // (`cos.toString()`) returns the function's source as a plain string, slipping
677
+ // past validateResultType, which only sees the value after stringification. No
678
+ // calculator expression needs either method, so the accessor is rejected on any
679
+ // operand; a string literal containing "toString" is a ConstantNode, not an
680
+ // accessor, and is unaffected.
681
+ if (stringifies) {
682
+ throw declared(ctx, 'disallowed_result_type', 'Converting a function to a string is not allowed — it would expose internal source.');
683
+ }
684
+ if (operationCall !== undefined) {
685
+ const variableHint = operationCall === 'derivative' ? ' and pass `variable`' : '';
686
+ throw declared(ctx, 'operation_as_function', `"${operationCall}" is an operation, not a function available inside expressions. Send the inner expression as \`expression\` with \`operation: "${operationCall}"\`${variableHint}.`);
687
+ }
688
+ return { tree, functionValues: [...valueNames].filter(inst.isFunction) };
689
+ }
690
+ /** Whether `expression` parses to multiple statements. */
691
+ parsesAsBlock(expression, inst) {
692
+ try {
693
+ return inst.parse(expression).type === 'BlockNode';
694
+ }
695
+ catch {
696
+ return false;
697
+ }
698
+ }
699
+ multipleExpressions(ctx) {
700
+ return declared(ctx, 'multiple_expressions', 'Multiple expressions are not allowed. Submit one expression per call.');
397
701
  }
398
702
  /** Reject scope keys that could pollute the object prototype chain. */
399
703
  validateScope(scope, ctx) {
400
704
  for (const key of Object.keys(scope)) {
401
705
  if (BLOCKED_SCOPE_KEYS.has(key)) {
402
- throw validationError(`Scope key "${key}" is not allowed — it conflicts with a reserved property name.`, { reason: 'reserved_scope_key', ...ctx.recoveryFor('reserved_scope_key') });
706
+ throw declared(ctx, 'reserved_scope_key', `Scope key "${key}" is not allowed — it conflicts with a reserved property name.`);
403
707
  }
404
708
  }
405
709
  }
406
710
  /** Reject result types that leak internals (functions, parsers, multi-expression ResultSets). */
407
711
  validateResultType(resultType, ctx) {
408
712
  if (BLOCKED_RESULT_TYPES.has(resultType)) {
409
- throw validationError(`Expression produced a ${resultType} — only numeric, string, matrix, complex, unit, and boolean results are allowed.`, { reason: 'disallowed_result_type', ...ctx.recoveryFor('disallowed_result_type') });
713
+ throw declared(ctx, 'disallowed_result_type', `Expression produced a ${resultType}, which cannot be returned — the result must be a value, not a function.`);
410
714
  }
411
715
  }
412
- /** Reject results holding a non-finite value (Infinity, -Infinity, NaN), including inside matrices and complex numbers. */
413
- validateFinite(raw, ctx) {
414
- if (containsNonFinite(raw)) {
415
- throw validationError('Expression evaluated to a non-finite result (Infinity, -Infinity, or NaN) — this typically means the operation is mathematically undefined (e.g., division by zero, log of zero). Check the expression.', { reason: 'undefined_result', ...ctx.recoveryFor('undefined_result') });
716
+ /**
717
+ * Inspect a result before it is formatted: reject a `help()` object (its
718
+ * rendering evaluates the documentation examples, #31), a collection too large
719
+ * to ever fit `maxResultLength` (checked before formatting, so it is never
720
+ * stringified), any non-finite number, scalar or nested (#21), and under
721
+ * numericType "Fraction", any float approximation, scalar or nested (#35).
722
+ */
723
+ validateResultValue(raw, ctx, numericType) {
724
+ const { maxResultLength } = this.config;
725
+ if (typeof raw === 'string' && raw.length > maxResultLength) {
726
+ throw this.resultTooLarge(ctx);
416
727
  }
728
+ const elementLimit = Math.floor(maxResultLength / MIN_CHARS_PER_ELEMENT);
729
+ const scan = {
730
+ elements: 0,
731
+ help: false,
732
+ nonFinite: false,
733
+ exactOnly: numericType === 'Fraction',
734
+ inexact: false,
735
+ };
736
+ scanResult(raw, elementLimit, scan);
737
+ if (scan.help) {
738
+ throw declared(ctx, 'disallowed_result_type', 'help() is not available inside expressions — read the calculator://help resource for the function reference.');
739
+ }
740
+ if (scan.elements > elementLimit)
741
+ throw this.resultTooLarge(ctx);
742
+ if (scan.nonFinite)
743
+ throw declared(ctx, 'undefined_result', UNDEFINED_RESULT_MESSAGE);
744
+ if (scan.inexact)
745
+ throw declared(ctx, 'fraction_unsupported', FRACTION_FLOAT_MESSAGE);
417
746
  }
418
747
  /** Reject results that exceed the configured maximum size. */
419
748
  validateResultSize(result, ctx) {
420
- if (result.length > this.config.maxResultLength) {
421
- throw validationError(`Result exceeds maximum size (${this.config.maxResultLength} characters). Reduce matrix dimensions or simplify the expression.`, { reason: 'result_too_large', ...ctx.recoveryFor('result_too_large') });
422
- }
749
+ if (result.length > this.config.maxResultLength)
750
+ throw this.resultTooLarge(ctx);
751
+ }
752
+ resultTooLarge(ctx) {
753
+ return declared(ctx, 'result_too_large', `Result exceeds maximum size (${this.config.maxResultLength} characters). Reduce matrix dimensions or simplify the expression.`);
423
754
  }
424
755
  /**
425
- * Reject expressions that access `.toString` / `.toLocaleString` on any value.
426
- * math.js permits these methods on function-valued identifiers (`cos.toString()`,
427
- * `import.toString()`), returning the function's source as a plain string — which
428
- * slips past {@link validateResultType}, whose function defense only inspects the
429
- * value AFTER stringification. The check is parse-time and AST-based rather than a
430
- * runtime `Function.prototype` patch: math.js itself calls `toString` on functions
431
- * internally while evaluating units, statistics, and complex results, so patching
432
- * the prototype would reject legitimate expressions. No real calculator expression
433
- * needs `.toString()`/`.toLocaleString()`, so blocking the accessor outright (dot
434
- * or bracket form, on any operand) is both sufficient and side-effect-free. String
435
- * literals that merely contain the text "toString" parse as ConstantNodes, not
436
- * accessors, so they are unaffected.
756
+ * Runs a synchronous function inside a vm sandbox with timeout protection and
757
+ * maps anything it throws to a declared reason via {@link classifyFailure}.
758
+ *
759
+ * The context exists only to carry the timeout — `fn` and everything it calls
760
+ * run in this realm — so one context is reused for every call and cleared
761
+ * afterwards. A fresh context per call costs ~100 µs and, under Bun, retains
762
+ * ~100 KB of memory per call.
437
763
  */
438
- validateNoFunctionStringification(expr, ctx) {
439
- let ast;
764
+ runWithTimeout(fn, ctx, stage) {
765
+ const { sandbox } = this;
766
+ sandbox.fn = fn;
767
+ beginEvaluation();
440
768
  try {
441
- ast = this.parse(expr);
769
+ TIMED_CALL.runInContext(sandbox, { timeout: this.config.evaluationTimeoutMs });
770
+ return sandbox.result;
442
771
  }
443
- catch {
444
- // Let the evaluate path surface parse errors with full mathjs context.
445
- return;
772
+ catch (err) {
773
+ throw this.classifyFailure(err, ctx, stage);
446
774
  }
447
- const accessesStringifier = ast.filter((node) => {
448
- if (node.type !== 'IndexNode')
449
- return false;
450
- const { dimensions } = node;
451
- return dimensions.some((dim) => dim.type === 'ConstantNode' && STRINGIFYING_METHODS.has(String(dim.value)));
452
- });
453
- if (accessesStringifier.length > 0) {
454
- throw validationError('Converting a function to a string is not allowed — it would expose internal source. Only numeric, string, matrix, complex, unit, and boolean results are allowed.', { reason: 'disallowed_result_type', ...ctx.recoveryFor('disallowed_result_type') });
775
+ finally {
776
+ sandbox.fn = undefined;
777
+ sandbox.result = undefined;
455
778
  }
456
779
  }
457
780
  /**
458
- * Runs a synchronous function inside a vm sandbox with timeout protection.
459
- * `numericType` is supplied only by the evaluate path so a Fraction-mode
460
- * conversion failure can be remapped to the actionable `fraction_unsupported`
461
- * error instead of the misleading `parse_failed` (#19).
781
+ * Classify a failure raised after the expression parsed (evaluation, result
782
+ * inspection, formatting, or a symbolic operation). First match wins:
783
+ *
784
+ * 1. Already-classified `McpError`s (result checks) pass through.
785
+ * 2. Timeout → `evaluation_timeout`.
786
+ * 3. A size guard (see size-guard.ts) → `result_too_large`.
787
+ * 4. Fraction mode calling a function with no Fraction implementation (sqrt,
788
+ * sin, log, factorial, …) → `fraction_unsupported` (#19); so does Fraction
789
+ * mode meeting a float with no equal Fraction (`pi * 2/3`, #35).
790
+ * 5. fraction.js `Division by Zero`, in any numericType → `undefined_result` (#21).
791
+ * 6. Name errors (undefined symbol/function/unit, disabled function, blocked
792
+ * property) → `parse_failed`.
793
+ * 7. A symbolic operation (simplify, derivative) that cannot process the parsed
794
+ * tree → `evaluation_failed`, with a hint about the symbolic engine rather
795
+ * than about argument values (see {@link symbolicFailure}).
796
+ * 8. Operand type/unit errors (typed-function `wrongType`, unit mismatch,
797
+ * string-to-number) → `type_mismatch`.
798
+ * 9. Anything else — arity, domain, singular matrix, dimensions, index →
799
+ * `evaluation_failed` (#29). Stage decides, not error class: a runtime
800
+ * `SyntaxError` such as `number("abc")` lands here, never in `parse_failed`.
462
801
  */
463
- runWithTimeout(fn, ctx, numericType) {
464
- const sandbox = { fn, result: undefined };
465
- try {
466
- vm.runInNewContext('result = fn()', sandbox, { timeout: this.config.evaluationTimeoutMs });
467
- return sandbox.result;
802
+ classifyFailure(err, ctx, stage) {
803
+ const { numericType } = stage;
804
+ if (err instanceof McpError)
805
+ return err;
806
+ const message = errorMessage(err);
807
+ // math.js's map/forEach/filter re-wrap a callback's error in a new one that
808
+ // embeds its message, so a timeout inside a callback loses the error code.
809
+ const timedOut = (err instanceof Error && 'code' in err && err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') ||
810
+ VM_TIMEOUT_ERROR.test(message);
811
+ if (timedOut) {
812
+ return timeout(`Expression evaluation timed out after ${this.config.evaluationTimeoutMs / 1000} seconds. Simplify the expression or reduce matrix dimensions.`, { reason: 'evaluation_timeout', ...ctx.recoveryFor('evaluation_timeout') });
468
813
  }
469
- catch (err) {
470
- if (err instanceof Error && 'code' in err && err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
471
- throw timeout(`Expression evaluation timed out after ${this.config.evaluationTimeoutMs / 1000} seconds. Simplify the expression or reduce matrix dimensions.`, { reason: 'evaluation_timeout', ...ctx.recoveryFor('evaluation_timeout') });
472
- }
473
- const message = err instanceof Error ? err.message : String(err);
474
- // Fraction mode cannot represent an irrational/transcendental result (sqrt, sin,
475
- // log, …); math.js surfaces this as an implicit-conversion error. Remap to a
476
- // dedicated, actionable error rather than the syntax-oriented parse_failed.
477
- if (numericType === 'Fraction' && FRACTION_CONVERSION_ERROR.test(message)) {
478
- throw validationError('This expression has no exact fractional value (irrational or transcendental result, e.g. sqrt, sin, log). Retry with numericType "number" or "BigNumber".', { reason: 'fraction_unsupported', ...ctx.recoveryFor('fraction_unsupported') });
479
- }
480
- // All other non-timeout errors from the VM are expression-related.
481
- throw validationError(`Invalid expression: ${message}`, {
482
- reason: 'parse_failed',
483
- ...ctx.recoveryFor('parse_failed'),
484
- });
814
+ const limitError = sizeLimitErrorIn(err);
815
+ if (limitError) {
816
+ return declared(ctx, 'result_too_large', `Size limit exceeded: ${limitError.message}`);
485
817
  }
818
+ if (numericType === 'Fraction' && FRACTION_CONVERSION_ERROR.test(message)) {
819
+ return declared(ctx, 'fraction_unsupported', FRACTION_FUNCTION_MESSAGE);
820
+ }
821
+ if (numericType === 'Fraction' && FLOAT_TO_FRACTION_ERROR.test(message)) {
822
+ return declared(ctx, 'fraction_unsupported', FRACTION_FLOAT_MESSAGE);
823
+ }
824
+ if (FRACTION_DIVISION_BY_ZERO.test(message)) {
825
+ return declared(ctx, 'undefined_result', UNDEFINED_RESULT_MESSAGE);
826
+ }
827
+ if (NAME_ERROR.test(message)) {
828
+ return declared(ctx, 'parse_failed', `Invalid expression: ${message}`);
829
+ }
830
+ if (stage.operation !== 'evaluate')
831
+ return symbolicFailure(stage, message);
832
+ const data = err?.data;
833
+ const category = data?.category;
834
+ if (category === 'wrongType' &&
835
+ Array.isArray(data?.actual) &&
836
+ data.actual.includes('function')) {
837
+ return functionAsValue(stage.functionValues, message);
838
+ }
839
+ if (category === 'wrongType' || TYPE_MISMATCH_ERROR.test(message)) {
840
+ return declared(ctx, 'type_mismatch', `Type mismatch: ${message}`);
841
+ }
842
+ return declared(ctx, 'evaluation_failed', `Evaluation failed: ${message}`);
843
+ }
844
+ }
845
+ /**
846
+ * `evaluation_failed` for a symbolic operation that parsed but could not be
847
+ * carried out. The hint is written at the throw site: the contract hint is about
848
+ * fixing an argument, but here the syntax and arguments are fine — the symbolic
849
+ * engine has no rule for part of the expression (a function such as `floor` or
850
+ * an operator such as `==` in a derivative, or a matrix or conditional node).
851
+ */
852
+ function symbolicFailure(stage, message) {
853
+ const { operation } = stage;
854
+ const noRule = operation === 'derivative' && NO_DERIVATIVE_RULE.exec(message);
855
+ if (noRule) {
856
+ const [, kind, name] = noRule;
857
+ return validationError(`The derivative operation has no rule for the ${kind} "${name}", so it cannot differentiate this expression symbolically.`, {
858
+ reason: 'evaluation_failed',
859
+ recovery: {
860
+ hint: `"${name}" has no symbolic derivative rule — rewrite the expression without it, or evaluate it numerically with operation "evaluate", passing values for its variables (such as ${stage.variable}) through scope.`,
861
+ },
862
+ });
486
863
  }
864
+ return validationError(`The ${operation} operation cannot process this expression symbolically: ${message}`, {
865
+ reason: 'evaluation_failed',
866
+ recovery: {
867
+ hint: `The ${operation} engine cannot handle part of this expression (for example a matrix or conditional) — rewrite it with scalar functions and operators, or evaluate it numerically with operation "evaluate" and values passed through scope.`,
868
+ },
869
+ });
870
+ }
871
+ /**
872
+ * `type_mismatch` for a function where a value belongs: math.js reads a name or
873
+ * definition next to a value as multiplication (`5 min` is 5 times the minimum
874
+ * function, `(f(x) = x^2)(3)` is the function times 3). The contract hint is about
875
+ * units, so the hint is written here (#38), naming the functions the expression
876
+ * uses as values and the unit to write for one that shadows a unit name.
877
+ */
878
+ function functionAsValue(names, detail) {
879
+ const described = names.map((name) => {
880
+ const unit = FUNCTION_SHADOWED_UNITS[name];
881
+ return unit ? `"${name}" is a function, not the unit "${unit}"` : `"${name}" is a function`;
882
+ });
883
+ const unitHints = names.flatMap((name) => {
884
+ const unit = FUNCTION_SHADOWED_UNITS[name];
885
+ return unit
886
+ ? [`"${name}" names a function here; for the unit, write ${unit} (\`5 ${unit}\`).`]
887
+ : [];
888
+ });
889
+ return validationError(`Type mismatch: a function was used as a value${described.length ? ` (${described.join('; ')})` : ''}. ${detail}`, {
890
+ reason: 'type_mismatch',
891
+ recovery: {
892
+ hint: [
893
+ ...unitHints,
894
+ 'To call a function, use parentheses (`sin(1)`, `f(3)`); a function name or definition next to a value multiplies it instead.',
895
+ ].join(' '),
896
+ },
897
+ });
898
+ }
899
+ /** A validation error carrying `reason` and the recovery hint its contract entry declares. */
900
+ function declared(ctx, reason, message) {
901
+ return validationError(message, { reason, ...ctx.recoveryFor(reason) });
902
+ }
903
+ /** Message of a thrown value, whatever its type. */
904
+ function errorMessage(err) {
905
+ return err instanceof Error ? err.message : String(err);
487
906
  }
488
907
  // --- Init/accessor pattern ---
489
908
  let _service;
@@ -520,11 +939,13 @@ const HELP_CONTENT = `# Calculator Help
520
939
  | e | 2.71828... | Euler's number |
521
940
  | phi | 1.61803... | Golden ratio |
522
941
  | i | sqrt(-1) | Imaginary unit |
523
- | Infinity | Infinity | Positive infinity |
524
- | NaN | NaN | Not a number |
942
+ | Infinity | Infinity | Positive infinity — intermediate values only |
943
+ | NaN | NaN | Not a number — intermediate values only |
525
944
  | true | true | Boolean true |
526
945
  | false | false | Boolean false |
527
946
 
947
+ \`Infinity\` and \`NaN\` work inside an expression (\`1/Infinity\` => \`0\`, \`isNaN(NaN)\` => \`true\`), but a result that is Infinity, -Infinity, or NaN anywhere in it fails with \`undefined_result\`: \`Infinity\` and \`NaN\` on their own fail.
948
+
528
949
  ## Functions
529
950
 
530
951
  > **Standard notation accepted:** \`ln\` works as natural log (canonical \`log\`), and the \`arc*\` inverse-trig names (\`arcsin\`, \`arccos\`, \`arctan\`, \`arcsinh\`, …) work as their \`a*\` equivalents (\`asin\`, \`acos\`, \`atan\`, …). Both forms are valid across evaluate, simplify, and derivative.
@@ -533,11 +954,25 @@ const HELP_CONTENT = `# Calculator Help
533
954
  abs, ceil, floor, round, sign, sqrt, cbrt, exp, expm1, log (also: ln), log2, log10, log1p, pow, mod, gcd, lcm, nthRoot, hypot, fix, cube, square, unaryMinus, unaryPlus
534
955
 
535
956
  ### Trigonometry
536
- sin, cos, tan, asin (arcsin), acos (arccos), atan (arctan), atan2, sinh, cosh, tanh, asinh (arcsinh), acosh (arccosh), atanh (arctanh), sec, csc, cot, asec (arcsec), acsc (arccsc), acot (arccot), sech (arcsech), csch (arccsch), coth (arccoth)
957
+ sin, cos, tan, asin (arcsin), acos (arccos), atan (arctan), atan2, sinh, cosh, tanh, asinh (arcsinh), acosh (arccosh), atanh (arctanh), sec, csc, cot, asec (arcsec), acsc (arccsc), acot (arccot), sech, csch, coth, asech (arcsech), acsch (arccsch), acoth (arccoth)
537
958
 
538
959
  ### Statistics
539
960
  mean (aliases: average, avg), median, mode, std (aliases: stdev, stddev), variance, min, max, sum, prod, quantileSeq, mad, count (aliases: length, len)
540
961
 
962
+ \`std\` and \`variance\` (and the \`stdev\`/\`stddev\` aliases) return the sample statistic by default. An optional normalization string after the array or matrix picks the divisor:
963
+
964
+ | Normalization | Divisor | \`std([2, 4, 6], …)\` | \`variance([2, 4, 6], …)\` |
965
+ |:--------------|:--------|:--------------------|:-------------------------|
966
+ | \`"unbiased"\` (default, sample) | n − 1 | \`2\` | \`4\` |
967
+ | \`"uncorrected"\` (population) | n | \`1.632993161855452\` | \`2.6666666666666665\` |
968
+ | \`"biased"\` | n + 1 | \`1.4142135623730951\` | \`2\` |
969
+
970
+ \`std([2, 4, 6], "uncorrected")\` => \`1.632993161855452\`. The normalization must follow an array or matrix: \`std(2, 4, 6, "uncorrected")\` fails.
971
+
972
+ - \`mad\` is the unscaled median absolute deviation (no 1.4826 factor): \`mad([1, 2, 3, 4, 100])\` => \`1\`
973
+ - \`quantileSeq\` interpolates linearly: \`quantileSeq([1, 2, 3, 4], 0.25)\` => \`1.75\`. A third argument \`true\` declares the data already sorted and skips sorting, so unsorted data then gives a wrong answer: \`quantileSeq([4, 3, 2, 1], 0.25, true)\` => \`3.25\`
974
+ - \`mode\` always returns an array, even with a single mode: \`mode([1, 2, 2, 3])\` => \`[2]\`
975
+
541
976
  ### Matrix
542
977
  det, inv, transpose, trace, zeros, ones, identity, diag, size, reshape, flatten, concat, sort, cross, dot, eigs, expm, sqrtm, kron, pinv, range
543
978
 
@@ -556,55 +991,60 @@ equal, unequal, larger, largerEq, smaller, smallerEq, compare, deepEqual
556
991
  ### Unit Conversion
557
992
  Syntax: \`value unit to targetUnit\`
558
993
 
559
- Common units: m, cm, mm, km, inch, ft, yard, mile, lightyear (ly), kg, g, lb, oz, s, min, hour, day, mph, knot (kt), celsius, fahrenheit, kelvin, liter, gallon, joule, watt, newton, pascal, bar, psi, radian, degree
994
+ Common units: m, cm, mm, km, inch, ft, yard, mile, lightyear (ly), kg, g, lb, oz, s, minute, hour, day, mph, knot (kt), celsius, fahrenheit, kelvin, liter, gallon, joule, watt, newton, Pa, bar, psi, radian, degree
995
+
996
+ \`min\` is the minimum function, not minutes, so write \`minute\`: \`5 minute to s\` => \`300 s\`. Pascals are \`Pa\`: \`1 bar to Pa\` => \`100000 Pa\`.
560
997
 
561
998
  ## Syntax Examples
562
999
 
1000
+ Each example shows the exact result string \`calculate\` returns.
1001
+
563
1002
  ### Basic arithmetic
564
- \`2 + 3 * 4\` => 14
1003
+ \`2 + 3 * 4\` => \`14\`
565
1004
 
566
1005
  ### Functions
567
- \`sqrt(144)\` => 12
568
- \`sin(pi / 2)\` => 1
569
- \`log(1000, 10)\` => 3
1006
+ \`sqrt(144)\` => \`12\`
1007
+ \`sin(pi / 2)\` => \`1\`
1008
+ \`log(1000, 10)\` => \`2.9999999999999996\` (floating-point rounding); with \`precision: 10\`, \`log(1000, 10)\` => \`3\`
570
1009
 
571
- ### Variables (via scope parameter)
1010
+ ### Variables (scope parameter, evaluate only)
572
1011
  Provide scope: { "x": 5, "y": 3 }
573
- Expression: \`x^2 + y\` => 28
1012
+ Expression: \`x^2 + y\` => \`28\`
574
1013
 
575
1014
  ### Matrices
576
- \`[1, 2; 3, 4]\` — 2x2 matrix
577
- \`det([1, 2; 3, 4])\` => -2
578
- \`inv([1, 2; 3, 4])\` => [[-2, 1], [1.5, -0.5]]
1015
+ \`[1, 2; 3, 4]\` — 2x2 matrix (\`;\` separates rows)
1016
+ \`det([1, 2; 3, 4])\` => \`-2\`
1017
+ \`inv([1, 2; 3, 4])\` => \`[[-2, 1], [1.5, -0.5]]\`
579
1018
 
580
1019
  ### Complex numbers
581
- \`2 + 3i\` => 2 + 3i
582
- \`sqrt(-4)\` => 2i
583
- \`abs(3 + 4i)\` => 5
1020
+ \`2 + 3i\` => \`2 + 3i\`
1021
+ \`sqrt(-4)\` => \`2i\`
1022
+ \`abs(3 + 4i)\` => \`5\`
1023
+ \`log(-1)\` => \`3.141592653589793i\` — the log of a negative number is complex, not undefined
584
1024
 
585
1025
  ### Unit conversion
586
- \`5 kg to lbs\` => 11.02 lbs
587
- \`100 celsius to fahrenheit\` => 212 fahrenheit
588
- \`1 mile to km\` => 1.60934 km
1026
+ \`5 kg to lbs\` => \`11.023113109243878 lbs\`
1027
+ \`100 celsius to fahrenheit\` => \`211.99999999999997 fahrenheit\`
1028
+ \`1 mile to km\` => \`1.609344 km\`
589
1029
 
590
- > **Note:** Unit conversions use IEEE 754 floating-point arithmetic. Results may include minor rounding artifacts (e.g., 211.99999999999997 instead of 212). Use the \`precision\` parameter to round.
1030
+ > **Note:** Unit conversions use IEEE 754 floating-point arithmetic, so results can carry rounding artifacts like the one above. Use the \`precision\` parameter to round: with \`precision: 6\`, \`100 celsius to fahrenheit\` => \`212 fahrenheit\`.
591
1031
 
592
1032
  ### Precision
593
- Use the precision parameter (1\u201316 significant digits) for numeric results.
1033
+ The \`precision\` parameter (1\u201316 significant digits) rounds numeric results: with \`precision: 4\`, \`1 / 3\` => \`0.3333\`. Fraction results always print exactly, and symbolic operations ignore it.
594
1034
 
595
1035
  ### Operations
596
1036
 
597
- - **evaluate** (default): Compute a numeric result. Use the \`numericType\` parameter to control precision:
598
- - \`"number"\` (default): 64-bit IEEE 754 float — fastest. Standard for most calculations.
599
- - \`"BigNumber"\`: Arbitrary-precision decimal — use when intermediate values overflow (e.g. \`10000! / 9999!\` overflows as a 64-bit float but evaluates correctly as a BigNumber). Slower than \`"number"\`.
600
- - \`"Fraction"\`: Exact rational arithmetic — eliminates floating-point rounding (e.g. \`0.1 + 0.2 = 0.3\` exactly). Limited to expressions with exactly-rational results; an irrational or transcendental result (\`sqrt(2)\`, \`sin(1)\`, \`log(3)\`, …) fails with a \`fraction_unsupported\` error — use \`"number"\` or \`"BigNumber"\` for those.
1037
+ - **evaluate** (default): Compute a numeric result. The \`numericType\` parameter picks the number representation:
1038
+ - \`"number"\` (default): 64-bit IEEE 754 float — fastest, about 16 significant digits. A value past about 1.8e308 overflows to Infinity and fails with \`undefined_result\` (\`171!\`, \`2^1024\`, \`exp(1000)\`).
1039
+ - \`"BigNumber"\`: decimal with 64 significant digits and a much wider exponent range; slower than \`"number"\`. Use it when a value overflows the float range: with \`numericType: "BigNumber"\`, \`2^2000\` => \`1.148130695274254524232833201177681984022317702088695200477642737e+602\`. Results are 64-digit approximations, not exact: \`10000! / 9999!\` => \`9999.999999999999999999999999999999999999999999999999999999999996\`, and adding \`precision: 16\` rounds it, \`10000! / 9999!\` => \`10000\`.
1040
+ - \`"Fraction"\`: exact rational arithmetic — \`0.1 + 0.2\` => \`3/10\`, \`1/3 + 1/6\` => \`1/2\`. An expression fails with \`fraction_unsupported\` when its result has no exact rational value (\`sqrt(2)\`, \`sin(1)\`, \`log(3)\`), when it calls a function Fraction mode cannot compute, even for a rational result (\`sqrt(4)\`, \`5!\`, \`combinations(5, 2)\`), or when it uses a value Fraction mode holds only as a rounded 64-bit float (\`pi\`, \`e\`, \`2^(1/2)\`, \`sin(pi)\`, a complex number with a non-integer part, \`number(x)\`, or \`random()\`); use \`"number"\` or \`"BigNumber"\` for those. A unit conversion with an irrational factor returns a close rational approximation, not an exact value: \`30 deg to rad\` => \`191068/364913 rad\`. Whole-number indexes and dimensions work as in \`"number"\`: \`[1, 2, 3][2]\` => \`2/1\`, \`sum([1, 2; 3, 4], 1)\` => \`[4/1, 6/1]\`.
601
1041
 
602
- When \`"number"\` evaluation returns an \`undefined_result\` error (division by zero, overflow), retry with \`numericType: "BigNumber"\`.
1042
+ An \`undefined_result\` caused by overflow (large powers, factorials, \`exp\`) can be retried with \`numericType: "BigNumber"\`. Division by zero, \`0/0\`, and \`log(0)\` are undefined in every numeric type, and BigNumber does not change that.
603
1043
 
604
- - **simplify**: Reduce algebraic expressions symbolically (e.g., "2x + 3x" => "5 * x", "sin(x)^2 + cos(x)^2" => 1). Supports algebraic rules and common trigonometric identities (Pythagorean, double-angle, tan/sec/csc/cot relationships).
1044
+ - **simplify**: Reduce an algebraic expression symbolically: \`2x + 3x\` => \`5 * x\`, \`sin(x)^2 + cos(x)^2\` => \`1\`. Supports algebraic rules and common trigonometric identities (Pythagorean, double-angle, tan/sec/csc/cot relationships). \`scope\`, \`precision\`, and \`numericType\` are ignored.
605
1045
 
606
- **Known limits:** The built-in simplifier does not perform polynomial factoring or rational cancellation. Expressions like \`(x^2 - 1) / (x - 1)\` are returned unchanged (\`unchanged: true\` in the output). For these cases, consider rewriting by hand or using \`evaluate\` with a numeric scope.
1046
+ **Known limits:** The built-in simplifier does not perform polynomial factoring or rational cancellation: \`(x^2 - 1) / (x - 1)\` => \`(x ^ 2 - 1) / (x - 1)\`, returned with \`unchanged: true\`. For these cases, rewrite by hand or use \`evaluate\` with a numeric scope.
607
1047
 
608
- - **derivative**: Compute symbolic derivative (requires variable parameter, e.g., variable: "x")
1048
+ - **derivative**: Compute a symbolic derivative; requires the \`variable\` parameter. With \`variable: "x"\`, \`x^2\` => \`2 * x\`. \`scope\`, \`precision\`, and \`numericType\` are ignored.
609
1049
  `;
610
1050
  //# sourceMappingURL=math-service.js.map