@fitchmultz/pi-calculator 4.1.6 → 4.1.7
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.
- package/CHANGELOG.md +17 -4
- package/README.md +2 -2
- package/decimal.ts +93 -27
- package/docs/development.md +32 -0
- package/docs/reference.md +83 -0
- package/eval.ts +161 -137
- package/expr-eval-fork.d.ts +8 -2
- package/index.ts +21 -9
- package/package.json +88 -69
- package/statistics.ts +151 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.1.7 - 2026-11-01
|
|
4
|
+
|
|
5
|
+
<!-- npm-release:unreleased:start -->
|
|
6
|
+
<!-- npm-release:unreleased:end -->
|
|
7
|
+
|
|
8
|
+
- Adopt the strict quality policy baseline and current Pi practice (#48) ([50e0e33](https://github.com/fitchmultz/pi-calculator/commit/50e0e33a0443a2d0ec1c12a5cf4080342ba61850))
|
|
9
|
+
|
|
10
|
+
### What's Changed
|
|
11
|
+
|
|
12
|
+
- Adopt the strict quality policy baseline and current Pi practice by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/48
|
|
13
|
+
|
|
14
|
+
**Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.6...v4.1.7
|
|
15
|
+
|
|
3
16
|
## 4.1.6 - 2026-11-01
|
|
4
17
|
|
|
5
18
|
<!-- npm-release:unreleased:start -->
|
|
@@ -8,8 +21,8 @@
|
|
|
8
21
|
- Use direct Flash for automatic release remediation (#49) ([544c540](https://github.com/fitchmultz/pi-calculator/commit/544c540e8d12cbe0ec8c6cde7050628f41700a4e))
|
|
9
22
|
|
|
10
23
|
### What's Changed
|
|
11
|
-
* Use direct Flash for necessary release remediation by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/49
|
|
12
24
|
|
|
25
|
+
- Use direct Flash for necessary release remediation by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/49
|
|
13
26
|
|
|
14
27
|
**Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.5...v4.1.6
|
|
15
28
|
|
|
@@ -22,8 +35,8 @@
|
|
|
22
35
|
- Pin reviewed release source binding (#46) ([2a75d8b](https://github.com/fitchmultz/pi-calculator/commit/2a75d8beebdf247c7703d35b8a5944ab7b9cf7cb))
|
|
23
36
|
|
|
24
37
|
### What's Changed
|
|
25
|
-
* Bind automatic releases to the reviewed version by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/46
|
|
26
38
|
|
|
39
|
+
- Bind automatic releases to the reviewed version by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/46
|
|
27
40
|
|
|
28
41
|
**Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.4...v4.1.5
|
|
29
42
|
|
|
@@ -35,8 +48,8 @@
|
|
|
35
48
|
- Enable reviewed npm version remediation (#44) ([20c90cf](https://github.com/fitchmultz/pi-calculator/commit/20c90cfb5e28452cfa3f7bf2d7923659b6b18a86))
|
|
36
49
|
|
|
37
50
|
### What's Changed
|
|
38
|
-
* Enable reviewed npm version remediation by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/44
|
|
39
51
|
|
|
52
|
+
- Enable reviewed npm version remediation by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/44
|
|
40
53
|
|
|
41
54
|
**Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.3...v4.1.4
|
|
42
55
|
|
|
@@ -48,8 +61,8 @@
|
|
|
48
61
|
- Enable reviewed automatic npm releases (#42) ([f7ce653](https://github.com/fitchmultz/pi-calculator/commit/f7ce6539ac13a509750e4960c4ef5182c5530b40))
|
|
49
62
|
|
|
50
63
|
### What's Changed
|
|
51
|
-
* Enable reviewed automatic npm releases by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/42
|
|
52
64
|
|
|
65
|
+
- Enable reviewed automatic npm releases by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/42
|
|
53
66
|
|
|
54
67
|
**Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.2...v4.1.3
|
|
55
68
|
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
pi-calculator adds a calculator tool to [Pi](https://github.com/earendil-works/pi), with 40 significant digits of decimal precision. Use it to check totals or evaluate scientific expressions.
|
|
4
4
|
|
|
5
|
-

|
|
5
|
+

|
|
6
6
|
|
|
7
7
|
## Install and try it
|
|
8
8
|
|
|
@@ -56,7 +56,7 @@ Keep expressions within 4,096 characters. See [limits](docs/reference.md#limits)
|
|
|
56
56
|
|
|
57
57
|
See [result format](docs/reference.md#result-format) to use the calculator tool from another extension.
|
|
58
58
|
|
|
59
|
-
Read the [migration notes](https://github.com/fitchmultz/pi-calculator/blob/main/docs/reference.md#
|
|
59
|
+
Read the [migration notes](https://github.com/fitchmultz/pi-calculator/blob/main/docs/reference.md#upgrading-from-v2) before upgrading.
|
|
60
60
|
|
|
61
61
|
## Verification
|
|
62
62
|
|
package/decimal.ts
CHANGED
|
@@ -14,61 +14,127 @@ export function resetDecimal(): void {
|
|
|
14
14
|
Decimal.set(DECIMAL_CONFIG);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const GUARD_DECIMAL_CONFIG = {
|
|
18
|
+
defaults: true,
|
|
19
|
+
precision: DECIMAL_PRECISION + 10,
|
|
20
|
+
rounding: DecimalBase.ROUND_HALF_UP,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
/** Wider working clone; its extra digits keep intermediate results exact. */
|
|
24
|
+
export const GuardDecimal = DecimalBase.clone(GUARD_DECIMAL_CONFIG);
|
|
25
|
+
|
|
26
|
+
/** Restore the guard clone's configuration once an expression run finishes. */
|
|
27
|
+
export function resetGuardDecimal(): void {
|
|
28
|
+
GuardDecimal.set(GUARD_DECIMAL_CONFIG);
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
const DECIMAL_LITERAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
18
32
|
|
|
19
33
|
function decimalFromString(value: string): DecimalValue {
|
|
20
|
-
if (!DECIMAL_LITERAL.test(value))
|
|
34
|
+
if (!DECIMAL_LITERAL.test(value)) {
|
|
35
|
+
throw new Error("invalid decimal literal");
|
|
36
|
+
}
|
|
21
37
|
const decimal = new Decimal(value);
|
|
22
|
-
if (!decimal.isFinite())
|
|
38
|
+
if (!decimal.isFinite()) {
|
|
39
|
+
throw new Error("decimal literal overflow");
|
|
40
|
+
}
|
|
23
41
|
if (decimal.isZero() && /[1-9]/.test(value.replace(/[eE].*$/, ""))) {
|
|
24
42
|
throw new Error("decimal literal underflow");
|
|
25
43
|
}
|
|
26
44
|
const rounded = decimal.toSignificantDigits(DECIMAL_PRECISION);
|
|
27
|
-
if (!rounded.isFinite())
|
|
28
|
-
|
|
45
|
+
if (!rounded.isFinite()) {
|
|
46
|
+
throw new Error("decimal literal overflow");
|
|
47
|
+
}
|
|
48
|
+
if (rounded.isZero() && !decimal.isZero()) {
|
|
49
|
+
throw new Error("decimal literal underflow");
|
|
50
|
+
}
|
|
29
51
|
return rounded;
|
|
30
52
|
}
|
|
31
53
|
|
|
32
54
|
export function toDec(value: unknown): DecimalValue {
|
|
33
|
-
if (value instanceof Decimal)
|
|
34
|
-
|
|
55
|
+
if (value instanceof Decimal) {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
return decimalFromString(value);
|
|
60
|
+
}
|
|
35
61
|
if (typeof value === "number") {
|
|
36
|
-
if (!Number.isFinite(value))
|
|
62
|
+
if (!Number.isFinite(value)) {
|
|
63
|
+
throw new Error("non-finite number");
|
|
64
|
+
}
|
|
37
65
|
return new Decimal(String(value));
|
|
38
66
|
}
|
|
39
67
|
throw new Error(`expected number, got ${typeof value}`);
|
|
40
68
|
}
|
|
41
69
|
|
|
70
|
+
/** Span of the quoted literal starting at `start`, or 0 when there is none. */
|
|
71
|
+
function quotedSpan(expression: string, start: number, quote: string): number {
|
|
72
|
+
const end = expression.indexOf(quote, start + 1);
|
|
73
|
+
if (end < 0) {
|
|
74
|
+
throw new Error("unclosed string in expression");
|
|
75
|
+
}
|
|
76
|
+
return end + 1 - start;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Decimal literal text at `start`, or undefined when those digits belong to a name. */
|
|
80
|
+
function decimalLiteral(expression: string, start: number): string | undefined {
|
|
81
|
+
const previous = expression[start - 1];
|
|
82
|
+
if (previous !== undefined && /[\w.]/.test(previous)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return expression.slice(start).match(/^(?:\d+\.\d+|\d+\.|\.\d+|\d+)(?:[eE][+-]?\d+)?/)?.[0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reject syntax the calculator refuses rather than normalizes. */
|
|
89
|
+
function rejectUnsupported(expression: string, start: number, ch: string): void {
|
|
90
|
+
if (ch === ";") {
|
|
91
|
+
throw new Error("multiple expressions are not supported");
|
|
92
|
+
}
|
|
93
|
+
if (ch === "/" && expression[start + 1] === "*") {
|
|
94
|
+
throw new Error("comments are not supported");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Nesting depth after one bracket, rejecting the limit as it is crossed. */
|
|
99
|
+
function trackDepth(depth: number, ch: string): number {
|
|
100
|
+
if (ch === "(" || ch === "[") {
|
|
101
|
+
if (depth + 1 > MAX_EXPRESSION_DEPTH) {
|
|
102
|
+
throw new Error(`expression is too deeply nested (max ${MAX_EXPRESSION_DEPTH})`);
|
|
103
|
+
}
|
|
104
|
+
return depth + 1;
|
|
105
|
+
}
|
|
106
|
+
if ((ch === ")" || ch === "]") && depth > 0) {
|
|
107
|
+
return depth - 1;
|
|
108
|
+
}
|
|
109
|
+
return depth;
|
|
110
|
+
}
|
|
111
|
+
|
|
42
112
|
/** Preserve numeric literal precision and normalize ** outside quoted strings. */
|
|
43
113
|
export function decimalizeExpression(expression: string): string {
|
|
44
|
-
if (expression.includes("\\"))
|
|
114
|
+
if (expression.includes("\\")) {
|
|
115
|
+
throw new Error("escape sequences are not supported");
|
|
116
|
+
}
|
|
45
117
|
|
|
46
118
|
let result = "";
|
|
47
119
|
let i = 0;
|
|
48
120
|
let nestingDepth = 0;
|
|
49
121
|
|
|
50
122
|
while (i < expression.length) {
|
|
51
|
-
const ch = expression[i]
|
|
123
|
+
const ch = expression[i];
|
|
124
|
+
if (ch === undefined) {
|
|
125
|
+
break; // the loop bound keeps the index inside the string
|
|
126
|
+
}
|
|
52
127
|
|
|
53
128
|
if (ch === '"' || ch === "'") {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
i = end + 1;
|
|
129
|
+
const span = quotedSpan(expression, i, ch);
|
|
130
|
+
result += expression.slice(i, i + span);
|
|
131
|
+
i += span;
|
|
58
132
|
continue;
|
|
59
133
|
}
|
|
60
134
|
|
|
61
|
-
|
|
62
|
-
if (ch === ";") throw new Error("multiple expressions are not supported");
|
|
135
|
+
rejectUnsupported(expression, i, ch);
|
|
63
136
|
|
|
64
|
-
|
|
65
|
-
nestingDepth++;
|
|
66
|
-
if (nestingDepth > MAX_EXPRESSION_DEPTH) {
|
|
67
|
-
throw new Error(`expression is too deeply nested (max ${MAX_EXPRESSION_DEPTH})`);
|
|
68
|
-
}
|
|
69
|
-
} else if ((ch === ")" || ch === "]") && nestingDepth > 0) {
|
|
70
|
-
nestingDepth--;
|
|
71
|
-
}
|
|
137
|
+
nestingDepth = trackDepth(nestingDepth, ch);
|
|
72
138
|
|
|
73
139
|
if (ch === "*" && expression[i + 1] === "*") {
|
|
74
140
|
result += "^";
|
|
@@ -76,10 +142,10 @@ export function decimalizeExpression(expression: string): string {
|
|
|
76
142
|
continue;
|
|
77
143
|
}
|
|
78
144
|
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
result += `d("${
|
|
82
|
-
i +=
|
|
145
|
+
const literal = decimalLiteral(expression, i);
|
|
146
|
+
if (literal !== undefined) {
|
|
147
|
+
result += `d("${literal}")`;
|
|
148
|
+
i += literal.length;
|
|
83
149
|
continue;
|
|
84
150
|
}
|
|
85
151
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Development
|
|
2
|
+
|
|
3
|
+
[Back to the README](../README.md)
|
|
4
|
+
|
|
5
|
+
## Local verification
|
|
6
|
+
|
|
7
|
+
Requires Node.js 24 or later. From a checkout:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm ci --ignore-scripts
|
|
11
|
+
npm run verify
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`verify` runs the quality gates first — both lint passes, the format check, both compiler projects, the baseline drift check and the policy verifier — then the committed harness, the numerical and work-limit checks, and an actual Pi extension-load check. It covers schema and structured result contracts, native nested validation/policy/evaluation errors and native tool-card rendering.
|
|
15
|
+
|
|
16
|
+
The individual gates run on their own: `npm run lint` (the semantic pass, then compiler diagnostics over the files a compiler project owns), `npm run lint:agent`, `npm run lint:fix`, `npm run format`, `npm run format:check`, `npm test`, `npm run lint:baseline`, and `npm run policy`. The configuration, the baseline it is checked against, and the harness probes are described in [AGENTS.md](../AGENTS.md).
|
|
17
|
+
|
|
18
|
+
The type-aware lint pass and `tsc` need a host with native builds for `oxlint-tsgolint` and TypeScript. On a host without them (Android on ARM64, for example) the compiler-dependent probes report an explicit skip, so the acceptance gate has to be run on a qualified host or in CI.
|
|
19
|
+
|
|
20
|
+
The locked development cohort uses Pi 1.4.0 and TypeBox 1.3.27. Pi supplies the schema and terminal UI libraries; `decimal.js` and `expr-eval-fork` are runtime dependencies. No evaluator or grammar compatibility layer is needed.
|
|
21
|
+
|
|
22
|
+
Update the development TypeBox pin alongside the qualified Pi host, using the version that host ships. Independent TypeBox updates are disabled in Renovate because they install duplicate host schema libraries and slow development startup. Each compatibility lane selects its host's TypeBox version.
|
|
23
|
+
|
|
24
|
+
## Compatibility qualification
|
|
25
|
+
|
|
26
|
+
The locked development cohort is reproducible local tooling. Release qualification selects the latest stable official Pi and the [fitchmultz/pi fork](https://github.com/fitchmultz/pi) main once per run, freezing the official version and fork commit throughout the checks.
|
|
27
|
+
|
|
28
|
+
Each host is qualified separately; a matching version string alone does not establish compatibility. CI runs the full verification against both frozen hosts, including fresh Git and packed npm consumers. See the [compatibility workflow](../.github/workflows/pi-compatibility.yml).
|
|
29
|
+
|
|
30
|
+
## Release process
|
|
31
|
+
|
|
32
|
+
When `NPM_RELEASE_ENABLED` is `true`, each main merge enters the automatic version queue. Its version PR must pass current-head compatibility and reviews before the main-only pipeline publishes matching npm and GitHub releases. The release caller reuses the same frozen host inputs for qualification and publishing. See the [version workflow](../.github/workflows/npm-version.yml), [release workflow](../.github/workflows/npm-release.yml), and [changelog](../CHANGELOG.md).
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Calculator reference
|
|
2
|
+
|
|
3
|
+
[Back to the README](../README.md)
|
|
4
|
+
|
|
5
|
+
## Installation options
|
|
6
|
+
|
|
7
|
+
Requires Node.js 24 or later and Pi 1.0.0 or later. The published npm package is `@fitchmultz/pi-calculator`.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pi install npm:@fitchmultz/pi-calculator
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Git installation is also supported:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pi install git:github.com/fitchmultz/pi-calculator
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
When switching from Git to npm, run `pi list`, remove the exact Git source it shows with `pi remove <source>`, then install the scoped npm package. This avoids loading the calculator twice. Existing Git installs can stay on Git.
|
|
20
|
+
|
|
21
|
+
Start a new Pi session with `pi` after installation. The package adds one `calculator` tool with one required `expression` string. Pi supplies the schema and terminal UI libraries; evaluation uses `decimal.js` and `expr-eval-fork`.
|
|
22
|
+
|
|
23
|
+
Pi extensions execute with full system access. Review the source before installing.
|
|
24
|
+
|
|
25
|
+
## Expressions
|
|
26
|
+
|
|
27
|
+
Operators are `+`, `-`, `*`, `/`, `%`, `^` and `**`; constants are `PI` and `E`. Arrays support zero-based indexing: `[1,2,3][2]` returns `3`. Combine independent results in one flat array:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
[0.1 + 0.2, 2^64]
|
|
31
|
+
[stdev([2,4,4,4,5,5,7,9]), stdevs([2,4,4,4,5,5,7,9])]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Functions
|
|
35
|
+
|
|
36
|
+
- **Trigonometry:** `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh` and `atanh`. Trig uses radians. `radians(degrees)` and `degrees(radians)` convert angles.
|
|
37
|
+
- **Roots, powers and logarithms:** `sqrt`, `cbrt`, `abs`, `pow`, `exp`, `expm1`, `ln`/`log`, `log1p`, `log2`, `log10`/`lg`. Both `ln` and `log` are natural logarithms. For small inputs, use `expm1(x)` for `exp(x)-1` and `log1p(x)` for `ln(1+x)` to preserve precision.
|
|
38
|
+
- **Rounding:** `ceil`, `floor`, `round`, `roundTo`, `trunc` and `sign`. `roundTo(value, places)` rounds halves away from zero; negative places round to tens, hundreds and so on.
|
|
39
|
+
- **Percentages:** `percent(rate, amount)` gives `rate%` of `amount`: `percent(15, 200)` returns `30`.
|
|
40
|
+
- **Statistics:** `sum`, `mean`, `median`, `stdev` and `stdevs` take numeric arrays. `stdev` is population standard deviation; `stdevs` is sample standard deviation and requires at least two elements.
|
|
41
|
+
- **Extremes and distance:** `min`, `max`, `hypot`/`pyt` take either an array or individual arguments.
|
|
42
|
+
- **Factorials:** `n!` and `fac(n)` compute factorials for integers from 0 through 1000.
|
|
43
|
+
|
|
44
|
+
Aggregates require finite numeric elements. Sums preserve cancellation remainders before rounding; means and even medians round after division.
|
|
45
|
+
|
|
46
|
+
Input literals and arithmetic are rounded to 40 significant digits using half-up rounding. Results are rounded decimal strings. For example, `exp(ln(1000))` can differ slightly from `1000` because intermediate calculations round.
|
|
47
|
+
|
|
48
|
+
## Result format
|
|
49
|
+
|
|
50
|
+
The tool returns a finite scalar or a flat array of finite scalars, represented as decimal strings. A nested array, nonnumeric result or nonfinite member fails the whole expression. Large finite values use scientific notation.
|
|
51
|
+
|
|
52
|
+
Model-facing content contains only the value, such as `0.3`, or a JSON array of strings, such as `["0.3","18446744073709551616"]`.
|
|
53
|
+
|
|
54
|
+
Both `details` and native `structuredContent` contain the same object:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"expression": "[0.1 + 0.2, 2^64]",
|
|
59
|
+
"value": ["0.3", "18446744073709551616"]
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`expression` is trimmed. `value` is a string or string array. The tool declares an `outputSchema`, so nested and codemode callers can receive these values without reparsing display text.
|
|
64
|
+
|
|
65
|
+
Evaluation failures throw and become native error results. Schema and policy checks remain Pi-owned. The tool requests native strict JSON-schema sampling where the provider supports it and uses Pi's normal fallback elsewhere. Pi's tool card displays the safely escaped expression above the result.
|
|
66
|
+
|
|
67
|
+
## Limits
|
|
68
|
+
|
|
69
|
+
- Expressions: at most 4,096 characters and 128 nesting levels.
|
|
70
|
+
- Output: serialized expression/value details must fit within 50 KiB. Oversized results are rejected rather than truncated.
|
|
71
|
+
- Factorials: operands from 0 through 1000, with a shared 1,000-step factorial budget per expression. Each factorial costs at least one step.
|
|
72
|
+
- Circular trig: `sin`, `cos` and `tan` reject absolute inputs above `1e100`.
|
|
73
|
+
- Hyperbolic functions: reject absolute inputs above 10,000.
|
|
74
|
+
- Modulo: `%` rejects operand exponent gaps above 10,000.
|
|
75
|
+
- Expensive operations: share a 10,000-unit work budget. Circular trig and `atan2` calls cost 100 units each, allowing at most 100 per expression when no other operation uses that budget.
|
|
76
|
+
|
|
77
|
+
<a id="migration"></a>
|
|
78
|
+
|
|
79
|
+
## Upgrading from v2
|
|
80
|
+
|
|
81
|
+
Version 3 removed the old angle-conversion names: replace `deg(x)` with `radians(x)` and `rad(x)` with `degrees(x)`. There are no compatibility aliases.
|
|
82
|
+
|
|
83
|
+
Tool content no longer includes `expression =`. Consumers should read the expression from details and handle both string and string-array values.
|
package/eval.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { Parser } from "expr-eval-fork";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DECIMAL_PRECISION,
|
|
4
|
+
Decimal,
|
|
5
|
+
decimalizeExpression,
|
|
6
|
+
GuardDecimal,
|
|
7
|
+
resetDecimal,
|
|
8
|
+
resetGuardDecimal,
|
|
9
|
+
toDec,
|
|
10
|
+
type DecimalValue,
|
|
11
|
+
} from "./decimal.ts";
|
|
12
|
+
import { exactSum, mean, standardDeviation } from "./statistics.ts";
|
|
3
13
|
|
|
4
14
|
export const MAX_EXPRESSION_LENGTH = 4096;
|
|
5
15
|
const MAX_FACTORIAL_OPERAND = 1000;
|
|
@@ -14,16 +24,10 @@ const E = new Decimal("2.718281828459045235360287471352662497757");
|
|
|
14
24
|
const MAX_TRIG_ABS = new Decimal("1e100");
|
|
15
25
|
const SMALL_ATAN_ABS = new Decimal("1e-100");
|
|
16
26
|
const LARGE_ATAN_ABS = new Decimal("1e100");
|
|
17
|
-
const GUARD_DECIMAL_CONFIG = {
|
|
18
|
-
defaults: true,
|
|
19
|
-
precision: DECIMAL_PRECISION + 10,
|
|
20
|
-
rounding: Decimal.ROUND_HALF_UP,
|
|
21
|
-
} as const;
|
|
22
|
-
const GuardDecimal = Decimal.clone(GUARD_DECIMAL_CONFIG);
|
|
23
27
|
|
|
24
28
|
let factorialWorkLeft = 0;
|
|
25
29
|
let expensiveWorkLeft = 0;
|
|
26
|
-
const EMPTY_VARIABLES: Record<string, never> =
|
|
30
|
+
const EMPTY_VARIABLES: Record<string, never> = nullMap({});
|
|
27
31
|
|
|
28
32
|
const parser = new Parser({
|
|
29
33
|
allowMemberAccess: false,
|
|
@@ -38,11 +42,22 @@ const parser = new Parser({
|
|
|
38
42
|
},
|
|
39
43
|
});
|
|
40
44
|
|
|
41
|
-
function nullMap<T extends object>(values: T): T {
|
|
42
|
-
|
|
45
|
+
function nullMap<T extends object>(values: Readonly<T>): T {
|
|
46
|
+
const target = { ...values };
|
|
47
|
+
Object.setPrototypeOf(target, null);
|
|
48
|
+
return target;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
/** Read an argument position the arity check has already guaranteed. */
|
|
52
|
+
function argumentAt<T>(values: readonly T[], index: number): T {
|
|
53
|
+
const value = values[index];
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
throw new Error(`missing argument at index ${index}`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function requireArity(name: string, args: readonly unknown[], count: number): void {
|
|
46
61
|
if (args.length !== count) {
|
|
47
62
|
throw new Error(`${name}() needs exactly ${count} argument${count === 1 ? "" : "s"}`);
|
|
48
63
|
}
|
|
@@ -50,16 +65,24 @@ function requireArity(name: string, args: unknown[], count: number): void {
|
|
|
50
65
|
|
|
51
66
|
function factorial(value: unknown) {
|
|
52
67
|
const n = toDec(value);
|
|
53
|
-
if (!n.isInteger() || n.lt(0))
|
|
54
|
-
|
|
68
|
+
if (!n.isInteger() || n.lt(0)) {
|
|
69
|
+
throw new Error("factorial needs a non-negative integer");
|
|
70
|
+
}
|
|
71
|
+
if (n.gt(MAX_FACTORIAL_OPERAND)) {
|
|
72
|
+
throw new Error(`factorial operand too large (max ${MAX_FACTORIAL_OPERAND})`);
|
|
73
|
+
}
|
|
55
74
|
|
|
56
75
|
const count = n.toNumber();
|
|
57
76
|
const cost = Math.max(count, 1);
|
|
58
|
-
if (cost > factorialWorkLeft)
|
|
77
|
+
if (cost > factorialWorkLeft) {
|
|
78
|
+
throw new Error("factorial work budget exceeded");
|
|
79
|
+
}
|
|
59
80
|
factorialWorkLeft -= cost;
|
|
60
81
|
|
|
61
82
|
let result = 1n;
|
|
62
|
-
for (let i = 2; i <= count; i++)
|
|
83
|
+
for (let i = 2; i <= count; i++) {
|
|
84
|
+
result *= BigInt(i);
|
|
85
|
+
}
|
|
63
86
|
return new Decimal(result.toString()).toSignificantDigits(DECIMAL_PRECISION);
|
|
64
87
|
}
|
|
65
88
|
|
|
@@ -71,7 +94,9 @@ function decimals(values: unknown, name: string, minLength = 1): DecimalValue[]
|
|
|
71
94
|
return values.map((value, index) => {
|
|
72
95
|
try {
|
|
73
96
|
const number = toDec(value);
|
|
74
|
-
if (!number.isFinite())
|
|
97
|
+
if (!number.isFinite()) {
|
|
98
|
+
throw new Error("expected a finite number");
|
|
99
|
+
}
|
|
75
100
|
return number;
|
|
76
101
|
} catch {
|
|
77
102
|
throw new Error(`${name}(): invalid number at index ${index}`);
|
|
@@ -79,78 +104,12 @@ function decimals(values: unknown, name: string, minLength = 1): DecimalValue[]
|
|
|
79
104
|
});
|
|
80
105
|
}
|
|
81
106
|
|
|
82
|
-
function decimalArguments(args: unknown[], name: string): DecimalValue[] {
|
|
107
|
+
function decimalArguments(args: readonly unknown[], name: string): DecimalValue[] {
|
|
83
108
|
const values = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
|
|
84
|
-
if (values.length === 0)
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function sum(xs: DecimalValue[], Ctor = Decimal, divisor = 1): DecimalValue {
|
|
89
|
-
if (xs.some((x) => !x.isFinite())) return Ctor.sum(...xs).div(divisor);
|
|
90
|
-
|
|
91
|
-
// Decimal.sum also truncates across large exponent gaps. Signed BigInt chunks
|
|
92
|
-
// keep the exact sum sparse, including intermediate values outside Decimal's range.
|
|
93
|
-
const width = BigInt(Ctor.precision);
|
|
94
|
-
const base = 10n ** width;
|
|
95
|
-
const bins = new Map<bigint, bigint>();
|
|
96
|
-
const add = (key: bigint, value: bigint) => bins.set(key, (bins.get(key) ?? 0n) + value);
|
|
97
|
-
for (const x of xs) {
|
|
98
|
-
if (x.isZero()) continue;
|
|
99
|
-
const digits = x.toExponential().split("e")[0]!.replace(".", "");
|
|
100
|
-
const exponent = BigInt(x.e) - BigInt(x.sd() - 1);
|
|
101
|
-
const key = exponent >= 0n ? exponent / width : (exponent - width + 1n) / width;
|
|
102
|
-
add(key, BigInt(digits) * 10n ** (exponent - key * width));
|
|
103
|
-
}
|
|
104
|
-
for (let key of [...bins.keys()].sort((a, b) => Number(a - b))) {
|
|
105
|
-
for (;;) {
|
|
106
|
-
const value = bins.get(key)!;
|
|
107
|
-
const carry = value / base;
|
|
108
|
-
bins.set(key, value % base);
|
|
109
|
-
if (!carry) break;
|
|
110
|
-
add(++key, carry);
|
|
111
|
-
}
|
|
109
|
+
if (values.length === 0) {
|
|
110
|
+
throw new Error(`${name}() needs at least one number`);
|
|
112
111
|
}
|
|
113
|
-
|
|
114
|
-
const keys = [...bins.keys()].filter((key) => bins.get(key) !== 0n).sort((a, b) => Number(b - a));
|
|
115
|
-
if (keys.length === 0) return new Ctor(0);
|
|
116
|
-
let key = keys[0]!;
|
|
117
|
-
let coefficient = bins.get(key)!;
|
|
118
|
-
let i = 1;
|
|
119
|
-
const digitCount = (n: bigint) => n.toString().replace("-", "").length;
|
|
120
|
-
const retained = Ctor.precision + Math.ceil(Math.log10(divisor)) + 2;
|
|
121
|
-
for (; i < keys.length; i++) {
|
|
122
|
-
const next = keys[i]!;
|
|
123
|
-
if (digitCount(coefficient) >= retained || key - next > 2n) break;
|
|
124
|
-
coefficient = coefficient * base ** (key - next) + bins.get(next)!;
|
|
125
|
-
key = next;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Normalized chunks have magnitude < base, so the leading remaining chunk
|
|
129
|
-
// determines the tail's sign. A sticky digit preserves which side of a tie it lies on.
|
|
130
|
-
let shift = 0n;
|
|
131
|
-
if (i < keys.length) {
|
|
132
|
-
shift = BigInt(Math.max(2, retained - digitCount(coefficient)));
|
|
133
|
-
coefficient = coefficient * 10n ** shift + (bins.get(keys[i]!)! > 0n ? 1n : -1n);
|
|
134
|
-
}
|
|
135
|
-
// Divide and round before restoring the exponent to avoid intermediate overflow/underflow.
|
|
136
|
-
const [mantissa, exponent] = new Ctor(coefficient.toString()).div(divisor).toExponential().split("e");
|
|
137
|
-
return new Ctor(`${mantissa}e${BigInt(exponent!) + key * width - shift}`);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function mean(xs: DecimalValue[]): DecimalValue {
|
|
141
|
-
return sum(xs, Decimal, xs.length);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function standardDeviation(xs: DecimalValue[], sample: boolean): DecimalValue {
|
|
145
|
-
// Scale before subtraction; a power of ten preserves small spreads around a large offset.
|
|
146
|
-
const scale = new GuardDecimal(`1e${GuardDecimal.max(...xs.map((x) => x.abs())).e}`);
|
|
147
|
-
const scaled = xs.map((x) => new GuardDecimal(x).div(scale));
|
|
148
|
-
// Center on an input first so a large common offset cannot round away the spread.
|
|
149
|
-
const shifted = scaled.map((x) => x.minus(scaled[0]!));
|
|
150
|
-
const center = sum(shifted, GuardDecimal, xs.length);
|
|
151
|
-
const squared = shifted.map((x) => x.minus(center).pow(2));
|
|
152
|
-
return new Decimal(sum(squared, GuardDecimal, sample ? xs.length - 1 : xs.length)
|
|
153
|
-
.sqrt().times(scale).toSignificantDigits(DECIMAL_PRECISION));
|
|
112
|
+
return decimals(values, name);
|
|
154
113
|
}
|
|
155
114
|
|
|
156
115
|
function tangent(value: DecimalValue): DecimalValue {
|
|
@@ -162,30 +121,47 @@ function tangent(value: DecimalValue): DecimalValue {
|
|
|
162
121
|
function arctangent(value: DecimalValue): DecimalValue {
|
|
163
122
|
// Decimal.atan can loop when argument reduction overflows or underflows.
|
|
164
123
|
const abs = value.abs();
|
|
165
|
-
if (abs.lt(SMALL_ATAN_ABS))
|
|
166
|
-
|
|
124
|
+
if (abs.lt(SMALL_ATAN_ABS)) {
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
if (abs.gt(LARGE_ATAN_ABS)) {
|
|
128
|
+
return PI.div(value.isNegative() ? -2 : 2);
|
|
129
|
+
}
|
|
167
130
|
return Decimal.atan(value);
|
|
168
131
|
}
|
|
169
132
|
|
|
170
133
|
function arctangent2(y: DecimalValue, x: DecimalValue): DecimalValue {
|
|
171
|
-
if (x.isZero() || y.isZero() || !x.isFinite() || !y.isFinite())
|
|
134
|
+
if (x.isZero() || y.isZero() || !x.isFinite() || !y.isFinite()) {
|
|
135
|
+
return Decimal.atan2(y, x);
|
|
136
|
+
}
|
|
172
137
|
const ratio = y.div(x);
|
|
173
|
-
if (ratio.abs().lt(SMALL_ATAN_ABS))
|
|
174
|
-
|
|
138
|
+
if (ratio.abs().lt(SMALL_ATAN_ABS)) {
|
|
139
|
+
if (x.isNegative()) {
|
|
140
|
+
return y.isNegative() ? PI.negated() : PI;
|
|
141
|
+
}
|
|
142
|
+
return ratio;
|
|
143
|
+
}
|
|
144
|
+
if (ratio.abs().gt(LARGE_ATAN_ABS)) {
|
|
145
|
+
return PI.div(y.isNegative() ? -2 : 2);
|
|
146
|
+
}
|
|
175
147
|
return Decimal.atan2(y, x);
|
|
176
148
|
}
|
|
177
149
|
|
|
178
150
|
function expm1(value: DecimalValue): DecimalValue {
|
|
179
151
|
const x = new GuardDecimal(value.toString());
|
|
180
152
|
if (x.abs().gte(0.1)) {
|
|
181
|
-
return new Decimal(
|
|
153
|
+
return new Decimal(
|
|
154
|
+
GuardDecimal.exp(x).minus(1).toSignificantDigits(DECIMAL_PRECISION).toString(),
|
|
155
|
+
);
|
|
182
156
|
}
|
|
183
157
|
let term = x;
|
|
184
158
|
let total = x;
|
|
185
159
|
for (let n = 2; n <= DECIMAL_PRECISION + 10; n++) {
|
|
186
160
|
term = term.times(x).div(n);
|
|
187
161
|
const next = total.plus(term);
|
|
188
|
-
if (next.eq(total))
|
|
162
|
+
if (next.eq(total)) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
189
165
|
total = next;
|
|
190
166
|
}
|
|
191
167
|
return new Decimal(total.toSignificantDigits(DECIMAL_PRECISION).toString());
|
|
@@ -194,21 +170,27 @@ function expm1(value: DecimalValue): DecimalValue {
|
|
|
194
170
|
function log1p(value: DecimalValue): DecimalValue {
|
|
195
171
|
const x = new GuardDecimal(value.toString());
|
|
196
172
|
if (x.abs().gte(0.1)) {
|
|
197
|
-
return new Decimal(
|
|
173
|
+
return new Decimal(
|
|
174
|
+
GuardDecimal.ln(x.plus(1)).toSignificantDigits(DECIMAL_PRECISION).toString(),
|
|
175
|
+
);
|
|
198
176
|
}
|
|
199
177
|
let power = x;
|
|
200
178
|
let total = x;
|
|
201
179
|
for (let n = 2; n <= DECIMAL_PRECISION + 10; n++) {
|
|
202
180
|
power = power.times(x);
|
|
203
181
|
const next = n % 2 === 0 ? total.minus(power.div(n)) : total.plus(power.div(n));
|
|
204
|
-
if (next.eq(total))
|
|
182
|
+
if (next.eq(total)) {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
205
185
|
total = next;
|
|
206
186
|
}
|
|
207
187
|
return new Decimal(total.toSignificantDigits(DECIMAL_PRECISION).toString());
|
|
208
188
|
}
|
|
209
189
|
|
|
210
190
|
function spendExpensiveWork(cost: number): void {
|
|
211
|
-
if (cost > expensiveWorkLeft)
|
|
191
|
+
if (cost > expensiveWorkLeft) {
|
|
192
|
+
throw new Error("expression work budget exceeded");
|
|
193
|
+
}
|
|
212
194
|
expensiveWorkLeft -= cost;
|
|
213
195
|
}
|
|
214
196
|
|
|
@@ -218,18 +200,25 @@ function trigonometric(
|
|
|
218
200
|
maxAbs?: DecimalValue,
|
|
219
201
|
): (x: DecimalValue) => DecimalValue {
|
|
220
202
|
return (x) => {
|
|
221
|
-
if (maxAbs && x.abs().gt(maxAbs)) {
|
|
222
|
-
throw new Error(
|
|
203
|
+
if (maxAbs !== undefined && x.abs().gt(maxAbs)) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`${name}() argument too large (max absolute value ${maxAbs.toString()})`,
|
|
206
|
+
);
|
|
223
207
|
}
|
|
224
208
|
spendExpensiveWork(TRIG_WORK);
|
|
225
209
|
return fn(x);
|
|
226
210
|
};
|
|
227
211
|
}
|
|
228
212
|
|
|
229
|
-
function hyperbolic(
|
|
213
|
+
function hyperbolic(
|
|
214
|
+
name: string,
|
|
215
|
+
fn: (x: DecimalValue) => DecimalValue,
|
|
216
|
+
): (x: DecimalValue) => DecimalValue {
|
|
230
217
|
return (x) => {
|
|
231
218
|
if (x.abs().gt(MAX_HYPERBOLIC_ABS)) {
|
|
232
|
-
throw new Error(
|
|
219
|
+
throw new Error(
|
|
220
|
+
`${name}() argument too large (max absolute value ${MAX_HYPERBOLIC_ABS})`,
|
|
221
|
+
);
|
|
233
222
|
}
|
|
234
223
|
spendExpensiveWork(x.isFinite() ? Math.max(1, Math.ceil(x.abs().toNumber())) : 1);
|
|
235
224
|
return fn(x);
|
|
@@ -239,7 +228,9 @@ function hyperbolic(name: string, fn: (x: DecimalValue) => DecimalValue): (x: De
|
|
|
239
228
|
function modulo(a: unknown, b: unknown) {
|
|
240
229
|
const left = toDec(a);
|
|
241
230
|
const right = toDec(b);
|
|
242
|
-
if (right.isZero())
|
|
231
|
+
if (right.isZero()) {
|
|
232
|
+
throw new Error("modulo by zero");
|
|
233
|
+
}
|
|
243
234
|
const exponentGap = left.isFinite() && right.isFinite() ? Math.abs(left.e - right.e) : 0;
|
|
244
235
|
if (exponentGap > MAX_MODULO_EXPONENT_GAP) {
|
|
245
236
|
throw new Error(`modulo exponent gap too large (max ${MAX_MODULO_EXPONENT_GAP})`);
|
|
@@ -248,29 +239,39 @@ function modulo(a: unknown, b: unknown) {
|
|
|
248
239
|
return left.mod(right);
|
|
249
240
|
}
|
|
250
241
|
|
|
251
|
-
function roundTo(...args: unknown[]) {
|
|
242
|
+
function roundTo(...args: readonly unknown[]) {
|
|
252
243
|
requireArity("roundTo", args, 2);
|
|
253
244
|
const digits = toDec(args[1]);
|
|
254
245
|
if (!digits.isInteger() || digits.abs().gt(MAX_ROUND_DIGITS)) {
|
|
255
|
-
throw new Error(
|
|
246
|
+
throw new Error(
|
|
247
|
+
`roundTo() digits must be an integer from -${MAX_ROUND_DIGITS} to ${MAX_ROUND_DIGITS}`,
|
|
248
|
+
);
|
|
256
249
|
}
|
|
257
250
|
const places = digits.toNumber();
|
|
258
251
|
const value = toDec(args[0]);
|
|
259
|
-
if (places >= 0)
|
|
252
|
+
if (places >= 0) {
|
|
253
|
+
return value.toDecimalPlaces(places);
|
|
254
|
+
}
|
|
260
255
|
const scale = new Decimal(10).pow(-places);
|
|
261
256
|
return value.div(scale).toDecimalPlaces(0).times(scale);
|
|
262
257
|
}
|
|
263
258
|
|
|
264
259
|
function arrayIndex(values: unknown, index: unknown): unknown {
|
|
265
|
-
if (!Array.isArray(values))
|
|
260
|
+
if (!Array.isArray(values)) {
|
|
261
|
+
throw new Error("indexing needs an array");
|
|
262
|
+
}
|
|
266
263
|
let n: DecimalValue;
|
|
267
264
|
try {
|
|
268
265
|
n = toDec(index);
|
|
269
266
|
} catch {
|
|
270
267
|
throw new Error("array index needs an integer");
|
|
271
268
|
}
|
|
272
|
-
if (!n.isInteger())
|
|
273
|
-
|
|
269
|
+
if (!n.isInteger()) {
|
|
270
|
+
throw new Error("array index needs an integer");
|
|
271
|
+
}
|
|
272
|
+
if (n.lt(0) || n.gte(values.length)) {
|
|
273
|
+
throw new Error("array index out of range");
|
|
274
|
+
}
|
|
274
275
|
return values[n.toNumber()];
|
|
275
276
|
}
|
|
276
277
|
|
|
@@ -306,7 +307,12 @@ const decimalUnary: Record<string, (x: DecimalValue) => DecimalValue> = {
|
|
|
306
307
|
};
|
|
307
308
|
|
|
308
309
|
parser.unaryOps = nullMap({
|
|
309
|
-
...Object.fromEntries(
|
|
310
|
+
...Object.fromEntries(
|
|
311
|
+
Object.entries(decimalUnary).map(([name, fn]) => [
|
|
312
|
+
name,
|
|
313
|
+
(value: unknown) => fn(toDec(value)),
|
|
314
|
+
]),
|
|
315
|
+
),
|
|
310
316
|
"+": toDec,
|
|
311
317
|
"-": (value: unknown) => toDec(value).negated(),
|
|
312
318
|
"!": factorial,
|
|
@@ -319,7 +325,9 @@ parser.binaryOps = nullMap({
|
|
|
319
325
|
"/": (a, b) => {
|
|
320
326
|
const numerator = toDec(a);
|
|
321
327
|
const denominator = toDec(b);
|
|
322
|
-
if (denominator.isZero())
|
|
328
|
+
if (denominator.isZero()) {
|
|
329
|
+
throw new Error("division by zero");
|
|
330
|
+
}
|
|
323
331
|
return numerator.div(denominator);
|
|
324
332
|
},
|
|
325
333
|
"%": modulo,
|
|
@@ -328,38 +336,40 @@ parser.binaryOps = nullMap({
|
|
|
328
336
|
});
|
|
329
337
|
parser.ternaryOps = nullMap({});
|
|
330
338
|
|
|
331
|
-
const hypot = (...args: unknown[]) => Decimal.hypot(...decimalArguments(args, "hypot"));
|
|
339
|
+
const hypot = (...args: readonly unknown[]) => Decimal.hypot(...decimalArguments(args, "hypot"));
|
|
332
340
|
|
|
333
341
|
parser.functions = nullMap({
|
|
334
|
-
d: (...args: unknown[]) => {
|
|
342
|
+
d: (...args: readonly unknown[]) => {
|
|
335
343
|
requireArity("d", args, 1);
|
|
336
|
-
if (typeof args[0] !== "string")
|
|
344
|
+
if (typeof args[0] !== "string") {
|
|
345
|
+
throw new Error("invalid decimal literal");
|
|
346
|
+
}
|
|
337
347
|
return toDec(args[0]);
|
|
338
348
|
},
|
|
339
|
-
fac: (...args: unknown[]) => {
|
|
349
|
+
fac: (...args: readonly unknown[]) => {
|
|
340
350
|
requireArity("fac", args, 1);
|
|
341
351
|
return factorial(args[0]);
|
|
342
352
|
},
|
|
343
|
-
pow: (...args: unknown[]) => {
|
|
353
|
+
pow: (...args: readonly unknown[]) => {
|
|
344
354
|
requireArity("pow", args, 2);
|
|
345
355
|
return toDec(args[0]).pow(toDec(args[1]));
|
|
346
356
|
},
|
|
347
|
-
atan2: (...args: unknown[]) => {
|
|
357
|
+
atan2: (...args: readonly unknown[]) => {
|
|
348
358
|
requireArity("atan2", args, 2);
|
|
349
359
|
const values = args.map(toDec);
|
|
350
360
|
spendExpensiveWork(TRIG_WORK);
|
|
351
|
-
return arctangent2(values
|
|
361
|
+
return arctangent2(argumentAt(values, 0), argumentAt(values, 1));
|
|
352
362
|
},
|
|
353
|
-
min: (...args: unknown[]) => Decimal.min(...decimalArguments(args, "min")),
|
|
354
|
-
max: (...args: unknown[]) => Decimal.max(...decimalArguments(args, "max")),
|
|
355
|
-
sum: (...args: unknown[]) => {
|
|
363
|
+
min: (...args: readonly unknown[]) => Decimal.min(...decimalArguments(args, "min")),
|
|
364
|
+
max: (...args: readonly unknown[]) => Decimal.max(...decimalArguments(args, "max")),
|
|
365
|
+
sum: (...args: readonly unknown[]) => {
|
|
356
366
|
requireArity("sum", args, 1);
|
|
357
|
-
return
|
|
367
|
+
return exactSum(decimals(args[0], "sum"));
|
|
358
368
|
},
|
|
359
369
|
hypot,
|
|
360
370
|
pyt: hypot,
|
|
361
371
|
roundTo,
|
|
362
|
-
percent: (...args: unknown[]) => {
|
|
372
|
+
percent: (...args: readonly unknown[]) => {
|
|
363
373
|
requireArity("percent", args, 2);
|
|
364
374
|
const rate = toDec(args[0]);
|
|
365
375
|
const amount = toDec(args[1]);
|
|
@@ -368,29 +378,32 @@ parser.functions = nullMap({
|
|
|
368
378
|
? amount.div(100).times(rate)
|
|
369
379
|
: rate.div(100).times(amount);
|
|
370
380
|
},
|
|
371
|
-
radians: (...args: unknown[]) => {
|
|
381
|
+
radians: (...args: readonly unknown[]) => {
|
|
372
382
|
requireArity("radians", args, 1);
|
|
373
383
|
return toDec(args[0]).times(PI).div(180);
|
|
374
384
|
},
|
|
375
|
-
degrees: (...args: unknown[]) => {
|
|
385
|
+
degrees: (...args: readonly unknown[]) => {
|
|
376
386
|
requireArity("degrees", args, 1);
|
|
377
387
|
return toDec(args[0]).times(180).div(PI);
|
|
378
388
|
},
|
|
379
|
-
mean: (...args: unknown[]) => {
|
|
389
|
+
mean: (...args: readonly unknown[]) => {
|
|
380
390
|
requireArity("mean", args, 1);
|
|
381
391
|
return mean(decimals(args[0], "mean"));
|
|
382
392
|
},
|
|
383
|
-
median: (...args: unknown[]) => {
|
|
393
|
+
median: (...args: readonly unknown[]) => {
|
|
384
394
|
requireArity("median", args, 1);
|
|
385
395
|
const xs = decimals(args[0], "median").sort((a, b) => a.comparedTo(b));
|
|
386
396
|
const mid = Math.floor(xs.length / 2);
|
|
387
|
-
|
|
397
|
+
if (xs.length % 2 !== 0) {
|
|
398
|
+
return argumentAt(xs, mid);
|
|
399
|
+
}
|
|
400
|
+
return mean([argumentAt(xs, mid - 1), argumentAt(xs, mid)]);
|
|
388
401
|
},
|
|
389
|
-
stdev: (...args: unknown[]) => {
|
|
402
|
+
stdev: (...args: readonly unknown[]) => {
|
|
390
403
|
requireArity("stdev", args, 1);
|
|
391
404
|
return standardDeviation(decimals(args[0], "stdev"), false);
|
|
392
405
|
},
|
|
393
|
-
stdevs: (...args: unknown[]) => {
|
|
406
|
+
stdevs: (...args: readonly unknown[]) => {
|
|
394
407
|
requireArity("stdevs", args, 1);
|
|
395
408
|
return standardDeviation(decimals(args[0], "stdevs", 2), true);
|
|
396
409
|
},
|
|
@@ -400,27 +413,34 @@ parser.consts = nullMap({ PI, E });
|
|
|
400
413
|
|
|
401
414
|
function normalizeExpression(expression: string): string {
|
|
402
415
|
const trimmed = expression.trim();
|
|
403
|
-
if (
|
|
416
|
+
if (trimmed === "") {
|
|
417
|
+
throw new Error("Expression is empty");
|
|
418
|
+
}
|
|
404
419
|
if (trimmed.length > MAX_EXPRESSION_LENGTH) {
|
|
405
420
|
throw new Error(`Expression too long (max ${MAX_EXPRESSION_LENGTH} chars)`);
|
|
406
421
|
}
|
|
407
422
|
return trimmed;
|
|
408
423
|
}
|
|
409
424
|
|
|
410
|
-
function resultString(
|
|
411
|
-
|
|
425
|
+
function resultString(value: unknown): string {
|
|
426
|
+
const result = typeof value === "number" ? new Decimal(String(value)) : value;
|
|
412
427
|
if (!(result instanceof Decimal)) {
|
|
413
428
|
const type = Array.isArray(result) ? "array" : typeof result;
|
|
414
429
|
throw new Error(`Expression did not evaluate to a number (got ${type})`);
|
|
415
430
|
}
|
|
416
431
|
if (!result.isFinite()) {
|
|
417
|
-
if (result.isNaN())
|
|
432
|
+
if (result.isNaN()) {
|
|
433
|
+
throw new Error("Result is NaN");
|
|
434
|
+
}
|
|
418
435
|
throw new Error(result.isPositive() ? "Result is Infinity" : "Result is -Infinity");
|
|
419
436
|
}
|
|
420
437
|
return result.toString();
|
|
421
438
|
}
|
|
422
439
|
|
|
423
|
-
export function evaluateExpression(expression: string): {
|
|
440
|
+
export function evaluateExpression(expression: string): {
|
|
441
|
+
expression: string;
|
|
442
|
+
value: string | string[];
|
|
443
|
+
} {
|
|
424
444
|
const normalized = normalizeExpression(expression);
|
|
425
445
|
let result: unknown;
|
|
426
446
|
factorialWorkLeft = MAX_FACTORIAL_WORK;
|
|
@@ -433,14 +453,18 @@ export function evaluateExpression(expression: string): { expression: string; va
|
|
|
433
453
|
.replace(/^\[DecimalError\]\s*/, "")
|
|
434
454
|
.replace(/^parse error \[\d+:\d+\]:\s*/, "")
|
|
435
455
|
.replace(/\bT[A-Z]+:\s*/g, "");
|
|
436
|
-
if (/precision limit exceeded/i.test(message))
|
|
437
|
-
|
|
438
|
-
|
|
456
|
+
if (/precision limit exceeded/i.test(message)) {
|
|
457
|
+
message = "numeric argument exceeds precision limit";
|
|
458
|
+
}
|
|
459
|
+
if (error instanceof RangeError && /call stack/i.test(message)) {
|
|
460
|
+
message = "expression is too deeply nested";
|
|
461
|
+
}
|
|
462
|
+
throw new Error(`Invalid expression: ${message}`, { cause: error });
|
|
439
463
|
} finally {
|
|
440
464
|
factorialWorkLeft = 0;
|
|
441
465
|
expensiveWorkLeft = 0;
|
|
442
466
|
resetDecimal();
|
|
443
|
-
|
|
467
|
+
resetGuardDecimal();
|
|
444
468
|
}
|
|
445
469
|
|
|
446
470
|
const value = Array.isArray(result) ? result.map(resultString) : resultString(result);
|
package/expr-eval-fork.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
// Re-exporting keeps this file a module, so the declaration augments the package's typings.
|
|
2
|
+
import type { Parser } from "expr-eval-fork";
|
|
3
|
+
|
|
4
|
+
export type { Parser };
|
|
2
5
|
|
|
3
6
|
declare module "expr-eval-fork" {
|
|
4
7
|
interface Parser {
|
|
5
8
|
binaryOps: Record<string, (left: unknown, right: unknown) => unknown>;
|
|
6
|
-
ternaryOps: Record<
|
|
9
|
+
ternaryOps: Record<
|
|
10
|
+
string,
|
|
11
|
+
(condition: unknown, consequent: unknown, alternate: unknown) => unknown
|
|
12
|
+
>;
|
|
7
13
|
}
|
|
8
14
|
}
|
package/index.ts
CHANGED
|
@@ -13,26 +13,38 @@ const calculatorTool = defineTool({
|
|
|
13
13
|
"Use calculator for non-trivial calculations; simple arithmetic does not need a tool call. Results are rounded decimal strings. In calculator expressions, use sum([...]) for totals and roundTo(value, places) for requested rounding. Put independent results in a flat array.",
|
|
14
14
|
],
|
|
15
15
|
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
|
16
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
16
17
|
parameters: Type.Object(
|
|
17
18
|
{
|
|
18
19
|
expression: Type.String({
|
|
19
20
|
minLength: 1,
|
|
20
21
|
maxLength: MAX_EXPRESSION_LENGTH,
|
|
21
|
-
description:
|
|
22
|
+
description:
|
|
23
|
+
"Numeric expression: + - * / % ^ **, PI, E, roots, trig, n! (0–1000), sum/mean/median. log/ln are natural; log2/log10 choose a base. Use expm1(x) for exp(x)-1 and log1p(x) for ln(1+x) when x is small. Trig uses radians; radians(degrees) and degrees(radians) convert angles. percent(rate, amount) gives rate% of amount. stdev(array) is population; stdevs(array) is sample. roundTo(value, places) rounds halves away from zero.",
|
|
22
24
|
}),
|
|
23
25
|
},
|
|
24
26
|
{ additionalProperties: false },
|
|
25
27
|
),
|
|
26
|
-
outputSchema: Type.Object(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
outputSchema: Type.Object(
|
|
29
|
+
{
|
|
30
|
+
expression: Type.String(),
|
|
31
|
+
value: Type.Union([Type.String(), Type.Array(Type.String())]),
|
|
32
|
+
},
|
|
33
|
+
{ additionalProperties: false },
|
|
34
|
+
),
|
|
35
|
+
renderCall(args: { readonly expression?: string }, theme) {
|
|
36
|
+
return new Text(
|
|
37
|
+
theme.fg("toolTitle", theme.bold("calculator ")) +
|
|
38
|
+
theme.fg("dim", JSON.stringify(args.expression ?? "")),
|
|
39
|
+
0,
|
|
40
|
+
0,
|
|
41
|
+
);
|
|
32
42
|
},
|
|
33
43
|
async execute(_toolCallId, params) {
|
|
34
44
|
const evaluated = evaluateExpression(params.expression);
|
|
35
|
-
const text = Array.isArray(evaluated.value)
|
|
45
|
+
const text = Array.isArray(evaluated.value)
|
|
46
|
+
? JSON.stringify(evaluated.value)
|
|
47
|
+
: evaluated.value;
|
|
36
48
|
return {
|
|
37
49
|
content: [{ type: "text", text }],
|
|
38
50
|
details: evaluated,
|
|
@@ -41,6 +53,6 @@ const calculatorTool = defineTool({
|
|
|
41
53
|
},
|
|
42
54
|
});
|
|
43
55
|
|
|
44
|
-
export default function (pi: ExtensionAPI) {
|
|
56
|
+
export default function (pi: ExtensionAPI): void {
|
|
45
57
|
pi.registerTool(calculatorTool);
|
|
46
58
|
}
|
package/package.json
CHANGED
|
@@ -1,71 +1,90 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
2
|
+
"name": "@fitchmultz/pi-calculator",
|
|
3
|
+
"version": "4.1.7",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"description": "Deterministic high-precision calculator tool for Pi",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"eval.ts",
|
|
13
|
+
"decimal.ts",
|
|
14
|
+
"expr-eval-fork.d.ts",
|
|
15
|
+
"README.md",
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"statistics.ts",
|
|
19
|
+
"docs"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi",
|
|
24
|
+
"calculator",
|
|
25
|
+
"decimal"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/fitchmultz/pi-calculator.git"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=24.0.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"check": "node check.ts",
|
|
36
|
+
"check:load": "node check-extension.ts",
|
|
37
|
+
"typecheck": "tsc",
|
|
38
|
+
"typecheck:declarations": "tsc -p tsconfig.declarations.json",
|
|
39
|
+
"lint": "node scripts/lint.mjs all",
|
|
40
|
+
"lint:agent": "node scripts/lint.mjs agent",
|
|
41
|
+
"lint:fix": "node scripts/lint.mjs fix",
|
|
42
|
+
"format": "oxfmt --write .",
|
|
43
|
+
"format:check": "oxfmt --check .",
|
|
44
|
+
"policy": "node scripts/policy.ts",
|
|
45
|
+
"test": "node --test \"test/**/*.test.ts\"",
|
|
46
|
+
"verify": "node scripts/checks.mjs lint format:check typecheck typecheck:declarations lint:baseline policy test check check:load",
|
|
47
|
+
"check:compat": "npm run verify",
|
|
48
|
+
"lint:baseline": "node scripts/lint-baseline.ts",
|
|
49
|
+
"lint:scope": "node scripts/lint.mjs scope",
|
|
50
|
+
"format:write": "oxfmt --write .",
|
|
51
|
+
"check:quality": "node scripts/checks.mjs lint format:check typecheck typecheck:declarations lint:baseline policy"
|
|
52
|
+
},
|
|
53
|
+
"pi": {
|
|
54
|
+
"extensions": [
|
|
55
|
+
"./index.ts"
|
|
56
|
+
],
|
|
57
|
+
"image": "https://raw.githubusercontent.com/fitchmultz/pi-calculator/main/.github/readme/calculator-flow.png"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"decimal.js": "10.6.0",
|
|
61
|
+
"expr-eval-fork": "3.0.3"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
65
|
+
"@earendil-works/pi-tui": "*",
|
|
66
|
+
"typebox": "*"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"@earendil-works/pi-coding-agent": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"@earendil-works/pi-tui": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"typebox": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@earendil-works/pi-ai": "^1.4.0",
|
|
81
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
82
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
83
|
+
"@types/node": "24.19.1",
|
|
84
|
+
"oxfmt": "0.75.0",
|
|
85
|
+
"oxlint": "1.90.0",
|
|
86
|
+
"oxlint-tsgolint": "7.0.2005",
|
|
87
|
+
"typebox": "1.3.27",
|
|
88
|
+
"typescript": "7.0.2"
|
|
89
|
+
}
|
|
71
90
|
}
|
package/statistics.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { DECIMAL_PRECISION, Decimal, GuardDecimal, type DecimalValue } from "./decimal.ts";
|
|
2
|
+
|
|
3
|
+
/** Absent bins hold zero, which is what the sparse sum below assumes. */
|
|
4
|
+
function bin(bins: Map<bigint, bigint>, key: bigint): bigint {
|
|
5
|
+
return bins.get(key) ?? 0n;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function addBin(bins: Map<bigint, bigint>, key: bigint, value: bigint): void {
|
|
9
|
+
bins.set(key, (bins.get(key) ?? 0n) + value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Exact significand digits as a signed integer, keeping every input digit. */
|
|
13
|
+
function significandDigits(value: DecimalValue): string {
|
|
14
|
+
const [digits] = value.toExponential().split("e");
|
|
15
|
+
if (digits === undefined) {
|
|
16
|
+
throw new Error("expected an exact decimal significand");
|
|
17
|
+
}
|
|
18
|
+
return digits.replace(".", "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Spread each value across the bins its exponent falls in, keeping the exact sum sparse. */
|
|
22
|
+
function fillBins(xs: readonly DecimalValue[], bins: Map<bigint, bigint>, width: bigint): void {
|
|
23
|
+
for (const x of xs) {
|
|
24
|
+
if (x.isZero()) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const exponent = BigInt(x.e) - BigInt(x.sd() - 1);
|
|
28
|
+
const key = exponent >= 0n ? exponent / width : (exponent - width + 1n) / width;
|
|
29
|
+
addBin(bins, key, BigInt(significandDigits(x)) * 10n ** (exponent - key * width));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Carry each bin into the next so every stored bin stays below the base. */
|
|
34
|
+
function carryBins(bins: Map<bigint, bigint>, base: bigint): void {
|
|
35
|
+
for (const start of [...bins.keys()].sort((a, b) => Number(a - b))) {
|
|
36
|
+
let key = start;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const value = bin(bins, key);
|
|
39
|
+
const carry = value / base;
|
|
40
|
+
bins.set(key, value % base);
|
|
41
|
+
if (carry === 0n) {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
key += 1n;
|
|
45
|
+
addBin(bins, key, carry);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type DecimalConstructor = typeof Decimal;
|
|
51
|
+
|
|
52
|
+
const digitCount = (n: bigint) => n.toString().replace("-", "").length;
|
|
53
|
+
|
|
54
|
+
/** Fold the leading bins into one coefficient; a sticky digit keeps the tail's side of a tie. */
|
|
55
|
+
function leadingCoefficient(
|
|
56
|
+
bins: Map<bigint, bigint>,
|
|
57
|
+
keys: readonly bigint[],
|
|
58
|
+
base: bigint,
|
|
59
|
+
retained: number,
|
|
60
|
+
): [coefficient: bigint, key: bigint, shift: bigint] {
|
|
61
|
+
const leading = keys[0];
|
|
62
|
+
if (leading === undefined) {
|
|
63
|
+
return [0n, 0n, 0n];
|
|
64
|
+
}
|
|
65
|
+
let key = leading;
|
|
66
|
+
let coefficient = bin(bins, key);
|
|
67
|
+
let index = 1;
|
|
68
|
+
while (index < keys.length) {
|
|
69
|
+
const next = keys[index];
|
|
70
|
+
if (next === undefined) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (digitCount(coefficient) >= retained || key - next > 2n) {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
coefficient = coefficient * base ** (key - next) + bin(bins, next);
|
|
77
|
+
key = next;
|
|
78
|
+
index += 1;
|
|
79
|
+
}
|
|
80
|
+
// Normalized chunks have magnitude < base, so the leading remaining chunk
|
|
81
|
+
// determines the tail's sign.
|
|
82
|
+
let shift = 0n;
|
|
83
|
+
const tail = index < keys.length ? keys[index] : undefined;
|
|
84
|
+
if (tail !== undefined) {
|
|
85
|
+
shift = BigInt(Math.max(2, retained - digitCount(coefficient)));
|
|
86
|
+
coefficient = coefficient * 10n ** shift + (bin(bins, tail) > 0n ? 1n : -1n);
|
|
87
|
+
}
|
|
88
|
+
return [coefficient, key, shift];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Sum `xs` exactly, then divide, so large exponent gaps keep every input digit. */
|
|
92
|
+
export function exactSum(
|
|
93
|
+
xs: readonly DecimalValue[],
|
|
94
|
+
Ctor: DecimalConstructor = Decimal,
|
|
95
|
+
divisor = 1,
|
|
96
|
+
): DecimalValue {
|
|
97
|
+
if (xs.some((x) => !x.isFinite())) {
|
|
98
|
+
return Ctor.sum(...xs).div(divisor);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Decimal.sum also truncates across large exponent gaps. Signed BigInt chunks
|
|
102
|
+
// keep the exact sum sparse, including intermediate values outside Decimal's range.
|
|
103
|
+
const width = BigInt(Ctor.precision);
|
|
104
|
+
const base = 10n ** width;
|
|
105
|
+
const bins = new Map<bigint, bigint>();
|
|
106
|
+
fillBins(xs, bins, width);
|
|
107
|
+
carryBins(bins, base);
|
|
108
|
+
|
|
109
|
+
const keys = [...bins.keys()]
|
|
110
|
+
.filter((key) => bins.get(key) !== 0n)
|
|
111
|
+
.sort((a, b) => Number(b - a));
|
|
112
|
+
if (keys.length === 0) {
|
|
113
|
+
return new Ctor(0);
|
|
114
|
+
}
|
|
115
|
+
const retained = Ctor.precision + Math.ceil(Math.log10(divisor)) + 2;
|
|
116
|
+
const [coefficient, key, shift] = leadingCoefficient(bins, keys, base, retained);
|
|
117
|
+
// Divide and round before restoring the exponent to avoid intermediate overflow/underflow.
|
|
118
|
+
const [mantissa, exponent] = new Ctor(coefficient.toString())
|
|
119
|
+
.div(divisor)
|
|
120
|
+
.toExponential()
|
|
121
|
+
.split("e");
|
|
122
|
+
if (mantissa === undefined || exponent === undefined) {
|
|
123
|
+
throw new Error("expected an exact decimal exponent form");
|
|
124
|
+
}
|
|
125
|
+
return new Ctor(`${mantissa}e${BigInt(exponent) + key * width - shift}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function mean(xs: readonly DecimalValue[]): DecimalValue {
|
|
129
|
+
return exactSum(xs, Decimal, xs.length);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function standardDeviation(xs: readonly DecimalValue[], sample: boolean): DecimalValue {
|
|
133
|
+
// Scale before subtraction; a power of ten preserves small spreads around a large offset.
|
|
134
|
+
const widest = GuardDecimal.max(...xs.map((x) => x.abs()));
|
|
135
|
+
const scale = new GuardDecimal(`1e${widest.e}`);
|
|
136
|
+
const scaled = xs.map((x) => new GuardDecimal(x).div(scale));
|
|
137
|
+
const anchor = scaled[0];
|
|
138
|
+
if (anchor === undefined) {
|
|
139
|
+
throw new Error("statistics need at least one number");
|
|
140
|
+
}
|
|
141
|
+
// Center on an input first so a large common offset cannot round away the spread.
|
|
142
|
+
const shifted = scaled.map((x) => x.minus(anchor));
|
|
143
|
+
const center = exactSum(shifted, GuardDecimal, xs.length);
|
|
144
|
+
const squared = shifted.map((x) => x.minus(center).pow(2));
|
|
145
|
+
return new Decimal(
|
|
146
|
+
exactSum(squared, GuardDecimal, sample ? xs.length - 1 : xs.length)
|
|
147
|
+
.sqrt()
|
|
148
|
+
.times(scale)
|
|
149
|
+
.toSignificantDigits(DECIMAL_PRECISION),
|
|
150
|
+
);
|
|
151
|
+
}
|