exprforge 0.6.1 → 0.7.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.
package/README.md CHANGED
@@ -32,8 +32,15 @@ dependencies.
32
32
 
33
33
  **[▶ Try it live](https://theraccoonbear.github.io/exprforge/)** — write
34
34
  a formula in the browser and watch it emitted across every target
35
- language at once, no install required. Runs the real, current library
36
- (see `playground/`), not a frozen demo build.
35
+ language at once, no install required, or switch to the Differentiation
36
+ tab to get a formula's derivative and a numeric spot-check side by side.
37
+ Runs the real, current library (see `playground/`), not a frozen demo
38
+ build.
39
+
40
+ Every open pull request also gets its own live preview of the
41
+ playground, deployed automatically to
42
+ `https://theraccoonbear.github.io/exprforge/pr-<N>/` and linked in a
43
+ comment on the PR (see `.github/workflows/deploy-pr-preview.yml`).
37
44
 
38
45
  ## Motivation
39
46
 
@@ -68,7 +75,9 @@ Two shapes of real use this tends to fall into:
68
75
  **What it does**: turns one small, pure-arithmetic AST into
69
76
  identical-behavior source text for 16 real target languages, a native
70
77
  evaluator, and its own readable printer — all from the same tree, walked
71
- once per target.
78
+ once per target. Symbolic differentiation (`differentiate`) works over
79
+ that same AST too, so a derivative is just another tree, emittable and
80
+ evaluable exactly the same way.
72
81
 
73
82
  **What it deliberately won't do** — not gaps waiting on a future
74
83
  release, but a boundary held on purpose everywhere in this project:
@@ -325,6 +334,53 @@ validate against), a wrong argument *count* is a structurally malformed
325
334
  call regardless of target, checked unconditionally at the same tier as
326
335
  `checkUnboundVars` — see `primitives.js`.
327
336
 
337
+ ## Symbolic differentiation (`differentiate`)
338
+
339
+ ```js
340
+ const { fn, differentiate, emit, evaluate } = require("exprforge");
341
+
342
+ const f = fn`
343
+ f(x):
344
+ return x^2 * sin(x);
345
+ `;
346
+ const df = { name: "df_dx", params: f.params, body: differentiate(f.body, "x") };
347
+
348
+ console.log(emit(df, "python").source);
349
+ console.log(evaluate(df, [Math.PI])); // -π² ≈ -9.8696
350
+ ```
351
+
352
+ `differentiate(node, varName)` returns an ordinary AST node — the
353
+ symbolic derivative of `node` with respect to `varName` — in the exact
354
+ same representation as everything else, so it's emittable to all 18
355
+ targets via `emit()`/`emitMany()` and evaluable via `evaluate()`
356
+ unchanged. The input is never mutated.
357
+
358
+ - **Covers every differentiable primitive**: sum/difference/product/
359
+ quotient rule, plus the chain rule for `sqrt abs sin cos tan asin acos
360
+ atan log log2 log10 exp pow atan2 min max hypot` (`pow` picks power
361
+ rule, exponential rule, or the general product-and-chain-rule case,
362
+ depending on which side of `^` actually varies with respect to
363
+ `varName`).
364
+ - **`floor ceil round trunc sign` throw** at differentiation time, with a
365
+ clear error naming the offending call — these are piecewise-constant/
366
+ discontinuous primitives with no meaningful derivative, so this fails
367
+ loudly instead of silently producing a wrong AST.
368
+ - **Output is simplified, not the raw mechanical rules verbatim.** A
369
+ bottom-up pass folds constant subtrees (`num op num` → `num`) and
370
+ eliminates arithmetic identities (`x + 0`, `x * 1`, `x / 1`, `x^0`,
371
+ `x^1`, `0 - x`) to a fixpoint, so a real formula's derivative doesn't
372
+ come back buried in the `* 1`/`+ 0` swell every mechanical
373
+ product/chain rule application produces.
374
+ - **Verified numerically, not hand-checked algebraically** — every rule's
375
+ test asserts the symbolic result against a central-difference
376
+ approximation at several sample points (see `test/differentiate.test.js`),
377
+ the same "proof by running" approach this project already uses for
378
+ round-tripping expr syntax (see "Testing").
379
+
380
+ Try it interactively in the [live playground](https://theraccoonbear.github.io/exprforge/)'s
381
+ Differentiation tab — enter a formula, see the derivative and a numeric
382
+ spot-check side by side.
383
+
328
384
  ## Math utilities (`exprforge/math`)
329
385
 
330
386
  A separate, additive export — `require("exprforge")` is unchanged — of
@@ -0,0 +1,345 @@
1
+ // exprforge/differentiate.js
2
+ //
3
+ // Symbolic differentiation: differentiate(node, varName) returns an AST
4
+ // node representing the derivative of `node` with respect to `varName`.
5
+ //
6
+ // The result is an ordinary AST in the exact same representation as
7
+ // everything else -- emittable to all 18 targets via emit()/emitMany()
8
+ // unchanged, and evaluable via evaluate() for the numerical verification
9
+ // the issue describes (central-difference proof).
10
+ //
11
+ // Scope: all differentiable primitives in the AST grammar.
12
+ // Non-differentiable operations (floor, ceil, round, trunc, sign) throw
13
+ // with a clear error at differentiation time rather than silently
14
+ // producing a wrong result.
15
+ //
16
+ // Design notes:
17
+ // - differentiateRaw() is purely mechanical -- product/quotient/chain
18
+ // rules produce expression swell (terms like `* 1`, `+ 0`) verbatim,
19
+ // which keeps every rule obviously correct by inspection. simplify()
20
+ // (below) is a separate bottom-up pass over that raw output --
21
+ // constant folding plus arithmetic-identity elimination -- run
22
+ // automatically by the exported differentiate(), so callers never
23
+ // see the raw swell. A standalone, general-purpose version of this
24
+ // (usable on any AST, not just differentiate()'s output) is issue #9.
25
+ // - Every rule is structural: it only looks at node.type and recurses.
26
+ // No alpha-renaming, no capture-avoiding substitution -- the output
27
+ // is a fresh tree built from the input's subterms, never mutating
28
+ // the input.
29
+
30
+ const { num, v, bin, call, add, mul, sub, div, neg, select, cmp } = require("./ast.js");
31
+
32
+ // The non-differentiable built-in primitives. These are piecewise-constant
33
+ // or discontinuous -- no meaningful derivative exists. Throwing here
34
+ // catches the mistake at differentiation time rather than silently
35
+ // producing a semantically wrong AST that happens to evaluate.
36
+ const NON_DIFFERENTIABLE = new Set(["floor", "ceil", "round", "trunc", "sign"]);
37
+
38
+ // differentiate(node, varName) -> Node
39
+ //
40
+ // Returns the symbolic derivative of `node` with respect to the variable
41
+ // named `varName`. The input is never mutated.
42
+ function differentiate(node, varName) {
43
+ return simplify(differentiateRaw(node, varName));
44
+ }
45
+
46
+ function differentiateRaw(node, varName) {
47
+ switch (node.type) {
48
+ // d/dx c = 0
49
+ case "num":
50
+ return num(0);
51
+
52
+ // d/dx x = 1, d/dx y = 0
53
+ case "var":
54
+ return node.name === varName ? num(1) : num(0);
55
+
56
+ // Binary arithmetic: product rule, quotient rule, sum/difference rule
57
+ case "bin":
58
+ return differentiateBin(node, varName);
59
+
60
+ // Function calls: chain rule + one derivative rule per intrinsic
61
+ case "call":
62
+ return differentiateCall(node, varName);
63
+
64
+ // select/cmp: differentiate both branches (both are always
65
+ // evaluated by design -- this is a value, not a branch). The
66
+ // condition's derivative is irrelevant (it selects, not computes).
67
+ case "select":
68
+ return select(
69
+ node.cond,
70
+ differentiateRaw(node.then, varName),
71
+ differentiateRaw(node.else, varName),
72
+ );
73
+
74
+ default:
75
+ throw new Error(
76
+ `differentiate(): unexpected node type "${node.type}" -- ` +
77
+ `"let"/"outputs"/"field" must already be resolved by expandMacros/collectLets before differentiation`,
78
+ );
79
+ }
80
+ }
81
+
82
+ function differentiateBin(node, varName) {
83
+ const { op, left, right } = node;
84
+ const dl = differentiateRaw(left, varName);
85
+ const dr = differentiateRaw(right, varName);
86
+
87
+ switch (op) {
88
+ // d/dx (f + g) = f' + g'
89
+ case "+":
90
+ return add(dl, dr);
91
+
92
+ // d/dx (f - g) = f' - g'
93
+ case "-":
94
+ return sub(dl, dr);
95
+
96
+ // Product rule: d/dx (f * g) = f' * g + f * g'
97
+ case "*":
98
+ return add(mul(dl, right), mul(left, dr));
99
+
100
+ // Quotient rule: d/dx (f / g) = (f' * g - f * g') / g²
101
+ case "/":
102
+ return div(
103
+ sub(mul(dl, right), mul(left, dr)),
104
+ mul(right, right),
105
+ );
106
+
107
+ default:
108
+ throw new Error(`differentiateBin(): unknown op "${op}"`);
109
+ }
110
+ }
111
+
112
+ function differentiateCall(node, varName) {
113
+ const { name, args } = node;
114
+
115
+ if (NON_DIFFERENTIABLE.has(name)) {
116
+ throw new Error(
117
+ `differentiate(): "${name}" is not differentiable (piecewise-constant/discontinuous)`,
118
+ );
119
+ }
120
+
121
+ // Unary primitives: chain rule is d/dx f(u) = f'(u) * u'
122
+ // where u = args[0] and u' = differentiate(args[0], varName).
123
+ //
124
+ // Binary primitives: chain rule is d/dx f(u, v) = (∂f/∂u * u' + ∂f/∂v * v')
125
+ // where partial derivatives are computed treating the other arg as constant.
126
+
127
+ const du = differentiateRaw(args[0], varName);
128
+
129
+ switch (name) {
130
+ // d/dx sqrt(u) = u' / (2 * sqrt(u))
131
+ case "sqrt":
132
+ return div(du, mul(num(2), call("sqrt", args[0])));
133
+
134
+ // d/dx abs(u) = u' * sign(u)
135
+ case "abs":
136
+ return mul(du, call("sign", args[0]));
137
+
138
+ // d/dx sin(u) = u' * cos(u)
139
+ case "sin":
140
+ return mul(du, call("cos", args[0]));
141
+
142
+ // d/dx cos(u) = -u' * sin(u)
143
+ case "cos":
144
+ return mul(neg(du), call("sin", args[0]));
145
+
146
+ // d/dx tan(u) = u' / cos²(u) = u' * (1 + tan²(u))
147
+ // Using 1/cos² form via sec² identity avoids needing a sec builtin.
148
+ case "tan":
149
+ return mul(du, add(num(1), mul(call("tan", args[0]), call("tan", args[0]))));
150
+
151
+ // d/dx asin(u) = u' / sqrt(1 - u²)
152
+ case "asin":
153
+ return div(du, call("sqrt", sub(num(1), mul(args[0], args[0]))));
154
+
155
+ // d/dx acos(u) = -u' / sqrt(1 - u²)
156
+ case "acos":
157
+ return div(neg(du), call("sqrt", sub(num(1), mul(args[0], args[0]))));
158
+
159
+ // d/dx atan(u) = u' / (1 + u²)
160
+ case "atan":
161
+ return div(du, add(num(1), mul(args[0], args[0])));
162
+
163
+ // d/dx log(u) = u' / u
164
+ case "log":
165
+ return div(du, args[0]);
166
+
167
+ // d/dx log2(u) = u' / (u * ln(2))
168
+ case "log2":
169
+ return div(du, mul(args[0], num(Math.LN2)));
170
+
171
+ // d/dx log10(u) = u' / (u * ln(10))
172
+ case "log10":
173
+ return div(du, mul(args[0], num(Math.LN10)));
174
+
175
+ // d/dx exp(u) = u' * exp(u)
176
+ case "exp":
177
+ return mul(du, call("exp", args[0]));
178
+
179
+ // d/dx pow(u, v) -- three cases:
180
+ // 1. v is constant: d/dx u^v = v * u^(v-1) * u' (power rule)
181
+ // 2. u is constant: d/dx c^v = c^v * ln(c) * v' (exponential rule)
182
+ // 3. Both vary: d/dx u^v = u^v * (v' * ln(u) + v * u'/u)
183
+ case "pow": {
184
+ const dv = differentiateRaw(args[1], varName);
185
+ const uIsConst = isConstant(args[0], varName);
186
+ const vIsConst = isConstant(args[1], varName);
187
+
188
+ if (vIsConst) {
189
+ // Power rule: v * u^(v-1) * u'
190
+ return mul(
191
+ mul(args[1], call("pow", args[0], sub(args[1], num(1)))),
192
+ du,
193
+ );
194
+ }
195
+ if (uIsConst) {
196
+ // Exponential rule: c^v * ln(c) * v'
197
+ return mul(
198
+ mul(node, call("log", args[0])),
199
+ dv,
200
+ );
201
+ }
202
+ // General: u^v * (v' * ln(u) + v * u'/u)
203
+ return mul(
204
+ node,
205
+ add(
206
+ mul(dv, call("log", args[0])),
207
+ mul(args[1], div(du, args[0])),
208
+ ),
209
+ );
210
+ }
211
+
212
+ // d/dx atan2(u, v) = (u' * v - u * v') / (u² + v²)
213
+ // Partial w.r.t. first arg (u): v / (u² + v²)
214
+ // Partial w.r.t. second arg (v): -u / (u² + v²)
215
+ case "atan2": {
216
+ const dv = differentiateRaw(args[1], varName);
217
+ const denom = add(mul(args[0], args[0]), mul(args[1], args[1]));
218
+ return div(
219
+ sub(mul(du, args[1]), mul(args[0], dv)),
220
+ denom,
221
+ );
222
+ }
223
+
224
+ // d/dx min(u, v):
225
+ // If u < v: derivative is du (min is u)
226
+ // If v < u: derivative is dv (min is v)
227
+ // If equal: undefined, but both branches evaluated anyway
228
+ case "min": {
229
+ const dv = differentiateRaw(args[1], varName);
230
+ return select(cmp(args[0], "<", args[1]), du, dv);
231
+ }
232
+
233
+ // d/dx max(u, v):
234
+ // If u > v: derivative is du (max is u)
235
+ // If v > u: derivative is dv (max is v)
236
+ case "max": {
237
+ const dv = differentiateRaw(args[1], varName);
238
+ return select(cmp(args[0], ">", args[1]), du, dv);
239
+ }
240
+
241
+ // d/dx hypot(u, v) = (u * u' + v * v') / hypot(u, v)
242
+ case "hypot": {
243
+ const dv = differentiateRaw(args[1], varName);
244
+ return div(
245
+ add(mul(args[0], du), mul(args[1], dv)),
246
+ call("hypot", args[0], args[1]),
247
+ );
248
+ }
249
+
250
+ default:
251
+ throw new Error(`differentiate(): unknown call "${name}"`);
252
+ }
253
+ }
254
+
255
+ // Check whether a node is a constant with respect to varName -- a num
256
+ // literal, or a var reference to anything OTHER than the differentiation
257
+ // variable. Doesn't recurse into subtrees -- if the node is a call/bin,
258
+ // it's not constant (even if all its leaves are). This is deliberately
259
+ // conservative: we only need to distinguish "definitely constant" (num,
260
+ // unrelated var) from "might not be" (everything else) for the pow()
261
+ // special cases.
262
+ function isConstant(node, varName) {
263
+ if (node.type === "num") return true;
264
+ if (node.type === "var") return node.name !== varName;
265
+ return false;
266
+ }
267
+
268
+ // Bottom-up algebraic simplification. Handles the expression swell that
269
+ // differentiation rules inevitably produce (terms like `* 0`, `+ 0`,
270
+ // `* 1`, `/ 1`, `^ 0`, `^ 1`). Runs to fixpoint — a single pass can
271
+ // create new simplifiable patterns (e.g. `0 * (x + 0)` → `0 * x` → `0`).
272
+ function simplify(node) {
273
+ if (!node || typeof node !== "object") return node;
274
+
275
+ // Recurse bottom-up first.
276
+ if (node.type === "bin") {
277
+ node = { ...node, left: simplify(node.left), right: simplify(node.right) };
278
+ } else if (node.type === "call") {
279
+ node = { ...node, args: node.args.map(simplify) };
280
+ } else if (node.type === "select") {
281
+ node = {
282
+ ...node,
283
+ then: simplify(node.then),
284
+ else: simplify(node.else),
285
+ cond: { ...node.cond, left: simplify(node.cond.left), right: simplify(node.cond.right) },
286
+ };
287
+ } else {
288
+ return node; // num, var — nothing to simplify
289
+ }
290
+
291
+ // --- bin node simplifications ---
292
+ if (node.type === "bin") {
293
+ const { op, left, right } = node;
294
+
295
+ // Constant folding: if both operands are num literals, evaluate.
296
+ if (left.type === "num" && right.type === "num") {
297
+ switch (op) {
298
+ case "+": return num(left.value + right.value);
299
+ case "-": return num(left.value - right.value);
300
+ case "*": return num(left.value * right.value);
301
+ case "/": return num(left.value / right.value);
302
+ }
303
+ }
304
+
305
+ if (op === "+") {
306
+ if (left.type === "num" && left.value === 0) return right;
307
+ if (right.type === "num" && right.value === 0) return left;
308
+ }
309
+
310
+ if (op === "-") {
311
+ if (right.type === "num" && right.value === 0) return left;
312
+ if (left.type === "num" && left.value === 0) {
313
+ // 0 - x → -(x): if x is a num, fold to negated literal
314
+ if (right.type === "num") return num(-right.value);
315
+ return mul(num(-1), right);
316
+ }
317
+ }
318
+
319
+ if (op === "*") {
320
+ if (left.type === "num" && left.value === 0) return num(0);
321
+ if (right.type === "num" && right.value === 0) return num(0);
322
+ if (left.type === "num" && left.value === 1) return right;
323
+ if (right.type === "num" && right.value === 1) return left;
324
+ // -1 * x → negated
325
+ if (left.type === "num" && left.value === -1) return mul(num(-1), right);
326
+ if (right.type === "num" && right.value === -1) return mul(num(-1), left);
327
+ }
328
+
329
+ if (op === "/") {
330
+ if (left.type === "num" && left.value === 0) return num(0);
331
+ if (right.type === "num" && right.value === 1) return left;
332
+ }
333
+ }
334
+
335
+ // --- call node simplifications ---
336
+ if (node.type === "call" && node.name === "pow" && node.args.length === 2) {
337
+ const [base, exp] = node.args;
338
+ if (exp.type === "num" && exp.value === 0) return num(1);
339
+ if (exp.type === "num" && exp.value === 1) return base;
340
+ }
341
+
342
+ return node;
343
+ }
344
+
345
+ module.exports = { differentiate };
package/index.js CHANGED
@@ -4,6 +4,7 @@ const { forComponents } = require("./util.js");
4
4
  const { expr } = require("./expr.js");
5
5
  const { fn } = require("./fn.js");
6
6
  const { evaluate } = require("./evaluate.js");
7
+ const { differentiate } = require("./differentiate.js");
7
8
  const { loadMacro, loadExtern, expandMacros, createRegistry } = require("./macros.js");
8
9
  const { loadExpr, loadExprSource } = require("./load-expr.js");
9
10
  const emitters = require("./emitters/registry.js");
@@ -132,6 +133,9 @@ module.exports = {
132
133
  // A native interpreter over the AST -- evaluate(fn, args) computes a
133
134
  // result directly in JS, no codegen/compile step. See evaluate.js.
134
135
  evaluate,
136
+ // Symbolic differentiation: differentiate(node, varName) returns an
137
+ // AST node for the derivative. See differentiate.js.
138
+ differentiate,
135
139
  // Register a macro: a name usable inside fn`...`/expr`...` text
136
140
  // beyond the built-in primitives, inline-expanded at build time,
137
141
  // never emitted as a real call. See macros.js's own header comment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exprforge",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Author a math expression once as an AST (or readable infix text via expr/fn), emit identical-behavior implementations in JS, TypeScript, Python, C#, Lua, QB64, C, Java, Go, Rust, Perl, PHP, Julia, Fortran, Zig, Scheme, and COBOL, plus a native evaluator and its own readable syntax printer.",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -16,6 +16,7 @@
16
16
  "expr.js",
17
17
  "fn.js",
18
18
  "evaluate.js",
19
+ "differentiate.js",
19
20
  "macros.js",
20
21
  "primitives.js",
21
22
  "load-expr.js",
@@ -25,6 +26,7 @@
25
26
  "math/"
26
27
  ],
27
28
  "scripts": {
29
+ "dev": "npm run dev --prefix playground",
28
30
  "build": "node build.js",
29
31
  "test": "node --test",
30
32
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-exclude=\"test/**\"",
@@ -36,7 +38,9 @@
36
38
  "math",
37
39
  "cross-language",
38
40
  "transpiler",
39
- "qb64"
41
+ "qb64",
42
+ "differentiation",
43
+ "calculus"
40
44
  ],
41
45
  "license": "MIT",
42
46
  "author": {