exprforge 0.5.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 +147 -15
- package/ast.js +19 -0
- package/differentiate.js +345 -0
- package/emitters/exprsyntax.js +25 -5
- package/fn.js +91 -8
- package/index.js +4 -0
- package/load-expr.js +75 -40
- package/macros.js +23 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
[](https://github.com/theraccoonbear/exprforge/actions/workflows/test-zig.yml)
|
|
20
20
|
[](https://github.com/theraccoonbear/exprforge/actions/workflows/test-scheme.yml)
|
|
21
21
|
[](https://github.com/theraccoonbear/exprforge/actions/workflows/test-cobol.yml)
|
|
22
|
+
[](https://github.com/theraccoonbear/exprforge/actions/workflows/test-coverage.yml)
|
|
22
23
|
|
|
23
24
|
## Brief
|
|
24
25
|
|
|
@@ -31,8 +32,15 @@ dependencies.
|
|
|
31
32
|
|
|
32
33
|
**[▶ Try it live](https://theraccoonbear.github.io/exprforge/)** — write
|
|
33
34
|
a formula in the browser and watch it emitted across every target
|
|
34
|
-
language at once, no install required
|
|
35
|
-
|
|
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`).
|
|
36
44
|
|
|
37
45
|
## Motivation
|
|
38
46
|
|
|
@@ -67,7 +75,9 @@ Two shapes of real use this tends to fall into:
|
|
|
67
75
|
**What it does**: turns one small, pure-arithmetic AST into
|
|
68
76
|
identical-behavior source text for 16 real target languages, a native
|
|
69
77
|
evaluator, and its own readable printer — all from the same tree, walked
|
|
70
|
-
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.
|
|
71
81
|
|
|
72
82
|
**What it deliberately won't do** — not gaps waiting on a future
|
|
73
83
|
release, but a boundary held on purpose everywhere in this project:
|
|
@@ -140,13 +150,13 @@ editor buffer accepts:
|
|
|
140
150
|
const { loadExprSource, evaluate, emit } = require("exprforge");
|
|
141
151
|
|
|
142
152
|
const defs = loadExprSource(`
|
|
143
|
-
cross3(ax, ay, az, bx, by, bz):
|
|
153
|
+
macro cross3(ax, ay, az, bx, by, bz):
|
|
144
154
|
let rx = ay * bz - az * by;
|
|
145
155
|
let ry = az * bx - ax * bz;
|
|
146
156
|
let rz = ax * by - ay * bx;
|
|
147
157
|
return { rx, ry, rz };
|
|
148
158
|
|
|
149
|
-
crossLength(ax, ay, az, bx, by, bz):
|
|
159
|
+
fn crossLength(ax, ay, az, bx, by, bz):
|
|
150
160
|
let c = cross3(ax, ay, az, bx, by, bz);
|
|
151
161
|
return sqrt(c.rx^2 + c.ry^2 + c.rz^2);
|
|
152
162
|
`);
|
|
@@ -155,6 +165,20 @@ evaluate(defs.crossLength, [1, 0, 0, 0, 1, 0]); // 1
|
|
|
155
165
|
emit(defs.crossLength, "rust").source; // a real fn crossLength(...) -- no trace of cross3 left
|
|
156
166
|
```
|
|
157
167
|
|
|
168
|
+
Every definition starts with `fn` or `macro` — never optional, never
|
|
169
|
+
implied. `fn` means "hand this back to me": `defs.crossLength` exists
|
|
170
|
+
because it's marked `fn`. `macro` means "inline this into whatever
|
|
171
|
+
references it later in this same buffer, but don't hand it back on its
|
|
172
|
+
own": `cross3` is fully usable *inside* `crossLength` — that's the whole
|
|
173
|
+
point — but `defs.cross3` doesn't exist; `Object.keys(defs)` here is just
|
|
174
|
+
`["crossLength"]`. There's no default either way, on purpose: a helper
|
|
175
|
+
you only ever meant as an internal step for something else can't
|
|
176
|
+
accidentally end up looking like part of your file's real, callable
|
|
177
|
+
output just because nothing said otherwise. Mark it `fn` instead if you
|
|
178
|
+
*do* want `cross3` usable standalone too — both marks register the
|
|
179
|
+
definition identically for inlining purposes; the only difference is
|
|
180
|
+
whether it also lands in what this call returns.
|
|
181
|
+
|
|
158
182
|
`cross3` never appears in `crossLength`'s emitted output, in any target —
|
|
159
183
|
by the time `loadExprSource` returns, `defs.crossLength` is
|
|
160
184
|
self-contained arithmetic, `cross3`'s formula copied in and simplified
|
|
@@ -310,6 +334,53 @@ validate against), a wrong argument *count* is a structurally malformed
|
|
|
310
334
|
call regardless of target, checked unconditionally at the same tier as
|
|
311
335
|
`checkUnboundVars` — see `primitives.js`.
|
|
312
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
|
+
|
|
313
384
|
## Math utilities (`exprforge/math`)
|
|
314
385
|
|
|
315
386
|
A separate, additive export — `require("exprforge")` is unchanged — of
|
|
@@ -770,13 +841,28 @@ grammar above; and that `let`/`return` are ordinary identifiers
|
|
|
770
841
|
means `v("let") * 2`, not a syntax error, since `expr`'s own grammar has
|
|
771
842
|
no `stmt`/`signature` rules to make either one special.
|
|
772
843
|
|
|
844
|
+
`loadExprSource`/`loadExpr` (below) parse the exact same `program`
|
|
845
|
+
grammar, repeatedly, over one shared buffer — with one deliberate
|
|
846
|
+
difference: `signature` is no longer optional, and gains a mandatory
|
|
847
|
+
leading keyword:
|
|
848
|
+
|
|
849
|
+
```
|
|
850
|
+
signature := ("fn" | "macro") IDENT "(" (IDENT ("," IDENT)*)? ")" ":"
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
`fn`/`macro` are contextual the same way `let`/`return` already are —
|
|
854
|
+
special only in this exact position, ordinary identifiers everywhere
|
|
855
|
+
else (a parameter, or even a signature name, genuinely called `fn` still
|
|
856
|
+
works: `` fn`fn(x): return x * 2;` `` parses as a function named `fn`,
|
|
857
|
+
unaffected, since a *single* `` fn`...` `` call never runs in this
|
|
858
|
+
stricter mode at all — see "Loading a `.expr` file" below for what the
|
|
859
|
+
two keywords mean and why the keyword is mandatory there specifically.
|
|
860
|
+
|
|
773
861
|
## Printing an AST back out, and a native evaluator
|
|
774
862
|
|
|
775
863
|
Two things that fall out of `fn` existing: `emitters.expr` is a real,
|
|
776
864
|
registered target that prints any AST *back out* as `fn`/`expr` source
|
|
777
|
-
text (the reverse of parsing it)
|
|
778
|
-
from several composed helpers, or just getting a readable string to log
|
|
779
|
-
or paste into a future `fn`/`expr` call. And `evaluate(fn, args)` (also
|
|
865
|
+
text (the reverse of parsing it). And `evaluate(fn, args)` (also
|
|
780
866
|
exported from the main package) is a native tree-walking interpreter
|
|
781
867
|
over the same AST, computing a result directly in JS with no codegen or
|
|
782
868
|
compile step — the same node types every emitter already handles,
|
|
@@ -786,17 +872,36 @@ backed by the real `Math.*` functions.
|
|
|
786
872
|
const { emit, evaluate } = require("exprforge");
|
|
787
873
|
|
|
788
874
|
emit(normalize2, "expr").source;
|
|
789
|
-
// "normalize2(x, y):\n let mag = sqrt(((x^2) + (y^2)));\n return { nx: (x / mag), ny: (y / mag) };\n"
|
|
875
|
+
// "fn normalize2(x, y):\n let mag = sqrt(((x^2) + (y^2)));\n return { nx: (x / mag), ny: (y / mag) };\n"
|
|
790
876
|
|
|
791
877
|
evaluate(normalize2, [3, 4]);
|
|
792
878
|
// { nx: 0.6, ny: 0.8 }
|
|
793
879
|
```
|
|
794
880
|
|
|
881
|
+
Read this output for what it actually is, not as a pretty-printer of
|
|
882
|
+
whatever you originally typed: `expandMacros()` runs before *every*
|
|
883
|
+
emitter, "expr" included (same as Rust's/COBOL's/etc. own `crossLength`
|
|
884
|
+
never mentions `cross3` either — see "Examples, flashiest first" above)
|
|
885
|
+
— so a macro-free formula like `normalize2` above prints back out
|
|
886
|
+
genuinely readable, but a formula built from several composed
|
|
887
|
+
macros/helpers prints its fully-reduced canonical form instead:
|
|
888
|
+
gensym'd internal let names, multi-output fields flattened to
|
|
889
|
+
`name__field`, all of it. That's not a readability regression to fix —
|
|
890
|
+
it's the same "converges on the true, expanded form" property every
|
|
891
|
+
other target already has, just visible here because "expr" is the one
|
|
892
|
+
target whose reduced output happens to also be valid input to itself
|
|
893
|
+
again. What that buys you is real, just not "pretty debug output": a
|
|
894
|
+
concrete, load-bearing way to confirm a composed formula actually
|
|
895
|
+
reduces to what you expect (`test/conformance.test.js`'s own round-trip
|
|
896
|
+
check — print, reparse, re-evaluate, compare — is exactly this, run
|
|
897
|
+
against every sample this project has).
|
|
898
|
+
|
|
795
899
|
### Loading a `.expr` file (`loadExpr`)
|
|
796
900
|
|
|
797
901
|
`loadExpr(path)` goes the other direction from `emit(fn, "expr")` above:
|
|
798
902
|
reads a `.expr` file (that same round-trip text format) and parses it as
|
|
799
|
-
zero or more `name(params): let ...; return ...;`
|
|
903
|
+
zero or more `fn name(params): let ...; return ...;` / `macro
|
|
904
|
+
name(params): let ...; return ...;` definitions, each `fn`-marked one
|
|
800
905
|
usable directly with `evaluate()`/`emit()`/`emitMany()` — this is the
|
|
801
906
|
file-backed sibling of `loadExprSource` in "Examples, flashiest first"
|
|
802
907
|
above:
|
|
@@ -804,17 +909,23 @@ above:
|
|
|
804
909
|
```js
|
|
805
910
|
const { loadExpr, evaluate } = require("exprforge");
|
|
806
911
|
|
|
807
|
-
const defs = loadExpr("./formulas/vectors.expr");
|
|
912
|
+
const defs = loadExpr("./formulas/vectors.expr"); // "fn hyp(a, b): return sqrt(a^2 + b^2);"
|
|
808
913
|
evaluate(defs.hyp, [3, 4]); // 5
|
|
809
914
|
```
|
|
810
915
|
|
|
811
916
|
A function defined earlier in the file is available to a function defined
|
|
812
917
|
**later** in the same file — as an inline macro, the exact same
|
|
813
918
|
"expanded, not called" model `loadMacro` itself uses above (see that
|
|
814
|
-
section for why)
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
919
|
+
section for why) — **regardless of whether it's marked `fn` or `macro`**.
|
|
920
|
+
The keyword only decides what's in the object this call actually returns:
|
|
921
|
+
`fn` means "hand this back to me too," `macro` means "inline-only, never
|
|
922
|
+
returned on its own" (see "Examples, flashiest first" above for the full
|
|
923
|
+
`cross3`/`crossLength` walkthrough — `cross3` is `macro`, `crossLength`
|
|
924
|
+
is `fn`, and only `defs.crossLength` exists). Neither keyword is optional
|
|
925
|
+
— every definition states one explicitly; a bare `name(params):` with
|
|
926
|
+
neither throws, naming both keywords and what each means. A `.expr` file
|
|
927
|
+
can also reference globally loaded macros, not just earlier definitions
|
|
928
|
+
in the same file — the two sources merge.
|
|
818
929
|
|
|
819
930
|
`loadExpr(path)` is a thin `fs.readFileSync` wrapper around
|
|
820
931
|
**`loadExprSource(text, label?)`** — the same parser, given source text
|
|
@@ -968,6 +1079,27 @@ and a redundant sanity check each time. Unset locally, so a plain
|
|
|
968
1079
|
`npm test` still runs everything your own machine's installed toolchains
|
|
969
1080
|
allow.
|
|
970
1081
|
|
|
1082
|
+
**Coverage**: `npm run test:coverage` runs the same suite through Node's
|
|
1083
|
+
own built-in instrumentation (`--experimental-test-coverage` — no
|
|
1084
|
+
external dependency), honoring whatever toolchains are on your machine,
|
|
1085
|
+
with no threshold enforced. CI's own "Test Coverage" workflow (badge
|
|
1086
|
+
above) is deliberately narrower and stricter: it runs only the
|
|
1087
|
+
toolchain-free `Interpreter` slice (`EXPRFORGE_TEST_TARGETS=Interpreter`,
|
|
1088
|
+
same filter every `test-*.yml` workflow already uses), gated on a fixed
|
|
1089
|
+
threshold, since that's the one environment where the number means the
|
|
1090
|
+
same thing on every run — installing zero, one, or a different subset of
|
|
1091
|
+
the 17 per-language toolchains would make an aggregate threshold either
|
|
1092
|
+
flaky or meaningless. Core logic (`ast.js`/`evaluate.js`/`expr.js`/
|
|
1093
|
+
`fn.js`/`index.js`/`load-expr.js`/`macros.js`/`math/`/`primitives.js`/
|
|
1094
|
+
`samples/`/`util.js`) sits at 95-100% in every environment, toolchains or
|
|
1095
|
+
not; per-target emitter coverage is intentionally excluded from that
|
|
1096
|
+
mental model — code that only really runs inside a real compiled/
|
|
1097
|
+
interpreted program can't be exercised without that target's own
|
|
1098
|
+
toolchain, and that verification already happens for real, by actually
|
|
1099
|
+
compiling and running the output, in the 17 other workflows — a lower
|
|
1100
|
+
coverage *percentage* there isn't itself a problem this gate is
|
|
1101
|
+
positioned to catch.
|
|
1102
|
+
|
|
971
1103
|
A few of these needed real debugging to get right, all found by actually
|
|
972
1104
|
compiling/running against a real toolchain rather than assumed to work:
|
|
973
1105
|
|
package/ast.js
CHANGED
|
@@ -323,6 +323,25 @@ function collectVarRefs(node, refs = new Set()) {
|
|
|
323
323
|
// emitFunction(), cobol.js's own override) already runs unconditionally,
|
|
324
324
|
// so it's the natural single checkpoint for this too.
|
|
325
325
|
function checkUnboundVars(fn) {
|
|
326
|
+
// checkUnboundVars is called both internally (every real consumption
|
|
327
|
+
// path runs it after expandMacros, see macros.js's own comment on
|
|
328
|
+
// expandMacros) AND directly by callers who want the check on its
|
|
329
|
+
// own (e.g. the playground's useExprForge.ts) -- unlike the internal
|
|
330
|
+
// callers, a direct caller hasn't necessarily gone through
|
|
331
|
+
// expandMacros' own equivalent guard first, so this needs its own
|
|
332
|
+
// copy: without it, `fn.name` below crashes with a raw "Cannot read
|
|
333
|
+
// properties of undefined (reading 'name')" for the same "looked up
|
|
334
|
+
// a macro-only name loadExprSource() never returned" mistake
|
|
335
|
+
// expandMacros' own guard exists to catch clearly instead.
|
|
336
|
+
if (!fn || typeof fn !== "object" || typeof fn.name !== "string" || !Array.isArray(fn.params) ||
|
|
337
|
+
!fn.body || typeof fn.body !== "object" || typeof fn.body.type !== "string") {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`checkUnboundVars: expected a {name, params, body} function definition, got ` +
|
|
340
|
+
`${fn === null ? "null" : typeof fn} -- if this came from a loadExprSource()/loadExpr() result ` +
|
|
341
|
+
`object, double check the definition you're looking up was actually marked "fn" (exported), not ` +
|
|
342
|
+
`"macro" (private -- never included in what that call returns)`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
326
345
|
assertSafeIdentifier(fn.name, "fn.name");
|
|
327
346
|
for (const p of fn.params) assertSafeIdentifier(p, "fn.params");
|
|
328
347
|
|
package/differentiate.js
ADDED
|
@@ -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/emitters/exprsyntax.js
CHANGED
|
@@ -64,8 +64,8 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
64
64
|
// `` fn`...` `` call, and exactly what the round-trip test reparses
|
|
65
65
|
// with zero unwrapping first.
|
|
66
66
|
//
|
|
67
|
-
// ALWAYS includes the "name(params):" signature line -- every
|
|
68
|
-
// emitter's formatFunction includes the full declaration per
|
|
67
|
+
// ALWAYS includes the "fn name(params):" signature line -- every
|
|
68
|
+
// other emitter's formatFunction includes the full declaration per
|
|
69
69
|
// base.js's own documented contract ("Full source text for one
|
|
70
70
|
// function, including any language-specific signature/type/wrapper
|
|
71
71
|
// syntax"); this was the one target that didn't, dropping fn.name/
|
|
@@ -75,6 +75,24 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
75
75
|
// reparsing this emitter's own output via fn() could only ever
|
|
76
76
|
// recover a bare Node, never a runnable {name, params, body}, unlike
|
|
77
77
|
// literally every other target's output being immediately usable.
|
|
78
|
+
//
|
|
79
|
+
// Always "fn", never "macro" -- and this is a real, permanent,
|
|
80
|
+
// one-way loss, not an oversight: fn.name/fn.params/fn.body is ALL
|
|
81
|
+
// this function ever receives, and "was this originally marked
|
|
82
|
+
// fn/macro" isn't part of that shape at all (see load-expr.js's own
|
|
83
|
+
// loop -- the exported/private flag is read once, to decide whether
|
|
84
|
+
// to copy the result into loadExprSource's returned object, and then
|
|
85
|
+
// discarded; it was never stored on the def itself, by design --
|
|
86
|
+
// every def looks identical regardless of how it was produced,
|
|
87
|
+
// that's the whole "sugar lowers to the same primitives" guarantee).
|
|
88
|
+
// So there is nothing left here to recover a "macro" marking FROM --
|
|
89
|
+
// "fn" is simply the only correct thing to print for "you asked to
|
|
90
|
+
// print this one, standalone" once that context is gone. Same shape
|
|
91
|
+
// of loss as `2^3` reprinting as `2^3` but `pow(2, 3)` never coming
|
|
92
|
+
// back as `pow` (see emitExpr's own pow special-case above), or
|
|
93
|
+
// comments/whitespace never surviving either -- this printer
|
|
94
|
+
// round-trips VALUES, never original source text/structure.
|
|
95
|
+
//
|
|
78
96
|
// Body lines (every let, the return) are indented 2 spaces deeper
|
|
79
97
|
// than the signature line itself -- a Python-esque pretty-print
|
|
80
98
|
// convention, not something the parser requires (whitespace is
|
|
@@ -83,7 +101,7 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
83
101
|
// AST comes out reading the same way automatically.
|
|
84
102
|
formatFunction: (fn, bodyStr, letBindings = []) => {
|
|
85
103
|
const body = [...letLines(letBindings), `return ${bodyStr};`].map((line) => ` ${line}`);
|
|
86
|
-
return [
|
|
104
|
+
return [`fn ${fn.name}(${fn.params.join(", ")}):`, ...body].join("\n") + "\n";
|
|
87
105
|
},
|
|
88
106
|
// Each output field gets its own line (4 spaces -- one level deeper
|
|
89
107
|
// than "return {" itself, which sits at the usual 2), rather than
|
|
@@ -91,7 +109,9 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
91
109
|
// this against a hand-formatted multi-output example and noticing
|
|
92
110
|
// the printer didn't follow its own convention once a suite had
|
|
93
111
|
// more than a couple of fields (a real, wide, 5-output formula made
|
|
94
|
-
// this one very long line instead of something readable).
|
|
112
|
+
// this one very long line instead of something readable). Signature
|
|
113
|
+
// line always "fn" here too -- see formatFunction's own comment
|
|
114
|
+
// above for why (same reasoning, same permanent one-way loss).
|
|
95
115
|
formatSuite: (fn, outputStrs, letBindings = []) => {
|
|
96
116
|
const entries = Object.entries(outputStrs);
|
|
97
117
|
const fieldLines = entries.map(([name, valueStr], i) => {
|
|
@@ -99,7 +119,7 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
99
119
|
return ` ${name}: ${valueStr}${comma}`;
|
|
100
120
|
});
|
|
101
121
|
const lines = [
|
|
102
|
-
|
|
122
|
+
`fn ${fn.name}(${fn.params.join(", ")}):`,
|
|
103
123
|
...letLines(letBindings).map((line) => ` ${line}`),
|
|
104
124
|
" return {",
|
|
105
125
|
...fieldLines,
|
package/fn.js
CHANGED
|
@@ -28,6 +28,42 @@
|
|
|
28
28
|
// expr()'s behavior changes: `` expr`let * 2` `` still means
|
|
29
29
|
// v("let") * 2 today, same as before this file existed.
|
|
30
30
|
//
|
|
31
|
+
// parseProgram() has a SECOND, stricter mode -- parseProgram(parser,
|
|
32
|
+
// { requireExportKeyword: true }) -- used only by load-expr.js's
|
|
33
|
+
// repeated-parse loop (loadExprSource()/loadExpr()), never by fn() below.
|
|
34
|
+
// In that mode the grammar becomes:
|
|
35
|
+
//
|
|
36
|
+
// program := signature stmt* returnStmt // no longer optional
|
|
37
|
+
// signature := ("fn" | "macro") IDENT "(" (IDENT ("," IDENT)*)? ")" ":"
|
|
38
|
+
//
|
|
39
|
+
// Why this exists, and why it's scoped to load-expr.js specifically: a
|
|
40
|
+
// single fn`...` call never auto-exports anything -- you already hold
|
|
41
|
+
// the one thing it returns, in an ordinary JS variable, and decide
|
|
42
|
+
// yourself what happens to it. loadExprSource()/loadExpr() are different:
|
|
43
|
+
// they parse a WHOLE BUFFER of back-to-back definitions and hand back a
|
|
44
|
+
// {name: FnDef} object covering every one of them, automatically, with
|
|
45
|
+
// no per-definition opt-in -- so a definition meant purely as an internal
|
|
46
|
+
// building block for a later one in the same buffer (e.g. cross3, when
|
|
47
|
+
// only crossLength's fully-inlined result is meant to be used/emitted)
|
|
48
|
+
// used to land in that returned object too, indistinguishable from a
|
|
49
|
+
// definition you actually meant to use standalone. "fn"/"macro" make
|
|
50
|
+
// that choice explicit, per definition, with no default to get wrong:
|
|
51
|
+
// "fn" means "yes, include this in what you hand back"; "macro" means
|
|
52
|
+
// "no -- inline-expand this into whatever references it later in this
|
|
53
|
+
// same buffer, same as loadMacro()'s own AST-fn-def tier, but never
|
|
54
|
+
// return it on its own." Nothing else differs between the two -- both
|
|
55
|
+
// go through the exact same registration path (toMacro/fileRegistry, see
|
|
56
|
+
// load-expr.js), so a "macro"-marked definition is, structurally, no
|
|
57
|
+
// different from one registered via loadMacro(name, fn`...`) directly;
|
|
58
|
+
// the keyword only decides whether load-expr.js's own loop additionally
|
|
59
|
+
// copies the result into the object it returns.
|
|
60
|
+
//
|
|
61
|
+
// A single fn`...` call's own (still-optional) signature line never
|
|
62
|
+
// accepts "fn"/"macro" -- there's nothing there for them to opt out of,
|
|
63
|
+
// so recognizing them would just be unearned ceremony; `` fn`fn(x):
|
|
64
|
+
// return x * 2;` `` still means what it always has, a function literally
|
|
65
|
+
// named "fn", identical to before this mode existed.
|
|
66
|
+
//
|
|
31
67
|
// Duplicate let-names are deliberately NOT checked here -- letChain()
|
|
32
68
|
// doesn't check either (ast.js); collectLets() already does, at
|
|
33
69
|
// emission time. Same "defer semantic validation to emission" precedent
|
|
@@ -116,6 +152,12 @@ function parseReturnStatement(parser) {
|
|
|
116
152
|
// still ends up an error either way, just reported as a missing ":"
|
|
117
153
|
// rather than a missing "return"; not worth deeper lookahead to improve
|
|
118
154
|
// one malformed-input error message.)
|
|
155
|
+
//
|
|
156
|
+
// Only used in fn()'s own (non-strict) mode -- see this file's header
|
|
157
|
+
// comment. requireExportKeyword mode never calls this: a signature is no
|
|
158
|
+
// longer optional there, so there's no "is this a signature or a
|
|
159
|
+
// statement" ambiguity left to resolve by lookahead at all -- see
|
|
160
|
+
// parseProgram below.
|
|
119
161
|
function looksLikeSignature(parser) {
|
|
120
162
|
const t = parser.peek();
|
|
121
163
|
if (t.type !== "IDENT" || t.value === "let" || t.value === "return") return false;
|
|
@@ -123,24 +165,56 @@ function looksLikeSignature(parser) {
|
|
|
123
165
|
return next.type === "OP" && next.value === "(";
|
|
124
166
|
}
|
|
125
167
|
|
|
126
|
-
|
|
127
|
-
|
|
168
|
+
// `requireExportKeyword` -- see this file's own header comment -- is the
|
|
169
|
+
// ONLY thing that changes here: false (fn()'s own mode, the default)
|
|
170
|
+
// parses exactly the signature grammar this always has, no "fn"/"macro"
|
|
171
|
+
// recognized at all (they're ordinary identifiers there, same as any
|
|
172
|
+
// other word); true (load-expr.js's mode) requires the very first token
|
|
173
|
+
// to literally be "fn" or "macro", consumed here before the name. This
|
|
174
|
+
// couldn't be optional-but-recognized in requireExportKeyword mode
|
|
175
|
+
// without real ambiguity (a definition genuinely named "fn"/"macro"
|
|
176
|
+
// would be indistinguishable from the keyword) -- making it MANDATORY
|
|
177
|
+
// removes that ambiguity entirely instead of needing to resolve it: a
|
|
178
|
+
// signature is required, so the first token is unconditionally expected
|
|
179
|
+
// to be the keyword, full stop.
|
|
180
|
+
function parseSignature(parser, { requireExportKeyword = false } = {}) {
|
|
181
|
+
let exported = true; // meaningless outside requireExportKeyword mode -- see parseProgram
|
|
182
|
+
if (requireExportKeyword) {
|
|
183
|
+
const t = parser.peek();
|
|
184
|
+
if (t.type !== "IDENT" || (t.value !== "fn" && t.value !== "macro")) {
|
|
185
|
+
parser.error(
|
|
186
|
+
'every definition needs to start with "fn" (exported -- included in what this call returns) ' +
|
|
187
|
+
'or "macro" (private -- inline-expanded into whatever references it later in this same source, ' +
|
|
188
|
+
'never returned on its own) -- e.g. "fn crossLength(...):" or "macro cross3(...):"',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
exported = t.value === "fn";
|
|
192
|
+
parser.next(); // consume "fn"/"macro"
|
|
193
|
+
}
|
|
194
|
+
const name = expectIdent(parser, "as the function name starting a signature");
|
|
128
195
|
parser.expectOp("(");
|
|
129
196
|
const params = [];
|
|
130
197
|
if (!parser.isOp(")")) {
|
|
131
|
-
params.push(expectIdent(parser, "as a parameter name in a
|
|
198
|
+
params.push(expectIdent(parser, "as a parameter name in a signature"));
|
|
132
199
|
while (parser.isOp(",")) {
|
|
133
200
|
parser.next();
|
|
134
|
-
params.push(expectIdent(parser, "as a parameter name in a
|
|
201
|
+
params.push(expectIdent(parser, "as a parameter name in a signature"));
|
|
135
202
|
}
|
|
136
203
|
}
|
|
137
204
|
parser.expectOp(")");
|
|
138
205
|
parser.expectOp(":");
|
|
139
|
-
return { name, params };
|
|
206
|
+
return { name, params, exported };
|
|
140
207
|
}
|
|
141
208
|
|
|
142
|
-
function parseProgram(parser) {
|
|
143
|
-
|
|
209
|
+
function parseProgram(parser, { requireExportKeyword = false } = {}) {
|
|
210
|
+
// requireExportKeyword mode: a signature is mandatory, so it's parsed
|
|
211
|
+
// unconditionally -- parseSignature itself throws a clear error if
|
|
212
|
+
// the buffer doesn't actually start with "fn"/"macro". Non-strict
|
|
213
|
+
// (fn()'s own) mode keeps the original "maybe there's no signature
|
|
214
|
+
// at all" lookahead, completely unchanged.
|
|
215
|
+
const signature = requireExportKeyword
|
|
216
|
+
? parseSignature(parser, { requireExportKeyword: true })
|
|
217
|
+
: (looksLikeSignature(parser) ? parseSignature(parser) : null);
|
|
144
218
|
|
|
145
219
|
const bindings = [];
|
|
146
220
|
while (isKeyword(parser, "let")) {
|
|
@@ -152,7 +226,16 @@ function parseProgram(parser) {
|
|
|
152
226
|
const body = parseReturnStatement(parser);
|
|
153
227
|
const result = bindings.length > 0 ? letChain(bindings, body) : body;
|
|
154
228
|
|
|
155
|
-
|
|
229
|
+
if (!signature) return result;
|
|
230
|
+
const def = { name: signature.name, params: signature.params, body: result };
|
|
231
|
+
// `exported` is only ever meaningful to load-expr.js's own loop (the
|
|
232
|
+
// one caller that runs in requireExportKeyword mode) -- omitted
|
|
233
|
+
// entirely from fn()'s own return shape below, so a plain fn`...`
|
|
234
|
+
// call's result is byte-for-byte identical to before this mode
|
|
235
|
+
// existed; nothing downstream (evaluate()/emit()/checkUnboundVars/...)
|
|
236
|
+
// has ever known or needed to know about it.
|
|
237
|
+
if (requireExportKeyword) def.exported = signature.exported;
|
|
238
|
+
return def;
|
|
156
239
|
}
|
|
157
240
|
|
|
158
241
|
// Same token-splicing loop expr() uses in expr.js -- see that file's
|
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/load-expr.js
CHANGED
|
@@ -4,26 +4,39 @@
|
|
|
4
4
|
// test/conformance.test.js's assertExprSyntaxRoundTrips) as zero or more
|
|
5
5
|
// function definitions, using the exact same grammar/engine fn`...`
|
|
6
6
|
// already uses (see fn.js's parseProgram), just applied repeatedly
|
|
7
|
-
// instead of once.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// instead of once, in fn.js's stricter requireExportKeyword mode (see
|
|
8
|
+
// its own header comment for the full rationale). Two entry points:
|
|
9
|
+
// loadExprSource(text) parses text directly (no filesystem involved --
|
|
10
|
+
// usable anywhere source text comes from, including a browser);
|
|
11
|
+
// loadExpr(path) reads a real file first and delegates to it.
|
|
11
12
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
13
|
+
// Every definition MUST have a "fn name(params):" or "macro name(params):"
|
|
14
|
+
// signature line -- no bare "name(params):" (fn.js's own requireExportKeyword
|
|
15
|
+
// mode rejects it outright), and no signature-less bare-Node definition
|
|
16
|
+
// either (which would have no name for a later definition, or the
|
|
17
|
+
// caller, to refer to it by anyway). "fn" and "macro" are otherwise
|
|
18
|
+
// identical -- both get registered into this source's own local macro
|
|
19
|
+
// registry below, so BOTH are available to whatever's defined later in
|
|
20
|
+
// the same source as an inline macro (the exact same "inline expansion,
|
|
21
|
+
// not runtime calls" model loadMacro() itself uses, see macros.js's own
|
|
22
|
+
// header comment, and for the same reasons: no call graph, no linking
|
|
23
|
+
// problem, no runtime coupling). The ONLY difference: a "macro"
|
|
24
|
+
// definition is never copied into the object this returns -- it exists
|
|
25
|
+
// purely to be inlined into something else in this same source, the
|
|
26
|
+
// same role a helper registered via loadMacro(name, fn`...`) directly
|
|
27
|
+
// already plays; a "fn" definition is both registered AND returned, so
|
|
28
|
+
// it's directly usable on its own (evaluate()/emit()/emitMany()) too.
|
|
29
|
+
// There's no default: every definition states which one it is, so a
|
|
30
|
+
// definition meant only as an internal building block for another one
|
|
31
|
+
// (e.g. cross3, when only crossLength's fully-inlined result actually
|
|
32
|
+
// gets used) can never accidentally show up in what this call hands
|
|
33
|
+
// back just because nothing said otherwise.
|
|
22
34
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
35
|
+
// And, structurally, no recursion, for either kind: a definition is only
|
|
36
|
+
// added to this source's own local registry AFTER it's been fully parsed
|
|
37
|
+
// and expanded (see the loop below), so it's never resolvable through
|
|
38
|
+
// its own name while its own body is being expanded, whether directly or
|
|
39
|
+
// transitively through another not-yet-defined function.
|
|
27
40
|
const fs = require("node:fs");
|
|
28
41
|
const { Parser, tokenizeSegment } = require("./expr.js");
|
|
29
42
|
const { parseProgram } = require("./fn.js");
|
|
@@ -42,19 +55,27 @@ function tokenizeFile(source, label) {
|
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
57
|
* Parses `source` (plain text, not a file path -- see loadExpr below for
|
|
45
|
-
* the file-reading variant) as zero or more "name(params): let ...;
|
|
46
|
-
* return ...;"
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
58
|
+
* the file-reading variant) as zero or more "fn name(params): let ...;
|
|
59
|
+
* return ...;" / "macro name(params): let ...; return ...;" definitions
|
|
60
|
+
* back-to-back, in the same grammar fn`...` uses for one (fn.js's
|
|
61
|
+
* requireExportKeyword mode -- see its own header comment). Returns an
|
|
62
|
+
* object keyed by the name of every "fn"-marked definition ONLY, each
|
|
63
|
+
* value the fully-expanded {name, params, body} -- ready to pass
|
|
64
|
+
* straight into evaluate()/emit()/emitMany(). A "macro"-marked
|
|
65
|
+
* definition is registered for inlining into later definitions in the
|
|
66
|
+
* same source (see this file's own header comment) but never appears in
|
|
67
|
+
* the returned object. `label` identifies the source in error messages
|
|
68
|
+
* (e.g. a file path, or just "playground" for an in-browser text buffer
|
|
69
|
+
* that was never written to disk at all -- this is the one entry point
|
|
70
|
+
* here that has no `fs` dependency, so it's the one usable from a
|
|
71
|
+
* browser).
|
|
55
72
|
*
|
|
56
|
-
* Throws if any definition
|
|
57
|
-
*
|
|
73
|
+
* Throws if any definition doesn't start with "fn"/"macro" (a bare
|
|
74
|
+
* "name(params):" signature, or no signature at all, are both
|
|
75
|
+
* rejected), or if two definitions share a name -- regardless of
|
|
76
|
+
* whether either or both are "fn" vs "macro"; the two share one
|
|
77
|
+
* namespace, same as loadMacro()/loadExtern() already do for the
|
|
78
|
+
* process-wide registry.
|
|
58
79
|
*
|
|
59
80
|
* `registry` (see macros.js's createRegistry()) defaults to the
|
|
60
81
|
* process-wide default when omitted -- pass a session's own (see
|
|
@@ -66,28 +87,42 @@ function loadExprSource(source, label = "loadExprSource()", registry = undefined
|
|
|
66
87
|
|
|
67
88
|
const fileRegistry = new Map(); // name -> {arity, fn, alreadyExpanded} -- see toMacro in macros.js
|
|
68
89
|
const defs = {};
|
|
90
|
+
// Tracked independently of `defs` -- a "macro"-marked definition
|
|
91
|
+
// never lands in `defs` at all (see above), so `defs` alone can't
|
|
92
|
+
// catch two macro-marked definitions (or a macro and a fn) sharing a
|
|
93
|
+
// name; every parsed name, exported or not, goes through this Set.
|
|
94
|
+
const seenNames = new Set();
|
|
69
95
|
|
|
70
96
|
while (parser.peek().type !== "EOF") {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (defs[raw.name]) {
|
|
97
|
+
// requireExportKeyword: true -- see fn.js's own header comment.
|
|
98
|
+
// Throws its own clear error if this definition doesn't start
|
|
99
|
+
// with "fn"/"macro"; there's no longer a "no signature at all"
|
|
100
|
+
// case to separately detect here the way there used to be.
|
|
101
|
+
const raw = parseProgram(parser, { requireExportKeyword: true });
|
|
102
|
+
if (seenNames.has(raw.name)) {
|
|
78
103
|
throw new Error(`${label}: duplicate function name "${raw.name}" -- names must be unique in one file`);
|
|
79
104
|
}
|
|
105
|
+
seenNames.add(raw.name);
|
|
80
106
|
|
|
81
107
|
// Expanded against whatever's already in fileRegistry (earlier
|
|
82
108
|
// definitions in this same source) PLUS every macro/extern
|
|
83
109
|
// registered in `registry` (expandMacros merges both -- see
|
|
84
|
-
// macros.js).
|
|
110
|
+
// macros.js). `raw` carries an extra `exported` field (see
|
|
111
|
+
// fn.js's parseProgram) that expandMacros' own fn-def branch
|
|
112
|
+
// ignores -- it only ever reads/returns name/params/body, so
|
|
113
|
+
// `expanded` below comes back with exactly those three keys
|
|
114
|
+
// regardless.
|
|
85
115
|
const expanded = expandMacros(raw, fileRegistry, registry);
|
|
86
|
-
|
|
116
|
+
if (raw.exported) {
|
|
117
|
+
defs[raw.name] = expanded;
|
|
118
|
+
}
|
|
87
119
|
|
|
88
120
|
// Available to whatever's defined AFTER this point in the source
|
|
89
121
|
// -- never to itself (expanded above, against fileRegistry
|
|
90
|
-
// BEFORE this line adds it) or to anything defined earlier.
|
|
122
|
+
// BEFORE this line adds it) or to anything defined earlier. Both
|
|
123
|
+
// "fn" and "macro" definitions are registered here identically
|
|
124
|
+
// -- see this file's own header comment for why "exported" only
|
|
125
|
+
// ever affects `defs` above, nothing about inlining eligibility.
|
|
91
126
|
// `expanded` has nothing left to resolve (macro calls/field
|
|
92
127
|
// access are already gone), so no extraRegistry/registry needs
|
|
93
128
|
// passing here.
|
package/macros.js
CHANGED
|
@@ -688,6 +688,29 @@ function expandBody(node, ctx) {
|
|
|
688
688
|
* process-wide default ones.
|
|
689
689
|
*/
|
|
690
690
|
function expandMacros(fnOrNode, extraRegistry = null, registry = defaultRegistry) {
|
|
691
|
+
// Every real caller (evaluate(), every emitter's emitFunction()) runs
|
|
692
|
+
// this first, unconditionally, before touching fnOrNode.type/.name/
|
|
693
|
+
// .body itself -- so this is the one place positioned to catch a
|
|
694
|
+
// caller passing something that isn't actually a Node or a
|
|
695
|
+
// {name, params, body} at all (undefined, null, a typo'd lookup that
|
|
696
|
+
// silently evaluated to undefined, ...) with ONE clear message,
|
|
697
|
+
// instead of letting it fall through to expandBody below and crash
|
|
698
|
+
// with a raw "Cannot read properties of undefined (reading 'type')"
|
|
699
|
+
// several frames later -- or, worse, having emitMany() (see index.js)
|
|
700
|
+
// catch and report that same confusing crash once per language,
|
|
701
|
+
// 18 near-identical unhelpful errors instead of one. Concretely
|
|
702
|
+
// motivated by the "macro"-marked definitions loadExprSource() never
|
|
703
|
+
// returns (see load-expr.js) -- a caller looking up a macro-only name
|
|
704
|
+
// in the returned object gets `undefined` back, and previously the
|
|
705
|
+
// very next thing that happened with it was exactly this crash.
|
|
706
|
+
if (!isFnDefShape(fnOrNode) && !isNode(fnOrNode)) {
|
|
707
|
+
throw new Error(
|
|
708
|
+
`expandMacros: expected an AST Node ({type: ...}) or a {name, params, body} function ` +
|
|
709
|
+
`definition, got ${fnOrNode === null ? "null" : typeof fnOrNode} -- if this came from a ` +
|
|
710
|
+
`loadExprSource()/loadExpr() result object, double check the definition you're looking up was ` +
|
|
711
|
+
`actually marked "fn" (exported), not "macro" (private -- never included in what that call returns)`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
691
714
|
const ctx = { extraRegistry, aliases: new Map(), registry };
|
|
692
715
|
if (isFnDefShape(fnOrNode)) {
|
|
693
716
|
return { name: fnOrNode.name, params: fnOrNode.params, body: expandBody(fnOrNode.body, ctx) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "exprforge",
|
|
3
|
-
"version": "0.
|
|
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,8 +26,10 @@
|
|
|
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",
|
|
32
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-exclude=\"test/**\"",
|
|
30
33
|
"prepublishOnly": "npm test"
|
|
31
34
|
},
|
|
32
35
|
"keywords": [
|
|
@@ -35,7 +38,9 @@
|
|
|
35
38
|
"math",
|
|
36
39
|
"cross-language",
|
|
37
40
|
"transpiler",
|
|
38
|
-
"qb64"
|
|
41
|
+
"qb64",
|
|
42
|
+
"differentiation",
|
|
43
|
+
"calculus"
|
|
39
44
|
],
|
|
40
45
|
"license": "MIT",
|
|
41
46
|
"author": {
|