diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f52d621..fddb18e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,34 @@ concurrency: cancel-in-progress: true jobs: + rust-lint: + name: Lint Rust Code + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v6 + + # Modeled on DoenetML's "Lint Rust Code" job: install a minimal + # stable toolchain with the rustfmt + clippy components, then gate + # on formatting and a warning-free clippy. Uses rustup directly + # (not the devcontainer) so the lint job stays fast and independent + # of the wasm/Node toolchain the build/test jobs need. + - name: Install toolchain + run: | + rustup set profile minimal + rustup toolchain install stable --component rustfmt --component clippy + rustup default stable + + # `--all` covers the whole workspace (the wasm crate is not a + # default member; see rust-test). + - name: Cargo fmt + run: cargo fmt --all -- --check + + # `--workspace --all-targets` lints the wasm crate and the test / + # example targets too, not just the default-member library. + - name: Cargo clippy + run: cargo clippy --workspace --all-targets -- -D warnings + rust-build: name: Rust build runs-on: ubuntu-latest @@ -78,20 +106,54 @@ jobs: steps: - uses: actions/checkout@v6 - # Builds the nodejs-target wasm (build-wasm.sh), then runs the legacy - # JS suite (converted to TS) against the js-compat drop-in (published - # to npm as `math-expressions`). Not all tests pass yet — see - # active-plans/JS_TEST_COVERAGE_AUDIT.md — so this job is informational - # until the compat layer is finished; `|| true` keeps a partial suite - # from failing CI. Drop that once it is green. - - name: Build wasm and run the js-compat suite + # Builds everything the published tarball contains (`build:package` — + # both wasm targets, the rs-wasm bindings, the Vite lib build), then + # runs the legacy JS suite (converted to TS) against the js-compat + # drop-in (published to npm as `math-expressions`). The full build + # rather than just the wasm, because `spec/build_{esm,umd}.spec.ts` + # exercise `dist/` — the artifact a consumer actually installs — and + # skip themselves when it is absent. + # + # This job gates. It used to end in `|| true`, from when the suite + # was hundreds of failures deep and the number was still falling; + # that made it incapable of reporting a regression, which is the + # only thing it is for now that the suite is green. The one test + # that cannot pass soundly is `it.skip`ped at its site with the + # reason, rather than left red — see `COMPAT_TEST_FAILURE_SUMMARY.md`. + - name: Build the package and run the js-compat suite + uses: devcontainers/ci@v0.3 + with: + runCmd: | + npm ci + cd packages/math-expressions-js-compat + npm run build:package + npm run typecheck + npm test + + package-publishability: + name: package publishability + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # The only job that sees this package the way npm consumers will. + # Everything else here resolves through workspace symlinks and finds + # build outputs already present, which hides exactly the failures + # that matter at publish time: an unpublished `dependencies` entry, + # an `exports` target inside a git-ignored directory nothing built, + # a runtime asset missing from `files`. `verify:package` packs the + # tarball, installs it into a throwaway project outside the + # workspace, and drives it through both supported loading paths — + # Node's self-loading fallback and a browser/Web-Worker host + # injecting the `--target web` build. It gates: this one is not + # informational. + - name: Pack, install and consume the tarball uses: devcontainers/ci@v0.3 with: runCmd: | npm ci cd packages/math-expressions-js-compat - ./build-wasm.sh - npm test || true + npm run verify:package # ---- GitHub Pages: the playground at the site root, Rust API docs at /docs ---- # Deploys only on pushes to main. Requires the repo's Pages source to be set to diff --git a/Cargo.toml b/Cargo.toml index 22d1020a..83d3b6a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,12 @@ lto = "fat" codegen-units = 1 panic = "abort" strip = "debuginfo" + +# Note on `panic = "abort"` and diagnosability (DOENET_INTEGRATION item 2): it +# was long assumed that this line is why a wasm panic reaches the browser as a +# bare `RuntimeError: unreachable`. It is not. std runs the panic hook *before* +# aborting, so the message survives this profile intact — what was missing was +# a hook, since the default one writes to a stderr that goes nowhere on +# wasm32-unknown-unknown. One is now installed unconditionally in the wasm +# crate; measured cost 1,958 bytes. There is no separate diagnostic profile +# because a separate profile turned out to buy nothing. diff --git a/active-plans/ASSUMPTIONS_ENGINE_PLAN.md b/active-plans/ASSUMPTIONS_ENGINE_PLAN.md new file mode 100644 index 00000000..929337c6 --- /dev/null +++ b/active-plans/ASSUMPTIONS_ENGINE_PLAN.md @@ -0,0 +1,182 @@ +# Assumptions engine — completion plan + +Goal: close the remaining feature gaps in the assumptions engine so +`packages/math-expressions-js-compat/spec/slow_assumptions.spec.ts` passes +*except* for the one test that cannot be passed soundly — see +"Accepted divergence" below. **Reached.** Passing that one test was never the +target and was never reachable: legacy's expected answers there are partly +false, so it is skipped with its reason rather than carried as a red test. + +## Status + +| stage | failing | note | +|-------|--------:|------| +| start of session | 565 / 845 | | +| after Phase 1 (done) | **234 / 845** | default-assumptions binding fix | +| now (2026-08-14) | **0 / 845** | 843 pass, 2 skipped (`define constants`, `logical combinations`) | +| target | 0 | `logical combinations` is skipped, not failing — see below | + +## Accepted divergence — `logical combinations` + +Skipped at its site, with the reason, since the ninth review pass: it cannot be +made green without asserting something untrue, and a permanently red test in a +gating job is a check that has stopped checking. The assertions below are what +it would report if it ran. + +That test hides **six** failing assertions (vitest aborts +an `it` at its first failure; re-measure by converting that `it`'s `expect` to +`expect.soft`, then revert the scaffolding): spec lines 7357, 7415, 7417, 7418, +7419, 7420. On all six, legacy commits to an answer and this engine declines. +The engine is **incomplete here, never unsound**. Two root causes: + +1. **Contradictory premises** (spec:7357). `x ∈ R and x ∉ R ⟹ is_real(x)`: + legacy's `and` is `left || right`, so the first conjunct wins and it answers + `true`. `Facts::and_meet` meets the two conflicting definite answers to + `None`. Deliberate; see the doc comment on `and_meet` in + `src/assumptions/facts.rs`. +2. **Non-realness does not propagate through an operator** (spec:7415, + 7417–7420). Under `x ∈ C, x ∉ R, y ∈ R`, legacy answers `false` for + `is_real/nonpositive/nonnegative/positive/negative(x·y)`. Three of those + five are **mathematically false**: `y = 0` is a model of the premises, and + there `x·y = 0`, which *is* real, nonpositive and nonnegative. The other two + (`positive`, `negative`) are sound, and we still answer `undefined` because + `combine::mul` in `src/assumptions/infer/combine/mod.rs` never carries a + `real: Some(false)` operand through the product. + +### Known gap, deliberately not closed + +Adding non-realness propagation rules to `combine::add` and `combine::mul` (and +their `combine::pow` sibling module) would close the sound half of cause 2 (two assertions), and +we are **not** doing it. Those rules turn facts that are `None` today into +`Some(false)`, and `simplify`'s rewrites are gated on exactly those facts — so +more definite answers means different rewrites, which on DoenetML's answer path +means different **grading**. Two assertions in a test that stays red either way +do not buy that risk. Anyone revisiting this must diff the whole compat suite +by (file, name, occurrence) and the `simplify` corpora before believing it is +inert. + +**The gap is incompleteness, but it was not inert, and the one place it was +load-bearing has been fixed rather than left.** A `simplify` rule gated on a +realness fact is safe when it *requires* `Some(true)`: a missing fact costs a +rewrite and nothing else. It is unsafe when it treats `None` as permission, and +exactly one rule in the crate did — `simplify_root`'s odd-root sign extraction, +which asked `is_real(rest) != Some(false)`. Because `combine` never carries +non-realness through an operator, `is_real(sqrt(-2))` is `Some(false)` while +`is_real(x·sqrt(-2))` is `None`, so the residual as a whole read as real and +the sign came out: `cbrt(-x·sqrt(-2)) → -cbrt(x·sqrt(-2))`, a different number. +The engine contradicted itself about it — at `x = 1` the residual is closed, +the fact is `Some(false)`, and it correctly declined — and `equals` inherited +the contradiction, answering `true` for the symbolic pair and `false` for the +`x = 1` instance. That is a wrong answer on the grading path. + +The fix is on the consumer, not on `combine`: the guard is per subexpression, +so a residual declines when any *part* of it is provably non-real. That +over-declines — a non-real part does not make the whole non-real — which only +ever leaves an expression as written, and it turns no `None` into `Some(false)`, +so no other rewrite anywhere moves. `tests/doenet_review_fixes.rs` → +`odd_root_sign_extraction_declines_over_a_non_real_part` pins it. A `grep` for +`Some(false)` outside `src/assumptions/` finds no other realness consumer, so +that was the whole risk surface; anyone who later adds one must check its +polarity against this note. + +## Phase 1 — default assumptions source (DONE, −331) + +`lib/assumptions/element_of_sets.ts` built its predicates over a module-level +`EMPTY = new wasm.Assumptions()`, and `handleFor(undefined)` returned it. The spec +calls `is_real(me.fromText("x+y"))` with **no** second argument, expecting the +global store populated by `me.add_assumption(...)`. Every no-argument query +therefore answered "unknown". + +Fix: `handleFor(undefined)` now returns `Context.assumptions` (the live handle). +The `EMPTY` fallback was additionally made lazy — as written it forced the wasm +load during module evaluation, the same hazard documented at +`lib/math-expressions.ts:1251`. + +This proved the Rust reasoner was already correct for the bulk of these cases +(`is_real(x+y)` with `x,y ∈ R` returns `true` when handed the right store); the +failures were a binding defect, **not** a reasoning-depth gap. + +## The 234 post-Phase-1 failures, in six groups (historical) + +These groups are the breakdown of the **234** figure in the status table, not of +what is failing today — the suite is at 0 failing (see "Accepted divergence" +above). Kept as the record of what the work was. + +### Group A — negated assumptions (16) · Rust +`variable_facts` in `assumptions/infer.rs` only reads `Expr::Relation`; it ignores +`Expr::Not`. Needed: negation-normalize each stored fact before interpreting — +`not(x>0)` ⇒ `x≤0`, `not(x≥0)` ⇒ `x<0`, `not(x≠0)` ⇒ `x=0`, `not(x=0)` ⇒ `x≠0`, +with double-negation elimination (`not(not(p))` ⇒ `p`). + +### Group B — arithmetic reasoning gaps (~96) · Rust +`sum 8 · subtraction 10 · product 22 · quotient 14 · power 42`. Confirmed rules: +- **Zero factor**: any factor known `= 0` makes the product exactly zero + (`nonneg`/`nonpos` true, `positive`/`negative`/`nonzero` false). `combine_mul` + currently demands `real` before any sign reasoning and has no zero short-circuit. +- **Complex closure**: `x,y ∈ C` ⇒ `x+y`, `x·y`, `x^y` complex. `combine_pow` + never sets `complex` from a complex base/exponent. +- **Sum with a known-zero term**: `x ∈ C, x≠0, y=0` ⇒ `x+y` nonzero and complex. +- **Powers**: `x ∈ R, y>0` ⇒ `x^y` complex; `x ∈ R, x≠0` ⇒ `x^y` nonzero. + Power is the largest single bucket — expect several sub-rules. + +### Group C — function domain facts (6) · Rust +`x ∈ C` ⇒ `sin(x)`, `sqrt(x)`, `exp(x)`, `abs(x)` are complex. `apply_facts` +currently derives facts only from a **real** argument, so a complex argument +yields nothing. + +### Group D — literal & operator evaluation (8) · Rust +- `sin(0)` ⇒ integer/real (needs constant folding of the argument). +- `sqrt(-4)` ⇒ complex true, real false. +- `-2.2/(5-5)`, `(-6+6)/(5-5)` ⇒ division by zero: every predicate false. +- Non-numeric nodes — tuple `(5,2)`, relation `5=3` — ⇒ every predicate false + (currently `Facts::unknown()` via the catch-all arm). + +### Group E — `get_assumptions` structural rebuild (~103) · TypeScript +`interval containment 64 · element interval 16 · derived 5 · add/get misc 8 · misc 1`. +Today `Context.get_assumptions()` ignores its argument and returns a **Context**, +so the spec's `ordered_trees_equal(...)` is always false. Required semantics: +- Accept `"x"` or a nested-array form `[["x"]]` / `[["a","b"]]`; return an **AST** + (or `undefined` when nothing is known). +- **Orientation**: facts are re-stated with the queried variable on the left + (`xx`). +- **Transitive closure** over `=` and `<`/`≤` (`xa and x=c and b<=d`, across + all 64 bracket/negation combinations (the single largest sub-bucket). + +Implement in the compat lib over the existing `_assumptionTexts` list rather than +widening the wasm ABI: the required shape is a JS-API concern, and keeping it in +TS avoids a rebuild cycle. The Rust store stays the source of truth for predicates. + +### Group F — robustness (5) · TypeScript +The first row of each `sum/product/quotient/power` table has `input[0] === undefined`; +`me.add_assumption(me.from(undefined))` throws +`Cannot read properties of undefined (reading 'length')`. Adding an undefined or +empty assumption must be a no-op. + +## Sequencing + +Phases run **sequentially, not in parallel**: the Rust phases require rebuilding +`vendor/wasm`, which would change behaviour underneath a concurrently-measuring +TypeScript phase. + +1. **Phase 2 — Rust engine** (Groups A–D, ~126 tests). Files: `src/assumptions/` + (`infer.rs`, `facts.rs`, possibly a new negation helper). Rebuild via + `packages/math-expressions-js-compat/build-wasm.sh`. +2. **Phase 3 — TypeScript** (Groups E–F, ~108 tests). Files: + `lib/math-expressions.ts` (+ a new `lib/assumptions/` helper module). + +## Verification & risk + +- Per phase: `npx vitest run spec/slow_assumptions.spec.ts`. +- **Regression risk (Rust)**: `tests/assumptions.rs` and `tests/assumptions_corpus.rs` + validate the engine against the JS oracle, and `infer.rs` deliberately mirrors + several JS conservatisms (no interval arithmetic in sums; odd powers of + negatives unsigned). Run `cargo test` for the whole crate after Phase 2 — a + "smarter" rule that contradicts the oracle is a regression, not an improvement. +- **Regression risk (suite-wide)**: full `npx vitest run` after each phase. + Baseline to beat: **940 failing** (see `COMPAT_TEST_FAILURE_SUMMARY.md`). + Judge by name-level diff, never by aggregate counts. +- Per repo convention, split any file that grows past ~200 lines into a subfolder. diff --git a/active-plans/COMPAT_TEST_FAILURE_SUMMARY.md b/active-plans/COMPAT_TEST_FAILURE_SUMMARY.md new file mode 100644 index 00000000..454f570c --- /dev/null +++ b/active-plans/COMPAT_TEST_FAILURE_SUMMARY.md @@ -0,0 +1,330 @@ +# js-compat test failure summary + +Snapshot of `packages/math-expressions-js-compat` (`npx vitest run`) on branch +`doenet`, after rebuilding `vendor/wasm` (`bash build-wasm.sh`) so results reflect +current Rust source. + +**Current totals (2026-08-14, at `41b9cb4`, re-measured): +0 failed / 6341 passed / 6352 total** (11 skipped, 0 todo). The suite is green. +The eleventh skip is `slow_assumptions` → `logical combinations`, where **legacy +commits to answers that are mathematically false and this engine declines to** +(see below); it was carried as the one red test until the ninth review pass, +which skipped it at its site with the reason and dropped the `|| true` from the +CI job, so a *new* failure can now be reported. Nothing about the divergence +changed — only whether the check is capable of checking. +Previous snapshots: 380, 162, 97, 83, 82, 61, 55, 54, 46, 43, 38, 20, 16, 12, 11, 7. +(The dated 2026-08-11 line this replaces read `1 failed / 6316 passed / 6329 +total`, 12 skipped and 2 todo; that was a historical snapshot at `7082f8a`, and +the suite has grown tests since.) + +Both `*-numerical-errors` files are at **zero**, and so are `slow_simplify` and +`slow_rational`. + +Ten of the eleven skips are **wontfix, not pending**: nine are the deprecated +`match` conditions under `quick_trees` below — read the 54 → 46 step as a +reclassification, not nine defects fixed — and the tenth is `logical +combinations`. The eleventh, `define constants`, is skipped for a reason of its +own recorded at the test. + +## How to measure + +This snapshot is reproducible from a clean tree: run `bash build-wasm.sh` then +`npx vitest run`, both from inside `packages/math-expressions-js-compat`. +Rebuild the wasm first or the run measures the previous engine; that alone +accounted for a 4-test discrepancy while an earlier snapshot was being taken, +and `build-wasm.sh` lives in the package, not at the repo root. + +Judge changes by name-level diff against a baseline worktree, never by aggregate +counts (see memory `js-compat-suite-baseline-diff`). When diffing, note that +several spec files contain **duplicate test names**, so keying a comparison by +name alone silently drops results — key by (file, name, occurrence). Some specs +also derive the test *name* from the expected string, so updating an expectation +renames its test; those show up as removed-plus-added rather than failed→passed, +and the honest check is that no name present in *both* runs went passing→failing. + +**A matched pair means the two runs differ by the change set and nothing else.** +On a tree someone else is editing, a baseline goes stale in minutes; capture both +halves back to back or the diff is measuring the other person. Two past +attributions were confounded exactly this way — one over-claimed another change +set's fixes, and one made a resource guard look like a correctness fix. + +A further caution: `slow_simplify`-style tests bundle 5–20 assertions each, so a +per-test bucket count is an upper bound on what any one fix buys. Attributing a +test to the first assertion that fails hides everything behind it. + +## Remaining divergences by spec file + +| count | spec file | root cause / category | +| ----: | ----------------------------------------------- | ---------------------------------------------------------------------------------------- | +| 1 | `slow_assumptions` | one skipped test, `logical combinations` — legacy answers unsoundly where we decline, see below | + +The six other failures were feature gaps, now closed (see "Feature gaps +closed" below): `quick_trees` (`allow_extended_match`, graceful invalid match +conditions) and `slow_math-expressions` (container/union coercion flags, an +integer-assumption equality, the `nthroot` derivative). Nine `quick_trees` +predicate/`RegExp` match tests stay **wontfix**-skipped. + +`quick_solve` is at zero, and its last failure was fixed at the root rather than +adopted as a divergence — see below. + +### `simplify` now has one fixpoint per value, not one per spelling + +`-3y-v <= 2xz+r` solved to `(-2xz-r-v)/3` where alpha94 gives `-((2xz+r+v)/3)`. +Same value, but *both* were fixpoints of `simplify`, so which one came out +depended only on how the input was spelled. Idempotence was never the issue +(`simplify∘simplify == simplify` held throughout, asserted in +`simplify_corpus.rs`); **confluence** was. + +The cause was two rules that disagreed, keyed on something mathematically +irrelevant — the *magnitude* of the coefficient: + +- `rule_distribute_neg_over_sum` fired only for a coefficient of exactly `−1` + over a lone sum, and distributed **unconditionally**. +- `rule_distribute_sign` fired for any negative coefficient, but only when it + reduced the sign count. + +So `-(x+y)` distributed and `-2(x+y)` did not, and `-(a+b)/3` — whose canonical +coefficient is `−1/3` — took the second branch. Meanwhile `(-a-b)/3` has a +*positive* coefficient, so neither rule looked at it. Both stood still. More +generally: the engine only ever pushed signs **into** a sum and never pulled one +out, so any input already spelled the disfavoured way was a fixpoint by default. +A preference expressed by only one of the two rewrites is not a normal form. + +**The fix** is the converse rewrite, `rule_factor_sign_out_of_sum` +(`normalize/simplify.rs`): a sum of `k` terms with `n` negated factors its `−1` +out iff `2n ≥ k + 2`. That complements `rule_distribute_sign`'s push-in +threshold exactly — with the tie there moved from "decline" to "push in", which +is load-bearing rather than cosmetic. Writing the factored spelling's negated +count as `m = k − n`: + +- distributed is stable iff `2n ≤ k + 1`; +- factored is stable iff `2m ≤ k − 2`, i.e. `2n ≥ k + 2`. + +Exact complements, so precisely one of the two spellings is stable for every +sum: never both (two fixpoints — the bug) and never neither (a ping-pong). +Moving either threshold by one re-opens one of those. The tie cases (`k` odd, +`2n = k+1`, e.g. `-x-y+z` vs `-(x+y-z)`) are what a paper argument gets wrong +first; they are pinned in `tests/simplify_sign_fixpoint.rs`. + +The old unconditional distribution survives, narrowed, as +`rule_flatten_negated_sum_term`: a negated sum is spliced into its parent only +when it is a *term of a larger sum*, which is the only position where terms can +meet and cancel. That preserves `(q + 12 - (q+2))/2 → 5`, the `` +midpoint shape that motivated the original rule. + +**Cost: one row.** `-(x+y)` now stays factored where alpha94 distributes it to +`-x-y`. That row is unavoidable — `-(x+y)` costs one sign and `-x-y` costs two, +so every sign-counting rule prefers the factored form, and alpha94 prefers the +other only because it never consults a count. Keeping alpha94's answer would +require the distributed spelling to stay a fixpoint, which is precisely the bug. +Recorded as `DIVERGENCE (adopted):` in `tests/doenet_sign_distribution.rs`. + +**Measured: 0 net js-compat change** (11 failures, identical by name), 77 cargo +suites green, clippy clean. The `quick_solve` expectation reverted to alpha94's +spelling and now passes with no divergence note, and `-(x+y-z)` — a tie — +still matches alpha94 because ties push in. + +Two things fell out. `(-a-b)/(-c-d)` now reduces to `(a+b)/(c+d)`; it did not +before, in this engine **or** alpha94, because with no numeric coefficient +anywhere neither sign rule could see it. And the cost table at +`simplify.rs`'s sign cluster was wrong about `-(x+y)` — it claimed "unchanged" +while the unconditional rule preempted it and distributed. Both are now correct +and covered. + +### `slow_assumptions` — 4 of 5 fixed, 1 where legacy answers unsoundly + +The five test *names* here were hiding **52** failing assertions (each `it` +aborts at its first failure; re-run with `expect` → `expect.soft` to see them +all). Four of the five tests are now green. There is no separate plan document +for this — the writeup is the list below, and the tests are +`packages/math-expressions-rs/tests/assumptions_sound_reasoning.rs`. In brief: + +- `is integer / via assumptions` (**fixed**) — the text printer lost negation + scope on the assumption round trip: `paren_if_spaced` tested + `starts_with('(') && ends_with(')')`, which cannot tell `(a or b)` from + `(a) or (b)`, so `not(p or q)` re-parsed wrong. Replaced with a paren-balance + scan (`src/print/text.rs`, `latex.rs`). +- `strict pow` (**fixed**) — `pow_strict` is now a `ConstantPolicy` field + (default strict), reached from JS as `me.math.pow_strict` via a + `defineProperty` that routes to `set_constant_policy`. Off, `x^0 → 1` + unconditionally. +- `combined assumptions`, `combined assumptions, negated` (**fixed**) — the + predicate engine now reads the *derived* store (equality following, bound + chaining, disjunctions) instead of the flat one, and walks its boolean + structure with sound semantics (`and` = meet, `or` = join). See + `src/assumptions/infer/vars.rs` and `assumptions_sound_reasoning.rs`. +- `logical combinations` (**still failing — accepted**). 18 of its 24 failing + assertions were fixed by the change above. **6 remain**, measured by + converting that one `it`'s `expect` to `expect.soft` (revert the scaffolding + afterwards): spec lines **7357, 7415, 7417, 7418, 7419, 7420**. Note the + direction — on every one of them **legacy commits to an answer that is + mathematically false, or that we simply cannot yet prove, and this engine + declines instead**. It is *incomplete* here, never unsound. Two unrelated + root causes: + - **(a) contradictory premises** (1 assertion, spec:7357). + `x ∈ R and x ∉ R ⟹ is_real(x)` — legacy returns `true`, because its `and` + is `left || right` and the first conjunct wins. No `x` satisfies both + premises, so `true` is not entailed by anything; `Facts::and_meet` + (`src/assumptions/facts.rs`) meets the two definite answers to `None` and + we return `undefined`. + - **(b) non-realness does not propagate through an operator** (5 assertions, + spec:7415 and 7417–7420). Under `x ∈ C, x ∉ R, y ∈ R`: + - `is_real/nonpositive/nonnegative(xy)` — legacy returns `false`, which is + **wrong**: `y = 0` is a model of the premises, and there `xy = 0`, which + *is* real, nonpositive and nonnegative. We answer `undefined`. + - `is_positive/negative(xy)` — here `false` *is* sound (a value that is + non-real-or-zero is never positive or negative), and we still answer + `undefined`, because `combine::mul` in + `src/assumptions/infer/combine/mod.rs` never carries a `real: Some(false)` + operand through the product and so cannot reason by cases. Pure + incompleteness on our side. + + **Deliberately not fixed.** The rule that would close the sound half — + propagating non-realness through `+`/`*`/`^` — turns facts that are + `None` today into `Some(false)`, and `simplify`'s rewrites are gated on + exactly those facts. More definite answers means different rewrites, which + means different grading on DoenetML's answer path. That is not a trade + worth two assertions in a test that stays red either way (the three + genuinely-unsound legacy answers above are unreachable without adopting + legacy's unsoundness). Recorded as a known gap in + `ASSUMPTIONS_ENGINE_PLAN.md` § "Accepted divergence"; left unfudged rather + than asserted as intended behavior. + +### Feature gaps closed + +Six failures that were genuine feature gaps (not divergences) are now fixed — +each with a permanent test (`tests/missing_features.rs` for the core ones, the +spec files for the wasm/JS ones): + +- **`nthroot` derivative** (`slow_math-expressions`). `nthroot(x,k)` denotes + `x^(1/k)` but had no derivative-table entry, so on the faithful layer it left + a formal `nthroot'`. `calculus/diff.rs` now rewrites it to the power form and + differentiates that (as `sqrt`/`cbrt` already did). +- **Integer-assumption equality** (`slow_math-expressions`). The numeric + equality sampler was assumption-blind, so `(-1)^n·(-1)^n = 1` failed under + `n ∈ Z`. `EqOptions` gained an `assumptions` field; the `Assumptions` wasm + handle now passes its store to `equals`, and the sampler draws an + integer-proved variable over the integers (JS `integer_variables`). +- **Container coercion flags** (`slow_math-expressions`, two tests). The flags + existed but were mis-wired: `coerce_seqs` gated tuple↔vector on the wrong flag + (fixed to a two-step map matching legacy's non-transitive graph), and union + equality did no member matching (added an accept-only pairwise-matching stage + on raw members, so each pair coerces in isolation — a tuple can pair with a + vector and a closed-interval-spelled array in the same union). +- **Graceful invalid match conditions** (`quick_trees`). `trees.match` threw on + an unusable `variables` condition (`false`, an unknown kind string, a + `RegExp`); it now maps them to `VarKind::Nothing` (admits nothing), so the + match fails gracefully to no-match. +- **`allow_extended_match`** (`quick_trees`, `trig transformation`). A sum/ + product pattern can now match a subset of a larger sum/product. Implemented in + the JS `match` bridge (`lib/trees/flatten.ts`): the tree operands are put in + canonical order, operand subsets are matched by the existing Rust matcher, and + the untouched operands are returned as `_skipped` for the (already-present) + splice in `applyAllTransformations`. + +There is also one **skipped** test here, `define constants`, which the legacy +suite skipped with the note "although this passes, skip test as setting +`define_i`, etc., no longer changes mathjs". The Rust port *does* thread that +declaration all the way through (`src/constant_policy.rs`, surfaced as +`me.setConstantPolicy`), so this test is nearly enableable: 13 of its 14 +assertions pass as written. The one that does not is `is_real(i·x)` given +`x ∈ ℝ`, which answers `undefined` where the spec wants `false` — a missing +inference (a product of a nonzero real and the imaginary unit is not real), not +a declaration problem. Fix that and the test can be un-skipped. + +### `quick_trees` — 0 left, 9 wontfix + +#### WONTFIX — arbitrary per-parameter `match` conditions (9 tests, now skipped) + +Legacy let `variables` map a parameter to a **predicate function** (8 tests) or +a **`RegExp`** (1). [`VarKind`] is the closed replacement and the open forms are +deprecated; the specs are `it.skip`ped with a `[wontfix: …]` name prefix and a +block comment, so they stay as the record of what legacy accepted rather than +sitting in the failing bucket forever. + +Two reasons, and the second is the real one: + +1. A predicate would be called back into JS once per **candidate** binding — + the matcher backtracks, so the call count is a function of the search, not of + the input. It would stop being a pure Rust search. +2. **Nobody needs it.** DoenetML's `` + ([`MatchesPattern.js:259-274`](../tmp/DoenetML/packages/doenetml-worker-javascript/src/components/MatchesPattern.js#L259-L274)) + is the only real consumer and passes exactly two closures — + `(m) => !Number.isNaN(me.fromAst(m).evaluate_to_constant())` under + `requireNumericMatches`, `(m) => typeof m === "string"` under + `requireVariableMatches`. Those *are* `VarKind::Number` and + `VarKind::Variable`. No `RegExp` condition appears anywhere in DoenetML. + +The declarative form is also sharper: `Number` means "evaluates to a real +numeric constant", where the legacy specs' hand-written `typeof s === "number"` +quietly rejected `π`. + +Cost of the skip, and how it was covered: every legacy test exercising +`allow_permutations` and `allow_implicit_identities` *also* declared its +parameters with predicates, and those two options are supported and are what +Doenet passes. Skipping blind would have left both with **zero** coverage in the +suite. Two replacement tests ("… with parameters declared by kind") re-express +the same scenarios with declared kinds, including a negative control confirming +the kind is what fails the match (`e^(0.3s^2+3s+q)` matches under `true`, not +under `"number"`). + +#### Both formerly-open tests now fixed + +- **Graceful invalid match conditions** — "invalid matching conditions fail + gracefully". `interop.rs` now maps an unusable condition (`false`, an unknown + kind string, a `RegExp`) to `VarKind::Nothing` (admits nothing), so the match + fails to no-match rather than throwing. See "Feature gaps closed" above. +- **`allow_extended_match`** — "trig transformation". Implemented in the JS + `match` bridge (`lib/trees/flatten.ts`), reusing the Rust matcher over operand + subsets and feeding the existing `applyAllTransformations` splice via + `_skipped`. See "Feature gaps closed" above. + +## Highest-leverage remaining item + +**`slow_assumptions` → `logical combinations`** is the only divergence left, and +it is **accepted, not a bucket of work** — skipped at its site rather than left +red, so the suite's exit status is available to report regressions. Of its six failing assertions, three want +answers that are mathematically false (`y = 0` is a model, so `xy` really can be +real/nonpositive/nonnegative) and a fourth is legacy's first-conjunct-wins `and` +answering under a contradiction — reproducing any of them means encoding unsound +reasoning. The remaining two are a completeness gap we decline on purpose, +because closing it would move `simplify` and therefore grading (see that +section). This engine is *incomplete* on that test, never unsound. Every other +spec passes. + +Two things worth doing that no failing test covers: + +- **`equality/fuzzy.rs` is ~330 lines** and has an obvious seam (structural + equality vs. the sensitivity tolerance) that the file's own module doc already + names. Over the ~200-line split guideline. +- **`ops/numbers.rs` is ~640 lines** and holds two unrelated passes: numeric + folding (`evaluate_numbers` and the rounding family) and the polynomial-GCD + fraction cancellation (`reduce_rational`/`reduce_node`). The second reaches + into `polynomials::kernel` and owns its own resource cap, which makes the seam + wide. Splitting `reduce_rational` into its own module under `ops/` would leave + both halves under the guideline. + +## Rejected: folding roots to powers in `canonicalize` + +Recorded so it is not relitigated. `ops/transforms.rs:122` records keeping +`sqrt(x)` and `x^(1/2)` as distinct canonical trees as a deliberate decision, and +the oracle backs it — legacy also keeps them distinct in `.tree` and in printed +output, folding to a power only inside the explicit `normalize_function_names` +pass: + +``` +legacy fromText("sqrt(q)").tree -> ["apply","sqrt","q"] prints sqrt(q) +legacy fromText("q^(1/2)").tree -> ["^","q",["/",1,2]] prints q^(1/2) +``` + +Making `canonicalize` fold roots would break `q^(1/2)` round-tripping (the two +become one tree and must therefore print alike), move roots from the `Apply` to +the `Pow` rank in both comparators, cost `sqrt(8) → 2√2` unless +`rule_radical`'s numeric-base-only `Pow` arm is generalized, drop `sqrt` off its +dedicated `z.sqrt()` / `FixId::Sqrt` kernels onto generic `powc` (branch-cut +risk), and require regenerating ~130 fixture entries. + +A note for whoever picks these up: several past fixes turned out to be *bindings* +for engine code that already existed and was already exercised elsewhere. Before +implementing anything that looks like a missing feature here, grep the Rust core +for it first. diff --git a/active-plans/DOENET_COMPAT_PLAN.md b/active-plans/DOENET_COMPAT_PLAN.md new file mode 100644 index 00000000..9b22657a --- /dev/null +++ b/active-plans/DOENET_COMPAT_PLAN.md @@ -0,0 +1,987 @@ +# DoenetML compatibility — fix plan for issue #83 + +**Source:** [Doenet/math-expressions#83](https://github.com/Doenet/math-expressions/issues/83) +("Compatibility issues for use with Doenet"), re-verified against merged `main`. +**Goal:** make `math-expressions-js-compat` a genuine drop-in for DoenetML, and +unblock DoenetML "Stage 2" (depending on `math-expressions-rs` as a crate). +**Status:** Phase 1 **landed** (see §8); Phases 2–4 planning. Every claim below is +grounded in current source; file:line references are to `packages/` unless noted. + +> **Decision taken (2026-07-31): DoenetML owns the round-trip serialization fix.** +> That closes open decision 1 — **R13b is not ours**. We shipped R13a (below); the +> exact-value degradation across state save/load is handled on the DoenetML side. +> +> **Decision taken (2026-07-31): `Expr::Bool(bool)`.** That closes open decision 2; +> **R2-bool has landed** (see §8). The `null` half of R2 stays in Phase 2. + +This plan groups the 13 requests (R1–R13) by **where the fix lives** and +**effort**, sequences them into shippable phases, and calls out the handful that +are better solved (or co-owned) on the DoenetML side. + +--- + +## 0. Orientation — where each item actually lives + +Three surfaces are in play: + +- **Core crate** `math-expressions-rs/src/` — the Rust engine (`Expr`, printers, + serde bridge). +- **WASM crate** `math-expressions-rs-wasm/src-rust/` (Rust bindings) + + `src-js/` (the TS glue, incl. `compileRustExpr`). +- **Compat layer** `math-expressions-js-compat/lib/` — the legacy-API drop-in. + +Findings from the code audit that change the shape of the work: + +| Assumption in the issue | Reality in the code | Effect on the fix | +| --- | --- | --- | +| R3 needs new option plumbing | `to_latex_with_options` / `to_text_with_options` **already exist** ([wasm `core_ops.rs:41,56`](math-expressions-rs-wasm/src-rust/core_ops.rs#L41)) and read `notation`/`unicode` | R3 = add option *fields* to `LatexOpts`/`TextOpts` + forward from compat, not build plumbing | +| R4/R10 need new core logic | `get_component`/`substitute_component` **already exist** ([`ops/query.rs:74,83`](math-expressions-rs/src/ops/query.rs#L74)) but are `Seq`-only and unbound | R4/R10 = add a WASM binding + extend to `Expr::Matrix` | +| R2 = "add a match arm for Bool" | `Expr` has **no boolean-literal representation at all** ([`expr/tree.rs:20`](math-expressions-rs/src/expr/tree.rs#L20); `MathConst` = Pi/E/I/Inf/-Inf/NaN) | R2 needs a real boolean leaf in `Expr` + printer + serde — a medium core change, not a one-liner | +| R6 needs new machinery | `compileRustExpr` exists and is exported from the wasm package ([`src-js/index.ts:16`](math-expressions-rs-wasm/src-js/index.ts#L16)) | R6 = ~4 lines of compat wiring | +| R13 is a ~5-line `number_to_js` change (issue's framing) | **False, measured.** `1/2` and `0.5` are the *same* `Expr` (`Rat(1,2)`) — decimals parse to exact rationals. The issue's patch verbatim breaks **27** `parse_matches_js` fuzz cases | R13 re-scoped into R13a (safe, ship now) / R13b (needs a provenance decision) — see §2 | +| R13 is confined to `serde.rs` | The **printers** decimalize independently (`terminating_decimal` rule, [`print/text.rs:205`](math-expressions-rs/src/print/text.rs#L205)) — that is the `toLatex → "0.5"` complaint | R13b spans serde *and* both printers | + +--- + +## 1. Priority & sequencing overview + +| # | Item | Fix lives in | Effort | Phase | +| --- | --- | --- | --- | --- | +| **R13a** ✅ | Non-terminating rationals lose precision (`1/3` → `0.333…`) | Core (`serde.rs`) | **S** | 1 — **done** | +| **R13b** | Terminating rationals decimalize (`1/2` → `0.5`) | — | — | **DoenetML-side** | +| **R5** ✅ | Context-level op family (`me.simplify(expr)`) | Compat | **S** | 1 — **done** | +| **R6** ✅ | `Expression#f()` numeric evaluator | Compat | **S** | 1 — **done** | +| **R4** ✅ | `get_component()` unbound (+ matrices) | Core + WASM + compat | **S–M** | 1 — **done** | +| **R10** ✅ | `substitute_component()` unbound | Core + WASM + compat | **S** | 1 — **done** | +| **R2-bool** ✅ | `from_ast` rejects `boolean` leaves | Core (`Expr` + serde) | **M** | 1 — **done** | +| **R2-null** | `from_ast` rejects `null` leaves | Core / DoenetML (source unknown) | **S** | 2 | +| **R3** | Render options silently dropped | Core (printers) + WASM + compat | **M** | 2 | +| **R7** | Passes that silently no-op | Core + WASM + compat | **M** | 2 | +| **R1** | Browser/worker WASM loading seam | WASM(js) + compat (+ Doenet) | **M** | 2 | +| **R8** | Handle lifetime / `Sym` interner unbounded | Compat + Core | **M–L** | 3 | +| **R9** | `panic = "abort"` + WASM32 stack safety | Core | **L** | 3 | +| **R11** | MathML input (`mmlToAst`) | — (confirm not needed) | — | — | +| **R12** | Publish `3.0.0-alpha` to npm | Process | **S** | 4 | + +**Phase 1 — quick blocking wins** (R13, R5, R6, R4, R10, R2-bool): small, mostly +compat + contained core edits; clears most of the 32 reported failures. **Landed +in full — see §8.** +**Phase 2 — rendering & normalization fidelity** (R3, R7, R2-null, R1). +**Phase 3 — robustness & lifecycle** (R8, R9). +**Phase 4 — release** (R12). + +--- + +## 2. Phase 1 — quick blocking wins + +### R13 — exact rationals crossing to JS *(highest priority — and NOT a 5-line change)* + +> **Corrected after a code spike.** The issue proposes "emit `["/", num, den]` for +> `Number::Rat`". **That fix as stated is wrong** and breaks 27 fuzz cases. R13 is +> really two items with very different difficulty. Evidence below is measured, not +> inferred. + +**The blocker: `Number::Rat` has no provenance.** User-typed decimals parse to +*exact rationals*, by deliberate design +([`num/number.rs:3`](math-expressions-rs/src/num/number.rs#L3): "User-typed decimals +parse to exact rationals … never `Float`"). So `1/2` and `0.5` are **the identical +`Expr`**: + +| input | internal `Expr` | `to_text` | `to_latex` | `tree_json` (today) | +| --- | --- | --- | --- | --- | +| `1/2` | `Num(Rat(1,2))` | `0.5` | `0.5` | `0.5` | +| `0.5` | `Num(Rat(1,2))` | `0.5` | `0.5` | `0.5` | +| `cos(pi/3)` | `Num(Rat(1,2))` | `0.5` | `0.5` | `0.5` | +| `1/3` | `Num(Rat(1,3))` | `1/3` | `\frac{1}{3}` | **`0.3333333333333333`** | +| `5/6` | `Num(Rat(5,6))` | `5/6` | `\frac{5}{6}` | **`0.8333333333333334`** | +| `19.9` | `Num(Rat(199,10))` | `19.9` | `19.9` | `19.9` | + +Nothing at the JS boundary can separate `1/2` from `0.5` — the distinction is +destroyed at parse/canonicalize time. **Measured:** applying the issue's proposed +patch verbatim makes `cargo test --workspace` fail `parse_matches_js` with **27 +divergences**, all decimal literals turning into fractions (`19.9` → +`["/",199,10]`, `tan(14.2)` → `["apply","tan",["/",71,5]]`). + +Note also the printers use a **different** rule than serde: +`render_number` ([`print/text.rs:205`](math-expressions-rs/src/print/text.rs#L205)) +emits a positional decimal for any *terminating* rational (denominator 2^a·5^b) and +`a/b` otherwise — which is why `1/3` prints correctly but `1/2` does not. So the +issue's `(2/4).toLatex() → "0.5"` complaint is a **printer** defect, not a serde +one. R13 spans both sites. + +#### R13a — non-terminating rationals lose information *(safe, small, do now)* + +`1/3` → `0.3333333333333333` in `tree_json` is **irreversible loss** and has no +provenance ambiguity: a non-terminating rational can never have come from a decimal +literal. The printers already get this right; only serde is wrong. + +```rust +Number::Rat(num, den) if !is_terminating(den) => json!(["/", num, den]), +``` + +- Fixes the issue's `1/3`, `1/2+1/3` cases. +- **Sign convention — RESOLVED empirically:** the JS fixtures spell negative + rationals sign-on-numerator (`["/",-2,3]` in `ast-to-latex.json`, + `["/",-5,8]` in `expand-corpus.json`, `["/",-1,2]` in `simplify-corpus.json`), + which matches the Rust `Rat` normal form (`den > 0`, sign on `num`, + [`num/number.rs:60`](math-expressions-rs/src/num/number.rs#L60)). Emit directly. +- **Risk: very low.** `simplify_corpus` compares by `equals`, not tree-match + ([`simplify_corpus.rs:167`](math-expressions-rs/tests/simplify_corpus.rs#L167)), and + JS-tree agreement is explicitly advisory — so `0.5`-vs-`["/",1,2]` never gated + anything. The corpus fixtures *already* expect `["/",1,3]`, `["/",5,6]`, + `["/",-1,2]`, so this moves Rust **toward** the fixtures. + +#### R13b — terminating rationals need a provenance decision *(the real work)* + +`1/2`, `3/4`, `cos(pi/3)` are indistinguishable from `0.5`, `0.75`. Getting +`cos(pi/3) → ["/",1,2]` **and** keeping `19.9 → 19.9` requires distinguishing +decimal-origin from fraction-origin values. Options, for a maintainer call: + +1. **Track provenance in `Number`** (a decimal-origin marker, or a distinct + `Number::Dec` tier). Fully correct; touches the parser, arithmetic + (propagation rules), both printers, and serde. **Effort: M–L.** +2. **Make it an explicit render/serialize option** — e.g. a `preferFractions` + notation flag, which dovetails with **R3**'s options work. Cheap and gives + DoenetML exactly the behavior it wants globally, but a document mixing decimals + and fractions cannot get both right, and `0.5` typed by a student would echo + back as `1/2` (bad in a *decimals* lesson). +3. **Do nothing for R13b** — ship R13a, and tell DoenetML that terminating + rationals render as decimals. Given they call fractions "a first-class teaching + subject", this likely fails their requirement. + +*Recommendation:* ship **R13a now** (it is unambiguous and fixes real data loss), +and treat **R13b** as a scoped design task — option 1 if fraction fidelity must be +per-value, option 2 if a document-level policy is acceptable. **Confirm with +DoenetML which they need**, since option 2 is far cheaper. + +**Bonus severity the issue did not note:** because `tree_json` → `fromAst` +round-trips `Rat` → `Float`, DoenetML's `serializedComponentsReviver` **degrades +exact values on every state save/load**, not merely on display. R13a stops that for +non-terminating rationals. + +### R5 — regenerate the context-level operation family *(compat only)* + +**Root cause.** `Context` ([`math-expressions.ts:411`](math-expressions-js-compat/lib/math-expressions.ts#L411)) +stops at the factories; the legacy `me.simplify(expr)` free-function form is +absent. This one gap caused **all 79** `math.test.ts` cases to fail in the +issue's first run. + +**Fix (compat).** After `Context` is defined, mirror `Expression.prototype` onto +it, expression-first, without shadowing existing members (factories win): + +```ts +for (const name of Object.getOwnPropertyNames(Expression.prototype)) { + if (name === "constructor" || name in Context) continue; + const d = Object.getOwnPropertyDescriptor(Expression.prototype, name); + if (typeof d?.value !== "function") continue; // skip accessors (`tree`) + (Context as any)[name] = (expr: ExpressionLike, ...args: unknown[]) => + (Context.from(expr) as any)[name](...args); +} +``` + +DoenetML offered this as a PR — accept it. **Effort: S.** + +### R6 — wire `Expression#f()` *(compat only)* + +**Root cause.** `f` is in the `notImplemented` list +([`math-expressions.ts:362`](math-expressions-js-compat/lib/math-expressions.ts#L362)) +even though `compileRustExpr` exists and is exported +([`src-js/index.ts:16`](math-expressions-rs-wasm/src-js/index.ts#L16)). + +**Fix (compat).** Remove `"f"` from `notImplemented` and add: + +```ts +import { compileRustExpr } from "math-expressions-rs-wasm"; +import math from "./mathjs"; // the math.js instance, NOT the wasm module +Expression.prototype.f = function (this: Expression) { + const compiled = compileRustExpr(math, this._w); + return (bindings: Record) => compiled.evaluate(bindings); +}; +``` + +**Signature verified:** `compileRustExpr(math: MathJsInstance, expr: RustExprLike, +options?: { normalize?: boolean })` +([`tree-to-mathjs.ts:527`](math-expressions-rs-wasm/src-js/tree-to-mathjs.ts#L527)) — +the first argument is the **math.js instance**, and `math` is already imported in +the compat module. It normalizes via a temporary handle and frees it internally; +pass `{normalize:false}` only if the expression is already normalized. **Effort: S.** + +### R4 — bind `get_component()` and extend it to matrices + +**Root cause.** [`ops/query.rs::get_component`](math-expressions-rs/src/ops/query.rs#L74) +exists but (a) has no WASM binding and (b) only handles `Expr::Seq`, returning +`None` for `Expr::Matrix`. + +**Fix.** +1. **Core:** extend `get_component`/`substitute_component` to index `Expr::Matrix` + (row-major). **Indexing is 0-based** and already documented as a port of + `me.get_component` ([`ops/query.rs:71`](math-expressions-rs/src/ops/query.rs#L71)), + so the existing `Seq` behavior needs no change — only the `Matrix` arm is new + (confirm row/col order against the 63 call sites). +2. **WASM:** add `Expression::get_component(&self, i: usize) -> Option` + in `core_ops.rs` (mirrors `copy`/`substitute_var`). +3. **Compat:** remove `"get_component"` from `notImplemented`, add + `get_component(i) { return wrap(this._w.get_component(i), this.context); }`. + +**Effort: S–M** (the matrix indexing + call-site semantics are the only real +work). Clears 3 of the 32 failures and is load-bearing for editing +(`EssentialValueWriter.ts`). + +### R10 — bind `substitute_component()` *(minor)* + +Same shape as R4 for [`ops/query.rs:83`](math-expressions-rs/src/ops/query.rs#L83): +add the matrix case, a WASM binding, and compat wiring. One call site; do it +alongside R4 since they share code. **Effort: S.** + +### R2 (part 1) — boolean leaves + +**Root cause.** The published `Tree` type allows `boolean` +([`math-expressions.ts:14`](math-expressions-js-compat/lib/math-expressions.ts#L14)) +but `Expr` has **no boolean literal** — `try_from_js` falls through to +`Err("unexpected value …")` ([`serde.rs:44`](math-expressions-rs/src/expr/serde.rs#L44)), +so `["and", true, false]` is unconstructible. + +**Design decision (needs a call — see §6).** Give booleans a home in `Expr`. +*Recommended: `Expr::Bool(bool)`.* Folding them into `MathConst` looks smaller but +is a poor fit: every other `MathConst` serializes to a JSON **string** (`"pi"`, +`"e"`) or a special object (`{"$":"Inf"}`), whereas a boolean must serialize to a +JSON **boolean** — and `MathConst` means *mathematical constant* (π, e, i, ∞), not +a truth value. A distinct variant keeps both the serde shape and the semantics +honest. Then: +- `try_from_js`: `Value::Bool(b) => Ok()`. +- `to_js`: emit JSON `true`/`false` for the leaf (closes the type-faithful + round-trip — symbol-mapping to `"true"`/`"false"` does **not**, since it comes + back as a string). +- Printers (`text.rs`, `latex.rs`) and any exhaustive `match` on `Expr`/`MathConst` + gain arms — the compiler enumerates them. + +**Effort: M** (mechanical once the representation is chosen). The `null` half of +R2 moves to Phase 2 (§3). + +--- + +## 3. Phase 2 — rendering & normalization fidelity + +### R3 — honor render options (or throw) + +**Root cause.** Compat's `toLatex()/toString()/tex()` call the no-arg bindings +and ignore any options object +([`math-expressions.ts:112`](math-expressions-js-compat/lib/math-expressions.ts#L112)). +The WASM `*_with_options` bindings exist but only read `notation`/`unicode`; the +core `LatexOpts` has a single field (`notation`) and `TextOpts` two +([`print/latex.rs:12`](math-expressions-rs/src/print/latex.rs#L12), +[`print/text.rs:19`](math-expressions-rs/src/print/text.rs#L19)). + +**Fix (three layers, options exist — fields do not).** +1. **Core printers:** add fields to `LatexOpts`/`TextOpts` and implement them in + the number-rendering path: `pad_to_decimals`, `pad_to_digits`, `show_blanks`, + `explicit_multiplication_symbols`. (Padding hooks into the existing + `f64_positional_string`/number emitter; `show_blanks` controls how `Expr::Blank` + renders.) +2. **WASM readers:** extend `to_latex_with_options`/`to_text_with_options` + ([`core_ops.rs:41`](math-expressions-rs-wasm/src-rust/core_ops.rs#L41)) with + `read_opt_*` calls for the new keys, mirroring `read_opt_bool`. +3. **Compat:** forward the options object — `toLatex(opts)` → + `to_latex_with_options(JSON.stringify(opts))` when `opts` is non-empty. + +**Minimum bar** (if the printer work is deferred): make an unrecognized/non-empty +options object **throw** rather than silently no-op, so callers discover the gap. +Full implementation is required for DoenetML Stage 2 (`eval-math.ts` passes these +from the Rust core). Named counts: `padToDecimals` 9, `padToDigits` 9, +`showBlanks` 11, `explicitMultiplicationSymbols` 1. **Effort: M.** + +### R7 — real passes, or loud failures + +**Root cause.** Five passes are compat no-ops returning `this` +([`math-expressions.ts:377`](math-expressions-js-compat/lib/math-expressions.ts#L377)), +and `evaluate_numbers(_opts)` ignores its argument +([`math-expressions.ts:191`](math-expressions-js-compat/lib/math-expressions.ts#L191)). +Three are user-facing DoenetML features (`simplify="normalizeorder"`, +`"numberspreserveorder"`, answer grading). + +**Fix — split by need (issue's own guidance: implement the two we need, throw the rest).** +- **`default_order()`** — add a WASM binding over the core `normalize::order` + path (order without full aggressive simplify), compat forwards. *Implement.* +- **`evaluate_numbers({skip_ordering})`** — thread a `skip_ordering` option + through the WASM `evaluate_numbers` binding + ([`core_ops.rs:193`](math-expressions-rs-wasm/src-rust/core_ops.rs#L193)) into the + core pass; compat passes the flag through instead of dropping it. *Implement.* + (Same silent-ignored-option bug class as R3.) +- **`normalize_applied_functions`, `normalize_negative_numbers`, + `expand_relations`, `applyAllTransformations`** — change the compat stubs from + silent no-ops to `notImplemented(name)` so answer-grading paths surface the gap + at the call site instead of silently changing behavior. *Throw* until a + consumer proves it needs the real pass. + +**Effort: M.** + +### R2 (part 2) — the `null` leaves + +`Tree = number | string | boolean | Tree[]` has no `null`, so `me.fromAst(null)` +erroring is arguably *correct* — the defect is the unclear error and the six +downstream failures. **Action:** improve the `try_from_js` error to name the path, +and **investigate the source** of the `null`s DoenetML feeds in (the issue notes +they cluster around display-rounding / units / blanks — likely a blank `_` +serialized as `null` somewhere). This may resolve to a **DoenetML-side fix** (emit +the blank symbol, not `null`) or a decision to accept `null` → `Expr::Blank`. +Coordinate before choosing. **Effort: S** on our side once the source is known. + +### R1 — a supported browser/worker loading seam + +**Root cause.** [`lib/_wasm.ts`](math-expressions-js-compat/lib/_wasm.ts) is +node-only (`createRequire` over the `nodejs`-target build). DoenetML runs in a Web +Worker and had to alias the internal module path. + +**Fix — provide the seam; let DoenetML own byte-loading.** The WASM `web` build +already exists; what's missing is an injection point: +- Add a `setWasmModule(glue)` / `math-expressions/web` entry that accepts an + already-initialized `--target web` module, so the compat layer uses an injected + module instead of hard-wiring the node loader. +- Preserve a **synchronous** path (`initSync` from inlined bytes) — the legacy + API is sync and ~150 DoenetML files call it without `await` — and expose an + async `init(bytesOrUrl)` for the browser main thread. +- **No `fetch`:** instantiate from an `ArrayBuffer` only (VS Code web-worker host + blocks blob/data-URL fetch). + +**Ownership split (see §5):** *math-expressions* ships the injection API and a +`web` entry; *DoenetML* keeps its base64-inlining + `initSync` glue (bundling is a +consumer concern). This deletes DoenetML's fragile internal-path alias. +**Effort: M.** + +--- + +## 4. Phase 3 — robustness & lifecycle + +### R8 — bound memory: handles and the `Sym` interner + +**Root cause.** Compat `Expression` handles are never freed except in a few +throwaway spots ([`math-expressions.ts:282`](math-expressions-js-compat/lib/math-expressions.ts#L282)); +the `Sym` interner is append-only. DoenetML's long-lived worker mints an +expression per state-variable eval and per state-JSON round-trip → unbounded +growth. + +**Fix — phased, in preference order.** +1. **`free()`/`dispose()` on compat `Expression`** (+ the `__wbg_ptr !== 0` guard + the playground already uses, [`engines.ts:72`](playground/src/engines.ts#L72)). + Cheap, immediate, lets hosts own lifetimes. **S.** +2. **Cap / evict the `Sym` interner** (core) — no consumer-side discipline can fix + this one. Needs an eviction policy or a per-context arena. **M.** +3. **Value-first `Expression`** (hold the plain `Tree` as canonical state, + materialize a handle only for an operation's duration). Also removes the + uncached `get tree()` `JSON.parse` cost on DoenetML's two hottest calls + (`fromAst` ~675, `.tree` ~600 sites). Largest change; **L**; can be a + follow-up once (1)+(2) stop the bleeding. + +**Effort: M–L overall.** Do (1) immediately; schedule (2); treat (3) as a +separate design. + +### R9 — panic firewall + WASM32 stack safety + +**Root cause.** `panic = "abort"` ([`Cargo.toml:22`](Cargo.toml#L22)) turns any +reachable panic into a dead worker (JS raised catchable exceptions); +`ARCHITECTURE_REVIEW.md` still lists reachable panics (`Number::from_decimal_str`) +and `STACK_SAFETY_PLAN` items 21/23–26 are open (deep `Expr` overflows the ~1 MB +shadow stack, **including on `Drop`**). Student input is adversarial. + +**Fix.** +- **Keep `panic = "abort"`** (the size win, −35% wasm) and instead **eliminate + reachable panics at the boundary**: audit `unwrap`/`expect`/`assert`/`panic!` on + any path reachable from a `#[wasm_bindgen]` entry and return `Result` + (`from_decimal_str` first). +- **`STACK_SAFETY_PLAN` item 21 — iterative `Drop` for `Expr`** (the plan's + sequencing step 1, "smallest change, kills a whole crash class"). Gets *more* + important if R8 frees aggressively. +- Consider the bounded-boundary depth guard from `STACK_SAFETY_PLAN §3` for the + parse/serde entry points. + +**Effort: L.** Reference and advance `STACK_SAFETY_PLAN`; this is the one item +that is genuinely deep work. + +--- + +## 5. What is better changed on the DoenetML side + +Recording the ownership split so it is explicit: + +- **R1 byte-loading glue.** math-expressions provides the injection seam + (`setWasmModule` / `web` entry, sync + async init); **DoenetML keeps** the + base64-inlining and `initSync`-from-bytes — that is bundling policy specific to + the VS Code web-worker host, not something the library should encode. +- **R2 `null`.** If the `null`s trace back to DoenetML serializing a blank as + `null`, the fix is **DoenetML emitting the `_` blank symbol** (or an agreed + `null → Blank` mapping); math-expressions only owns the clearer error. +- **§5 of the issue — divergences DoenetML absorbs (no change requested):** the + more aggressive `simplify` (feature, not bug), exact-constant equality, and + pure-presentation formatter differences. DoenetML **updates its own tests** to + compare parsed trees / `equals` rather than exact strings. The 15 "assertion + divergence" failures of the 32 are DoenetML-side test updates. +- **R8 value caching (partial).** Even before a value-first `Expression`, + DoenetML can cache `.tree` at its call sites; the interner cap, however, must + be library-side. + +Everything else (R13, R3, R4, R5, R6, R7-implement, R9, R10) is +math-expressions-side, most of it in the compat layer or a contained core edit. + +## 5b. No action + +- **R11 MathML input.** No `fromMml`/`mmlToAst` call sites in DoenetML; the + `WHATS_LEFT.md §A.1` gap is confirmed not needed. Leave `fromMml` as + `notImplemented`; record the confirmation. GLSL/Guppy/MathML-output/`mathjsToAst` + likewise have no consumers. + +--- + +## 6. Open decisions (need a maintainer call) + +1. ~~**R13b provenance**~~ — **RESOLVED 2026-07-31: DoenetML owns the round-trip + serialization fix.** math-expressions ships R13a and does not track + decimal-origin provenance. If DoenetML later needs per-value fraction fidelity + in the *printers* (`(2/4).toLatex() → "0.5"`), reopen as a separate item — that + is a display question, distinct from the round-trip data question now settled. +2. ~~**R2 boolean representation**~~ — **RESOLVED 2026-07-31: `Expr::Bool(bool)`**, + shipped. `MathConst::True/False` and symbol-mapping both lose the JSON type on + the way out; see §2. The follow-on question this raised — whether interval + closures and `lts`/`gts` strictness flags should become `Expr::Bool` children — + was answered **no**, deliberately; see §8. +3. **R7 scope** — confirm implement-`default_order`+`skip_ordering`, throw the + other three (per the issue's own preference). + *(R13 negative-rational spelling: **resolved** — sign-on-numerator, matches + fixtures and the `Rat` normal form. No longer an open question.)* +4. **R3 depth** — implement the four printer options now (needed for Stage 2) vs + ship throw-on-unsupported first. *Recommendation: implement.* +5. **R9 policy** — keep `panic="abort"` + Result-ify reachable panics (recommended) + vs switch to `panic="unwind"` for wasm and `catch_unwind` at the boundary + (size cost, simpler firewall). +6. **R8 depth** — how far this cycle: (1) `free()`/`dispose()` now, (2) interner + cap next, (3) value-first `Expression` as a tracked follow-up. *Recommendation: + (1)+(2) now, (3) later.* + +--- + +## 7. Suggested first PR (smallest thing that unblocks the most) + +**PR 1 — no design decisions required, all verified low-risk:** +**R5 + R6 + R4 + R10 + R13a.** R5/R6 are pure compat (and R5 is offered to us as a +PR); R4/R10 are a core `Matrix` arm plus two bindings; R13a is a contained serde +change whose risk I measured (corpus compares by `equals`, fixtures already expect +the exact spelling). Nothing here is blocked on a maintainer call. + +**Deliberately *not* in PR 1:** R2-bool (blocked on the representation decision) and +R13b (blocked on the provenance decision). Bundling a decision-blocked M-item with +five unblocked S-items would stall the whole PR — the mistake in this plan's first +draft. + +*(Both decisions landed the same day; R2-bool went in immediately after PR 1 and is +part of the same change set.)* + +**PR 2:** R3 + R7 once decisions land. **PR 3+:** R1, then R8/R9 as their own +tracks. + +### Verification per item (this repo has strong differential infrastructure — use it) + +| Item | How we know it worked | +| --- | --- | +| R13a | `cargo test --workspace`; confirm `parse_matches_js` stays green (the canary that caught the naive fix); add corpus rows for `1/3`, `5/6` | +| R4/R10 | New `tests/` cases for `Seq` **and** `Matrix` indexing incl. out-of-range → `None`; wasm e2e for the binding | +| R5/R6 | js-compat spec; re-run DoenetML's `math.test.ts` (their 79-case suite is the real oracle) | +| R2-bool ✅ | Round-trip property: `from_ast(t).tree === t` for `true`/`false` leaves — done, plus a test that a boolean is not the *symbol* `"true"`, and one that the flag tuples did **not** become boolean children | +| R3/R7 | Assert options actually change output — a test that would pass against a silent no-op is worthless | +| all | Track the js-compat differential baseline — it must not regress | + +--- + +## 8. What Phase 1 actually landed (2026-07-31) + +**R5 + R6 + R4 + R10 + R13a**, as scoped in §7. Measured, both sides rebuilt from +the same source: js-compat differential **1461 → 1441 failing** (+20 passing, +zero newly failing); `cargo test --workspace` fully green including +`parse_matches_js`; the wasm e2e suite 51/51; 14 pre-existing `tsc` errors +removed, none added. + +Two things the plan had wrong, found while implementing: + +### Component paths are indexed over the **flattened** tree + +`get_component` had to index the same tree the caller sees, and `expr.tree` is +`expr::serde::to_js`, which **flattens first** +([`serde.rs:224`](../packages/math-expressions-rs/src/expr/serde.rs#L224)). `Expr` +keeps associative operators as the parser nested them, so `x+y+z` is +`Add[Add[x, y], z]` — two components — while the JS tree is +`["+", "x", "y", "z"]` — three. Indexing the unflattened tree would have silently +disagreed with every call site. The agreement is now pinned by a test that walks +`components()` against `to_js`'s operand array. + +### The API takes a **path**, not an index — and it is not sequence-only + +The plan described `get_component(i)` over `Expr::Seq`. The ported JS spec +([`quick_transformation.spec.ts:241`](../packages/math-expressions-js-compat/spec/quick_transformation.spec.ts#L241)) +shows the real surface: `get_component([2, 1, 2])` walking nested tuples, and +indexing works on *any* operator node (`["+", "x", 1]` has components `x` and +`1`), not just sequences. So the core signature is `&[usize]`, and the component +list is read off the JS spelling generically. + +That also settles the matrix question the plan flagged as "confirm row/col order": +**there is no choice to make** — a matrix is +`["matrix", ["tuple", rows, cols], ["tuple", ]]`, so its component `0` +is the dimension pair and an entry is the path `[1, row, col]`. Following the JS +spelling is the only option that cannot disagree with the 63 call sites. + +Shapes whose JS spelling carries boolean flags — `interval` and the `lts`/`gts` +mixed relation chains — still have no component list. See the R2 note below: the +boolean leaf alone does **not** close that, and closing it was deliberately +deferred. + +### Where the code lives + +- [`ops/components.rs`](../packages/math-expressions-rs/src/ops/components.rs) — new module (the old + `Seq`-only pair is gone from `ops/query.rs`) +- [`expr/serde.rs`](../packages/math-expressions-rs/src/expr/serde.rs) — `number_to_js` split on + `terminating_decimal()`, plus an out-of-JS-integer-range guard +- [`core_ops.rs`](../packages/math-expressions-rs-wasm/src-rust/core_ops.rs) — the two bindings +- [`math-expressions.ts`](../packages/math-expressions-js-compat/lib/math-expressions.ts) — `f()`, + the component methods, and the `Context` mirror + +One correction to §2's R5 snippet, which is also what the issue proposed: it +coerced with `Context.from(expr)`, but the argument is normally an `Expression` +already and `from` would read that as an AST. The landed version uses `toExpr`. + +### R2-bool, landed the same day (2026-07-31) + +`Expr::Bool(bool)` ([`expr/tree.rs`](../packages/math-expressions-rs/src/expr/tree.rs)), +both serde directions, both printers, and arms in the ten exhaustive matches the +compiler enumerated. `["and", true, false]` is constructible; a boolean crosses +back as a JSON boolean, not the string `"true"`. + +**No local number moved, and that is expected.** The js-compat differential stayed +at 1441 failing / 4744 passing, `cargo test --workspace` and clippy stayed green, +the wasm e2e suite stayed 51/51, `tsc` stayed clean. Nothing in this repo's suites +feeds a boolean *operand* — the parsers cannot produce one, and every `true` in the +ported specs is an interval/`lts` flag tuple, which already worked. The oracle for +this item is DoenetML's own suite. Verified here end to end by the round-trip tests +in `expr/serde.rs` and by a throwaway compat spec exercising +`me.fromAst(["or", ["and", true, "x"], false]).tree`. + +**Two things chosen deliberately, worth knowing before extending this:** + +*The printers are display-only for booleans.* Text prints `true`/`false` and LaTeX +prints `\operatorname{true}`; neither re-parses to a boolean, because no parser can +produce `Expr::Bool` — there is no text or LaTeX spelling to add. This breaks the +printers' usual round-trip contract for exactly this leaf. The AST round-trip is +the one DoenetML depends on, and it is exact. + +*Interval closure and `lts`/`gts` strictness stay metadata.* They are **not** +`Expr::Bool` children, and a test now asserts they never become any. Two reasons: +the flags carry more than a bool (`("lts", false)` is `Le`, `("gts", false)` is +`Ge` — a bare boolean loses which head it was under), and the metadata is what +makes `operands.len() == ops.len() + 1` structural instead of a runtime check. + +The consequence is that the boolean leaf did **not** complete component access for +those shapes, as §8 above first assumed. Doing so needs `components()` to +*synthesize* a `Bool` from the metadata and `rebuild()` to *destructure* it back — +and that introduces the first case where `substitute_component` fails on a **value** +rather than a path (`substitute_component(interval, [1,0], parse("x"))` has nowhere +to put an `x`). That is a real design commitment, so it waits for a call site that +needs it. None exists today: every DoenetML failure in this area is a `fromAst` +rejection, not component access into a closure tuple. + +--- + +## 9. PR #84 measurement follow-up (2026-07-31) + +DoenetML built `@doenet/math` against #84 and ran its **worker** suite: 3469 tests, +969 failing (72.1%), all genuine engine-swap divergences (JS control: 0 failures). +Their ranked asks and what landed here (numbers are their `#`, not the R-items): + +| # | Ask | Failures | Landed | +| --- | --- | ---: | --- | +| **1** | `perform_vector_matrix_additions_scalar_multiplications` missing | 431 (44.5%) | ✅ core [`ops/vector_matrix.rs`], wasm binding, compat method | +| **2** | `{"$":"None"}` emitted then rejected by `from_ast` | 90 (9.3%) | ✅ `MathConst::None`, serde both directions | +| **3a** | `fromAst(NaN/±Inf)` → `null` (JSON) → rejected | part of 76 | ✅ `astReplacer` on the compat `fromAst` stringify | +| **3b** | undefined quantities eval to `0` not `NaN` | part of 76 | ⏳ needs DoenetML's offered bisect (`evaluate_to_constant`?) | +| **4** | `me.math` ≠ legacy injected instance (`dopri`) | 5 | ⏳ decision: is `me.math` a compat instance or plain mathjs? | +| **5** | render options silently dropped | — (blocks Stage 2) | ◑ **min-bar**: compat now *throws* on a requested unsupported option (`padToDecimals`/`padToDigits`/`showBlanks`/`explicitMultiplicationSymbols`); full printer impl still owed | +| **6** | passes silently no-op | — (3 features) | ◑ `evaluate_numbers({skip_ordering:true})` now throws (was silently reordering); the five passes **stay no-ops** — see below | +| 7/8/9 | browser loader / handle freeing / panic-abort | — (ship blockers) | ⏳ = R1/R8/R9, unchanged | + +### Why the five passes stayed no-ops (not throws) + +The issue said "implement or throw — either is fine." **Measured: throwing is not +fine here.** Converting `default_order`/`normalize_negative_numbers`/ +`normalize_applied_functions`/`expand_relations`/`applyAllTransformations` to +`notImplemented` **regressed ~170 currently-passing js-compat specs** (4753 → 4584) +and aborted whole spec files at collection (total 6200 → 5866) — those inputs are +idempotent, so the no-op *is* the right answer and the specs legitimately pass on +the unchanged tree. So they remain no-ops until **properly implemented**. +`default_order` specifically needs the **JS ordering key** ported (legacy +`3+x → ["+","x",3]`, symbol-first), not Rust's canonical `cmp` (number-first) — a +`cmp`-based version would silently disagree with `simplify="normalizeorder"`, i.e. +the same silent-wrong class. That port is the real R7 work. + +`evaluate_numbers({skip_ordering})` **is** now a throw: it is a *distinct* option +that was being silently ignored (reordering `1+x+2` to `x+3`), it has no core +order-preserving mode, and rejecting it regressed nothing (specs don't pass it in a +file-aborting way). Item 5's guard likewise regressed nothing (total held at 6200). + +### Verification + +- Rust: `cargo test` green (59 suites), clippy clean; new unit tests in + [`ops/vector_matrix.rs`], serde `the_none_special_round_trips_…`, and an integration + test in [`tests/doenet_utils.rs`]. +- js-compat differential: **4753 → 4756 passing, 1444 → 1441 failing, zero + regression, total held at 6200**; end-to-end coverage in + `spec/quick_doenet_compat_pr84.spec.ts` (12/12). The real oracle for items 1–3 is + DoenetML's own worker suite, not reproducible here (no DoenetML checkout). + +### Still owed / needs a call + +- **3b, 4** — need DoenetML's bisect (3b) and a `me.math` decision (4). +- **5 full** — implement the four printer options (core `LatexOpts`/`TextOpts` + fields + wasm readers + compat forwarding); required for Stage 2. +- **6 real** — port the JS ordering key for `default_order`; give the other four + real passes or confirm they can stay no-ops. +- **Rebuild note:** the compat package's **vendored** wasm (`vendor/wasm/`, via + `npm run build:wasm`) must be regenerated for the core changes (items 1/2) to take + effect — it is separate from `math-expressions-rs-wasm/pkg`. + +--- + +## 10. PR #84 follow-up, round 2 (2026-08-01) + +Maintainer answers to the round-1 deferred items landed the rest of the list. + +| # | Ask | Landed | +| --- | --- | --- | +| **3b** | undefined quantity evaluates to `0`/`1` not `NaN` | ✅ **root-caused & fixed.** `evaluate_to_constant` ran `simplify_core` *first*, which absorbed the hole (`0·_ → 0`, `_/_ → 1`) before eval saw it. Now guards on a blank/`None` leaf *before* simplifying, like it already did for free variables. [`ops/evaluate.rs`] | +| **4** | `dopri` (Doenet dropping mathjs) | ✅ implemented as a **peer** compat export (`me.dopri` + named `dopri`), not under `me.math` — numeric.js `dopri(x0,x1,y0,f,tol,maxit)` contract with `.at()`/`.x`/`.y`, backed by the existing Rust `solve_ode`. [`math-expressions.ts`] | +| **5** | render options (Stage-2 blocker) | ✅ **all four implemented** end to end: `padToDigits`/`padToDecimals`/`showBlanks`/`explicitMultiplicationSymbols`, in `TextOpts`/`LatexOpts` + the number/blank/mul printers ([`print/*`], padding is a faithful port of `pad-numbers.js`), read by the wasm `*_with_options` entry points, forwarded by compat `toString/toText/toLatex/tex`. | +| **7** | browser/worker loader seam | ✅ `_wasm.ts` is now a **swappable provider**: `setWasmModule(mod)` injects an initialized `--target web` module; node/Vitest keep the lazy vendored fallback. `setWasmModule` is exported from the entry. Bundling (base64 inline + `initSync`, marking `node:module` external) stays host-side per R1. | +| **8** | handle lifetime / interner | ◑ `free()`/`dispose()`/`[Symbol.dispose]` on compat `Expression` (idempotent, nulls the handle) — the immediate win. `interner_size()` exposed (core `interner_len` + wasm binding + `me.interner_size()`) so growth can be **measured**. True eviction still owed — see below. | +| **9** | panic-abort + stack safety | ◑ **item 21 done**: iterative [`expr::tear_down`] dismantles a deep tree with a heap worklist (verified freeing a 200 k-deep tower in a 128 KiB thread), called from the wasm `Expression` `Drop`. `from_decimal_str` no longer `panic`s on a non-digit token (falls back to the JS `parseFloat` value). Boundary audit 23–26 still open — see below. | + +### Design notes / still owed + +- **8 — true interner eviction needs a redesign, not a cap.** A `Sym` is a raw + `u32` index into the append-only `names` table, so evicting or compacting would + dangle every live `Sym`. A safe cap/clear is impossible without generational or + ref-counted symbols (or the flat-arena `Expr` from STACK_SAFETY_PLAN §4). Shipped + the **gauge** (`interner_size()`) so DoenetML can send the growth numbers they + offered; size the redesign against those. +- **9 — `impl Drop for Expr` is blocked by E0509.** `Expr` derives `Clone` and is + destructured by value crate-wide (`match e { Expr::Add(xs) => … }`); a `Drop` + impl makes every such move a borrow error. So teardown is a free function called + from the ownership sink (the wasm handle) instead. The remaining boundary panics + (STACK_SAFETY_PLAN 23–26: iterative `Clone`/`PartialEq`/`Hash`, the rest of the + `unwrap`/`expect` audit) are the deeper, still-open part of item 9. +- **6 — `default_order`** still needs the JS ordering key ported (round-1 note). + +### Verification (round 2) + +- Rust: `cargo test` green (59 suites) + clippy clean, both crates; new tests in + `ops/evaluate.rs` (3b), `print/mod.rs` (padding), `expr/teardown.rs` (deep-tree + free on a 128 KiB stack). +- js-compat differential: **4753 → 4765 passing, zero regression, total steady** + (~6206); `spec/quick_doenet_compat_pr84.spec.ts` covers all of items 1–9 (19/19), + including the dopri scalar+system solve and the render options. +- **Rebuild note still applies:** regenerate the vendored wasm + (`npm run build:wasm` in js-compat) after any core/wasm change. + +--- + +## 11. Review of the round-2 work (2026-08-04) + +A review of `c110a56..02293bf` found eleven defects, all now fixed. Three +returned **silently wrong numbers** — the failure shape DOENET_INTEGRATION.md +§1 argues is worst on a grading path, because nothing logs and the student sees +a confident answer. + +| # | Defect | Fix | +| --- | --- | --- | +| **1** | The vector/matrix shape pass never flattened, so it silently no-op'd on any sum of 3+ addends built by `parse_text` — `fromText("x+(1,2)+(3,4)")` and `fromAst` of the same tree disagreed, and grading's componentwise branch never engaged. Its unit-test helper pre-flattened, which is what hid it. | `flatten` once at the entry point; test helper no longer pre-flattens [`ops/vector_matrix.rs`] | +| **2** | The prototype-mirroring loop installed `Context.toJSON`, so `JSON.stringify` passed the **property key** as the expression: `{me}` emitted a `math-expression` envelope `Context.reviver` would revive the library context from, and `{"(": me}` *threw* out of a plain stringify. `toJSON` is not on `Object.prototype`, so the `in Context` guard missed it. | `NOT_EXPRESSION_FIRST` skip set [`math-expressions.ts`] | +| **3** | `0 · {"$":"None"}` → `0`, dropping DoenetML's "no value here" | `is_infinite_factor` poisons on `None` → `NaN`, per item 3b [`normalize/constructors.rs`] | +| **4** | `dopri` swallowed an exception thrown by the derivative (the Rust side correctly refuses to unwind under `panic="abort"`, but the wrapper never surfaced it) and returned the initial condition; a wrong-width derivative integrated silently; the solution leaked its wasm handle. | capture-and-rethrow, width check, `free`/`dispose`/`Symbol.dispose` [`math-expressions.ts`] | +| **5** | The addition pass reordered addends, and an empty container swallowed its scalar (`3·()` → `()`) | ordered slot list; empty containers absorb nothing [`ops/vector_matrix.rs`] | +| **6** | `fromAst({})` reported `unknown special None` — that `None` was the `Option` from the `$` lookup, while `{"$":"None"}` is a *legal* tree. The §2 message the DoenetML team lost a cycle to. | the two cases report separately [`expr/serde.rs`] | +| **7** | `max`/`min`/`median` returned **wrong numbers** under NaN: `partial_cmp(…).unwrap_or(Equal)` is not a total order, so `max(4,NaN,3,2,1)` → 3 while `min` of the same list → 4. Rust's sort may also *panic* on detecting it, which `panic="abort"` turns into a module crash. | NaN short-circuits to NaN (IEEE/mathjs); `sort_by(f64::total_cmp)` keeps the comparator total by construction [`special_functions/aggregate.rs`] | +| **8** | `0/∞` → `NaN`, should be `0` — the guard read the base and ignored the exponent, and `∞^(-1)` *is* `0`. Collateral from the §4 `0/0` fix itself. | exponent-aware; `NaN`/`None` still poison at any exponent [`normalize/constructors.rs`] | +| **9** | `nCr`/`nPr` on a large float: `n.re.round() as i64` saturated at `i64::MAX`, so `nCr(1e20,3)` came back ~1275× low. Pre-existing in `combinatorial`, newly reachable from `simplify` via `fold_approximately`. | the float path stays in f64; only `r` needs an integer type [`special_functions/misc.rs`] | +| **10** | Hang vectors from a few characters of student input: `nCr(10^500,500)` 4.0 s (running product is quadratic in the result size), `log₂(2^200000)` 2.2 s (one factor stripped per iteration). | `balanced_product` + result-size bound charged against `max_pow_bits`; `integer_log` binary-searches the exponent. **4055 ms → 21 ms** and **2190 ms → 0.5 ms**, same accepted inputs [`misc.rs`, `exp_log.rs`] | +| **11** | `fold_numeric_applications` was not canonical-out, contradicting its own doc — harmless via `simplify()` (which re-simplifies after), but the exported pass handed out `["+",55,3]` | re-canonicalize when anything folded [`normalize/fold_apply.rs`] | + +### Verification (round 3) + +- Rust: **616 passing, 0 failing** (was 602), clippy clean. New suite + `tests/doenet_review_fixes.rs` (7) plus inline regressions in + `ops/vector_matrix.rs` (3), `special_functions/aggregate.rs` (3), + `normalize/fold_apply.rs` (1). +- js-compat differential against a rebuilt pre-fix tree: **zero newly failing, + zero newly passing** across the 6230 pre-existing tests — these are edge cases + (NaN, ∞, huge/float arguments) the legacy corpus does not exercise, so the + fixes are behaviour-preserving on everything it *does*. + `spec/quick_doenet_review_fixes.spec.ts` adds 17, all passing. +- **Rebuild note still applies:** regenerate the vendored wasm + (`./build-wasm.sh` in js-compat) after any core change, or the JS suite tests + the old engine. + +--- + +## 12. DOENET_INTEGRATION §2–§5 (2026-08-04) + +DoenetML's integration report filed four engine-level items after the permanent +switch to the Rust engine. All four are fixed. §1 of that report is not a fifth +item: its two WASM traps are §2 and §5 reached through an `assert_eq!` in +DoenetML's own core, which `panic = "abort"` reduces to a bare `unreachable`. + +| § | Defect | Fix | +| --- | --- | --- | +| **2** | `evaluate_numbers` collected like terms (`x²+3x²` → `4x²`), so `simplify="numbers"` — specified as "fold numeric constants, leave the symbolic structure alone" — was indistinguishable from `simplify="full"`. A wrong answer, a lost public attribute, and the `simplify_math` trap, from one behaviour. | `add` runs with like-term collection off under `without_like_term_collection`. *Cancellation still collapses* (`3x−3x` → `0`); like **powers** in `mul` still combine, because the legacy oracle needs `i·i → −1`, which is that same merge [`normalize/constructors.rs`, `ops/numbers.rs`] | +| **3** | Inverse trig never folded: `asin(1)` stayed symbolic, so `simplifyOnCompare` could not grade it. Conspicuous next to the log and combinatoric identities, which do fold. | `inverse_trig_special_value` **inverts the forward table** rather than tabulating the values again — one table, so the directions cannot drift, and correctness reduces to the index range being the principal branch. Values are compared **in the `Exact` ring**, not as trees, so the fold recognizes a number rather than a spelling [`eval_exact/eval.rs`, `normalize/special_values.rs`] | +| **4** | `log_b(a)` was inert — it combined with nothing, and `log_b(a) − log(a)/log(b)` never reached zero. | change-of-base rewrite in `fold_special_values`, declining where the numeric pass would answer exactly (`log_2(8)` is `3`, not `log 8 / log 2`) [`normalize/special_values.rs`, `normalize/fold_apply.rs`] | +| **5** | `.tree` decimalized every terminating rational, so `3/6` was `0.5` and the structural criteria (`ReducedFraction`, `ExactValue`) could not see a fraction that was no longer there. This is **R13b**, deferred in §2 above pending a provenance decision — now taken. | a `Spelling` (`Fraction` \| `Decimal`) carried on `Number::Rat`/`BigNumber::Rat` [`num/number.rs`] | + +### R13b — the provenance decision, and why it is not a serde patch + +The issue's framing ("a ~5-line `number_to_js` change") does not work, for the +reason §2 measured: user-typed decimals parse to *exact rationals*, so `0.5` and +`1/2` are the same `Rat(1, 2)` and the distinction is gone before anything +reaches the boundary. The maintainer call was the **broad** rule: + +> A non-integer exact rational spells as a fraction unless it descends from a +> decimal literal. `Decimal` is contagious through arithmetic, exactly as +> `Float` is, which makes `Fraction` the join identity and hence the right +> default for integers, floats, and every value the engine computes. + +So `3/6` → `["/",1,2]`, `cos(pi/3)` → `["/",1,2]`, while `0.5` → `0.5`, +`0.1+0.2` → `0.3`, `19.9` → `19.9`, `1/2 + 0.25` → `0.75`. + +The spelling is **not part of a number's identity**: `Number`/`BigNumber` have +hand-written `PartialEq`/`Hash` that ignore it, so `0.5 == 1/2` structurally and +canonical trees stay comparable and hashable. `tests/doenet_integration_fixes.rs +:: spelling_does_not_affect_equality` pins that, because getting it wrong would +silently split canonical trees in two. + +Spelling originates in exactly two places — `from_decimal_str` (the literal) and +`round_to_decimals` (the operation whose purpose *is* the decimal spelling) — +and propagates through `binop`, `neg`, and `checked_pow_int`. Three passes work +in spelling-free `BigRational` and so restore one explicitly: `fold_apply` +(joined over the arguments, so `abs(-3.5)` is `3.5` and `mean(1,2,3,4)` is +`5/2`), and `ops::numbers::reduce_node` via `spelling_of`/`respell`. + +**The subtle half.** `present`'s `split_number` moved *every* rational +coefficient under a fraction bar, so `0.5·x` presented as `Div(x, 2)` — and +re-canonicalizing that left two plain integers with the decimal origin destroyed. +That is why `0.5^2` came back as `1/4` several passes downstream of anything +that looked responsible. `split_number` now applies the spelling gate, which +also lets `present_exponent` stop being a special case: `x^(3/2)` and `x^1.5` +now differ because the *values* differ. + +### Verification (round 4) + +- Rust: **631 passing, 0 failing** (was 617), clippy **0 diagnostics**. New + suite `tests/doenet_integration_fixes.rs` (14). +- js-compat differential against a rebuilt HEAD (`640d32c`): **zero newly + failing, zero newly passing** across the 6247 pre-existing tests. + `spec/quick_doenet_integration_fixes.spec.ts` adds 14, all passing. +- `tsc --noEmit`: 3356 errors, against 3357 at HEAD (pre-existing noise; no new + ones). +- Four legacy-corpus expectations that pinned the *old* behaviour were updated + rather than worked around, each with the reason in place: + `serde.rs` (terminating rationals now split by spelling — the old test became + two, one per spelling), `fold_apply.rs` (`median(1,2,3,4)` → `5/2`), + `preserve_order.rs` (`2/4+x` → `["/",1,2]`, which now *matches* legacy), and + `quick_doenet_compat_pr84.spec.ts` (`asin(1)` folds, so the exactness-gate + example moved to `asin(2)`/`atan(1/3)`). +- **Rebuild note still applies:** regenerate the vendored wasm + (`./build-wasm.sh` in js-compat) after any core change, or the JS suite tests + the old engine. + +### Not addressed, and why + +- **§6** is DoenetML's own list; nothing requested. +- **§7** (`panic = "abort"` / wasm32 stack safety) is `STACK_SAFETY_PLAN` items + 21 and 23–26, unchanged by this round. +- **§9** (scientific-notation threshold) is an open question to the maintainer, + not a defect — it needs an answer, not a patch. +- §2's operand *ordering* is untouched: DoenetML explicitly says the ordering + axis is fine, and matching legacy's `default_order` exactly is a separate job. + Several `slow_simplify.spec.ts` blocks still fail on ordering (and on + pre-existing `{"$":"Inf"}` / negative-zero divergences) *after* their + like-term content became correct. + +### §3 — what the inverse fold does *not* do + +It is a table lookup, not an identity engine. Three kinds of "not in the table", +only the first of which is closed: + +1. **Same value, differently written** — closed. The first cut compared + canonicalized *expressions*, which made the fold depend on whether the + radical rules had happened to rationalize the argument first: `asin(√2/2)` + folded, `asin(1/√2)` did not. Comparison now happens in `Exact`, whose normal + form is zero exactly when the value is, so every spelling of a lattice value + folds. Pinned by `inverse_trig_ignores_how_the_argument_is_written`. +2. **Off-lattice exact values** — `acos((1+√5)/4)` is `π/5` and does not fold. + Inherent to the π/12 lattice, and symmetric with the forward direction, which + does not fold `cos(π/5)` either. Extending means a larger table or a real + algebraic-number inversion, not a tweak. +3. **Symbolic identities** — none implemented: parity (`asin(−x) → −asin x`, + `acos(−x) → π − acos x`), complementary (`asin x + acos x → π/2`, + `atan x + atan(1/x) → π/2`), and composition (`sin(asin x) → x`, + `cos(asin x) → √(1−x²)`). Note the asymmetry — the *forward* fold does carry a + parity + π-shift layer (`normalize_trig_arg`); the inverse direction has no + counterpart. + +`equals` decides all of (2) and (3) by numerical sampling, so **grading is not +affected** — it is `simplify` / `.tree` that leaves them alone. Parity is the +cheapest of the three to add if a display-level normal form is ever wanted. + +*(2) and (3) were both asked for and are now closed — see §13.* + +## 13. Closing §3's limits (2)/(3) (2026-08-04) + +Everything in the list above except item 1 is now implemented. Three pieces, +each independently useful: + +**A. A second lattice (π/10) in `eval_exact::eval`.** The pentagonal angles are +constructible, so half of them are in the surd ring: `cos 36° = (1+√5)/4`, +`sin 18° = (√5−1)/4` and their reflections. The other half are not — +`sin 36° = √(10−2√5)/4` needs a radical nested one level deeper than the +`π^i·e^j·√r` basis holds — and they decline rather than approximate. + +The asymmetry propagates cleanly: `sin_at` tries twelfths then tenths, while +tangent stays on the twelfths alone, because a tangent needs the sine *and* the +cosine of the same angle and on this lattice exactly one of the two nests. The +inverse walk moved to units of π/60 (the first unit both lattices fit in) and +skips indices that are on neither, so it evaluates ~22 candidates where the +twelfths-only version evaluated 13 — not the 61 a naive π/60 sweep would. + +**B. General inversion in the `Exact` ring** (`value.rs`). Inversion used to +handle a rational or a single surd term; anything with a two-term denominator +declined. That made `sec(π/12) = 4/(√6+√2)` unfoldable even though its value, +`√6−√2`, is in the ring — and correspondingly `asec(√6−√2)` did not invert. + +Now: pick a prime `p` dividing some radicand, split `x = A + √p·B` over the +subfield generated by the remaining primes, and use +`1/x = (A − √p·B)/(A² − p·B²)`. The denominator involves strictly fewer primes, +so the recursion bottoms out at a rational. `A² − p·B² = 0` would put +`√p = A/B` in the smaller subfield, which it is not, so the denominator is +nonzero whenever `x` is. `inverse` now takes the shared op budget (term count +can double per prime), and radicands carrying π or e still decline — `1/(1+π)` +is not a polynomial in π, so it is genuinely outside this ring. + +This is what makes the reciprocal branches work on *both* lattices: +`sec(π/5) → √5−1`, `csc(π/10) → 1+√5`, `acot(2+√3) → π/12`. + +`eval_apply` also routes sec/csc/cot through `trig_exact` now, so +`is_zero("sec(π/5) − (√5−1)")` is certified rather than undecided; before, only +sin/cos/tan reached the tables from the `exact_eval` entry point. + +**C. The identity layer** (`normalize/special_values.rs`). Four rules, all +unconditional — no domain hypothesis, so no assumptions machinery: + +| rule | form | why it is sound | +| --- | --- | --- | +| parity | `asin(−u) → −asin u`, `acos(−u) → π − acos u` | asin/atan/acsc/acot are odd, acos/asec reflect through π/2 | +| composition | `f(f⁻¹(u)) → u` | each inverse *is* a right inverse of its branch | +| mixed composition | `cos(asin u) → √(1−u²)`, `sec(atan u) → √(1+u²)`, … | the branch ranges make the principal root the right one | +| complementary | `asin u + acos u → π/2`, `acsc u + asec u → π/2` | `acos = π/2 − asin` by definition of the branch | + +All 36 outer/inner pairs go through one path: `g⁻¹(u)` is turned into the pair +`(sin θ, cos θ)` for `θ = g⁻¹(u)`, and the outer function is read off that pair. +The direct pairs are not special-cased — `sec(asec u)` is `1/cos(acos(1/u))` is +`1/(1/u)` is `u`, and the smart constructors finish the job. + +Three things deliberately left out, each for a stated reason: + +- **`asin(sin x) → x`** and its family. True only on the principal branch + (`asin(sin 3) = π − 3`), so it needs a range assumption on `x`, not a rewrite. +- **`atan u + acot u → π/2`.** This library defines `acot z` as `atan(1/z)` + (`special_functions::trig_inverse::ACOT`), which makes the sum `π/2` for + positive `u` and `−π/2` for negative `u`. Worth knowing: `equals` answers + `true` for the symbolic `atan(x)+acot(x) == pi/2` while answering `false` at + `x = −1` — a sampling gap. Folding would have written that gap into the + simplified tree, where it is far harder to undo. Pinned as a negative test. +- **Parity through a sum.** `acos(−x−1)` is left alone. The trigger is + `strip_negation`, which is stricter than the forward direction's + `neg_leading`: its result is guaranteed not to be negated in turn, so a + rewrite keyed on it cannot ping-pong inside the fixpoint. Negating `−x−1` + just parks a `−1` in front of the sum, which would. + +### Verification (round 5) + +- Rust: **616 passing, 0 failing**, clippy **0 diagnostics**. 10 new tests — + 9 in `tests/special_values.rs` (the pass's own suite), 1 in + `tests/exact_is_zero.rs` for the general reciprocal. +- js-compat differential against the same rebuilt pre-§2 baseline: **zero newly + failing, zero newly passing** across the 6247 pre-existing tests. + `spec/quick_trig_identities.spec.ts` adds 11, all passing. +- `tsc --noEmit`: no errors in the new spec. +- One existing expectation moved: `inverse_trig_declines_everything_off_the_lattice` + listed `asin(-3)` among the "stays an `Apply`" cases. Parity now pulls the + sign out, so it asserts the exact shape `−asin(3)` instead — the point of the + test (no angle is invented off the lattice) is unchanged. + +--- + +## 14. Three printer / display-rounding open items (2026-08-05) + +DoenetML filed three more against the printers and display-rounding (items 8, +9, 10 in the thread). Items 8 and 10 are fully resolved; item 9 is split — its +unambiguous half landed, the rest waits on the corpus DoenetML offered. + +| # | Ask | Landed | +| --- | --- | --- | +| **8** | Display rounding turned an exact rational into a decimal even when rounding changed nothing (`round_numbers_to_precision(5/2, 3)` was `2.5`, legacy kept `\frac{5}{2}`) | ✅ `round_to_decimals` now returns the value **unchanged** — keeping its spelling — when the rounded value equals the input; it still decimalizes when rounding genuinely changes the value (`1/3 → 0.333`). [`num/number.rs`] | +| **10a** | A negative leading coefficient printed `a + (-3) b` instead of `a - 3 b`; a negative fraction `(-2)/3` instead of `-2/3` | ✅ a display-only `normalize_display_negative_fractions` pass (port of the legacy one) pulls the minus out of a `/` numerator, `split_sign` now splits a `Mul` with a negative leading factor, and `render_mul` renders a leading negative inline (no parens) at `NEG` precedence. [`print/mod.rs`, `print/text.rs`, `print/latex.rs`] | +| **10b** | The integral head lost its `∫` glyph and wrapped the integrand in parens (`int_a^b(f(x) dx)`) | ✅ `int → ∫` under unicode, and an integral-head case in `render_apply` renders `∫_a^b ` with no parentheses. [`print/mod.rs`, both printers] | +| **9 (i)** | `i^2` stayed `["^","i",2]` instead of folding to `-1` | ✅ `i^n → {1, i, −1, −i}` (`n mod 4`) in the unconditionally-sound `fold_special_values` pass. `equals` already certified `i²=−1` numerically, so this is the display/`simplify` half only. [`normalize/special_values/`] | +| **9 (roots)** | `cbrt(x^3)`, `nthroot(x^3,3)`, `sqrt(16x²y⁴)` keep their radicals; `sqrt(-4)` stayed symbolic | ✅ **resolved** after the maintainer settled the convention (see `ROOT_SIMPLIFICATION_SPEC.md`): a *number* under a root folds, preferring the real root else the principal complex root; a *variable* radicand never folds. The concrete gap was even roots of negative numbers — `sqrt(-4) → 2i`, `sqrt(-2) → i·sqrt(2)` — now folded (q = 2 is always exact). Odd roots already preferred the real value (`cbrt(-8) → -2`) and variable radicands already stayed put. Higher even roots (`(-16)^(1/4)`) need the surd-lattice form and stay symbolic for now. [`normalize/simplify.rs`] | + +### Why 10a is a display pass, not a tree change, and the round-trip cost + +`a - 3 b` re-parses to `Neg(Mul(3,b))`, an **equal but distinct** tree from the +`Mul(-3,b)` that produced it — which is exactly why the old `split_sign` +refused to touch a `Mul` ("would not round-trip", its comment). Two facts make +the change safe anyway: the parsers **never emit** a `Mul` with a negative +leading factor (they use `Neg` and bare negative numbers — verified across all +four parser tree-fixtures, zero hits), so `tests/roundtrip.rs`'s corpus never +exercised the form; and the whole transformation runs at the printer entry, +never on a stored tree. The absorbed style difference DoenetML already accepted +(`(x²)/2 → x²/2`, no numerator parens) means a few fraction cases still diverge +from JS on paren *placement* only (`-2 x/3` vs JS `-(2 x)/3`) — re-blessed in +`ast-output-known-divergences.json`, with the sign now correctly out front. + +### Item 8 supersedes §12's "round_to_decimals imposes the decimal spelling" + +§12 established `round_to_decimals` as one of the two origins of a `Decimal` +spelling. Item 8 refines that: it imposes the decimal spelling **only when it +rounds**. A no-op round (`5/2` to 3 s.f., `3/6` to 3 d.p.) now preserves the +existing spelling, so a fraction a student sees stays a fraction. The +`doenet_integration_fixes::rounding_produces_decimals` expectation and its JS +mirror were updated to the refined rule (the `1/3 → 0.33` half is unchanged). + +### Verification (round 6) + +- Rust: **green, 0 failing** (658 tests), clippy **0 diagnostics**. New + `tests/doenet_open_items.rs` (12, incl. the item-9 numeric roots added after + the maintainer settled the convention). `output_established` re-blessed: the + negative-coefficient/fraction cases now **match JS** (stale divergences + removed); the remaining fraction cases changed to the sign-out-front, + no-numerator-paren form. +- js-compat differential (vendored wasm rebuilt both sides): **+40 passing, + zero regressions** — verified at the file *and* the individual-assertion + level (`4902 → 4942` passing, 24+ tests fixed, 0 passed→fail). The gains land + exactly in the printer specs: `quick_ast-to-latex` +15, `quick_ast-to-text` + +9, `quick_latex-to-ast-to-latex` +5, `quick_text-to-ast-to-text` +5. + `spec/quick_doenet_printer_and_rounding.spec.ts` adds 6, all passing; + `quick_doenet_integration_fixes.spec.ts`'s rounding case updated to the item-8 + rule. +- **Rebuild note still applies:** regenerate the vendored wasm (`./build-wasm.sh` + in js-compat) after any core change, or the JS suite tests the old engine. + +### For DoenetML — the item 8 subtlety worth knowing + +`me.fromAst(["/",5,2])` is a **`Div` of two integers**, and display rounding +maps over each integer separately, so it was never the case that reached the +bug — it stayed `5/2` all along. The bug shows on a **bare rational** (`5/2` +after `.simplify()`, or any computed value like `cos(pi/3) → 1/2`), which is +what the display path actually rounds. Both now stay fractions. diff --git a/active-plans/DOENET_INTEGRATION.md b/active-plans/DOENET_INTEGRATION.md new file mode 100644 index 00000000..a99197e4 --- /dev/null +++ b/active-plans/DOENET_INTEGRATION.md @@ -0,0 +1,203 @@ +# math-expressions: what DoenetML still needs + +**For:** maintainers of [`Doenet/math-expressions`](https://github.com/Doenet/math-expressions) +**Against:** `siefkenj/math-expressions@doenet`, `970c1c3` +**Date:** 2026-08-04 + +Each item below is self-contained enough to file as an issue, and carries the response it got +inline. Nothing that has already been fixed is repeated here — see the git history of this file if +you want the record of what was. + +DoenetML has switched permanently to the Rust engine. There is no JavaScript engine to fall back to, +so everything below is on the path to shipping. + +## Filed — three items + +As filed. The [Response](#response) below resolves them: **1** is fixed, **2** is answered as not a +defect, and **3** is the only one still open. + +**1. Display rounding loses precision at large magnitudes** +— 8 failures, and it is the *normal* display path rather than an edge case. + +```js +me.round_numbers_to_precision_plus_decimals(2e21, 3, 2).tree; // → 1.9999999999999997e+21 +me.round_numbers_to_precision(2e21, 3).tree; // → 2e+21 ✓ digits alone is exact +``` + +`` defaults to `displayDigits = 3`, `displayDecimals = 2`, so every large number a student +sees goes through the broken combination. Rounding `2e21` to 3 significant figures is `2.00e21`, +which is exactly representable. The value is parsed exactly and survives untouched until this step — +only the rounding corrupts it, and asking for *more* digits eventually returns the exact answer, +which points at a decimal-string round trip. + +**2. `parseScientificNotation` has no effect** +— low severity, but a documented option that silently does nothing. + +```js +new me.converters.textToAstObj({ parseScientificNotation: true }).convert("7e-12"); +// → ["+",["*",7,"e"],-12] expected 7e-12 +``` + +Either honour it or drop it from the option list in `lib/converters/text-to-ast.ts`. + +**3. WASM32 stack safety** — a crash class reachable +from student input, and already your own `STACK_SAFETY_PLAN.md`. Deep expressions can overflow the +~1 MB shadow stack, including on `Drop`, and the input arrives from a text box. Steps 1 and 2 of your +plan — iterative `Drop`, parser depth cap — close the vector end-to-end. + +Items 1 and 2 are new in this revision and were found while auditing what we had assumed were our own +failures; see the note below. The two items we had open before are fixed in `970c1c3`: +`evaluate_to_constant()` now reports ±Infinity rather than a "no value" marker, and the printer implements the +ECMAScript scientific-notation threshold with `avoidScientificNotation` honored. + +We also filed one of these wrongly and want that on the record: we claimed `panic = "abort"` was why +wasm panics reached us as a bare `unreachable`. It was not — std runs the panic hook before aborting; +what was missing was a hook at all, since the default writes to a stderr that goes nowhere on +wasm32-unknown-unknown. You installed one for 1,958 bytes and the diagnosability problem is gone. + +## Where we are + +`packages/doenetml-worker-javascript`: **344 failures of 3,436 executed — 90.0% passing.** + +Four pins in a row of progress with no regressions: `cdc5343` → `02293bf` fixed 36 tests, +`02293bf` → `08bd4dc` fixed 10, `08bd4dc` → `970c1c3` fixed 59. None broke anything. `02293bf` also +let us delete the last two workarounds in our seam — with **no change in results either way**, which +is how we verify an upstream fix actually covers our usage. `packages/math/src/engine-rust.ts` is now +a straight re-export. + +Most of what was left was ours: 61 coordinate/array mismatches from a bug in our own dependency +resolution, 16 unattributed `matchesPattern` cases, 15 blank-comparison scoring failures in our +`booleanLogic.js`, 12 tagged-value leaks into `.tree` consumers, and 5 residual `unexpected value +null` call sites. 8 were item 1 above. + +**These counts are stale.** They were measured at pin `970c1c3`; the branch is several revisions past +it and most of the clusters above have since been closed from one side or the other. Treat them as +the shape of the work, not as current numbers, and re-measure before citing any of them. + +### How items 1 and 2 were found + +Worth recording because the pattern has now repeated: we had classified the scientific-notation +cluster as ours — the engine implements the threshold correctly, so our expectations looked like the +thing that was out of date. Instrumenting the component instead showed the parsed `value` was exactly +`2e21` and only `valueForDisplay` was wrong, which took three probes to narrow from "our expectations" +to a one-line call. Item 2 turned up in the same pass, after we spent a while assuming our own call +site was at fault before testing the flag in isolation. + +The general lesson, for both of us: a cluster that looks like stale test expectations is worth one +boundary probe before it is written off, and the probe should print with `String()` rather than +`JSON.stringify` — `JSON.stringify(Infinity)` is `"null"`, which already cost us one wrong report and +nearly cost us a missed fix. + +## Reproducing + +```bash +git submodule update --init --recursive # vendor/math-expressions @ 970c1c3 +npm run build -w packages/math +cd packages/doenetml-worker-javascript +npx vitest run -t '@group1' # and @group2, @group3 +npx vitest run -t '^(?!.*@(?:group1|group2|group3))' # group4 +``` + +Every engine-level claim is reproducible in isolation, without DoenetML: + +```js +import me from "math-expressions"; +console.log(String(me.fromAst(-Infinity).evaluate_to_constant())); +``` + +That form is what moved nine items off this list and onto ours — printed with `String()`, not +`JSON.stringify`, which renders `Infinity` as `null` and cost us a wrong report. + +## Response + +**Item 1 — display rounding: fixed.** Your read was right down to the mechanism. The float branch of +`Number::round_to_decimals` computed `(v · 10^d).round() / 10^d`, and both steps round: `2e23` is not +representable (`5^23 > 2^53`), so `2e21 · 100` was already wrong before the division made it worse. +Exact values never took that branch, which is why a `` with a typed literal was fine and a +computed one was not, and why asking for more digits recovered — a larger `digits` drives `d` +negative, and `10^-19` happened to survive the round trip. + +Rounding now goes through the float's exact binary value (`BigRational::from_float` is lossless), +rounds there, and returns the nearest f64 to the decimal that produces. That is what legacy was doing +with `parseFloat(math.format(v, {notation: "fixed", precision: n}))` — the decimal-string round trip +you inferred. Ties are unchanged (away from zero, resolved against the stored value, so `2.675` → +`2.67`). + +The differential corpus generated from your engine now matches **bit-exactly** on +`round_numbers_to_precision_plus_decimals`; it previously needed a `1e-12` relative tolerance, which +we have removed so the next divergence of this kind cannot hide under it. + +**Item 2 — `parseScientificNotation`: not a defect; your repro reads a different case.** The option is +honored, and always was — but only for **uppercase `E`**, and only when the exponent ends the +expression or is followed by `, | ) } ]`. Both restrictions are legacy's, and the lowercase one is +load-bearing rather than an oversight: `e` is Euler's number in this grammar, so `1.2e-3` is +`1.2·e − 3`. Your own spec asserts exactly that (`spec/quick_text-to-ast.spec.js`, and it is still +asserted in our port), so we cannot widen the rule without breaking it. + +```js +new me.converters.textToAstObj({ parseScientificNotation: true }).convert("7E-12"); // → 7e-12 ✓ +new me.converters.textToAstObj({ parseScientificNotation: false }).convert("7E-12"); // → ["+",["*",7,"E"],-12] +new me.converters.textToAstObj({ parseScientificNotation: true }).convert("7e-12"); // → ["+",["*",7,"e"],-12] (Euler) +``` + +So: neither honour-differently nor drop. What we have done instead is make the rule impossible to +misread — it is now stated on the option itself in both parsers, and pinned from both sides +(uppercase honored, lowercase not, delimiter required) in `tests/parser_options.rs` and +`spec/quick_doenet_display_rounding.spec.ts`. + +That leaves the real question yours: students type `7e-12`. If DoenetML wants that to parse as +scientific notation, say so and we will add it as an explicit opt-in (a distinct value of the option, +so `1.2e-3` keeps its legacy meaning by default). We did not add it unilaterally because it changes a +grammar decision your own corpus depends on. + +**Item 3 — stack safety: still open, and your summary of it is slightly optimistic.** Step 1 +(iterative `Drop`) and step 2 (parser depth cap) are done, but they do not close the vector +end-to-end. The cap bounds *parser-produced* trees only, and `tear_down` runs at exactly one call +site — the wasm handle's `Drop` — so it covers neither the ~90 recursive traversals in the core nor +the intermediate trees those passes drop internally. Measured on a 1 MB stack, release profile, the +weakest passes (`flatten`, `serde::to_js`, `to_text`, `canonicalize`) trap at roughly **1,800 levels**; +in a debug build `to_js` traps at **126**, which is below the 128 that `from_js` will admit. + +Two vectors are reachable from your side today and are not behind the parser cap: + +- `unflatten_left` / `unflatten_right` turn width into depth with no bound. `["+", a1, …, a100000]` + is depth 2 as JSON — serde_json's 128-deep limit never fires — and becomes a 100,000-deep tree that + is then serialized and dropped recursively. +- `substitute_var` composes: `e = e.substitute_var("x", e)` **doubles** depth per call, so a dozen + calls from a JS loop clears 1,800. + +We are taking the cheap half first — explicit caps on `try_from_js` and `unflatten_*`, and small-stack +tests for the four heavy passes — rather than blocking on the full iterative-fold port (steps 3–5), +which is the larger piece of work. Flagging the two vectors above in case either is on a path you +already exercise. + +**Verification.** Rust: 661 tests, 0 failing; clippy clean. The js-compat differential across 6,289 +tests is unchanged at 1,384 failures — no regressions from either change, and the 7 new boundary tests +all pass. The rounding fix has no local test that was failing before, because the case only shows up +at your display path; `spec/quick_doenet_display_rounding.spec.ts` now covers it at the library +boundary in the form you filed it. + +**One note on your remaining clusters.** "12 tagged-value leaks into `.tree` consumers" may be ours, +not yours: `me.round_numbers_to_decimals(-Infinity, 2).tree` is `{$: "-Inf"}`, where legacy gave +`-Infinity`. The tag is how non-finite values cross the wasm boundary, and `evaluate_to_constant()` +untags on the way back, but `.tree` does not. If those 12 are that shape, send one and we will take +it. + +**Resolved — `.tree` now untags.** It was ours. `.tree` hands back `Infinity`, `-Infinity` and `NaN` +as JS scalars, matching legacy and what `typeof x === "number"` consumers test. The *wire* format +stays tagged in both directions, because JSON cannot hold those three values; the replacer re-tags on +the way in, so `fromAst(x).tree` is still a fixpoint — it just holds at the value level rather than +the wire level. `{$: "None"}` is the one exception in both directions: it has no JS scalar to become, +and you already emit and read it in that form. + +Two consequences worth flagging, since they change what a caller sees: + +- `me.utils.flatten` / `unflattenLeft` / `unflattenRight` / `match` take these untagged trees now, so + a `.tree` value can be fed straight back into them. Previously they went through a bare + `JSON.stringify`, which writes `null` for a non-finite — a silently wrong tree rather than an error. +- `evaluate_to_constant()` answers `NaN` — never `null` — whenever there is no numeric value, which + is legacy's contract restored. It briefly distinguished the two, `NaN` for an indeterminate form + and `null` for something genuinely undecided such as a free variable. That distinction is real but + `null` is the wrong way to carry it: it coerces to `0`, satisfies `<=`, and slips past + `Number.isNaN`, so an expression with no value read as a real one everywhere the consumer had not + been individually taught otherwise. A caller that wants the distinction can ask `variables()`. diff --git a/active-plans/DONE_ODE_PLAN.md b/active-plans/DONE_ODE_PLAN.md index e1413673..608deb5a 100644 --- a/active-plans/DONE_ODE_PLAN.md +++ b/active-plans/DONE_ODE_PLAN.md @@ -64,9 +64,15 @@ extracted from the component: New module `src/mathjs_compat/ode.rs` (f64-only, like `src/mathjs_compat/`), wasm-exposed. - **Method**: Dormand–Prince RK45 (the same tableau as `numeric.dopri` and - scipy's `RK45`) with the standard PI step-size controller and the free - 4th-order dense-output interpolant (the DP "b*" polynomial — gives `at(t)` - without extra function evaluations). + scipy's `RK45`) with the free 4th-order dense-output interpolant (the DP + "b*" polynomial — gives `at(t)` without extra function evaluations). + - **Step control amended (2026-08-07).** This said "the standard PI step-size + controller", and it was built that way. That is a different *accuracy + request* than numeric's — relative rather than absolute local error — and + on a growing solution it takes a third of the steps and misses the caller's + tolerance. `tolerance` and `maxIterations` are authored `` + attributes whose meaning is numeric's, so the controller is now numeric's + too, step for step. See `upstream_requests/18` in the DoenetML repo. - **Vector field evaluation**: two constructors — 1. `solve_ode(f: js_callback, …)` taking a JS closure across the wasm boundary (drop-in for Doenet's current usage; one boundary call per diff --git a/active-plans/JS_MATH_TO_RUST_PLAN.md b/active-plans/JS_MATH_TO_RUST_PLAN.md new file mode 100644 index 00000000..8c8e06b6 --- /dev/null +++ b/active-plans/JS_MATH_TO_RUST_PLAN.md @@ -0,0 +1,211 @@ +# Moving the mathematics out of JS + +**Status: done.** Phases A (`a5d1867`), B (`133ce6f`), C (`087c3ab`). +`lib/` went from **5,918 to 2,578 lines** (−3,340, −56%); the Rust core gained +~3,370. The js-compat suite moved 379 → **377 failing with zero newly-failing +names** at every phase boundary, and `cargo test` stayed green throughout. +`slow_polynomial` is 205/205 with no spec expectation touched, and runs in 0.16 s +instead of dominating the suite. + +Outcome per phase, and what was learned, is recorded at the end of this file. + +Goal: `packages/math-expressions-js-compat` becomes a binding shim. Every +algorithm lives in the Rust core. The only substantial JS allowed is glue that +exists because we *deliberately* chose a Rust API shape that needs reassembling +on the JS side — and each such case is named here with its reason. + +## What counts as math + +Not everything in `lib/` is math. Deep structural equality on arrays, unwrapping +an Expression to its AST, and JSON marshalling are plumbing and stay. The test +is whether the code decides a *mathematical* result: an ordering, a normal form, +a derived fact, a polynomial. + +## Inventory + +| module | lines | verdict | destination | +|--------|------:|---------|-------------| +| `lib/polynomial/polynomial.ts` | 1784 | math | Rust `polynomials/` — Phase C | +| `lib/polynomial/single-var-poly.ts` | 313 | math | subsumed by Rust `univariate.rs` — Phase C | +| `lib/assumptions/derive/*` | 471 | math | Rust `assumptions/derive/` — Phase B | +| `lib/assumptions/expand_relations.ts` | 173 | math | Rust `assumptions/expand.rs` — Phase B | +| `lib/assumptions/store.ts` | 183 | mixed | logic → Rust; handle bookkeeping stays — Phase B | +| `lib/assumptions/mutate.ts` | 156 | math | Rust `assumptions/store.rs` — Phase B | +| `lib/trees/default_order.ts` | 193 | math (150 of it) | already in Rust, just private — Phase A | +| `lib/expression/variables.ts` | 137 | math | already in Rust `ops/query.rs` — Phase A | +| `lib/assumptions/clean.ts` | 127 | math | Rust `assumptions/clean.rs` — Phase B | +| `lib/assumptions/linear.ts` | 113 | math | already in Rust `grade::solve_linear` — Phase A | +| `lib/assumptions/logical.ts` | 89 | math | already in Rust `simplify::push_not` — Phase A | +| `lib/expression/evaluation.ts` | 20 | shim | stays (already delegates) | +| `lib/trees/basic.ts` | 24 | plumbing | stays (deep-equal + substitute wrapper) | +| `lib/trees/util.ts` | 35 | plumbing | `subsets` dies with the polynomial code | + +Roughly **3,400 lines of JS**, of which ~3,200 is math that should not be there. + +## The pleasant surprise + +Much of this is already written in Rust and merely unreachable: + +- `ops::query::{variables, operators, functions}` — complete; only `operators` + lacks a wasm binding. +- `normalize/default_order.rs` already carries a **faithful port of the legacy JS + `sort_key`/`arrayCompare`**, quirks included. It is a private module fn with no + binding. This is exactly `compare_function`. +- `grade::solve_linear` — stronger than the JS version (which recovers + coefficients by evaluating `f(0)` and `f(e_v) − f(0)`); not exposed. +- `normalize::simplify::push_not` — De Morgan + relation negation; reachable only + through `simplify_logical`, which *also* canonicalizes each relation and so + reorients `x > a` into `a < x`. That reorientation is the whole reason + `logical.ts` reimplemented it. +- `polynomials/ratform.rs` has **kernel interning** (`Kernels`/`kernelize`, + private): maximal non-rational subtrees — `sin(x)`, `x^(1/2)`, `π` — replaced by + fresh `$k0`, `$k1` symbols, deduped by structural equality. This is precisely the + "arbitrary tree as an opaque polynomial variable" facility the JS engine builds + with `stringify_vars`, already written. + +So Phase A is mostly *exposure*, not implementation. + +## Phase A — expose what exists + +New bindings (pattern: free fn, JSON in / JSON out, `expr::serde::try_from_js` +and `to_js`, per `interop.rs:229`): + +| binding | backs | deletes | +|---------|-------|---------| +| `operators(tree_json)` | `ops::query::operators` | `variables.ts` (137) | +| `cmp_default_order(a_json, b_json) -> i32` | make `default_order::{sort_key, cmp_key}` `pub`; new `pub fn cmp_default_order` | `default_order.ts` sort_key/compare_function (150) | +| `solve_linear(tree_json, var)` | `grade::solve_linear` | `linear.ts` (113) | +| `linear_decomposition(tree_json, vars_json)` | new thin wrapper over the same coefficient extraction | — | +| `push_not(tree_json)`, `flatten_logical(tree_json)` | `normalize::simplify::push_not`, `default_order::flatten` (make `pub`) | `logical.ts` (89) | + +`cmp_default_order` must expose the **legacy** key (`default_order.rs:565`), not +`normalize::order::cmp` — the latter is documented as deliberately +non-JS-compatible, and the polynomial variable order depends on the legacy one. + +Risk: `push_not` without canonicalization is a *new* entry point. Verify it does +not reorient relations; if it does, the not-pushdown needs its own path. + +## Phase B — port the missing assumptions logic + +Nothing in Rust does relation expansion or transitive closure today. + +- `src/assumptions/expand.rs` — chained inequalities, interval membership, and + interval containment into `and`/`or` of two-sided comparisons. The endpoint rule + (closed small end inside an open big end is the sole strict case) and the De + Morgan flip on negation carry over verbatim. `ops::to_intervals` already does + the tuple/array → `Expr::Interval` half. +- `src/assumptions/derive/` — the `COMBINED_OPERATOR` composition table + (`<`∘`≤` ⇒ `<`; `in`∘`subset` ⇒ `in`; the pairs that compose to nothing) and the + closure that chains stored facts. +- `src/assumptions/clean.rs` — canonical form for a stored fact. +- Widen the wasm `Assumptions` class: `add` currently takes **text syntax**; + `get`, `add_generic`, `remove_generic` are unexposed. Add AST-JSON variants. + +`store.ts` keeps only the handle/JS-object bookkeeping — that is genuine glue +(the JS API hands back trees; the Rust store answers predicates) and is named +here as an accepted exception. + +## Phase C — polynomials + +Decision: back the compat API with the **existing** Rust engine rather than +re-porting the legacy one. `multivariate.rs` already has recursive-dense ℚ with +content/PRS GCD; the JS engine computes the same GCDs the heavyweight way, via +Buchberger plus ideal intersection (`lcm(f,g)` from a Gröbner basis of +`⟨t·f, (1−t)·g⟩`, then `gcd = f·g/lcm`). + +1. `src/polynomials/kernels.rs` — lift `Kernels`/`kernelize` out of `ratform.rs`, + make it `pub(crate)`, switch the linear `Vec` dedupe to `HashMap` + (`Expr` is `Eq + Hash`). +2. `src/polynomials/groebner.rs` — **genuinely new**: monomial order, S-polynomials, + Buchberger with a pair queue, reduced basis. This is the one piece with no Rust + counterpart. +3. Compat bindings: `poly_gcd`, `poly_lcm`, `poly_reduce_rational`, + `poly_groebner`, `expression_to_polynomial`, `polynomial_to_expression`. +4. `lib/polynomial/polynomial.ts` → ~40 lines of wrappers; delete + `single-var-poly.ts` (Rust's `univariate.rs` covers the fast path). + +Spec consequence: ~115 assertions test mathematical results and survive +unchanged. ~90 test internal intermediates — `mono_less_than`, `hij` tables, +`poly_div` quotient lists, the `["polynomial", var, [[deg, coeff]]]` AST shape — +and get rewritten to test through the real API. Per the standing instruction, +legacy behaviour is not preserved where the result stays mathematically correct. + +Note: coefficients are **not** rationals. `expression_to_polynomial` treats `pi`, +`e`, `i` as numbers, so `9x^(2/3) - pi*x` yields the coefficient `["-", "pi"]`. +The Rust side must carry `Expr` coefficients, not `BigRational`. + +## Sequencing & verification + +Phases are **sequential**: each rebuilds `vendor/wasm`, which would move under a +concurrently-measuring sibling. + +- Baseline: **380 failing**, captured by name in `scratchpad/baseline-names.txt`. + Judge by name-level diff, never aggregate counts (memory + `js-compat-suite-baseline-diff`). +- `cargo test` green after every Rust phase. `tests/assumptions.rs` and + `assumptions_corpus.rs` validate against the JS oracle; a "smarter" rule that + contradicts them is a regression. +- Success is measured in **JS lines deleted** with the failure set no worse. +- Split any Rust file past ~200 lines into a subfolder (memory + `rust-file-organization`). + +--- + +## Outcome + +### Where the plan was wrong + +- **`ops::query::operators` was not a faithful port**, though it looked complete. + It reported only a fixed operator whitelist rather than every array head. Since + `expression_to_polynomial` whitelists operators, an unreported `tuple` made + `(3,4)` parse as a polynomial. Fixed and pinned with assertions. +- **`ops::to_intervals` could not be reused** for `expand_relations`: it recurses, + while the JS converts only the top level — a nested 2-tuple endpoint is an + endpoint, not an interval. +- **`ratform::cancel` could not back `reduce_rational_expression`.** On + `(t^100 − t)/t` it returns `t^(-1)(t^100 − t)` rather than cancelling; it hands + back a product with a reciprocal instead of a numerator/denominator *pair*, so + the "denominator leading coefficient is 1" rule has nothing to apply to; and + being dense over ℚ it cannot represent the `t^1000000` rows at all. +- **Kernel interning was not worth lifting.** It only pays off for an engine whose + variables are `String` names. The compat engine's variables are `Expr` trees + compared structurally, so `sin(x)` is already one variable — which is also why + `stringify_vars`/`destringify_vars` vanished rather than moved. +- **The `push_not` reorientation risk did not materialise.** `simplify.rs` rebuilds + `Expr::Relation` from the same operand vector with only `ops[0].negate()` + changed, so a standalone binding was safe. Pinned by a test. + +### Semantics that had to be preserved rather than tidied + +- `grade::linear_decomposition` must **reject exact fractions**, matching the JS + `typeof tree === "number"`. The rejection is load-bearing: when decomposition + fails, `get_assumptions_for_expr` falls back to per-variable facts, and that + fallback is where transitive consequences come from. +- `byvar` is **insertion-ordered, not a map** — the closure iterates it to build a + conjunction and the key order reaches that tree before `default_order` sorts it. +- A variable **met with no facts differs from one never seen**: only the latter + picks up the generic assumption (`Facts::{Absent, Empty, Tree}`). +- The closure reads the *previous* `derived` map while recomputing it, and + `clean_assumptions` can return a childless `["and"]`. Both are observable + through `me.assumptions.derived`. + +### Deliberate divergences + +- Three JS warts were not reproduced, none reachable from the spec: multiplying + zero by a polynomial read `p[1]` off a number; negating a zero polynomial + returned `false`; `mono_gcd` on two variables the default order cannot separate + advanced neither index and looped forever. +- A generic assumption that cleans to nothing is now a no-op; the JS left + `store.generic` undefined so the *next* `add_assumption` threw a `TypeError`. + +### Open items + +- `lib/assumptions/linear.ts`, `lib/assumptions/logical.ts` and + `lib/expression/variables.ts` are now shims with **zero importers**. They were + left in place because `package.json` maps `"./lib/*": "./lib/*"`, so every file + under `lib/` is a public deep-import path and removing one is an API change. +- `Expression.expand_relations()` is still a no-op stub. `assumptions::expand_relations` + is now `pub` and would back it directly, likely flipping the two failing + `quick_transformation.spec.ts :: expand relations …` names. +- `lib/polynomial/polynomial.ts` remains reachable only from its own spec; + `expression_to_polynomial` is now wired up on `Expression` but nothing calls it. diff --git a/active-plans/JS_RUST_DIFF.md b/active-plans/JS_RUST_DIFF.md index 60a7dba1..d0d3712a 100644 --- a/active-plans/JS_RUST_DIFF.md +++ b/active-plans/JS_RUST_DIFF.md @@ -202,21 +202,23 @@ Rust adds a `MAX_PARSE_DEPTH = 64` recursion guard (no JS counterpart). | JS `astToLatex` option | Rust `LatexOpts` | |---|---| -| `padToDigits`, `padToDecimals` | ❌ | -| `avoidScientificNotation` | ❌ | -| `showBlanks` (default true) | ❌ | +| `padToDigits`, `padToDecimals` | ✅ `pad_to_digits`, `pad_to_decimals` | +| `avoidScientificNotation` | ✅ `avoid_scientific_notation` | +| `showBlanks` (default true) | ✅ `show_blanks` | | `matrixEnvironment` | ❌ | | `convertLatexSymbols` / `allowedLatexSymbols` | ❌ | -`LatexOpts` is an **empty struct**. - | JS `astToText` option | Rust `TextOpts` | |---|---| | `output_unicode` | ✅ `unicode` | -| `padToDigits`, `padToDecimals` | ❌ | -| `avoidScientificNotation` | ❌ | -| `showBlanks` | ❌ | -| `explicitMultiplicationSymbols` | ❌ | +| `padToDigits`, `padToDecimals` | ✅ `pad_to_digits`, `pad_to_decimals` | +| `avoidScientificNotation` | ✅ `avoid_scientific_notation` | +| `showBlanks` | ✅ `show_blanks` | +| `explicitMultiplicationSymbols` | ✅ `explicit_multiplication_symbols` | + +The js-compat `astToText`/`astToLatex` classes forward these from their +constructor params (`converters/render-options.ts`); `Expression.toString`/ +`toLatex` forward whatever options object they are handed. Also: JS `ast→mathjs` deliberately **throws** on unsupported input (booleans, malformed AST, non-integer matrix dims) and asserts it — no Rust analog. diff --git a/active-plans/JS_RUST_TEST_DIVERGENCES.md b/active-plans/JS_RUST_TEST_DIVERGENCES.md index e2107c25..cf07e2d5 100644 --- a/active-plans/JS_RUST_TEST_DIVERGENCES.md +++ b/active-plans/JS_RUST_TEST_DIVERGENCES.md @@ -12,10 +12,11 @@ empirical, case-by-case output comparison. explicitly snapshotted (the `*-known-failures` / `*-known-divergences` files). - **ast→latex / ast→text**: measured by feeding each JS spec fixture's `ast` through `from_js → to_latex/to_text` and comparing to the JS `out` string. - This is exactly what the committed probe - `packages/math-expressions-rs/tests/zzz_divergence_probe.rs` does — the numbers + This is exactly what `packages/math-expressions-rs/tests/output_established.rs` + does (against the `ast-output-known-divergences.json` fixture) — the numbers below are reproducible with - `cargo test --test zzz_divergence_probe -- --nocapture`. + `cargo test --test output_established -- --nocapture`, and a divergence that + has been fixed shows up as a STALE snapshot entry (re-bless with `BLESS=1`). - **Rounding**: JS outputs generated by running `lib/math-expressions.js` in node on the `quick_rounding.spec.js` single-number cases; compared to Rust `round_numbers_to_precision/decimals`. @@ -30,20 +31,20 @@ empirical, case-by-case output comparison. |---|---:|---:|---| | text → ast | 551 (+85 edge) | **0** | asserted, all pass | | latex → ast | 617 (+49 edge) | **0** | asserted, all pass | -| ast → latex | 265 | **36** | §2 | -| ast → text | 247 | **82** | §3 | +| ast → latex | 265 | **15** | §2 | +| ast → text | 247 | **56** | §3 | | rounding (single number) | 24 | **0** | §4 | | derivative corpus | 300 | **2** | §5 | | evaluate corpus | 250 | **1** | §5 | | expand corpus | 247 | **1** | §5 | -| simplify corpus | 342 | **14** | §5 | +| simplify corpus | 342 | **4** | §5 | | ops corpus | 200 | 0 | §5 | | equality corpus | 824 | **0** | §5 | | assumptions corpus | 546 | **5** | §5 | | numeric / ode corpus | 17 | 0 | §5 | -| **Total divergences** | | **141** | | +| **Total divergences** | | **84** | | -All 141 are display-formatter differences (§2–§3) and snapshotted math +All 84 are display-formatter differences (§2–§3) and snapshotted math divergences (§5); none crash. --- @@ -57,237 +58,72 @@ Parser trees are the one place the JS output is still the spec. --- -## 2. ast → latex — 36 / 265 differ +## 2. ast → latex — 15 / 265 differ Rust's formatter is clean-slate ("the JS output strings are no longer the spec"), so these are deliberate unless noted. All are non-crashing style differences — every correctness/ambiguity bug has been fixed. -### A. Negative pulled out of fraction / fraction parens (intentional) — 16 case(s) -``` -["/",-2,3] - js: -\frac{2}{3} - rust: \frac{-2}{3} -["/",["-","a"],3] - js: -\frac{a}{3} - rust: \frac{-a}{3} -["/",["*",-2,"x"],3] - js: -\frac{2 x}{3} - rust: \frac{\left(-2\right) x}{3} -["/",["*",["-","a"],"x"],3] - js: -\frac{a x}{3} - rust: \frac{\left(-a\right) x}{3} -["+","z",["/",-2,3]] - js: z - \frac{2}{3} - rust: z + \frac{-2}{3} -["+","z",["/",["-","a"],3]] - js: z - \frac{a}{3} - rust: z + \frac{-a}{3} -… and 10 more of the same shape -``` - -### B. Delimiter spacing (intentional — JS pads `( … )`, `[ … ]`, `{ … }`) — 4 case(s) -``` -["apply","f",["tuple","x","y","z"]] - js: f\left( x, y, z \right) - rust: f\left(x, y, z\right) -["apply","nCr",["tuple","x","y"]] - js: \operatorname{nCr}\left( x, y \right) - rust: \operatorname{nCr}\left(x, y\right) -["apply","nPr",["tuple","x","y"]] - js: \operatorname{nPr}\left( x, y \right) - rust: \operatorname{nPr}\left(x, y\right) -["*","x",["+","y"]] - js: x \left(+ y\right) - rust: x \left(+y\right) -``` +**The enumeration lives in `tests/fixtures/ast-output-known-divergences.json`, +not here.** That file is machine-generated (`BLESS=1 cargo test --test +output_established`) and test-enforced: a new divergence, a changed one, and a +divergence that has been *fixed* all fail `output_established.rs`. Copying it +into prose is how the counts in this section went stale — they read 36 while the +snapshot held 15, because twenty-one of the listed cases had since been fixed. +What follows is the breakdown by cause, which the same test now checks. -### C. Malformed 1-arg `nthroot` — JS reads it as `\sqrt`, Rust as a function (edge) — 1 case(s) -``` -["^",["apply","nthroot",2],3] - js: \left(\sqrt{2}\right)^{3} - rust: \operatorname{nthroot}\left(2\right)^{3} -``` +| Cause | Cases | Intentional? | +|---|---:|---| +| Leibniz spacing — JS pads `\frac{ … }` and brace-wraps exponents | 10 | yes, style | +| `angle` rendering — `∠ABC` vs the list form `\angle\left( A, B, C \right)` | 3 | yes, redesign | +| Redundant parens JS puts round a self-delimiting `\frac` factor | 1 | yes, precedence-tracked | +| Spacing inside a unary-plus group (`\left(+ y\right)` vs `\left(+y\right)`) | 1 | yes, style | -### D. Leibniz spacing — intentional (JS pads `\frac{ … }`, brace-wraps exponents) — 10 case(s) +Two examples, verbatim from the snapshot: ``` ["derivative_leibniz","x",["tuple","t"]] js: \frac{ dx }{ dt } rust: \frac{dx}{dt} -["derivative_leibniz",["tuple","x",2],["tuple",["tuple","t",2]]] - js: \frac{ d^{2}x }{ dt^{2} } - rust: \frac{d^2x}{dt^2} -["derivative_leibniz",["tuple","mu",2],["tuple","tau","xi"]] - js: \frac{ d^{2}\mu }{ d\tau d\xi } - rust: \frac{d^2\mu}{d\tau d\xi} -["derivative_leibniz",["tuple","x",3],["tuple","s",["tuple","t",2]]] - js: \frac{ d^{3}x }{ ds dt^{2} } - rust: \frac{d^3x}{ds dt^2} -["derivative_leibniz",["tuple","x",3],["tuple",["tuple","s",2],["tuple","t",1]]] - js: \frac{ d^{3}x }{ ds^{2} dt } - rust: \frac{d^3x}{ds^2 dt} -["partial_derivative_leibniz","x",["tuple","t"]] - js: \frac{ \partial x }{ \partial t } - rust: \frac{\partial x}{\partial t} -["partial_derivative_leibniz",["tuple","x",2],["tuple",["tuple","t",2]]] - js: \frac{ \partial^{2}x }{ \partial t^{2} } - rust: \frac{\partial ^2x}{\partial t^2} -["partial_derivative_leibniz",["tuple","mu",2],["tuple","tau","xi"]] - js: \frac{ \partial^{2}\mu }{ \partial \tau \partial \xi } - rust: \frac{\partial ^2\mu}{\partial \tau \partial \xi} -["partial_derivative_leibniz",["tuple","x",3],["tuple","s",["tuple","t",2]]] - js: \frac{ \partial^{3}x }{ \partial s \partial t^{2} } - rust: \frac{\partial ^3x}{\partial s \partial t^2} -["partial_derivative_leibniz",["tuple","x",3],["tuple",["tuple","s",2],["tuple","t",1]]] - js: \frac{ \partial^{3}x }{ \partial s^{2} \partial t } - rust: \frac{\partial ^3x}{\partial s^2 \partial t} -``` - -### E. `angle` rendering — intentional (`∠ABC` vs list form) — 3 case(s) -``` ["angle","A","B","C"] js: \angle ABC rust: \angle\left( A, B, C \right) -["angle",["^","A",2],["_","B","n"],["prime","C"]] - js: \angle A^{2}B_{n}C' - rust: \angle\left( A^{2}, B_{n}, C' \right) -["*",["angle","A","B","C"],"x"] - js: \left( \angle ABC \right) x - rust: \angle\left( A, B, C \right) x ``` -### F. Scientific notation not emitted (intentional) — 2 case(s) -``` -1.23e-11 - js: 1.23 \cdot 10^{-11} - rust: 0.0000000000123 -["^",1.23e-11,5] - js: \left(1.23 \cdot 10^{-11}\right)^{5} - rust: 0.0000000000123^{5} -``` +Previously listed here and **since fixed** (they are no longer in the snapshot): +negative-fraction placement, delimiter padding, scientific-notation expansion, +and a malformed 1-argument `nthroot` that rendered as `\operatorname{nthroot}` +where JS reads it as `\sqrt` — see §7. -## 3. ast → text — 82 / 247 differ +--- -Same causes as §2, dominated by delimiter padding. -### A. Negative pulled out of fraction / fraction parens (intentional) — 15 case(s) -``` -["/",-2,3] - js: -2/3 - rust: (-2)/3 -["/",["-","a"],3] - js: -a/3 - rust: (-a)/3 -["/",["*",-2,"x"],3] - js: -(2 x)/3 - rust: (-2) x/3 -["/",["*",["-","a"],"x"],3] - js: -(a x)/3 - rust: (-a) x/3 -["+","z",["/",-2,3]] - js: z - 2/3 - rust: z + (-2)/3 -["+","z",["/",["-","a"],3]] - js: z - a/3 - rust: z + (-a)/3 -… and 9 more of the same shape -``` +## 3. ast → text — 56 / 247 differ + +Same causes as §2, dominated by delimiter padding. As in §2, the enumeration is +the snapshot file, not this prose. -### B. Delimiter spacing (intentional — JS pads `( … )`, `[ … ]`, `{ … }`) — 49 case(s) +| Cause | Cases | Intentional? | +|---|---:|---| +| Delimiter padding — JS writes `( x, y )`, Rust `(x, y)` (whitespace only) | 30 | yes, style | +| `not` → `¬`, and the minimal parens that follow from it | 9 | yes, redesign | +| Negative-fraction placement / parens round a product numerator | 7 | yes, redesign | +| `angle` rendering | 4 | yes, redesign | +| Sub/superscript associativity parens (`x^y_z` vs `x^(y_z)`) | 3 | yes, precedence-tracked | +| A factorial in an exponent (`x^(2!)` vs `x^2!`) | 3 | yes, round-trips | + +Two examples, verbatim from the snapshot: ``` -["*",["/",1,2],"x"] - js: (1/2) x - rust: 1/2 x +["not",["=","x","y"]] + js: not (x = y) + rust: ¬(x = y) ["^","x",["apply","factorial","a"]] js: x^a! rust: x^(a!) -["tuple",1,2] - js: ( 1, 2 ) - rust: (1, 2) -["prime",["tuple",1,2]] - js: ( 1, 2 )' - rust: (1, 2)' -["^",["tuple",1,2],"T"] - js: ( 1, 2 )^T - rust: (1, 2)^T -["vector",1,2] - js: ( 1, 2 ) - rust: (1, 2) -… and 43 more of the same shape -``` - -### C. Radical raised to a power — 1 case(s) -``` -["apply","nthroot",["tuple","x",4]] - js: nthroot( x, 4 ) - rust: nthroot(x, 4) -``` - -### D. Leibniz spacing — intentional (JS pads `\frac{ … }`, brace-wraps exponents) — 10 case(s) -``` -["derivative_leibniz","x",["tuple","t"]] - js: dx/dt - rust: d x/d t -["derivative_leibniz",["tuple","x",2],["tuple",["tuple","t",2]]] - js: d^2x/dt^2 - rust: d^2 x/d t^2 -["derivative_leibniz",["tuple","mu",2],["tuple","tau","xi"]] - js: d^2μ/dτdξ - rust: d^2 μ/d τ d ξ -["derivative_leibniz",["tuple","x",3],["tuple","s",["tuple","t",2]]] - js: d^3x/dsdt^2 - rust: d^3 x/d s d t^2 -["derivative_leibniz",["tuple","x",3],["tuple",["tuple","s",2],["tuple","t",1]]] - js: d^3x/ds^2dt - rust: d^3 x/d s^2 d t -["partial_derivative_leibniz","x",["tuple","t"]] - js: ∂x/∂t - rust: ∂ x/∂ t -["partial_derivative_leibniz",["tuple","x",2],["tuple",["tuple","t",2]]] - js: ∂^2x/∂t^2 - rust: ∂^2 x/∂ t^2 -["partial_derivative_leibniz",["tuple","mu",2],["tuple","tau","xi"]] - js: ∂^2μ/∂τ∂ξ - rust: ∂^2 μ/∂ τ ∂ ξ -["partial_derivative_leibniz",["tuple","x",3],["tuple","s",["tuple","t",2]]] - js: ∂^3x/∂s∂t^2 - rust: ∂^3 x/∂ s ∂ t^2 -["partial_derivative_leibniz",["tuple","x",3],["tuple",["tuple","s",2],["tuple","t",1]]] - js: ∂^3x/∂s^2∂t - rust: ∂^3 x/∂ s^2 ∂ t -``` - -### E. `angle` rendering — intentional (`∠ABC` vs list form) — 4 case(s) -``` -["angle","A","B","C"] - js: ∠ABC - rust: ∠(A, B, C) -["angle",["^","A",2],["_","B","n"],["prime","C"]] - js: ∠A^2B_nC' - rust: ∠(A^2, B_n, C') -["angle",["+","A","B"],["*","B","D"],["/","x","y"]] - js: ∠( A + B, B D, x/y ) - rust: ∠(A + B, B D, x/y) -["*",["angle","A","B","C"],"x"] - js: ( ∠ABC ) x - rust: ∠(A, B, C) x -``` - -### F. Scientific notation not emitted (intentional) — 3 case(s) -``` -1.23e-11 - js: 1.23 * 10^(-11) - rust: 0.0000000000123 -1.23e+22 - js: 1.23 * 10^22 - rust: 12300000000000000000000 -["^",1.23e-11,5] - js: (1.23 * 10^(-11))^5 - rust: 0.0000000000123^5 ``` --- + ## 4. Rounding — 0 / 24 differ All `quick_rounding.spec.js` single-number cases across @@ -298,7 +134,7 @@ behavior difference. --- -## 5. Math-operation corpora — 23 snapshotted divergences +## 5. Math-operation corpora — 13 snapshotted divergences These are Rust-vs-JS/mathjs differential tests over random inputs; every divergence is snapshotted. Reproduced verbatim from the fixture files. @@ -320,14 +156,14 @@ evaluate (log(sin(z)) - (5 / z)) (x*(b - 2)) / (2 - 2) ``` -**simplify** (`simplify-known-failures.json`, 14 of 342) — all division-by-zero / -malformed-input forms: +**simplify** (`simplify-known-failures.json`, 4 of 342) — three unparseable +inputs plus one genuine divergence: ``` -(2-2)*(1/(0x)) (2-2)/(0x) (3*) + 5* (3-3)^0 -(3x-3x)^0 -6/-0 /5+ 0*(1/(0)) -0*Infinity 0.5 7 0/0 1+2+ -1/((-1)(0)) 6/-0 +(3*) + 5* /5+ 1+2+ sin(pi)x ``` +The division-by-zero and `0^0` forms this list used to hold (`0/0`, `-6/-0`, +`(3-3)^0`, `0*Infinity`, …) have since been fixed and are no longer in the +fixture. **assumptions** (`assumptions-known-divergences.json`, 5 of 546) — JS returns Unknown where Rust returns True/False on squares/nonnegativity: @@ -378,7 +214,11 @@ and the `expand` guards that keep a single ± from being duplicated all match JS (`x^{y}^{z}` → `\left(x^{y}\right)^{z}`), `\lnot`/logical-operator parenthesization, unescaped LaTeX `%` (a comment-injection bug), the `\partial x` double space, `perp` → `⟂` in text, **radicals raised to a power** -(`\left(\sqrt{2}\right)^{3}`), and **units in a product** +(`\left(\sqrt{2}\right)^{3}`, at every arity and for every radical head), the +**bracket notations applied to a tuple** (`\left|\left( x, y \right)\right|`, +`|(x, y)|` — they used to fall through to `\abs`/a braceless `\sqrt`), a +**`nthroot` at any arity but two** (a plain `\sqrt{…}`, as JS reads it and as +`normalize::canonicalize` already assumes), and **units in a product** (`\left(x \%\right) y`, `\$ x`). **Remaining divergences are intentional redesign or accepted style** (Rust @@ -386,8 +226,6 @@ formatter is clean-slate; JS strings are no longer the spec): - Delimiter padding (`( x, y )` → `(x, y)`), `not` → `¬`, negative-fraction placement, scientific-notation expansion, sub/superscript associativity parens, `angle`/Leibniz spacing conventions. -- One edge case: a malformed 1-argument `nthroot` — JS interprets it as `\sqrt`, - Rust renders it as an ordinary function application. The parser (`text/latex → ast`), rounding, and the ops/equality/numeric/ode corpora show **zero** divergence — those are safe. diff --git a/active-plans/JS_TEST_COVERAGE_AUDIT.md b/active-plans/JS_TEST_COVERAGE_AUDIT.md index df85c78b..69caeabd 100644 --- a/active-plans/JS_TEST_COVERAGE_AUDIT.md +++ b/active-plans/JS_TEST_COVERAGE_AUDIT.md @@ -47,9 +47,9 @@ Rust tests live in `packages/math-expressions-rs/tests/` (+ inline `src/`). | `quick_mml-to-latex` | 1 | none | ⛔ | | `slow_math-expressions` | ~900 pairs + 14 | `equality_corpus.rs` (824-pair `equality-corpus.json`) + `equality.rs` (22 hand) | ✅ | | `slow_simplify` | 74 (474 expects) | `simplify_corpus.rs` (342) + `norm.rs` / `display.rs` / `expand.rs` / `matrix.rs` | ✅ | -| `slow_assumptions` | 44 (420 expects) | `assumptions_corpus.rs` (546) + `assumptions.rs` + `doenet_utils.rs` | ✅ | +| `slow_assumptions` | **845 run** (45 `it(` sites, 420 literal expects — most tests are generated from tables inside a loop, so the source count badly understates it) | `assumptions_corpus.rs` (546) + `assumptions.rs` + `doenet_utils.rs` | ✅ | | `slow_matrix` | 12 (~30) | `matrix.rs` (31) | ✅ | -| `slow_polynomial` | 23 | no public Rust polynomial/Groebner API (`src/polynomials` internal only) | ⛔ | +| `slow_polynomial` | 23 | `polynomial_compat.rs` (12) over `polynomials/compat/`, exported through `lib/polynomial/polynomial.ts` | ✅ | | `slow_rational` | 2 | `reduce_rational.rs` (5) | ✅ | | `slow_check-equality-numerical-errors` | 26 objs | `equality.rs` + **`tolerance.rs`** (fixture-driven, `equals`) | ✅ (16 sampling divergences snapshotted) | | `slow_check-symbolic-equality-numerical-errors` | 26 objs | Rust `equals_syntactic` is exact — ignores `allowed_error_in_numbers` | ⛔ behavioral divergence / 🔜 drop-in | @@ -92,7 +92,7 @@ not forgotten. Cross-referenced to [WHATS_LEFT.md](WHATS_LEFT.md) §A. | mathjs→ast | `quick_mathjs-to-ast` (28) | Not needed for Doenet — unused internally (WHATS_LEFT A.1 #5). | | ast→guppy | `quick_ast-to-guppy` (4) | Not needed for Doenet — legacy Guppy-editor XML (WHATS_LEFT A.1 #4). | | MathML (mml→latex) | `quick_mml-to-latex` (1) | No MathML parser/emitter in Rust (WHATS_LEFT A.1 #1–2). | -| polynomial / Groebner | `slow_polynomial` (23) | No public Rust polynomial API; `src/polynomials` is internal (JS_RUST_DIFF §4.2). | +| ~~polynomial / Groebner~~ | `slow_polynomial` (23) | **Now covered.** `polynomials/compat/` ports the legacy engine and `lib/polynomial/polynomial.ts` exports it; `tests/polynomial_compat.rs` pins the AST spellings. | | `expand_relations` | `quick_transformation` (few) | Op absent in Rust (JS_RUST_DIFF §3.1). | | emitter options | `quick_ast-to-latex` standalone (8) | `LatexOpts`/`TextOpts` fixed-behavior: matrix env, pad-to-digits/decimals, avoid-scientific-notation, `showBlanks` (JS_RUST_DIFF §2.2). | | syntactic tolerance | `slow_check-symbolic-equality-numerical-errors` (26) | Rust `equals_syntactic` does exact structural comparison (`na == nb`) and does **not** apply `allowed_error_in_numbers`; JS `equalsViaSyntax` does. Number-tolerance lives only on the numeric `equals` path in Rust. | diff --git a/active-plans/PORTING_PLAN.md b/active-plans/PORTING_PLAN.md index 4e5cc2cb..4e9c7512 100644 --- a/active-plans/PORTING_PLAN.md +++ b/active-plans/PORTING_PLAN.md @@ -237,6 +237,16 @@ Consequences and mechanics: NUMBER branches (needs gcd/reduction — lands with the `Number` arithmetic). - Normalisation folds constants exactly; `0.1 + 0.2 = 0.3` becomes structurally true, shrinking reliance on tolerance-based equality. +- **Shrinking, not removing (amended 2026-08-07).** Equality's bare-number + stage decides an *exact* pair exactly — that is this section's payoff, and + `10^20+1 ≠ 10^20+2` depends on it. It must not decide a `Float` pair that + way. A `Float` is by construction the result of inexact arithmetic, so its + low digits are an artifact of the route taken, and JS callers arrive at one + wherever a value crossed the JSON boundary or came back from evaluation: + DoenetML's `` generates + `0.30000000000000004` and expects `.3` to exclude it, exactly as the JS + library's `1e-12` relative epsilon did. When either side is a `Float`, + `equals` compares within `relative_tolerance`. `tests/equality.rs`. - Formatters render any rational whose denominator is `2^a·5^b` in decimal form (`Rat(1,2)` → `0.5`, not `1/2`), so decimal input round-trips exactly. Other denominators render as `a/b` / `\frac{a}{b}`. diff --git a/active-plans/PR84_REVIEW_KNOWN_ISSUES.md b/active-plans/PR84_REVIEW_KNOWN_ISSUES.md new file mode 100644 index 00000000..eaba39c1 --- /dev/null +++ b/active-plans/PR84_REVIEW_KNOWN_ISSUES.md @@ -0,0 +1,463 @@ +# PR #84 review — known issues and durable findings + +The durable ledger from the twenty-two review passes over +[Doenet/math-expressions#84](https://github.com/Doenet/math-expressions/pull/84). The pass-by-pass +history lives in the git log (`Review cycle N:` commits) and the PR's edit history; this file keeps +only what still describes the code. Every entry below was re-verified against the pin it names or +carries a symbol anchor checked to exist at the head this file is committed at (`41b9cb4` when the +anchors were first swept at the eleventh pass, re-spot-checked at the thirteenth, fourteenth and +fifteenth); the fifth pass re-reproduced each then-open entry through the built compat package. + +Conventions: "legacy" is `math-expressions@2.x` from npm. File paths are relative to +`packages/math-expressions-rs/src/` for `.rs` and `packages/math-expressions-js-compat/lib/` for +`.ts` unless said otherwise. + +**The `null` sentinel is gone (twentieth pass).** `evaluate_to_constant` used to answer `null` for a +free variable or a placeholder blank, and `NaN` only for an indeterminate form. Legacy answered +`NaN` for all of them, and legacy was right: `null` is *anti*-poisoning in JavaScript +(`Number(null)` is `0`, `null + 5` is `5`, `null <= 1` is `true`, `Number.isNaN(null)` is `false`), +so a value that did not exist behaved like zero in any consumer that had not been individually +taught to test for it. Roughly fourteen DoenetML grading defects were traced to that one inversion, +found one at a time over the preceding passes. The compat layer answers `NaN` now, and the +declarations say `number | Complex` rather than `number | Complex | null`. Entries below that +turned on the old sentinel are struck through or amended in place rather than deleted, so the +history stays readable. The native Rust API keeps `Option`, which is right where there is no +coercion hazard. + +## Known issues, open + +None of these block DoenetML (Doenet/DoenetML#1622); they are recorded for follow-up work. + +### Rust crate + +- **DP5(4) evaluates stage 7 twice per accepted step** (`mathjs_compat/ode.rs`, `solve_ode`): the + FSAL stage loop already produced `f(t+h, ynew)` into `k[6]`. 7 RHS calls per step instead of 6, + and through `solve_ode` each one is a JS boundary crossing. The `terminated_early` branch hanging + off it is dead. +- **`max_steps` is off by one** (`mathjs_compat/ode.rs`): an integration converging in exactly + `max_steps` steps reports `terminatedEarly`. The vanishing-step guard beside it also uses a + different scale from the completion test, so a large-`t` run can reject its final sliver. +- **`digits = +Infinity` disables the decimals mode** in + `round_numbers_to_precision_plus_decimals` (`ops/numbers.rs`), asymmetrically with `-Infinity`. +- **`evaluate_many`'s scalar fallback skips canonicalization** while the tape path canonicalizes, + giving intra-batch 1-ulp inconsistency. +- **`sort_key`'s `ignore_negatives` parameter is permanently `false`** at all three call sites, + making several branches of `normalize/default_order.rs` unreachable. (Its doc no longer + contradicts the code about whether nested keys propagate it — the `Pow`, `Apply` and unit + branches do, the rest do not.) +- **Two-argument `log` does not fold** (`log(8,2)` stays an application), while the `log_10` / + `log10` spellings do. +- **`polynomials::rootof::expr_to_upoly` reads a monomial of any degree**, so + `rootof(x^1000000000 - 1, 0)` allocates a billion `BigRational` zeros before `make_rootof` can + refuse the degree. Pre-existing, and untouched by the seventeenth pass's product reading, which + deliberately left that arm alone so the change could be a strict widening; the cap belongs on the + dense allocation, not on the product. +- **`mono_less_than` answers `true` in both directions** (`polynomials/compat/mono.rs`) for two + distinct variables that `cmp_default_order` ranks `Equal` — the tie case `mono_gcd` already + acknowledges. +- **`evaluate_to_constant` returns `Some(NaN)`** while its rustdoc mentions only `±∞`, and the + `evaluate_to_complex` beside it rejects `NaN`. Undocumented asymmetry. +- **Non-realness does not propagate through `+`, `*` or `^`** — incompleteness, deliberately + declined, with no consumer left that can be wrong about it. `real: Some(false)` is produced in + only three places (an explicit `x ∉ R` assumption, the `i` literal, a constant with a nonzero + imaginary part) and `combine::add`/`combine::mul` never carry it through an operator, so + `is_real(x+1)` with `x ∉ R` answers _unknown_ where legacy answers `false`. Adding the rules + would turn `None` into `Some(false)`, and `simplify`'s rewrites are gated on exactly those + facts; the one rewrite that treated `None` as permission (the odd-root sign extraction in + `normalize/simplify.rs`, `simplify_root`) now declines when any _part_ of the residual is + provably non-real, which over-declines and moves no other rewrite. +- **`MAX_UNFLATTEN_OPERANDS = 1000`** (`math-expressions-rs-wasm/src-rust/js_match.rs`) **exceeds + serde_json's 128-deep default**, so `unflatten_left` on a wide sum returns JSON that `from_ast` + then refuses. +- **An exact integer past f64 range crosses to JS as `Infinity`.** `simplify` folds `2^2000` + exactly and `to_text` prints all 613 digits, but `to_js` puts each part through an f64, so + `.tree` answers `Infinity` and `2^2000` and `2^2001` have the same `.tree` while `equals` still + tells them apart. Parity with legacy (which held everything in a JS number) — a limit of the AST + wire format, not a regression — recorded because `max_pow_bits` deliberately permits results a + thousand times past the f64 ceiling. +- **`1/(0^0)` stays written out** as `["/", 1, NaN]` rather than folding to `NaN`. Every other + arithmetic combination with a `NaN` operand folds. (The `{"$":"NaN"}` envelope no longer reaches + `.tree`; that half is fixed — see `engine-rust.ts` in DoenetML and the corresponding + `MATH_EXPRESSIONS_UPSTREAM_REQUESTS.md` entry.) + +### Compat layer + +- **Handle leaks, systemic.** `evaluate_to_constant` creates intermediates via `remove_units` and + `simplify` and frees neither; `Context.matrix` has the same shape; `equalSpecifiedSignErrors` + mints a `fromAst` per sign variant per recursion level (the `numSignErrorsMatched` grading + path); `trees/basic.ts`'s `evaluateNumbers` leaks two per rewrite per pattern per round inside + `applyAllTransformations`; `evaluate_numbers`'s `set_small_zero` branch and + `create_discrete_infinite_set` each discard intermediates. Systemically, every `toExpr(other, …)` + in `equals`/`add`/`match`/… leaks whenever the argument is a tree or a string — `substitute` was + the only method freeing carefully. +- **Nine declared parameters the implementation has no arity for.** Found by a member-by-member + audit of `types/math-expressions.d.ts` against `lib/` at the twenty-first pass, prompted by the + two the twentieth pass had found by accident (`match`'s `allow_permutations?: boolean`, and + `evaluate_to_constant`'s `| null`). `simplify`, `simplify_logical`, `collect_like_terms_factors`, + `simplify_ratios` and `expand` are all declared with options and are arity 0; `derivative`'s + `story` array is never written; `equalsViaReal`/`equalsViaComplex` ignore their `EqualsOptions`, + so their tolerances have no effect; and `isAnalytic`'s declared `string[]` arm is read as an + options object, so every flag comes out `false` — `match(true)` a second time. All nine are now + marked `@deprecated` and "accepted and ignored" in the declarations, and the `string[]` arm is + gone, which is honesty rather than a fix: the engine should either honor them or they should be + dropped. Verified against the built package, not read off the source — and re-verified the same + way at the twenty-second pass, where all nine still behave exactly as described. The verdict + stands: **accepted and ignored**, because each is a parameter legacy honored and this engine has + no arity for, so the only alternatives are engine work (the ask upstream) or breaking a legacy + call that compiles today; the declaration saying so costs neither. +- ~~**The parsers read a non-string as a pointer into linear memory.**~~ Fixed at the + twenty-second pass, and the reason it is recorded rather than quietly patched is that the + twenty-first pass's `add_unit` fix reported having swept "the rest of the string-taking entry + points; all were already guarded" — and `parse_text`/`parse_latex`, the two most-used entry + points in the package, were not. `me.fromText(5)`, `me.fromText(anExpression)` and + `me.fromText({})` were all `RuntimeError: memory access out of bounds`; an array tree was + `arg.charCodeAt is not a function`. (The module recovers — the allocation fails at the boundary + rather than corrupting the heap — but the message is engine-internal, and `` renders whatever the parser complains about, so a student could reach it.) They now + throw a `TypeError` naming the argument type and pointing at `fromAst`/`from`. A *throw*, where + `add_unit` took a coercion, because `add_unit`'s declaration invites an `Expression | Tree` and a + unit is a symbol, while `fromText` is declared to take a string and no other value has a faithful + reading — so nothing that used to succeed changed. `String` objects still parse, as wasm-bindgen + always read them. Pinned in `quick_doenet_open_items.spec.ts`, revert-fail-restore verified. + The rest of the sweep the twenty-first pass claimed *does* hold: every other string-taking wasm + entry reachable from the published surface was re-probed at runtime with an `Expression`, an + array tree and a number, and each is guarded (`varName` on + `derivative`/`integrate`/`critical_points`/`evaluate_many`/`solve_linear`/`add_unit`, + `JSON.stringify` or `tree_json()` on every options/AST parameter, `.toString()` on the assumption + texts). +- ~~**48 of the 114 declared `Expression` members are `undefined` at runtime.**~~ Decided at the + twenty-second pass, which is what the entry had been waiting for: **narrowed**, not documented. + The members are gone from both `Expression` and `Context` in + `types/math-expressions.d.ts` — 96 declarations, plus `Context`'s own `ZmodN` and + `parser_parameters`, which are Context-only properties and so fell outside the `Expression` audit + that found the 48. The argument for narrowing is that a `.d.ts` whose job is to describe a + drop-in earns nothing by promising members that are not there: keeping them made `expr.sin()` a + compile-time success and a runtime `TypeError`, which is the worse of the two places to find out, + and removing them moves the report to `tsc`, names the member, and costs a caller who was going + to fail anyway nothing. What is left is checkable and was checked: every member either interface + declares — 66 on `Expression`, 87 on `Context` — is present at runtime on the built package. The + gap itself is unchanged and is still the open ask upstream; it is enumerated in a comment at the + end of `Expression` and in `MATH_EXPRESSIONS_UPSTREAM_REQUESTS.md`, and a name goes back the + moment `lib/` implements it. DoenetML's vendored copy was narrowed in the same shape, and its + `npm run typecheck` is unchanged by it (22 packages clean, the same 5 not gated, the same 59 + pre-existing errors), which is the measurement that nothing called them. +- **`Context.toString(expr)` answers `"[object Object]"`.** The expression-first mirror skips + anything already `in Context`, and `toString` is inherited from `Object.prototype` — deliberately, + since shadowing it would break `String(me)`. The declaration promised it anyway; it no longer + does. `expr.toString()` is unaffected and is the only spelling that works. +- ~~**`Context.assumptions`, `get_assumptions`, `solve_linear`, `Context.from`, + `create_discrete_infinite_set` and `Context.class` all return or accept something the + declaration does not admit.**~~ Decided at the twenty-second pass, one verdict each, all six + measured against the built package first. Five were the **declaration** being wrong about a + deliberate implementation, and the declaration now says what the code does: `assumptions` is + typed as the object of methods it is (the per-variable facts are under `byvar`); + `get_assumptions` takes the three query shapes that work — a name, a *nested* `[["x","y"]]` list, + or an expression — and returns `Tree | undefined`, the bare `["x","y"]` it used to declare being + the one shape that answers `undefined` (legacy's own suite queries `[["x"]]`, so the nesting is + parity, not a quirk); `from` and `create_discrete_infinite_set` are declared `| undefined`, which + is legacy's failure value and what callers must check; and `class` takes a wasm handle, now + declared `never` so `new me.class(tree)` is a compile error rather than an object whose every + method fails. The sixth, `solve_linear`'s frozen `ABSENT_EXPRESSION`, is **accepted**: legacy + handed back an `Expression` whose `.tree` was `undefined` and callers read `.tree` + unconditionally, so declaring `| undefined` would break the callers the stand-in exists to serve. + Documented in place instead — test the `.tree`, not the result. +- ~~**`Expression.match` drops `allow_extended_match`; the free `utils.match` honors it.**~~ Fixed + at the twenty-second pass. The option is handled *outside* the Rust matcher — `trees/flatten.ts` + enumerates operand subsets — and `Expression.match` called the matcher directly, sharing only + `normalizeMatchOptions` while its comment claimed the two entry points could not drift. It now + delegates to that shared `match`, which is what makes the claim true, and `MatchOptions` declares + `allow_extended_match` because it now works from both. `x+y+z` against `a+b` bound `b` to `y+z` + here and to `y` with `_skipped: ["z"]` there; both answer the second now. The no-options path is + still gated on `hasOptions`, so an absent or empty options object keeps the legacy default where + every string leaf in the pattern binds. Pinned in `quick_doenet_open_items.spec.ts`, + revert-fail-restore verified. +- **`astToJson` and `astReplacer` are not interchangeable** despite their shared file's claim: + `astToJson` tags non-finites but does not unwrap an `Expression`, so the tree utils reject one + where `fromAst` accepts it. +- **`extendedMatch` produces `_skipped` but never `_skipped_before`**, leaving the `addLeft` path + in `trees/basic.ts` dead. (`_skipped` itself is live, set by `trees/flatten.ts`.) +- **`applyAllTransformations` folds numbers only after the extended-match splice**, where legacy + folds before it as well. The pre-fold's only observable effect is on which branch the + `result[0] === pattern[0]` test takes; noted in the code, to keep one `fromAst` round-trip per + rewrite. Separately, the `applyAllTransformations` _method_ on `Context` + (`math-expressions.ts`) is documented as a normalization pass folded into `canonicalize` and + returns `this`, silently discarding the caller's transformation list — it is neither; the real + pattern-rewriting driver lives in `trees/basic.ts`, which nothing re-exports (`Context.utils` + carries only `{match, flatten, unflattenLeft, unflattenRight}`). +- **`substitute_component` validates nothing**, where legacy validated the container head at each + level and the index range. `me.fromText("x*y").substitute_component(0, 5)` answers `5·y` instead + of throwing, and an out-of-range index returns `undefined` rather than an `Expression`, so the + caller fails a line later on `.tree`. `get_component` has the same shape one level down: its + container check runs on the receiver only, and the rest of the path indexes the operands of any + operator — `("(x*y, 3)").get_component([0,0])` answers `x`. The comment describing a matrix + entry as `[1, row, col]` describes a call the code rejects (`"matrix"` is not in + `COMPONENT_CONTAINERS`). DoenetML's `@doenet/math` `getComponent` wrapper restores the legacy + throw for the one call site that used it as a type test. +- ~~**`Expression#match` silently ignores `allow_extended_match`**~~ — the same finding as the + entry above, filed twice; fixed once, at the twenty-second pass, by delegating to the shared + implementation the way legacy's `Expression.prototype.match` did. +- **`ABSENT_EXPRESSION` snapshots the prototype before it is finished.** The `notImplemented` + methods and `applyAllTransformations` are attached after the IIFE builds it, so + `solve_linear(...).applyAllTransformations()` is a `TypeError` rather than the documented + "returns the stand-in itself"; `toText()`/`tex()` hand back the stand-in _object_ rather than + `""`, and `Symbol.dispose` is absent. +- **`me.from` never tries MathML** although `converters.MmlToAst` exists and works; legacy's + `create_from_multiple` had that third fallback, and `Context.fromMml` is still `notImplemented`. +- **`Context.reviver` drops the `assumptions` field** legacy restored onto a revived expression, + and `toJSON` no longer emits it — silent on both sides of a persist/revive round trip. +- **`evaluate_to_constant` does not read `nan_for_non_numeric`.** It now always behaves as legacy's + `true` default — `NaN` for anything with no numeric value — so the only remaining divergence is + that passing `false` is accepted and ignored rather than producing `null`. Marked `@deprecated` + in the published declarations. + _(Was: "always behaves as `false`, and DoenetML depends on it." Both halves were wrong to rely + on. The `null` sentinel is gone; see the note at the top of this file.)_ +- ~~**`evaluate_to_constant`'s blank-handling comments describe the wrong trees.**~~ Resolved by + deletion: `treeHasBareBlank`/`treeHasBlank` existed only to split blanks between the `NaN` and + `null` answers, and there is one answer now. +- **`equalSpecifiedSignErrors` does not require _exactly_ `n_sign_errors`,** as its docstring + says. `singleNegations` enumerates sign-invariant positions too, so negating `x` inside `x^2` + folds back and a perfectly correct answer scores as "1 sign error" on DoenetML's + `numSignErrorsMatched` path. Possibly legacy-faithful; the doc should not claim otherwise + either way. +- **The render-option key list is enumerated in four places and each is different.** + `converters/render-options.ts`'s `FORWARDED` is the authority; two lists in + `math-expressions.ts` omit `avoidScientificNotation` and `matrixEnvironment`, `ast-to-text.ts` + omits `notation` and `matrixEnvironment`, and + `packages/math-expressions-rs-wasm/src-js/wasm.ts` (outside this file's `lib/` path convention) + omits both and drops `unicode` from the LaTeX variant only. + +## Standing invariants worth knowing + +- **Nothing anywhere under `lib/` may dereference `wasm` at module scope.** `setWasmModule` is + re-exported from the package root, so importing it evaluates the whole barrel; a module-scope + `wasm` touch triggers the node fallback — throwing in a browser, and under node quietly pinning + the node build so a later injection can never win. The invariant is written at its site in + `lib/math-expressions.ts` (the `Context._assumptionsHandle` lazy accessors) and pinned by a spec + that injects a counting proxy and asserts zero touches during import. +- **The 11 skipped compat tests** are 9 in `quick_trees.spec.ts` and 2 in + `slow_assumptions.spec.ts`; all but one carry a `[wontfix: …]` tag in the test name saying why. + None is an engine unsoundness: legacy's expected answers there are partly false, so the tests + cannot be passed soundly. See `active-plans/ASSUMPTIONS_ENGINE_PLAN.md` ("Accepted divergence"). + The exception is `slow_assumptions.spec.ts`'s "define constants" (`:7292`), which carries a plain + comment rather than a tag — worth tagging so the count stays self-explaining. +- **Aggregates have no default parser spelling**: `fromText("sum(3,17,5-4)")` parses as + `s·u·m·(…)` unless `appliedFunctionSymbols` is passed. Deliberate, matches legacy. + +## Fixed during review, kept for its contract + +**`add_unit` corrupted the wasm heap when handed the argument its declaration invites** +(twenty-first pass). The wasm entry point is `add_unit(unit: &str)`, and wasm-bindgen reads a +non-string argument as a pointer/length pair into linear memory. The published declaration says +`Expression | Tree`, as legacy's did, so `add_unit(me.fromText("%"))` — the documented call — gave +`RuntimeError: memory access out of bounds` and an array tree gave +`arg.charCodeAt is not a function`. The fix is the `varName` coercion `critical_points` already +used against the identical hazard, and it is worth recording that the hazard had been *named* in a +comment one method away for several passes without anyone checking which other methods had it. A +unit is a symbol, so its name is all the Rust side wants. Pinned in +`quick_doenet_open_items.spec.ts`, revert-fail-restore verified. + +**`f((a, b))` and `f(a, b)` are one tree, because `to_js` cannot tell them apart** (eighteenth +pass). `f((x, y))` parsed to `Apply(f, [Seq(Tuple, [x, y])])` and `f(x, y)` to `Apply(f, [x, y])`, +but `to_js` writes `["apply","f",["tuple","x","y"]]` for *both* — byte-identical JSON — and +`try_from_js` maps that back to the second. The serialization was not injective, and the JS AST is +the contract with every consumer, so an expression was not equal to itself after a round trip +through its own `.tree`: `me.fromText("f((1,2))").tree` equalled `me.fromText("f(1,2)").tree` while +`x.equals(me.fromAst(x.tree))` was **`false`**. + +The fix is in the **parsers**, not in canonicalization, because the printers read the raw tree: a +canonical-form fix would have repaired `equals` and left the display wrong. `parse::common::apply` +flattens a lone `Tuple` argument exactly as `expr::serde::try_from_js` always has, and every +`Expr::Apply` the two parsers build now goes through it — the call form, the simplified +application, `|…|`, `⌊…⌋`, `⌈…⌉`, `√`, `∛`, `…!` and the integral. Only a *lone* tuple flattens: in +`f((x, y), z)` the inner tuple is one of two arguments, survives the round trip intact, and is left +alone. Legacy had one tree for both spellings, so this is parity, not a new rule. + +It was never a grading defect — DoenetML's `checkEquality` rebuilds both operands with `me.fromAst` +one line before `.equals()`, so the distinction was erased on the way in — but it was a **display** +regression: the same saved JSON rendered `\sin\left(\left( x, y \right)\right)` before a +save/restore and `\sin\left( x, y \right)` after. Pinned in `tests/js_ast_image.rs`, whose +corpus sweep states the property directly (rendering is a function of the saved JSON) over every +parser fixture plus six hand-written spellings, and which fails both against the unfixed parsers +and against the plausible over-flattening variant that spreads *every* tuple argument. + +Two things followed from it. The sixteenth pass's `normalize::spread_list_argument` keeps its +place, but for the *other* list kinds — `mod([7,3])` and `["apply","mod",["list",7,3]]` are still +one sequence argument, and `Tuple` no longer reaches it from any parser — so its tests now exercise +the bracketed spelling, which is the one that can still fail if the branch is narrowed. And the +LaTeX printer's bracket notations turned out to be guarded on `args.len() == 1`, falling through to +`head\left(…\right)` otherwise and spelling the head as a command that does not exist: `abs(x, y)` +rendered as `\abs\left( x, y \right)` and `sqrt(x, y)` as `\sqrt\left( x, y \right)`, neither +of which MathJax can render. They now wrap the tuple, which is both what the JS AST says the +argument is and what legacy rendered (`print/latex.rs::sole_argument`, pinned in +`tests/formatter_fixes.rs`). + +**A `rootof` whose polynomial is written as a product reaches the same leaf** (seventeenth pass). +`canon_apply` rewrites `rootof(p, k)` into the `Expr::RootOf` leaf only when +`polynomials::rootof::from_apply_args` accepts, and `expr_to_upoly` read only a *sum of monomials*. +Canonicalization does not expand products, so a factored spelling was declined and stayed an +application of a head with no evaluation at all — an opaque atom. Two spellings of the same number +therefore compared unequal: `rootof((x-1)(x-2), 0)` was neither `1` nor `rootof(x^2-3x+2, 0)`. +`expr_to_upoly` now multiplies and adds polynomials. + +Three things the sixteenth pass wrote about this were imprecise, and measuring them is what set the +fix's shape. It is not "dense canonical" input that was required — sparse (`x^2-2`) reads fine, and +so does a *scaled* one: `make_rootof` normalizes to primitive integer coefficients with a positive +leading coefficient, so `rootof(2x^2-6x+4, 0)` and `rootof(x^2/2-3x/2+1, 0)` already equalled `1`. +The one shape that failed was an **unexpanded product** — `(x-1)(x-2)`, `2(x^2-2)`, `x(x-1)`. And +the degree guard cannot simply be `max_rootof_degree` applied everywhere: the first draft put it on +every arm and thereby *narrowed* `rootof(x^70 - x^69, 0)`, which the old reading accepted because +`make_rootof` takes the squarefree radical (degree 70 → `t^2 - t`). The cap is on products only, +where multiplying many-term polynomials grows the coefficients as well as the degree — +`(x^2+x+1)^200` alone spent ten seconds under a more generous cap — while a monomial sum costs +nothing to read at any degree and keeps its old, uncapped arm. Pinned in +`tests/rootof_adversarial.rs`, both the widening and the two refusals, verified to fail against the +unfixed reading. + +The residue this closes was never on a DoenetML path: `rootof` is in neither of DoenetML's +`appliedFunctionSymbols` lists (`utils/math.ts` has no occurrence of the name), and legacy +`math-expressions@2.x` has no `rootof` at all, so it is a defect in this engine's own new surface +rather than a regression. It is fixed rather than filed because that surface ships as +`math-expressions@3.x` to npm, where a library caller reaches it through the default text parser — +which applies any name followed by a parenthesized list — and through `\operatorname{rootof}` in +LaTeX. + +**A sequence argument is read as an argument list by the sampler too** (sixteenth pass). The same +split as the `det` entry below, on the same path, found by asking what else the two layers could +disagree about. Legacy's text parser wrote one tree for `mod(7,3)` and `mod((7,3))` — a head +applied to a tuple — so the extra parentheses cost nothing and both answered `1`. This parser kept +them apart, and only `normalize/fold_apply.rs` put them back together, via an `effective_args` +helper that spread a single list argument. The sampler in `eval_numeric/complex.rs` did not, and +`known_function("mod", 1)` is false, so it called the application an opaque atom: `simplify` gave +`1`, `equals(mod((7,3)), 1)` gave `false`, and `evaluate_to_constant` gave `None` — `` read +nothing and `` graded it wrong. `nPr` and `nCr` are the other two heads whose folder takes +the arity the spread produces. (The *parenthesized* spelling is no longer this helper's business — +the eighteenth pass's parser fix, above, makes `mod((7,3))` the same tree as `mod(7,3)` — but the +bracketed `mod([7,3])` and the JS `["apply","mod",["list",7,3]]` still are, and that is what the +tests now exercise.) + +An earlier pass had this in the open list, as "`fold_apply::is_variadic` tests 'has an exact +folder' rather than 'is an aggregate', so a tuple argument spreads into fixed-arity heads". Two +things in that were wrong, and both had to be established by measurement before the fix could be +the right one. The spreading is not the bug — it is legacy parity, and narrowing `is_variadic` to +the aggregates makes `mod((7,3))` stop being `1`, which is a *regression*. And the example given, +`["apply","mod",["tuple",7,3]]`, never took the branch: the JS deserializer flattens a tuple +argument into an argument list before any of this runs, so a DoenetML tree could not reach it and +only the text parser could — which is the observation the eighteenth pass followed back to the +parsers. + +The spread is now `normalize::spread_list_argument`, `pub(crate)` and consulted by both layers, the +way `det`/`trace` go through `matrix::scalar_reduction`. `head_evaluable` asks it for the effective +arity and `eval_apply` evaluates the spread list; `free_symbols` needed no change, for the same +reason it did not for `det`. The arity check still happens downstream on the spread list, so +`abs([-3,5])` stays symbolic on *both* layers rather than being forced into a two-argument `abs`. +Pinned in `tests/equality.rs`, `normalize/fold_apply.rs` and +`spec/quick_doenet_compat_pr84.spec.ts`, verified to fail against the unfixed engine. + +**`det`/`trace` of a literal matrix are evaluated, not sampled as unknowns** (fifteenth pass). +`equals` said a determinant differed from its own value: `\det\begin{pmatrix}1&2\\3&4\end{pmatrix}` +simplified to `-2` and compared `false` against `-2`, and `evaluate_to_constant` answered `None` on +it. The cause was the third numeric path — `equals` samples through `eval_numeric/complex.rs`, whose +`head_evaluable` asked `special_functions::eval1` alone. `DET` had no kernel and `TRACE`'s is a +scalar identity that cannot see into a `Matrix`, so `is_opaque_atom` classified the whole +application as an opaque atom and sampled it as a fresh variable, which agrees with `-2` nowhere. +The legacy JavaScript library answered `-2`/`5`/`true` to all of it, so this was a regression, on a +grading path. + +`matrix::scalar_reduction` is now the single place that decides whether an application of `det`/ +`trace` has a scalar value; `normalize/fold_apply.rs` (which keeps its `Num`-only gate, so +`simplify` is byte-unchanged) and `eval_numeric/complex.rs` both call it. `head_evaluable` takes the +argument list rather than its length so it can ask; `free_symbols` needed no change, since an +application that is no longer opaque already descends into its arguments and `Expr::Matrix` already +walks its entries — so `det([[x,2],[3,4]])` reports `x` and compares equal to `4x-6`. A matrix the +reducers decline (non-square, over `resource_limits`) still comes back as the `OtherOp` residual and +is still sampled as an unknown. `DET` also gained the scalar identity `TRACE` had, matching mathjs's +`det(2) = 2` and legacy's `det(x) == x`. + +Pinned in `tests/matrix.rs` and `spec/quick_doenet_compat_pr84.spec.ts`, both verified to fail +against the unfixed engine. `tests/functions_registry.rs`'s deny list — which is where the omission +was codified, as `erf`'s had been — now says in the file that it pins a decision rather than an +outside fact, and names the compat suite as the check that has outside authority. + +**Odd roots of negative reals read on the real branch on every numeric path** (eleventh pass). +The branch for `(negative)^(p/q)`, odd `q`, used to depend on whether the radicand was a perfect +power — `(-8)^(1/3)` folded to `-2` while `(-2)^(1/3)` evaluated to the principal +`0.6300 + 1.0911i` — so `equals` told the same number apart from itself and four DoenetML +`` cases regressed against legacy. Fixed at three sites: `rule_radical`'s `Pow` arm +(`normalize/simplify.rs`) pulls the sign out at simplify time, which is load-bearing because +`evaluate_to_constant` runs `simplify_core` and the certified-digits tape before any evaluator; +`eval_complex`'s `Pow` arm (`eval_numeric/complex.rs`, `odd_root_exponent`) takes the same branch +for sampling — matching the raw quotient-node exponent shape too, because that walk is +`evaluate_many`'s per-point fallback and gating on `Num(Rat)` alone diverges the batch and +single-point paths at 835 corpus points; and `CBRT::eval1`/`NTHROOT::eval2` +(`special_functions/powers.rs`) follow. Even roots, decimal exponents with even reduced +denominators (`(-8)^0.3333` = `3333/10000`), and complex bases stay principal. This is a +deliberate divergence from mathjs on the engine's *own* numeric paths (`x^(1/3)` at `x = -8` is +`-2` there, not `1 + i√3`), stated in `evaluate_fast_f64`'s rustdoc. **It does not extend to +`f()`**, which compiles the tree to math.js and so keeps mathjs's principal branch for a `Pow` +node: `f()` of `x^(1/3)` at `x = -8` is `1 + i√3`, while `cbrt` and `nthroot` — which map onto +math.js functions that take the real branch themselves — are `-2`. So `evaluate_many` and `f()` +disagree about the power spelling and agree about the root spellings, and a DoenetML +`x^(1/3)` still has a gap at negative inputs that `` grading does not. +That gap is unchanged from legacy (which also evaluated the power spelling principal through +`numericalf`), so it is a standing difference rather than a regression, and closing it would mean +mapping the odd-root `Pow` shape onto `nthRoot` in `tree-to-mathjs.ts`. Pinned in +`tests/odd_root_real_branch.rs` and `spec/quick_doenet_grading_gaps.spec.ts`, both verified to fail +against the unfixed engine. + +**`f()` could not compile `nthroot`** (twelfth pass). `functionConversions` in +`packages/math-expressions-rs-wasm/src-js/tree-to-mathjs.ts` maps AST heads onto math.js names, +and math.js spells this one `nthRoot`. An unknown head is not a compile error — it becomes a +`FunctionNode` over an undefined symbol and throws `Undefined function nthroot` on the first +`evaluate` — so `nthroot(x, n)` was unevaluable through `f()` at _every_ input, not only at +negative ones. `f()` is the plotting and root-finding entry point, so a DoenetML +`nthroot(x,3)` drew nothing at all; legacy plotted it. Now mapped, which also +puts an odd root of a negative on the real branch (`nthRoot(-8, 3) === -2`), consistent with the +odd-root entry above and with `cbrt`. Pinned in `spec/quick_doenet_compat_pr84.spec.ts`, verified +to fail with the mapping removed. + +**The sibling sweep that entry asked for, done** (thirteenth pass). Every spelling the Rust +registry can produce was diffed against `Object.keys(mathjs)` and against `functionConversions`, +and every author-typable spelling — the union of DoenetML's `appliedFunctionSymbolsDefault` and +`…Latex`, 69 of them — was then evaluated through `f()` at two in-domain points. `nthroot` was the +only head broken that way. One head, `rootof`, is deliberately unmapped: it is in neither of +DoenetML's applied lists, so it cannot be typed, and the `critical_points()` output that produces +it goes through `evaluate_to_constant`, never `f()`. + +**And the sweep's own blind spot** (fourteenth pass). It covered `f()` and `evaluate_to_constant`; +there is a third numeric path, the sampler `eval_complex` that `equals` runs on, and it fails +*differently* — a head it cannot evaluate becomes an opaque variable rather than a `NaN`, so the +divergence is an equality that answers `false` instead of a value that reads `NaN`. `det` and +`trace` are in that state; see the entry at the top of "Known issues, open". The registry test +(`tests/functions_registry.rs`) does not catch this class either: it asserts that the evaluable +list evaluates and the not-evaluable list does not, so it pins whatever is true rather than +testing the list against an outside authority. A head that *ought* to be evaluable, is not, and is +written into the deny list passes — which is exactly how `erf` was codified, and how `det` still +is. + +**`erf` had no evaluation kernel at all** (thirteenth pass) — the mirror image of `nthroot`, and +just as silent. `ERF` in `special_functions/misc.rs` carried parser spellings and LaTeX rendering +but no `eval1`, so `evaluate_to_constant("erf(0.5)")` was `None` and `evaluate_many` sampled `NaN` +at every point, while `f()` was right throughout because math.js *has* `erf`. A DoenetML +`erf(x)` therefore plotted a correct curve whose +`$$f(0.5)` read `NaN` and whose extrema search found nothing — and legacy +evaluated `erf` from all of those paths, so this was a regression. `eval1` is now a port of the +same W. J. Cody rational-Chebyshev approximation math.js uses, so the two paths agree to the last +bit rather than to a tolerance. Re-measured independently at the fourteenth pass over 38,385 +sample points (both tails, both interval boundaries, denormals, ±0, ±∞, NaN): **0 mismatches on +the shipped wasm build**, because wasm32 Rust uses the `libm` crate's fdlibm `exp` and V8 uses the +same one. A *native* `cargo test` build differs at ≤2 ulp (max relative 3.6e-16, all inside the +`erfc2` branch) because it links glibc's `exp` instead — a property of the two `exp`s, not of the +port, and ~2,800× under the 1e-12 `relative_tolerance` grading uses. Worth knowing because +`spec/quick_doenet_compat_pr84.spec.ts`'s exact `toBe` on an `erf` value is a stricter contract +than that. Pinned in `tests/erf.rs` (which also asserts the three numeric +entry points agree) and `spec/quick_doenet_compat_pr84.spec.ts`, verified to fail with `eval1` +removed. The general lesson is the one the sweep confirms: a head can be missing from *either* +path, and neither absence produces a warning. + +Everything else fixed during the review passes is described by its `Review cycle N:` commit and +its tests; the suite state at this head is `cargo test --workspace` 876 passed / 0 failed and the +compat suite 6,383 tests — 6,372 passing, 11 skipped, 0 failing — with `cargo fmt` and +`clippy -D warnings` clean. (These two numbers were written at the thirteenth pass and left to rot +through seven more; they were re-measured at the twenty-first and again at the twenty-second, each +from a redirected run whose own exit status was checked. If you are editing this line, re-run them — a count that says "at this +head" and is not is worse than no count.) diff --git a/active-plans/ROOT_SIMPLIFICATION_SPEC.md b/active-plans/ROOT_SIMPLIFICATION_SPEC.md new file mode 100644 index 00000000..9a7f3490 --- /dev/null +++ b/active-plans/ROOT_SIMPLIFICATION_SPEC.md @@ -0,0 +1,80 @@ +# Numeric root simplification — settled spec (item 9) + +**Status: resolved and implemented (2026-08-05).** This replaces the earlier +"questions" note; the convention below was confirmed by the maintainer and is +what the code now does. + +## The rule + +A **number** under a root folds; a **variable** radicand never folds. + +When a numeric radicand folds, use the **real** root if one exists, otherwise +the correct **principal complex** root — folding only when the principal value +is exactly representable, never falling back to a float. + +This needs no assumptions engine and introduces no `abs`: the only cases that +fold have a *numeric* radicand, whose sign is known. + +## What that produces + +| input | real root? | result | status | +| --- | --- | --- | --- | +| `cbrt(-8)`, `nthroot(-8,3)`, `(-8)^(1/3)`, `nthroot(-32,5)` | yes | `-2` | unchanged | +| `sqrt(8)` | yes | `2·sqrt(2)` | unchanged (perfect-power extraction) | +| `sqrt(-1)` | no | `i` | **new** | +| `sqrt(-4)` | no | `2i` | **new** | +| `sqrt(-2)` | no | `i·sqrt(2)` | **new** | +| `sqrt(-8)` | no | `2·i·sqrt(2)` | **new** | +| `(-4)^(1/2)` | no | `2i` | **new** | +| `sqrt(x^2)`, `cbrt(x^3)`, `sqrt(16x²y⁴)` | — (variable) | unchanged | never folds | + +`sqrt(16x²y⁴) → 4·sqrt(x²y⁴)` was already correct under this rule — the numeric +factor comes out, the variable factors stay under the radical. + +## Scope / what is deferred + +**Higher even roots of a negative number** (`(-16)^(1/4)`) are the principal +complex value `|r|^(1/q)·(cos(π/q)+i·sin(π/q))` — exact only when the angle lands +on the engine's surd lattice (`(-16)^(1/4) = √2(1+i)`, π/4 is on it) and a nested +radical otherwise (`(-1)^(1/5)`, `sin 36°` is off it). We **leave all higher +even roots symbolic for now** rather than fold some and not others; the square +root (q = 2) is always exact (`sqrt(-c) = sqrt(c)·i`) and is what the reported +cases needed. Building the general lattice form for roots is the follow-up if a +consumer needs it. + +## Where it lives + +- [`normalize/simplify.rs`](../packages/math-expressions-rs/src/normalize/simplify.rs) — + `simplify_root` (the `sqrt`/`cbrt`/`nthroot` application form) and + `fold_numeric_radical` (the `b^(p/q)` power form) each gained the + negative-even-root branch; `principal_imaginary_sqrt` builds `m·i·sqrt(r)`. + +## Why grading was never at stake — for the *even*-root cases + +`equals` already evaluated all of the even-root cases above on the principal +complex branch, so it answered `sqrt(-4) == 2i` **true** before this change; +for those rows this was only ever a `simplify` / `.tree` display gap. + +**Addendum (2026-08-14, eleventh review pass):** for *odd* roots of negatives +grading **was** at stake, in exactly the gap this spec left: the perfect-power +rows above folded real while a non-perfect radicand (`(-2)^(1/3)`) still +*evaluated* principal, so the branch depended on whether the radicand was a +perfect power and four DoenetML `` cases regressed against legacy. The +real-branch rule now extends to the evaluators: `rule_radical`'s `Pow` arm +pulls the sign out of a non-perfect odd root at simplify time +(`(-2)^(1/3) → -2^(1/3)`), and `eval_complex`, `CBRT::eval1` and +`NTHROOT::eval2` read `(negative real)^(1/odd)` on the real branch for +sampling. Even roots stay exactly as this spec settled them. See +`active-plans/PR84_REVIEW_KNOWN_ISSUES.md` ("Fixed during review") and +`tests/odd_root_real_branch.rs`. + +## Verification + +- Rust: full suite green (658 passing), clippy clean. Tests in + `tests/doenet_open_items.rs` (`sqrt_of_negative_folds_to_principal_imaginary`, + `prefer_a_real_root_when_one_exists`, `a_variable_radicand_never_folds`, + `higher_even_root_of_a_negative_stays_symbolic_for_now`). +- js-compat differential: **zero change** (`4942 passed / 1353 failed` before and + after), zero regressions — the fold is behaviour-preserving on everything the + legacy corpus exercises. End-to-end through wasm in + `spec/quick_doenet_printer_and_rounding.spec.ts`. diff --git a/active-plans/SIGNED_ZERO_INVESTIGATION.md b/active-plans/SIGNED_ZERO_INVESTIGATION.md new file mode 100644 index 00000000..af412f60 --- /dev/null +++ b/active-plans/SIGNED_ZERO_INVESTIGATION.md @@ -0,0 +1,155 @@ +# Tracking signed zero (±0) so `1/-0 → -∞` + +Investigation of what it takes to distinguish `+0` from `-0` in the exact model +so that division reports a signed infinity: + +- `1/0 → +∞` (already works) +- `1/-0 → -∞` (wanted) +- `1/((-1)*0) → -∞` (wanted — sign must flow through a product into the zero) + +These are already encoded as **known failures** in the corpus: +`tests/fixtures/simplify-known-failures.json` lists `6/-0`, `-6/-0`, +`1/((-1)(0))`; `tests/fixtures/simplify-corpus.json` records the desired +`6/-0 → -Inf`, `-6/-0 → Inf`, and (crucially) `-0 → 0`. + +## Current model (why it's +∞ today) + +- Zero is only ever `Number::Int(0)` (or `Rat` num 0 / `Float(0.0)`). There is + **no exact signed zero**. Multiple comments assert this as a deliberate + invariant: `simplify.rs:254`, `simplify.rs:285`, `constructors.rs:160`. +- `a/b` canonicalises to `Mul(a, Pow(b, -1))`; a literal `1/0` becomes + `Pow(Num(0), Num(-1))`. +- `is_zero_pole` (`simplify.rs:286`) recognises `Pow(0, negative)` and + `fold_infnan_pow` (`simplify.rs:305`) folds it to `+∞` **unconditionally**. +- The numerator's sign is already handled correctly downstream: + `fold_infnan_mul` (`simplify.rs:344`) computes the product sign, so + `6·(+∞) → +∞` and `(−6)·(+∞) → −∞`. **So the only missing sign is the + pole's own sign** — get `Pow(-0, -1) → -∞` and the rest composes for free. +- `(-1)*0` collapses in `mul`: the coefficient folds to `Int(0)` and + `annihilate` (`constructors.rs:150`) returns a bare `Num(0)`, discarding the + sign before the reciprocal is ever taken. `Neg(0)` canonicalises to + `Mul(-1, 0)` and meets the same fate. + +## Core difficulty: signed zero is *contextual* and *crosses node boundaries* + +The sign is **created** in one node (`(-1)*0`, an inner `Mul`) and **consumed** +in another (the outer `Pow(_, -1)`), which are canonicalised in separate steps. +So the sign cannot be computed locally at the division site from the collapsed +denominator (it's already `0` by then) — it must be represented as a **value +that survives in the tree** between the two steps. + +But the corpus also demands `-0 → 0`: a signed zero must be **invisible +everywhere except as the base of a negative power**. It must print as `0`, +serialize as `0`, and compare equal to `0`. + +## Recommended approach: `Number::NegZero`, value-equal to `0` + +Add an exact negative-zero that **is** zero for every purpose except a single +sign-reading predicate. This makes it safe to appear anywhere in a canonical +tree (no "leak" cleanup needed) because it is indistinguishable from `0` to all +consumers that don't explicitly ask for its sign. + +### `num/number.rs` +- New variant `Number::NegZero` + `pub fn is_neg_zero(&self) -> bool`. +- `PartialEq` / `Hash`: **`NegZero == Int(0)`**, identical hash. (So + `Expr::Num(NegZero) == Expr::Num(Int(0))`, dedup/like-term folding/HashMap + keys all treat it as plain zero.) +- `is_zero() → true`, `is_negative() → false`, `is_positive() → false` + (it is zero, not a negative number), `abs() → +0`, `to_f64() → -0.0`, + `to_bigrational() → Some(0)`, `spelling() → Fraction`. +- Sign logic for a zero *result*: `mul` must compute the zero's sign as the + XOR of operand signs — `(-3)·(+0) → -0`, `(-0)·(-0) → +0`, `2·0 → +0`. + `neg`: `neg(0) → -0`, `neg(-0) → +0`. `add`/`sub`: minimal IEEE rules + (`-0 + -0 → -0`, `-0 + +0 → +0`); low classroom impact, define for + consistency. `checked_div`: `0/(-5) → -0`. +- **Mechanical fallout:** every exhaustive `match self { Number::… }` in + `number.rs` (add/sub/mul via `binop`, `neg`, `magnitude_log10`, + `round_to_decimals`, `rational_parts`, `checked_pow_int`, `to_f64`, + `is_positive`, `is_negative`, `is_zero`, …) needs a `NegZero` arm. The + compiler enumerates these; ~10 files hold `match` arms over `Number`, ~36 + files reference `Number::Int` (most are `matches!`/`if let`, unaffected). + +### `normalize/constructors.rs` +- `annihilate` must **preserve the coefficient's sign**: return `Num(coeff)` + (which may be `NegZero`) instead of hard-coded `Num(Number::zero())`, so + `(-1)*0` yields `Num(NegZero)`. The indeterminate (`0·∞ → NaN`) branch is + unchanged. Requires threading `coeff` into `annihilate` (both call sites at + `constructors.rs:310,332`). +- `pow`: no change strictly required — `Pow(NegZero, -1)` stays an unevaluated + node (like `Pow(0,-1)` today) and is folded in simplify. Optionally fold it + directly to `Const(NegInf)` here. + +### `normalize/simplify.rs` +- `fold_infnan_pow`: neg-zero base + negative exp → `NegInf`; plain zero → + `Inf` (today's behaviour). +- `is_zero_pole` → add a sign-returning form (e.g. `zero_pole_sign() -> + Option`) so `fold_infnan_mul` / `fold_infnan_add` pick up the pole's + sign instead of assuming `+∞`. +- Update the cluster doc comment (`simplify.rs:243-266`) — the "no signed zero" + divergence is being removed. + +### `ops/preserve_order.rs` +- The direct division fold (`preserve_order.rs:158-165`) computes `NegInf/Inf` + from the numerator's sign only; incorporate `d.is_neg_zero()`. + +### serde / print +- `expr/serde.rs:334` (`serialize_number`) needs a `NegZero => json!(0)` arm; + confirm every printer path renders `NegZero` as `0`. (Because it's + value-equal to zero, most paths that branch on `is_zero()` already do the + right thing.) + +### tests +- Remove `6/-0`, `-6/-0`, `1/((-1)(0))` from `simplify-known-failures.json`; + keep/verify `-0 → 0`. Add: `1/-0 → -Inf`, `1/((-1)*0) → -Inf`, + `1/(2*(-3)*0) → -Inf`, `0/(-5) → 0` (prints 0), `-0 == 0` (equality), + `(-0)^2 → 0`, `1/(0-0) → +Inf`. + +## Risks / caveats +- **"Equal-but-distinguishable" footgun.** A value that `== 0` yet carries + hidden state is a classic trap. Contained here because the sign is read in + exactly one cluster (pole folding) via `is_neg_zero()`; everywhere else it is + literally zero. Note the F64 wrapper already takes the *opposite* policy + (`+0.0 != -0.0`), so document the asymmetry. +- **Partial IEEE semantics.** Adopting signed zero for division but nowhere + else can surprise: `1/(0*(-1)) → -∞` while `1/(0-0) → +∞`. Both match IEEE, + but it's a philosophical shift away from the current "exact model has no + signed zero" stance — worth a deliberate sign-off. +- **Verify no Number interning/caching** silently swaps a `NegZero` for a + cached `Int(0)` before pole-folding runs (Sym is interned; Number appears not + to be — confirm). + +## Alternative (rejected): infinitesimals (signed ε) + +Model an annihilated zero as a signed infinitesimal ε (ε > 0, −ε < 0), so +`1/ε → +∞`, `1/(−ε) → −∞`, and `(−1)·0 → −ε → −∞` all fall out of ordinary +arithmetic instead of special-cased pole folding. Elegant, but rejected: + +- **Conflicts with the core requirement.** The corpus demands `-0 → 0`, + `-0 == 0`, and the normalizer leans on `0·x → 0` / sum-absorption (~132 + `is_zero()` sites). An ε is *by definition non-zero*, so a faithful ε breaks + all of these (`-ε ≠ 0`, `ε·x` doesn't drop `x`, `1 + ε ≠ 1`). The only fix — + take the standard part everywhere except under a reciprocal — *is* the + `NegZero` design above, but with a value that's harder to contain (every + `is_zero()` consumer must standard-part it first). +- **Dual numbers can't do it.** With ε² = 0, ε is a zero-divisor and `1/ε` is + undefined — no infinity. Needs a Levi-Civita / hyperreal-style *invertible* + ε, i.e. a full ordered-field numeric tower (higher orders, truncation, + ordering): far more than one `NegZero` variant. +- **Its extra power isn't wanted here.** Infinitesimals would enable limit + evaluation (`sin(x)/x → 1`) and signed `0/0` with orders (`2ε/3ε → 2/3`), + but the latter contradicts the deliberate `0/0 → 0`/NaN behaviour DoenetML + relies on (`constructors.rs:143`). That's a separate limit-engine project + (belongs with the calculus module), not a route to `1/-0 → -∞`. + +## Alternative (rejected): local sign detection at the division site +Detect the denominator's sign structurally when building the reciprocal, without +a signed-zero value. Rejected: the sign genuinely crosses node boundaries, so +this is fragile for nested products (`1/(2*((-1)*0))`) and duplicates the sign +logic that `mul` already performs. + +## Effort estimate +Small-to-medium. The design is well-scoped (single consumer: pole folding; +single new value: `NegZero`), and the corpus already pins the expected results. +The bulk of the work is the mechanical `match`-arm fallout in `number.rs` plus +careful handling of the `mul`/`annihilate` sign-preservation and the three +folding sites. ~1 focused day including corpus/tests. diff --git a/active-plans/STACK_SAFETY_PLAN.md b/active-plans/STACK_SAFETY_PLAN.md index d9bcdde4..7e7f86df 100644 --- a/active-plans/STACK_SAFETY_PLAN.md +++ b/active-plans/STACK_SAFETY_PLAN.md @@ -1,16 +1,69 @@ # Stack-safety plan: iterative traversals for a small-stack WASM target -> **PROGRESS (re-audited 2026-07-22):** item 22 (parser depth caps) is DONE — -> `MAX_PARSE_DEPTH = 64` in `parse/common.rs`, enforced by `enter`/`leave` in -> both parsers, exercised by `tests/stack_safety.rs`; `from_js` documents its -> reliance on serde_json's 128-depth limit. Items 21, 23–26 remain open: -> iterative `Drop`, `children()`/iterative fold driver, pass port, -> `opaque_key` replacement, small-stack CI. Shares a `children()` primitive -> with IMPROVEMENT Phase 3/4. - -Status: **draft for decision — nothing implemented.** Companion to -PORTING_PLAN.md §7f (resource limits). Scope: everything already implemented -(parsers, normalization, ordering, evaluation, equality, formatters, expr::serde). +> **PROGRESS (re-audited 2026-08-04):** +> +> - **21 (iterative `Drop`) — DONE.** `expr/teardown.rs`, a `Vec` +> worklist, wired into `impl Drop for Expression` at the wasm boundary +> (`math-expressions-rs-wasm/src-rust/lib.rs`). Deviates from the plan +> deliberately: a free function `tear_down`, not `impl Drop for Expr`, since a +> `Drop` impl makes every by-value `match` destructure an E0509 error. +> - **22 (parser depth caps) — DONE.** `MAX_PARSE_DEPTH = 64` in +> `parse/common.rs`, enforced by `enter`/`leave` in both parsers, exercised by +> `tests/stack_safety.rs`; `from_js` documents its reliance on serde_json's +> 128-depth limit. +> - **26 (verification) — PARTIAL.** `tests/stack_safety.rs` has both the +> 10⁵-deep-paren test and a small-stack test (at 256 KB, not the planned +> 128 KB). What remains is the `-zstack-size` flag and its documentation; +> there is no `.cargo/config.toml` in the repo, so `build-wasm.sh` is where it +> would go. +> - **23 — HALF DONE.** `Expr::children()` and `map_children` already exist in +> `expr/visit.rs` and are used in 27 files. What is missing is only the +> iterative driver itself: no `fold`, no `Step`/`Prune`, no explicit-stack +> traversal anywhere except `teardown::tear_down`. Note the two existing +> helpers are by-reference; the by-value gap is why `flatten` still hand-rolls +> a full variant match *in the same file*, and a `drain_children`/ +> `map_children_mut` is the missing third member of the family. +> - **24, 25 — OPEN, and the real remaining exposure.** No pass is iterative; +> `opaque_key` is unchanged. +> +> Two corrections to §2 below, which is optimistic: +> +> - The recursion inventory is not ~10 functions. Counted against source it is +> **~90 in the core crate plus 4 in the wasm crate** — every `ops/` transform, +> the `equality_structural` predicates, `calculus::diff`, `eval_exact::eval`, +> the polynomial walkers, and so on. +> - §3(c) says canonicalize "adds ≤1 level". That is per *node*: `Div(a,b) → +> Mul[a, Pow(b,−1)]` is ×2 down a `Div` spine, so a chain of nested divisions +> doubles in depth. +> +> Measured depth at which each pass overflows a **1 MB stack** (the wasm32 +> default), release profile, on a single-child `Neg` tower / a two-child `Add` +> tower: +> +> | pass | traps at (Neg / Add) | bytes per level | +> |---|---|---| +> | `flatten`, `serde::to_js` | 2,976 / 1,824 | ~360 / ~585 | +> | `print::to_text` | 2,048 / 1,824 | ~520 / ~585 | +> | `canonicalize` | 2,624 / 1,984 | ~405 / ~537 | +> | derived `Clone` | 3,104 / 3,264 | ~340 | +> | `eval_complex` | 5,440 / 5,440 | ~195 | +> | derived `Drop` (no `tear_down`) | 32,768 / 13,056 | ~33 / ~82 | +> | derived `PartialEq` / `Hash` | loop-optimized / 21,760, 32,768 | ~49, ~33 | +> +> In a **debug** build the same passes are 5–20× worse: `to_js` overflows at +> **126 levels**, which is *below* the 128 that `from_js` admits. Release wasm +> has ~15× margin against `from_js`; a debug host has none. +> +> Unrelated but previously conflated with this plan: a wasm panic reaching the +> browser as a bare `unreachable` was blamed on `panic = "abort"`. That was +> wrong — std runs the panic hook before aborting, and there simply was no hook. +> One is installed now (`panic_report` in the wasm crate's `lib.rs`), so a stack +> overflow or assertion failure says what it was. That improves *diagnosis*, not +> safety; 23–25 are still the fix. + +Companion to PORTING_PLAN.md §7f (resource limits). Scope: everything already +implemented (parsers, normalization, ordering, evaluation, equality, formatters, +expr::serde). ## 1. Problem diff --git a/active-plans/WHATS_LEFT.md b/active-plans/WHATS_LEFT.md index ad91f5a1..3662fde5 100644 --- a/active-plans/WHATS_LEFT.md +++ b/active-plans/WHATS_LEFT.md @@ -65,17 +65,17 @@ Draft — designed but **not started** (§B.4–B.6): LIMITS, FULL_SIMPLIFY, SINGULARITY_TRANSFORM. These are greenfield design docs with zero implementation; none is a partial port. -### B.1 STACK_SAFETY_PLAN — item 22 done; 21, 23–26 open (highest risk) +### B.1 STACK_SAFETY_PLAN — 21, 22 done, 26 partial; 23–25 open (highest risk) Recursive traversals can overflow the ~1 MB wasm32 shadow stack on deep expressions (including on `Drop` — freeing a deep tree crashes). Sequenced: -- [ ] 21. Iterative `Drop` for `Expr` (kills the "freeing the tree crashes" class) +- [x] 21. Iterative `Drop` for `Expr` (kills the "freeing the tree crashes" class) — `expr/teardown.rs`, a `Vec` worklist called from `impl Drop for Expression` in the wasm crate; a free function rather than `impl Drop for Expr`, which would make by-value `match` destructuring an E0509 error - [x] 22. Parser depth cap at the ~4 self-nesting entry points (`MAX_PARSE_DEPTH = 64`, `enter`/`leave` in both parsers, `tests/stack_safety.rs`); `from_js` documents its reliance on serde_json's 128-depth limit rather than a bespoke check -- [ ] 23. `children(&Expr)` helper + iterative post-order `fold` driver in `expr/tree.rs` +- [ ] 23. `children(&Expr)` helper + iterative post-order `fold` driver in `expr/tree.rs` — *half done*: `Expr::children()` and `map_children` exist in `expr/visit.rs` (27 files use them); the `fold`/`Step`/`Prune` driver does not, and neither does a by-value `drain_children` - [ ] 24. Port the ~8 passes to the driver, in dependency order: `flatten` → `canonicalize` → `cmp` → `eval_complex`/`free_symbols`/`contains_blank`/`coerce_seqs` → `to_js`/`from_js` → formatters → `convert_units_in_term` - [ ] 25. Replace `opaque_key`; decide whether to replace derived `PartialEq` with an iterative version (per frame-size measurement) -- [ ] 26. Verification: small-stack CI test (128 KiB threads), 10⁵-deep-paren inputs, document `-zstack-size` +- [ ] 26. Verification: small-stack CI test (128 KiB threads), 10⁵-deep-paren inputs, document `-zstack-size` — *partial*: `tests/stack_safety.rs` has the 10⁵-deep-paren test and a 256 KB small-stack test; the `-zstack-size` flag is still nowhere in the build config > Note: shares a `children()`/`for_each_child` primitive with IMPROVEMENT > Phase 3/4 (items 30, 32) — build it once. diff --git a/package-lock.json b/package-lock.json index 2f8467bb..72bab15e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3250,10 +3250,11 @@ "version": "3.0.0-alpha1", "license": "(GPL-3.0 OR Apache-2.0)", "dependencies": { - "math-expressions-rs-wasm": "*", "mathjs": "^15.2.0" }, "devDependencies": { + "math-expressions-rs-wasm": "*", + "typescript": "^5.9.3", "underscore": "^1.13.6", "vite": "^8.0.16", "vitest": "^4.1.8" diff --git a/packages/math-expressions-js-compat/README.md b/packages/math-expressions-js-compat/README.md index 1fda9359..dbb8dc5e 100644 --- a/packages/math-expressions-js-compat/README.md +++ b/packages/math-expressions-js-compat/README.md @@ -4,8 +4,9 @@ This directory is `math-expressions-js-compat`, but it is **published to npm as `math-expressions`** (v3 — see `package.json` `name`). It's a drop-in replacement for the original math-expressions JavaScript API, implemented in TypeScript on top of the Rust core (`math-expressions-rs`) compiled to wasm. It has no math of -its own — every method delegates to the wasm bindings — and preserves the legacy -synchronous surface. (The older published JS library is `2.0.0-alpha94`.) +its own — every method delegates to the wasm bindings, and the few converters +that stay in TypeScript (AST ↔ math.js nodes, AST → Guppy XML) only relabel +notation — and preserves the legacy synchronous surface. (The older published JS library is `2.0.0-alpha94`.) ```ts import me from "math-expressions"; // the published name @@ -16,21 +17,39 @@ f.equals(me.fromText("1")); // true me.fromText("x^2").derivative("x").toString(); // "2 x" ``` -## Alpha limitation: wasm handle lifetimes +> **Using this from DoenetML?** See +> [`DOENET_INTEGRATION.md`](../../active-plans/DOENET_INTEGRATION.md) — the behavior changes, +> the known blockers, and the one request still open (wasm32 stack safety). + +## wasm handle lifetimes Every `Expression` this package returns wraps a Rust/wasm handle that owns memory -in the wasm heap. **This alpha does not free caller-owned handles** — there is no -`FinalizationRegistry`/GC auto-free (an earlier attempt corrupted the wasm heap -under rapid handle churn and was removed; see the playground's `engines.ts`). The -wrapper frees only its own short-lived internal temporaries (converters, tree-op -routing, `substitute` intermediates); expressions handed back to you are yours. - -For scripts and test runs this is harmless — the process exits and reclaims all -wasm memory. **Long-lived hosts** (a persistent worker, a server, a long-running -notebook) that create many expressions will accumulate wasm memory for the life -of the process. Until a public disposal API is exposed on the compat -`Expression`, avoid creating unbounded numbers of expressions in one long-lived -process. +in the wasm heap. **The wrapper does not free the handles it hands back** — it +frees only its own short-lived internal temporaries (converters, tree-op routing, +`substitute` intermediates). Yours are yours. + +They are not leaked outright: wasm-bindgen's generated glue registers each class +with a `FinalizationRegistry`, so a handle no longer reachable from JS is +released once the GC gets to it. But that is non-deterministic and can lag far +behind allocation, which is too late for a long-lived host that mints a handle +per evaluation. + +So release them eagerly where it matters: + +```ts +const e = me.fromText("x^2"); +try { + /* … */ +} finally { + e.free(); // alias: e.dispose(); also `using e = me.fromText(…)` +} +``` + +`free()` is idempotent, and a method call on a freed expression raises a +`TypeError` (naming the wasm method it tried to reach) rather than reading +through a dangling pointer. +For scripts and test runs none of this matters — the process exits and reclaims +all wasm memory. ## Layout @@ -38,48 +57,117 @@ process. (the `Context`/`me` factory + `Expression`); the other files mirror the old `lib/**` module paths (`trees/`, `converters/`, `assumptions/`, `expression/`) so unchanged specs that import `../lib/...` resolve here. -- `lib/wasm-types.ts` / `lib/_wasm.ts` — typed structural surface for the wasm - module and its synchronous loader. -- `vendor/wasm/` — the generated wasm bindings (git-ignored; build below). +- `lib/_wasm.ts` — the swappable wasm provider: the Node fallback loader and the + `setWasmModule` injection point a browser host calls. The typed surface of the + wasm module itself lives in `math-expressions-rs-wasm`'s `src-js/wasm.ts`. +- `types/math-expressions.d.ts` — the published type contract (`exports["."]`'s + `types`). Hand-written; `npm run typecheck` compiles `types/usage.ts` + against it. +- `vendor/wasm/`, `vendor/wasm-web/` — the generated wasm bindings, one per + target (git-ignored; build below). +- `scripts/` — `verify-package.mjs` and the two consumer programs it runs. - `spec/` — the original suite, copied verbatim from `tmp/js-legacy/spec` and renamed to `.spec.ts`. These run against this package. ## Build the wasm (required before tests) ``` -./build-wasm.sh # cargo build --target wasm32 + wasm-bindgen --target nodejs +./build-wasm.sh # both targets +./build-wasm.sh nodejs # or just one ``` -This emits a **nodejs-target** (synchronous) wasm-bindgen package into -`vendor/wasm/`, so the legacy synchronous API works with no `await`, and it loads -directly under Node / Vitest. A browser (`--target web`, async-init) build is -future work. +Two wasm-bindgen packages come out of one `cargo build`, and both are published: + +- `vendor/wasm/` — **nodejs** target. Instantiates synchronously at `require()` + time, so the legacy synchronous API works with no `await`. `lib/_wasm.ts` + loads it by itself when nothing was injected, which is why Node consumers need + no setup at all. +- `vendor/wasm-web/` — **web** target. ESM, with an async `default()` and a + synchronous `initSync()`. A browser or Web Worker cannot use the nodejs build, + so such a host instantiates this one from bytes and passes it to + `setWasmModule`. Shipping it is what lets a browser host consume this package + from npm without cargo — see "Publishing" below. ## Test ``` npm test # vitest run +npm run typecheck # tsc over lib/ and over the published declarations ``` -The suite is the legacy JS test corpus. It is **not expected to fully pass** yet: -the Rust core is intentionally not byte-for-byte identical (clean-slate -formatter, folded normalization passes) and some legacy areas are unported -(polynomial/Groebner, mathjs/guppy/MathML converters, richly-structured -`get_assumptions`). Those specs still *run* and fail per-assertion. See +The suite is the legacy JS test corpus and it passes: 6,383 tests, 6,372 +passing, 11 skipped, nothing failing. One of those skips is a divergence rather +than an unported feature — `slow_assumptions.spec.ts` → `logical combinations`, +on which **legacy commits to answers this engine declines to give**; the engine +is incomplete there, never unsound. It is skipped rather than left red because +the CI job gates, and a permanently red test would make that job unable to +report anything else. Vitest aborts +an `it` at its first failure, so the single failing test name hides **six** +failing assertions (spec lines 7357, 7415, 7417, 7418, 7419, 7420 — count them +by converting that `it`'s `expect` to `expect.soft`), from **two** unrelated +root causes: `Facts::and_meet` declining under contradictory premises where +legacy takes `left || right`, and non-realness never propagating through +`+`/`*`/`^` in `assumptions/infer/combine/mod.rs`. Both are written up in +`../../active-plans/COMPAT_TEST_FAILURE_SUMMARY.md`. Some legacy areas remain unported +(richly-structured `get_assumptions`; the MathML converters are ported, but +`Context.fromMml` is still `notImplemented` and `me.from` does not try MathML as +its third fallback the way legacy's `create_from_multiple` did). See `../../active-plans/JS_TEST_COVERAGE_AUDIT.md` for the coverage ledger. -**Known exclusion:** `spec/slow_check-symbolic-equality-numerical-errors.spec.ts` -is present but excluded from the run (see `vite.config.ts`). It exercises -`equalsViaSyntax` with number tolerance (unimplemented — Rust's `equals_syntactic` -is exact) and, on a perturbed exp/log input, drives a *synchronous* wasm call -into a long/hung computation the Vitest timeout cannot interrupt. Re-enable once -the core guards that input. +`typecheck` covers `lib/**` and `types/**`, not `spec/**`. The specs are the +legacy JS suite renamed to `.spec.ts` and carry thousands of type errors; +typing them is separate work. Keeping `lib/**` at zero is what makes +`math-expressions-rs-wasm`'s `src-js/wasm.ts` an enforced contract rather than +a claimed one. + +Nothing is excluded: `vite.config.ts` runs every `spec/**/*.spec.ts`. (This +paragraph used to record `slow_check-symbolic-equality-numerical-errors.spec.ts` +as excluded for hanging on a perturbed exp/log input. It has not been excluded +for some time, and it passes.) + +`spec/build_esm.spec.ts` and `spec/build_umd.spec.ts` are the exception to +"the suite tests `lib/`": they load `dist/`, the artifact a consumer installs. +They skip themselves when `dist/` is absent, so run `npm run build` first — or +run them the way CI does, after `npm run build:package`. ## Build the library ``` npm run build # vite build (ES + UMD) +npm run build:package # both wasm targets + the rs-wasm bindings + the above ``` -> The Vite browser build is scaffolded but not yet wired to a browser-target -> wasm; the node/vitest path (via `createRequire`) is the supported one today. +`build:package` is what `prepack` runs, so `npm pack` and `npm publish` produce a +complete tarball on their own. + +math.js is the one runtime dependency left as a bare import rather than inlined: +it is 95% of the bundle otherwise (1,002 kB inlined against 48 kB external), it +is in `dependencies` so npm resolves it, and a private copy would be a second +`math.create(math.all)` instance as well as dead weight. The UMD build resolves +it through the global `math`, as UMD externals must. + +`math-expressions-rs-wasm` is the opposite case: it is a workspace-internal +package that is *not* published, so the build inlines it and it sits in +`devDependencies`. Leaving it in `dependencies` made `npm install` of this +package fail outright with a registry 404. + +## Publishing + +``` +npm run verify:package # what CI's "package publishability" job runs +``` + +That packs the tarball, installs it into a throwaway project outside the +workspace, and drives it through both supported loading paths. Inside the +monorepo everything resolves through workspace symlinks and every build output +is already present, so it is the only check that sees what a consumer sees. + +What the tarball has to contain, and why: + +| | | +| --- | --- | +| `dist/` | the built entry point `main`/`exports` name. Git-ignored, so `prepack` builds it. | +| `types/math-expressions.d.ts` | `exports["."]`'s `types`. Without it every TypeScript consumer sees `any`. | +| `vendor/wasm/` | the Node fallback, loaded by `lib/_wasm.ts` with no host cooperation. | +| `vendor/wasm-web/` | the `--target web` build, exported as `math-expressions/wasm-web/*`. A browser host injects it through `setWasmModule`; without it, consuming this package in a browser still needs cargo. | +| `lib/` | the TypeScript sources, under `exports["./lib/*"]`. Read-only in practice: they import `math-expressions-rs-wasm` by bare specifier, and that package is bundled into `dist/` rather than published, so a consumer compiling `lib/` itself has to alias it. Take `dist/`. | diff --git a/packages/math-expressions-js-compat/build-wasm.sh b/packages/math-expressions-js-compat/build-wasm.sh index 8147455e..0c7f8392 100755 --- a/packages/math-expressions-js-compat/build-wasm.sh +++ b/packages/math-expressions-js-compat/build-wasm.sh @@ -1,13 +1,46 @@ #!/usr/bin/env bash -# Build the *nodejs-target* wasm-bindgen package for js-compat into ./vendor/wasm -# (git-ignored). The nodejs target instantiates the wasm synchronously at -# require() time — no async init — so the original synchronous math-expressions -# API (me.fromText(...).equals(...), no await) works under vitest's node runner. +# Build the wasm-bindgen packages this package ships, into ./vendor (git-ignored). +# Both targets are built, because both are published: +# +# vendor/wasm --target nodejs — the fallback `lib/_wasm.ts` loads through +# `createRequire` when nothing was injected. It instantiates +# the wasm synchronously at require() time, so the original +# synchronous API (me.fromText(...).equals(...), no await) +# works under Node and vitest with no init step. +# +# vendor/wasm-web --target web — ESM with an async `default()` and a +# synchronous `initSync()`, for browser and Web Worker hosts. +# Those cannot use the nodejs build, so they instantiate this +# one from bytes and hand it to `setWasmModule` (see +# `lib/_wasm.ts`). Shipping it is what lets such a host — for +# instance DoenetML — consume this package from npm with no +# Rust toolchain of its own; building it here is the only +# place cargo is needed. +# +# Pass a single target name to build just one (`./build-wasm.sh nodejs`). +# # Delegates to the single source-of-truth build script in math-expressions-rs-wasm; -# the vendor dir is kept separate from that package's shared pkg/ (which the +# the vendor dirs are kept separate from that package's shared pkg/ (which the # playground builds with --target web) to avoid clobber. # Requires: rustup target add wasm32-unknown-unknown + wasm-bindgen-cli matching # the wasm-bindgen crate version in Cargo.toml. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -exec bash "$HERE/../math-expressions-rs-wasm/build-wasm.sh" nodejs "$HERE/vendor/wasm" +BUILD="$HERE/../math-expressions-rs-wasm/build-wasm.sh" + +targets=("$@") +if [ "${#targets[@]}" -eq 0 ]; then + targets=(nodejs web) +fi + +# Both targets share one `cargo build`; only wasm-bindgen runs twice. +for target in "${targets[@]}"; do + case "$target" in + nodejs) bash "$BUILD" nodejs "$HERE/vendor/wasm" ;; + web) bash "$BUILD" web "$HERE/vendor/wasm-web" ;; + *) + echo "build-wasm.sh: unknown target '$target' (expected nodejs or web)" >&2 + exit 2 + ;; + esac +done diff --git a/packages/math-expressions-js-compat/lib/_wasm.ts b/packages/math-expressions-js-compat/lib/_wasm.ts index ef25f1e5..2a1aebf5 100644 --- a/packages/math-expressions-js-compat/lib/_wasm.ts +++ b/packages/math-expressions-js-compat/lib/_wasm.ts @@ -1,17 +1,110 @@ -// Synchronous loader for the Rust core's nodejs-target wasm bindings. +// Swappable provider for the Rust core's wasm bindings. // -// `build-wasm.sh` emits a CommonJS wasm-bindgen package into ../vendor/wasm that -// instantiates the wasm at require() time — no async init — so the original -// synchronous math-expressions API (`me.fromText(x).equals(y)`, no await) works -// as-is under Node / Vitest. We reach it through createRequire so the raw CJS -// module (and its `require('fs')` wasm read) bypasses the bundler transform. +// Two hosts, one seam: // -// Browser builds would instead use a --target web wasm + async init; that path -// is future work (see README). -import { createRequire } from "node:module"; +// • Node / Vitest — the default. The vendored *nodejs-target* build +// (`build-wasm.sh` → ../vendor/wasm) instantiates the wasm synchronously at +// require() time, so the synchronous math-expressions API +// (`me.fromText(x).equals(y)`, no await) works with no init step. Loaded +// lazily on first use through `createRequire`, so the raw CJS module (and +// its `require('fs')` wasm read) bypasses the bundler transform. +// +// • Browser / Web Worker — call {@link setWasmModule} with an already- +// initialized `--target web` module (after its `initSync(bytes)`) *before* +// the first parse. The `--target web` build must be instantiated from bytes +// (no `fetch`: the VS Code web-worker host blocks blob/data-URL fetch); the +// inlining + `initSync` glue is the host's, per DOENET_COMPAT_PLAN R1. Once +// injected, the node fallback below is never reached. +// +// This file deliberately has **no import of `node:module`**. A static import +// would be evaluated by a browser bundle even when `setWasmModule` is called +// first — marking the specifier external does not help, since the browser then +// has to resolve `node:module` at run time and cannot. Reaching the builtin +// through `process.getBuiltinModule` instead leaves nothing for a bundler to +// resolve: browser builds see only a `globalThis.process` probe, and no host- +// specific aliasing or stubbing is required to bundle this package. +// +// Everything downstream imports the default export and calls `wasm.parse_text(…)` +// etc.; the Proxy forwards each access to whichever module is current, so an +// injection that happens after this module is imported is still honored. import type { WasmModule } from "math-expressions-rs-wasm"; -const require = createRequire(import.meta.url); -const wasm = require("../vendor/wasm/math_expressions_wasm.js") as WasmModule; +/** `process.getBuiltinModule` — Node ≥ 20.16 / ≥ 22.3, absent in browsers. */ +type BuiltinModuleHost = { + getBuiltinModule?: (id: string) => { + createRequire(path: string): (id: string) => unknown; + }; +}; + +let injected: WasmModule | undefined; +let nodeFallback: WasmModule | undefined; +const swapListeners: Array<() => void> = []; + +/** + * Register a callback to run when {@link setWasmModule} swaps in a different + * module. + * + * Anything that caches a wasm *handle* has to drop it here: a handle belongs to + * the module that minted it, and handing one to a different module's function + * fails with "expected instance of Expression". The listener seam (rather than + * this file reaching into the caches) keeps the dependency one-way — + * `math-expressions.ts` imports `_wasm`, never the reverse. + */ +export function onWasmModuleChange(fn: () => void): void { + swapListeners.push(fn); +} + +/** + * Inject the wasm module to use — an initialized `--target web` wasm-bindgen + * module (post-`initSync`) for browser/worker hosts where the synchronous node + * loader is unavailable. Call once, before any parsing. Overrides the node + * fallback for every subsequent call. + */ +export function setWasmModule(mod: WasmModule): void { + const changed = injected !== mod; + injected = mod; + // Re-injecting the *same* module is a no-op, so caches keep their entries. + if (changed) { + for (const fn of swapListeners) fn(); + } +} + +/** + * Load the vendored nodejs-target build. Deferred so a browser host that + * injects first never reaches it, and so the `process` probe never runs where + * there is no `process`. + */ +function loadNodeFallback(): WasmModule { + const proc = (globalThis as { process?: BuiltinModuleHost }).process; + const mod = proc?.getBuiltinModule?.("node:module"); + if (!mod) { + throw new Error( + "math-expressions: no wasm module available. Outside Node, call " + + "`setWasmModule(mod)` with an initialized `--target web` wasm-bindgen " + + "module (after its `initSync(bytes)`) before parsing anything.", + ); + } + const req = mod.createRequire(import.meta.url); + return req("../vendor/wasm/math_expressions_wasm.js") as WasmModule; +} + +/** The module currently in effect: an injected one, else the node vendored build. */ +function current(): WasmModule { + if (injected) return injected; + if (!nodeFallback) { + nodeFallback = loadNodeFallback(); + } + return nodeFallback; +} + +// A Proxy so `import wasm from "./_wasm"` stays a stable reference that always +// reflects the current module (injected or node), resolved on first access. +const wasm = new Proxy({} as WasmModule, { + get: (_target, prop) => { + const mod = current(); + return Reflect.get(mod as object, prop, mod); + }, + has: (_target, prop) => prop in (current() as object), +}); export default wasm; diff --git a/packages/math-expressions-js-compat/lib/assumptions/element_of_sets.ts b/packages/math-expressions-js-compat/lib/assumptions/element_of_sets.ts index 90e1fb44..7976052b 100644 --- a/packages/math-expressions-js-compat/lib/assumptions/element_of_sets.ts +++ b/packages/math-expressions-js-compat/lib/assumptions/element_of_sets.ts @@ -1,16 +1,36 @@ // The `is_integer` / `is_real` / … predicates. Each takes an Expression (and an // optional assumptions source) and returns true / false / undefined, mapping to // the wasm `Assumptions` three-valued predicates. -import wasm from "../_wasm"; +import wasm, { onWasmModuleChange } from "../_wasm"; +import Context from "../math-expressions"; -const EMPTY = new wasm.Assumptions(); +// Constructed lazily, like `Context._assumptionsHandle` and for the same +// reason: a `new wasm.Assumptions()` evaluated in this module's body would +// force the wasm load before a host had any chance to `setWasmModule`. And +// dropped on a swap for the other reason a cached handle must be: it belongs to +// the module that minted it. +let emptyCache; +const empty = () => (emptyCache ??= new wasm.Assumptions()); +onWasmModuleChange(() => { + emptyCache = undefined; +}); function handleFor(assumptions) { - if (!assumptions) return EMPTY; - // Our Context exposes its live handle as `.assumptions`. + // No explicit source: consult the context's live global assumptions, so + // `is_real(me.fromText("x+y"))` sees `me.add_assumption(...)` state. The + // original JS predicates defaulted to the global store this way; falling + // back to an empty one made every no-argument query answer "unknown". + // `Context.assumptions` is the *facade*, not the handle — it mirrors the + // predicate methods, so calling one on it works, and it is a lazily built + // object that is never nullish (hence no fallback here). Reaching through it + // to `_assumptionsHandle` would be the tidier symmetry with the branch below, + // but the facade is what the rest of the API hands out, so this keeps one + // answer to "what are the current assumptions". + if (!assumptions) return Context.assumptions; + // A facade passed in explicitly, whose live handle is what the caller means. if (assumptions._assumptionsHandle) return assumptions._assumptionsHandle; if (typeof assumptions.is_integer === "function") return assumptions; // a raw handle - return EMPTY; + return empty(); } function rawExpr(expression) { diff --git a/packages/math-expressions-js-compat/lib/assumptions/index.ts b/packages/math-expressions-js-compat/lib/assumptions/index.ts new file mode 100644 index 00000000..47ee8d91 --- /dev/null +++ b/packages/math-expressions-js-compat/lib/assumptions/index.ts @@ -0,0 +1,5 @@ +// Barrel for the assumptions machinery: the three-valued predicates that read +// the wasm store, and the marshalling behind `me.add_assumption` / +// `me.get_assumptions`. +export * from "./element_of_sets"; +export * as store from "./store"; diff --git a/packages/math-expressions-js-compat/lib/assumptions/store.ts b/packages/math-expressions-js-compat/lib/assumptions/store.ts new file mode 100644 index 00000000..3c5f121c --- /dev/null +++ b/packages/math-expressions-js-compat/lib/assumptions/store.ts @@ -0,0 +1,115 @@ +// The assumption store's JS side: the handle, and the marshalling around it. +// +// The wasm `Assumptions` handle answers *predicates* (`is_real(x+y)`) and, in +// its other half, holds the same facts as trees — filed per variable, chained +// into their consequences, and handed back with the queried variable on the +// left. All of that is `assumptions::TreeStore` in the core. +// +// What is left here is the shape of the legacy API, which is a JS shape and not +// a reasoning question: every entry point takes an Expression *or* a raw tree, +// the query takes a params object, and the answer is a tree rather than a +// handle. That is the glue the plan names as an accepted exception. + +import { get_tree } from "../trees/util"; +import { astToJson, jsonToAst } from "../converters/ast-json"; + +/** A wasm `Assumptions` handle. */ +type Handle = any; + +/** + * The JSON spelling of an assumption, or undefined when there is not one. + * + * An empty assumption is a no-op rather than an error: the spec tables drive + * `me.add_assumption(me.from(input))` over rows whose input is undefined, + * meaning "no assumptions for this row". + */ +function json(expr_or_tree: any): string | undefined { + const tree = get_tree(expr_or_tree); + if (!Array.isArray(tree)) return undefined; + return astToJson(tree); +} + +export function add_assumption( + handle: Handle, + expr_or_tree: any, + exclude_generic?: boolean, +): number { + const tree = json(expr_or_tree); + return tree === undefined + ? 0 + : handle.add_ast(tree, Boolean(exclude_generic)); +} + +export function add_generic_assumption( + handle: Handle, + expr_or_tree: any, +): number { + const tree = json(expr_or_tree); + return tree === undefined ? 0 : handle.add_generic_ast(tree); +} + +export function remove_assumption(handle: Handle, expr_or_tree: any): number { + const tree = json(expr_or_tree); + return tree === undefined ? 0 : handle.remove_ast(tree); +} + +export function remove_generic_assumption( + handle: Handle, + expr_or_tree: any, +): number { + const tree = json(expr_or_tree); + return tree === undefined ? 0 : handle.remove_generic_ast(tree); +} + +/** + * Everything known about a variable, a list of variables (`[["a","b"]]`) or an + * expression, as a tree stating it — undefined when nothing is known. + * + * The query is passed through as written: which of the three shapes it is + * decides how it is answered, and the core decides that, since the first two + * are not expressions and would not survive being parsed as one. + */ +export function get_assumptions( + handle: Handle, + variables_or_expr: any, + params: any = {}, +): any { + const query = get_tree(variables_or_expr); + if (query === undefined) return undefined; + + let exclude_variables = params.exclude_variables; + if (exclude_variables === undefined) exclude_variables = []; + else if (!Array.isArray(exclude_variables)) + exclude_variables = [exclude_variables]; + + const out = handle.get_ast( + astToJson(query), + exclude_variables.map(String), + Boolean(params.omit_derived), + ); + return out === undefined ? undefined : jsonToAst(out); +} + +/** + * The inspection surface the legacy store object carried: the facts per + * variable, the ones derived from combining them, and the generic assumption. + * A variable recorded with no fact comes back as `null` over the wire and is + * restored to `undefined` here. + */ +export function byvar(handle: Handle): Record { + return revive(jsonToAst(handle.byvar_ast()) as Record); +} + +export function derived(handle: Handle): Record { + return revive(jsonToAst(handle.derived_ast()) as Record); +} + +export function generic(handle: Handle): any { + const g = jsonToAst(handle.generic_ast()); + return g === null ? undefined : g; +} + +function revive(map: Record): Record { + for (const k of Object.keys(map)) if (map[k] === null) map[k] = undefined; + return map; +} diff --git a/packages/math-expressions-js-compat/lib/converters/ast-json.ts b/packages/math-expressions-js-compat/lib/converters/ast-json.ts new file mode 100644 index 00000000..9409f3ea --- /dev/null +++ b/packages/math-expressions-js-compat/lib/converters/ast-json.ts @@ -0,0 +1,59 @@ +// Encoding an AST as the JSON the wasm `from_ast` reads. +// +// `JSON.stringify` has no representation for ±Infinity or NaN — it emits +// `null`, which `from_ast` rejects outright ("unexpected value null"). The +// wire format tags them instead, so anything handing a tree to wasm has to +// replace them on the way out. This lives on its own so the converters and +// `Expression.fromAst` tag them identically rather than one of them forgetting. + +/** The tagged form of a non-finite number, or the value unchanged. */ +export function tagNonFinite(value: unknown): unknown { + if (typeof value === "number" && !Number.isFinite(value)) { + if (Number.isNaN(value)) return { $: "NaN" }; + return { $: value > 0 ? "Inf" : "-Inf" }; + } + return value; +} + +/** `JSON.stringify` of a plain AST, with non-finite numbers tagged. */ +export function astToJson(ast: unknown): string { + return JSON.stringify(ast, (_key, value) => tagNonFinite(value)); +} + +/** + * The three tags that have a JS scalar, as a null-prototype lookup so a tag + * spelled `constructor` or `toString` cannot match an inherited property. + * + * `None` is deliberately absent: `{"$":"None"}` has no JS scalar to become, so + * it stays tagged in both directions. DoenetML emits it itself and reads it + * back unchanged. + */ +const UNTAGGED: Record = Object.assign(Object.create(null), { + Inf: Infinity, + "-Inf": -Infinity, + NaN: NaN, +}); + +/** + * `JSON.parse` reviver that turns the non-finite tags back into JS scalars — + * the inverse of [`tagNonFinite`], and the reason `.tree` reads as `Infinity` + * rather than `{"$":"Inf"}`. + * + * The wire format has to stay tagged (JSON cannot hold `Infinity`), but the + * *value* a caller sees should be the scalar legacy handed back, because that + * is what `typeof x === "number"` and `x === -Infinity` consumers test. Going + * back in, `astReplacer` re-tags, so a tree survives a `.tree` → `fromAst` + * round trip unchanged. + */ +export function untagNonFinite(_key: string, value: unknown): unknown { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + const tag = (value as { $?: unknown }).$; + if (typeof tag === "string" && tag in UNTAGGED) return UNTAGGED[tag]; + } + return value; +} + +/** `JSON.parse` of a wasm-produced AST, with non-finite tags decoded. */ +export function jsonToAst(json: string): unknown { + return JSON.parse(json, untagNonFinite); +} diff --git a/packages/math-expressions-js-compat/lib/converters/ast-to-guppy.ts b/packages/math-expressions-js-compat/lib/converters/ast-to-guppy.ts index 3f6e7783..7123f7d9 100644 --- a/packages/math-expressions-js-compat/lib/converters/ast-to-guppy.ts +++ b/packages/math-expressions-js-compat/lib/converters/ast-to-guppy.ts @@ -1,9 +1,368 @@ -// Compat stub: 'astToGuppy' has no Rust equivalent in the port (see -// active-plans/JS_TEST_COVERAGE_AUDIT.md). Constructing the converter and -// importing the module both succeed so specs still collect and run; only -// `convert()` throws, failing just those tests. -export default class { - convert(): never { - throw new Error("math-expressions-js-compat: astToGuppy is not implemented"); +/* + * math-expressions AST → Guppy XML. + * + * Ported from the legacy `lib/converters/ast-to-guppy.js`. Guppy is an editable + * math widget whose document is XML: `` nodes hold literal text and `` + * nodes are templates carrying their own LaTeX/plaintext renderings plus one + * `` child per editable slot. Emitting it is notation only, so it stays in + * TypeScript rather than crossing into the Rust core. + * + * The grammar is the usual expression/term/factor cascade; each level decides + * whether its operands need parentheses before handing them to an emitter. + * + * Copyright 2017 by Jim Fowler + * + * This file is part of a math-expressions library + * + * math-expressions is free software: you can redistribute + * it and/or modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or at your option any later version. + * + * math-expressions is distributed in the hope that it + * will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + */ + +import type { Tree } from "math-expressions-rs-wasm"; + +/** + * The AST as callers hand it over: a plain array literal such as + * `["+", 1, "x"]` widens to `(string | number)[]`, which is not assignable to + * `Tree`'s `[tag, ...operands]` tuple. Narrowed to `Tree` once, at `convert`. + */ +export type AstInput = Tree | AstInput[]; + +// --------------------------------------------------------------------------- +// Guppy `` templates +// --------------------------------------------------------------------------- + +function dfrac(a: string, b: string): string { + return ( + '\\dfrac{}{}\\frac{}{}()/()' + + a + + '' + + b + + "" + ); +} + +/** One-argument named function (`sin`, `log`, `exp`, …) — one editable slot. */ +function trig(name: string, parameter: string): string { + return ( + '\\' + + name + + '\\left(\\right) ' + + name + + '()' + + parameter + + "" + ); +} + +function sqrt(x: string): string { + return ( + '\\sqrt{}sqrt()' + + x + + "" + ); +} + +function power(x: string, y: string): string { + return ( + '{}^{}()^()' + + x + + '' + + y + + "" + ); +} + +function abs(x: string): string { + return ( + '\\left|\\right|abs()' + + x + + "" + ); +} + +function paren(x: string): string { + return ( + '\\left(\\right)()' + + x + + "" + ); +} + +/** + * Emitters keyed by AST operator. Every operand has already been rendered to + * Guppy XML by the caller, which is also where parenthesization is decided. + */ +const operators: Record string> = { + "+": (operands) => operands.join("+"), + // Unary minus: the sign lives inside the `` so `factor` can spot it by + // the leading `-` and re-parenthesize when it appears as an operand. + "-": (operands) => "-" + operands.join("-") + "", + "*": (operands) => + operands.join( + '\\cdot*', + ), + "/": (operands) => dfrac(operands[0], operands[1]), + "^": (operands) => power(operands[0], operands[1]), + sin: (operands) => trig("sin", operands[0]), + cos: (operands) => trig("cos", operands[0]), + tan: (operands) => trig("tan", operands[0]), + arcsin: (operands) => trig("arcsin", operands[0]), + arccos: (operands) => trig("arccos", operands[0]), + arctan: (operands) => trig("arctan", operands[0]), + arccsc: (operands) => trig("arccsc", operands[0]), + arcsec: (operands) => trig("arcsec", operands[0]), + arccot: (operands) => trig("arccot", operands[0]), + csc: (operands) => trig("csc", operands[0]), + sec: (operands) => trig("sec", operands[0]), + cot: (operands) => trig("cot", operands[0]), + log: (operands) => trig("log", operands[0]), + exp: (operands) => trig("exp", operands[0]), + ln: (operands) => trig("ln", operands[0]), + sqrt: (operands) => sqrt(operands[0]), + abs: (operands) => abs(operands[0]), + //"factorial": function(operands) { return operands[0] + "!"; }, +}; + +// The legacy file was damaged by an over-eager `factorial` → `this.factorial` +// find/replace (the same one that produced its "math-this.expressions" header). +// Kept verbatim so behavior matches: with no `factorial` emitter above, the +// name below is the only thing keeping factorial out of the function branch. +const FACTORIAL = "this.factorial"; + +const functionSymbols = [ + "sin", + "cos", + "tan", + "csc", + "sec", + "cot", + "arcsin", + "arccos", + "arctan", + "arccsc", + "arcsec", + "arccot", + "log", + "ln", + "exp", + "sqrt", + "abs", + FACTORIAL, +]; + +function isFunctionSymbol(symbol: string): boolean { + return functionSymbols.includes(symbol); +} + +const greekSymbols = [ + "pi", + "theta", + "Theta", + "alpha", + "nu", + "beta", + "xi", + "Xi", + "gamma", + "Gamma", + "delta", + "Delta", + "Pi", + "epsilon", + "rho", + "zeta", + "sigma", + "Sigma", + "eta", + "tau", + "upsilon", + "Upsilon", + "iota", + "phi", + "Phi", + "kappa", + "chi", + "lambda", + "Lambda", + "psi", + "Psi", + "omega", + "Omega", +]; + +function isGreekLetterSymbol(symbol: string): boolean { + return greekSymbols.includes(symbol); +} + +// --------------------------------------------------------------------------- +// Converter +// --------------------------------------------------------------------------- + +/** `new astToGuppy().convert(["+", 1, "x"])` → `"1+x"`. */ +export default class astToGuppy { + /* + factor = + '(' expression ')' | + number | + variable | + function factor | + factor '^' factor + '-' factor | + nonMinusFactor + */ + + factor(tree: Tree): string { + if (typeof tree === "string") { + if (isGreekLetterSymbol(tree)) { + return ( + '\\' + + tree + + ' $' + + tree + + "" + ); + } + + return "" + tree + ""; + } + + if (typeof tree === "number") { + return "" + tree + ""; + } + + if (!Array.isArray(tree)) { + return ""; + } + + let operator = tree[0]; + let operands: Tree[] = tree.slice(1); + + // `["apply", f, arg]` renders as whatever `f` renders as. + if (operator === "apply") { + operator = tree[1] as string; + operands = tree.slice(2); + } + + // No emitter for this operator — an unported function symbol, or `~`, + // which the legacy file dispatched on without ever adding a `"~"` entry. + // Falling through to the parenthesized default beats a `TypeError` out of + // `operators[operator](...)`. + const emit = operators[operator as string]; + if (!emit) { + return paren(this.expression(tree)); + } + + // Absolute value doesn't need any special parentheses handling, but its + // operand is really an expression + if (operator === "abs") { + return emit(operands.map((v) => this.expression(v))); + } else if (isFunctionSymbol(operator)) { + // A short or purely numeric factorial argument needs no grouping. + if ( + operator === FACTORIAL && + (String(operands[0]).length === 1 || + /^[0-9]*$/.test(String(operands[0]))) + ) + return emit(operands.map(String)); + + return emit(operands.map((v) => this.factor(v))); + } + + if (operator === "^" || operator === "~") { + return emit(operands.map((v) => this.factor(v))); + } + + return paren(this.expression(tree)); + } + + /** As {@link factor}, but bracketing anything that came back negated. */ + factorWithParenthesesIfNegated(tree: Tree): string { + const result = this.factor(tree); + + if (/^-/.test(result)) return paren(result); + + // else + return result; + } + + /* + term = + term '*' factor | + term nonMinusFactor | + term '/' factor | + factor + */ + + term(tree: Tree): string { + if (!Array.isArray(tree)) { + return this.factor(tree); + } + + const operator = tree[0]; + const operands: Tree[] = tree.slice(1); + + if (operator === "*") { + return operators[operator]( + operands.map((v, i) => { + const result = this.factorWithParenthesesIfNegated(v); + + // A following factor that starts with a digit would read as one + // number juxtaposed with the previous one, so spell the `*` out. + if (/^[0-9]/.test(result) && i > 0) return " * " + result; + else return result; + }), + ); + } + + if (operator === "/") { + return operators[operator](operands.map((v) => this.factor(v))); + } + + return this.factor(tree); + } + + /* + expression = + expression '+' term | + expression '-' term | + term + */ + + expression(tree: Tree): string { + if (!Array.isArray(tree)) { + return this.term(tree); + } + + const operator = tree[0]; + const operands: Tree[] = tree.slice(1); + + if (operator === "+" || operator === "-") { + return operators[operator]( + operands.map((v) => this.factorWithParenthesesIfNegated(v)), + ); + } + + return this.term(tree); + } + + convert(tree: AstInput): string { + // The emitters pad slots with empty ``; collapsing every adjacent + // `` merges those into the neighbouring literal text nodes. + return ( + "" + + this.expression(tree as Tree) + + "" + ).replace(/<\/e>/g, ""); } } diff --git a/packages/math-expressions-js-compat/lib/converters/ast-to-latex.ts b/packages/math-expressions-js-compat/lib/converters/ast-to-latex.ts index 5e7df7db..17d0f889 100644 --- a/packages/math-expressions-js-compat/lib/converters/ast-to-latex.ts +++ b/packages/math-expressions-js-compat/lib/converters/ast-to-latex.ts @@ -1,15 +1,20 @@ // `new astToLatex(params).convert(ast)` → LaTeX string, via wasm `from_ast` + -// `to_latex`. Emitter options are not honored (see ast-to-text.js). +// `to_latex_with_options`. Emitter options are forwarded the same way as in +// `ast-to-text.ts`. import wasm from "../_wasm"; +import { astToJson } from "./ast-json"; +import { renderOptions } from "./render-options"; export default class AstToLatex { - constructor(params) { + /** Emitter options, forwarded through `renderOptions`. */ + params: Record; + constructor(params?: Record) { this.params = params || {}; } convert(ast) { - const handle = wasm.from_ast(JSON.stringify(ast)); + const handle = wasm.from_ast(astToJson(ast)); try { - return handle.to_latex(); + return handle.to_latex_with_options(renderOptions(this.params)); } finally { handle.free(); // throwaway: created here, never handed to the caller } diff --git a/packages/math-expressions-js-compat/lib/converters/ast-to-text.ts b/packages/math-expressions-js-compat/lib/converters/ast-to-text.ts index f88fffe7..9ccc96f2 100644 --- a/packages/math-expressions-js-compat/lib/converters/ast-to-text.ts +++ b/packages/math-expressions-js-compat/lib/converters/ast-to-text.ts @@ -1,17 +1,22 @@ // `new astToText(params).convert(ast)` → text string, via wasm `from_ast` + -// `to_text`. The wasm emitter is fixed-behavior, so emitter options -// (output_unicode, padToDigits/padToDecimals, avoidScientificNotation, -// showBlanks) are NOT honored — those option-specific spec cases will differ. +// `to_text_with_options`. The constructor's emitter options (output_unicode, +// padToDigits/padToDecimals, avoidScientificNotation, showBlanks, +// explicitMultiplicationSymbols) are forwarded to the Rust printer; anything +// else in `params` is dropped (see `render-options.ts`). import wasm from "../_wasm"; +import { astToJson } from "./ast-json"; +import { renderOptions } from "./render-options"; export default class AstToText { - constructor(params) { + /** Emitter options, forwarded through `renderOptions`. */ + params: Record; + constructor(params?: Record) { this.params = params || {}; } convert(ast) { - const handle = wasm.from_ast(JSON.stringify(ast)); + const handle = wasm.from_ast(astToJson(ast)); try { - return handle.to_text(); + return handle.to_text_with_options(renderOptions(this.params)); } finally { handle.free(); // throwaway: created here, never handed to the caller } diff --git a/packages/math-expressions-js-compat/lib/converters/error.ts b/packages/math-expressions-js-compat/lib/converters/error.ts index 45009a63..341ab640 100644 --- a/packages/math-expressions-js-compat/lib/converters/error.ts +++ b/packages/math-expressions-js-compat/lib/converters/error.ts @@ -2,7 +2,9 @@ // through wasm-bindgen; this class exists so specs importing it resolve, and so // `new ParseError(...)` works if anything constructs one directly. export class ParseError extends Error { - constructor(message, location) { + /** Where in the source the parse failed, when the thrower knows. */ + location: unknown; + constructor(message: string, location?: unknown) { super(message); this.name = "ParseError"; this.location = location; diff --git a/packages/math-expressions-js-compat/lib/converters/flatten.ts b/packages/math-expressions-js-compat/lib/converters/flatten.ts index 351c88e0..ac33d8d8 100644 --- a/packages/math-expressions-js-compat/lib/converters/flatten.ts +++ b/packages/math-expressions-js-compat/lib/converters/flatten.ts @@ -1,5 +1,10 @@ // Legacy `converters/flatten` — re-export of the tree flatten helpers. -import { flatten, unflattenLeft, unflattenRight, allChildren } from "../trees/flatten"; +import { + flatten, + unflattenLeft, + unflattenRight, + allChildren, +} from "../trees/flatten"; export { flatten, unflattenLeft, unflattenRight, allChildren }; export default flatten; diff --git a/packages/math-expressions-js-compat/lib/converters/index.ts b/packages/math-expressions-js-compat/lib/converters/index.ts index 6b9862ca..af3a7f4f 100644 --- a/packages/math-expressions-js-compat/lib/converters/index.ts +++ b/packages/math-expressions-js-compat/lib/converters/index.ts @@ -1,21 +1,35 @@ -// The `me.converters` namespace. The four ported converters are backed by the -// Rust core; the mathjs / guppy / MathML converters have no Rust equivalent and -// are stubs that throw when used (see the individual files). +// The `me.converters` namespace. The text/LaTeX converters are backed by the +// Rust core; the guppy, mathjs and MathML ones are pure-notation converters +// ported directly to TypeScript (math.js nodes, Guppy XML and MathML never +// reach Rust — MathML is reduced to LaTeX first and handed to the Rust parser). import TextToAst from "./text-to-ast"; import LatexToAst from "./latex-to-ast"; import AstToText from "./ast-to-text"; import AstToLatex from "./ast-to-latex"; +import AstToGuppy from "./ast-to-guppy"; +import AstToMathjs from "./ast-to-mathjs"; +import MathjsToAst from "./mathjs-to-ast"; +import MmlToLatex from "./mml-to-latex"; +import MmlToAst from "./mml-to-ast"; export const textToAstObj = TextToAst; export const latexToAstObj = LatexToAst; export const astToTextObj = AstToText; export const astToLatexObj = AstToLatex; +export const astToGuppyObj = AstToGuppy; +export const astToMathjsObj = AstToMathjs; +export const mathjsToAstObj = MathjsToAst; +export const mmlToLatexObj = MmlToLatex; +export const mmlToAstObj = MmlToAst; -// Present so `me.converters.mmlToAstObj` etc. exist; unsupported at runtime. -export class mmlToAstObj { - convert() { - throw new Error("math-expressions-js-compat: MathML parsing is not implemented"); - } -} - -export { TextToAst, LatexToAst, AstToText, AstToLatex }; +export { + TextToAst, + LatexToAst, + AstToText, + AstToLatex, + AstToGuppy, + AstToMathjs, + MathjsToAst, + MmlToLatex, + MmlToAst, +}; diff --git a/packages/math-expressions-js-compat/lib/converters/latex-to-ast.ts b/packages/math-expressions-js-compat/lib/converters/latex-to-ast.ts index 43646b78..c6c21f58 100644 --- a/packages/math-expressions-js-compat/lib/converters/latex-to-ast.ts +++ b/packages/math-expressions-js-compat/lib/converters/latex-to-ast.ts @@ -1,9 +1,12 @@ // `new latexToAst(params).convert(latex)` → JS AST array, via the wasm // `parse_latex` / `parse_latex_with_options`. import wasm from "../_wasm"; +import { jsonToAst } from "./ast-json"; export default class LatexToAst { - constructor(params) { + /** Parser options, passed through as JSON when non-empty. */ + params: Record; + constructor(params?: Record) { this.params = params || {}; } convert(latex) { @@ -12,7 +15,9 @@ export default class LatexToAst { ? wasm.parse_latex_with_options(latex, JSON.stringify(this.params)) : wasm.parse_latex(latex); try { - return JSON.parse(handle.tree_json()); + // `jsonToAst`, not bare `JSON.parse`: `\infty` parses to the wire tag + // `{"$":"Inf"}` and callers expect the legacy scalar `Infinity`. + return jsonToAst(handle.tree_json()); } finally { handle.free(); // throwaway: created here, never handed to the caller } diff --git a/packages/math-expressions-js-compat/lib/converters/mathjs-to-ast.ts b/packages/math-expressions-js-compat/lib/converters/mathjs-to-ast.ts index 6f9bfe3e..bee7eee6 100644 --- a/packages/math-expressions-js-compat/lib/converters/mathjs-to-ast.ts +++ b/packages/math-expressions-js-compat/lib/converters/mathjs-to-ast.ts @@ -1,9 +1,116 @@ -// Compat stub: 'mathjsToAst' has no Rust equivalent in the port (see -// active-plans/JS_TEST_COVERAGE_AUDIT.md). Constructing the converter and -// importing the module both succeed so specs still collect and run; only -// `convert()` throws, failing just those tests. -export default class { - convert(): never { - throw new Error("math-expressions-js-compat: mathjsToAst is not implemented"); +/* + * math.js expression tree → math-expressions AST. + * + * Ported from the legacy `lib/converters/mathjs-to-ast.js`. This is pure + * notation shuffling — it walks the node tree math.js' parser produced and + * relabels it into the AST shape — so it stays in TypeScript rather than + * crossing into the Rust core, which never sees a math.js node. + * + * Copyright 2014-2017 by + * Jim Fowler + * Duane Nykamp + * + * This file is part of a math-expressions library + * + * math-expressions is free software: you can redistribute + * it and/or modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation, either + * version 3 of the License, or at your option any later version. + * + * math-expressions is distributed in the hope that it + * will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + */ + +import type { Tree, TreeArray } from "math-expressions-rs-wasm"; + +/** + * The structural subset of a math.js node this converter reads. Declared here + * rather than reusing mathjs' `MathNode` because the conversion dispatches on + * the `isXNode` marker flags — which the mathjs typings put on the concrete + * node classes, not on the base node the parser is declared to return. + */ +export interface MathJsNode { + type: string; + isConstantNode?: boolean; + isSymbolNode?: boolean; + isOperatorNode?: boolean; + isFunctionNode?: boolean; + isArrayNode?: boolean; + isParenthesisNode?: boolean; + /** ConstantNode */ + value?: Tree; + /** SymbolNode / FunctionNode */ + name?: string; + /** OperatorNode: the source token (`-`) and the function it denotes */ + op?: string; + fn?: string; + /** OperatorNode / FunctionNode operands */ + args?: MathJsNode[]; + /** ArrayNode entries */ + items?: MathJsNode[]; + /** ParenthesisNode */ + content?: MathJsNode; +} + +/** + * Operator emitters keyed by `","`, because neither half identifies an + * operator on its own: math.js reuses `-` for both `subtract` and `unaryMinus`. + * Unlisted combinations are rejected rather than guessed at. + */ +const operators: Record TreeArray> = { + "+,add": (operands) => ["+", ...operands], + "*,multiply": (operands) => ["*", ...operands], + "/,divide": (operands) => ["/", operands[0], operands[1]], + "-,unaryMinus": (operands) => ["-", operands[0]], + // The AST has no binary subtraction: `a - b` is `a + (-b)`. + "-,subtract": (operands) => ["+", operands[0], ["-", operands[1]]], + "^,pow": (operands) => ["^", operands[0], operands[1]], + "and,and": (operands) => ["and", ...operands], + "or,or": (operands) => ["or", ...operands], + "not,not": (operands) => ["not", operands[0]], + "==,equal": (operands) => ["=", ...operands], + "<,smaller": (operands) => ["<", operands[0], operands[1]], + ">,larger": (operands) => [">", operands[0], operands[1]], + "<=,smallerEq": (operands) => ["le", operands[0], operands[1]], + ">=,largerEq": (operands) => ["ge", operands[0], operands[1]], + "!=,unequal": (operands) => ["ne", operands[0], operands[1]], + "!,factorial": (operands) => ["apply", "factorial", operands[0]], +}; + +/** `new mathjsToAst().convert(math.parse("1+x"))` → `["+", 1, "x"]`. */ +export default class mathjsToAst { + convert(mathnode: MathJsNode): Tree { + if (mathnode.isConstantNode) return mathnode.value as Tree; + if (mathnode.isSymbolNode) return mathnode.name as string; + + if (mathnode.isOperatorNode) { + const key = [mathnode.op, mathnode.fn].join(","); + const emit = operators[key]; + if (!emit) + throw Error(`Unsupported operator: ${mathnode.op}, ${mathnode.fn}`); + return emit((mathnode.args ?? []).map((v) => this.convert(v))); + } + + if (mathnode.isFunctionNode) { + const converted = (mathnode.args ?? []).map((v) => this.convert(v)); + // A multi-argument call becomes a single `tuple` argument, since `apply` + // in the AST is always `["apply", name, oneArgument]`. + const args: Tree = + converted.length > 1 ? ["tuple", ...converted] : converted[0]; + return ["apply", mathnode.name as string, args]; + } + + if (mathnode.isArrayNode) { + // The legacy port read `.args` here, which current math.js calls `.items`. + const entries = mathnode.items ?? mathnode.args ?? []; + return ["vector", ...entries.map((v) => this.convert(v))]; + } + + if (mathnode.isParenthesisNode) + return this.convert(mathnode.content as MathJsNode); + + throw Error(`Unsupported node type: ${mathnode.type}`); } } diff --git a/packages/math-expressions-js-compat/lib/converters/mml-to-ast.ts b/packages/math-expressions-js-compat/lib/converters/mml-to-ast.ts new file mode 100644 index 00000000..c72e3f35 --- /dev/null +++ b/packages/math-expressions-js-compat/lib/converters/mml-to-ast.ts @@ -0,0 +1,25 @@ +/* + * Presentation MathML → math-expressions AST. + * + * Ported from the legacy `lib/converters/mml-to-ast.js`, which is nothing more + * than the composition of the MathML→LaTeX converter with the LaTeX parser — + * there is no separate MathML grammar. The LaTeX half is the Rust core's. + */ +import mmlToLatexObj from "./mml-to-latex"; +import latexToAstObj from "./latex-to-ast"; + +class mmlToAst { + mmlToLatex: mmlToLatexObj; + latexToAst: latexToAstObj; + + constructor() { + this.mmlToLatex = new mmlToLatexObj(); + this.latexToAst = new latexToAstObj(); + } + + convert(mml: string) { + return this.latexToAst.convert(this.mmlToLatex.convert(mml)); + } +} + +export default mmlToAst; diff --git a/packages/math-expressions-js-compat/lib/converters/mml-to-latex.ts b/packages/math-expressions-js-compat/lib/converters/mml-to-latex.ts index 8d3059b2..3d10b66d 100644 --- a/packages/math-expressions-js-compat/lib/converters/mml-to-latex.ts +++ b/packages/math-expressions-js-compat/lib/converters/mml-to-latex.ts @@ -1,9 +1,483 @@ -// Compat stub: 'mmlToLatex' has no Rust equivalent in the port (see -// active-plans/JS_TEST_COVERAGE_AUDIT.md). Constructing the converter and -// importing the module both succeed so specs still collect and run; only -// `convert()` throws, failing just those tests. -export default class { - convert(): never { - throw new Error("math-expressions-js-compat: mmlToLatex is not implemented"); +/* + * Presentation MathML → LaTeX. + * + * Ported from the legacy `lib/converters/mml-to-latex.js`. This is pure + * notation shuffling — an XML tree in, a LaTeX string out — so it stays in + * TypeScript rather than crossing into the Rust core, which has no MathML + * reader. + * + * The legacy converter delegated XML parsing to the `xml-parser` package. That + * package is unmaintained CommonJS and is only present in this monorepo as a + * transitive dependency of the published legacy library we diff against, so a + * verbatim port of its 1.2.1 `parse()` lives below instead of being added as a + * dependency. Its exact behaviour is load-bearing: it leaves text content + * completely unescaped, which is what lets the entity table in this file see + * raw `⋅` / `−` strings rather than the characters they denote. + */ + +// fix missing semicolons +const entities: Record = { + "Α": "\\Alpha", + "Α": "\\Alpha", + "Α": "\\Alpha", + "\\u0391;": "\\Alpha", + "Β": "\\Beta", + "Β": "\\Beta", + "Β": "\\Beta", + "\\u0392;": "\\Beta", + "Γ": "\\Gamma", + "Γ": "\\Gamma", + "Γ": "\\Gamma", + "\\u0393;": "\\Gamma", + "Δ": "\\Delta", + "Δ": "\\Delta", + "Δ": "\\Delta", + "\\u0394;": "\\Delta", + "Ε": "\\Epsilon", + "Ε": "\\Epsilon", + "Ε": "\\Epsilon", + "\\u0395;": "\\Epsilon", + "Ζ": "\\Zeta", + "Ζ": "\\Zeta", + "Ζ": "\\Zeta", + "\\u0396;": "\\Zeta", + "Η": "\\Eta", + "Η": "\\Eta", + "Η": "\\Eta", + "\\u0397;": "\\Eta", + "Θ": "\\Theta", + "Θ": "\\Theta", + "Θ": "\\Theta", + "\\u0398;": "\\Theta", + "Ι": "\\Iota", + "Ι": "\\Iota", + "Ι": "\\Iota", + "\\u0399;": "\\Iota", + "Κ": "\\Kappa", + "Κ": "\\Kappa", + "Κ": "\\Kappa", + "\\u039A;": "\\Kappa", + "Λ": "\\Lambda", + "Λ": "\\Lambda", + "Λ": "\\Lambda", + "\\u039B;": "\\Lambda", + "Μ": "\\Mu", + "Μ": "\\Mu", + "Μ": "\\Mu", + "\\u039C;": "\\Mu", + "Ν": "\\Nu", + "Ν": "\\Nu", + "Ν": "\\Nu", + "\\u039D;": "\\Nu", + "Ξ": "\\Xi", + "Ξ": "\\Xi", + "Ξ": "\\Xi", + "\\u039E;": "\\Xi", + "Ο": "\\Omicron", + "Ο": "\\Omicron", + "Ο": "\\Omicron", + "\\u039F;": "\\Omicron", + "Π": "\\Pi", + "Π": "\\Pi", + "Π": "\\Pi", + "\\u03A0;": "\\Pi", + "Ρ": "\\Rho", + "Ρ": "\\Rho", + "Ρ": "\\Rho", + "\\u03A1;": "\\Rho", + "Σ": "\\Sigma", + "Σ": "\\Sigma", + "Σ": "\\Sigma", + "\\u03A3;": "\\Sigma", + "Τ": "\\Tau", + "Τ": "\\Tau", + "Τ": "\\Tau", + "\\u03A4;": "\\Tau", + "Υ": "\\Upsilon", + "Υ": "\\Upsilon", + "Υ": "\\Upsilon", + "\\u03A5;": "\\Upsilon", + "Φ": "\\Phi", + "Φ": "\\Phi", + "Φ": "\\Phi", + "\\u03A6;": "\\Phi", + "Χ": "\\Chi", + "Χ": "\\Chi", + "Χ": "\\Chi", + "\\u03A7;": "\\Chi", + "Ψ": "\\Psi", + "Ψ": "\\Psi", + "Ψ": "\\Psi", + "\\u03A8;": "\\Psi", + "Ω": "\\Omega", + "Ω": "\\Omega", + "Ω": "\\Omega", + "\\u03A9;": "\\Omega", + "α": "\\alpha", + "α": "\\alpha", + "α": "\\alpha", + "\\u03B1;": "\\alpha", + "β": "\\beta", + "β": "\\beta", + "β": "\\beta", + "\\u03B2;": "\\beta", + "γ": "\\gamma", + "γ": "\\gamma", + "γ": "\\gamma", + "\\u03B3;": "\\gamma", + "δ": "\\delta", + "δ": "\\delta", + "δ": "\\delta", + "\\u03B4;": "\\delta", + "ε": "\\epsilon", + "ε": "\\epsilon", + "ε": "\\epsilon", + "\\u03B5;": "\\epsilon", + "ζ": "\\zeta", + "ζ": "\\zeta", + "ζ": "\\zeta", + "\\u03B6;": "\\zeta", + "η": "\\eta", + "η": "\\eta", + "η": "\\eta", + "\\u03B7;": "\\eta", + "θ": "\\theta", + "θ": "\\theta", + "θ": "\\theta", + "\\u03B8;": "\\theta", + "ι": "\\iota", + "ι": "\\iota", + "ι": "\\iota", + "\\u03B9;": "\\iota", + "κ": "\\kappa", + "κ": "\\kappa", + "κ": "\\kappa", + "\\u03BA;": "\\kappa", + "λ": "\\lambda", + "λ": "\\lambda", + "λ": "\\lambda", + "\\u03BB;": "\\lambda", + "μ": "\\mu", + "μ": "\\mu", + "μ": "\\mu", + "\\u03BC;": "\\mu", + "ν": "\\nu", + "ν": "\\nu", + "ν": "\\nu", + "\\u03BD;": "\\nu", + "ξ": "\\xi", + "ξ": "\\xi", + "ξ": "\\xi", + "\\u03BE;": "\\xi", + "ο": "\\omicron", + "ο": "\\omicron", + "ο": "\\omicron", + "\\u03BF;": "\\omicron", + "π": "\\pi", + "π": "\\pi", + "π": "\\pi", + "\\u03C0;": "\\pi", + "ρ": "\\rho", + "ρ": "\\rho", + "ρ": "\\rho", + "\\u03C1;": "\\rho", + "ς": "\\sigma", + // Legacy carried a bare `";"` here — the remains of a `ς` key that + // lost its head — with a note calling it unreachable because "text content + // never equals `;` alone after the surrounding markup is stripped". It does: + // `content()` matches `/^([^<]*)/`, so `;` yields exactly `";"`, and + // MathJax emits `;` for every semicolon separator. `f(x; y)` + // converted to `f ( x \sigma y )`. Spelled as the entity it was meant to be. + "ς": "\\sigma", + "ς": "\\sigma", + "\\u03C2;": "\\sigma", + "σ": "\\sigma", + "σ": "\\sigma", + "σ": "\\sigma", + "\\u03C3;": "\\sigma", + "τ": "\\tau", + "τ": "\\tau", + "τ": "\\tau", + "\\u03C4;": "\\tau", + "υ": "\\upsilon", + "υ": "\\upsilon", + "υ": "\\upsilon", + "\\u03C5;": "\\upsilon", + "φ": "\\phi", + "φ": "\\phi", + "φ": "\\phi", + "\\u03C6;": "\\phi", + "χ": "\\chi", + "χ": "\\chi", + "χ": "\\chi", + "\\u03C7;": "\\chi", + "ψ": "\\psi", + "ψ": "\\psi", + "ψ": "\\psi", + "\\u03C8;": "\\psi", + "ω": "\\omega", + "ω": "\\omega", + "ω": "\\omega", + "\\u03C9;": "\\omega", + "−": "-", + "−": "-", + "∞": "\\infty", + "∞": "\\infty", + "∞": "\\infty", + "⋅": "\\cdot", + "⋅": "\\cdot", + "⋅": "\\cdot", + "×": "\\times", + "×": "\\times", + "×": "\\times", +}; + +// --------------------------------------------------------------------------- +// XML parsing (verbatim port of `xml-parser` 1.2.1) +// --------------------------------------------------------------------------- + +/** One element of the parsed document. */ +export interface XmlNode { + name: string; + attributes: Record; + children: XmlNode[]; + /** + * Text directly after the open tag only — this parser reads content once and + * then switches to children, so text interleaved between child elements is + * silently dropped. Absent entirely on self-closing tags. + */ + content?: string; +} + +export interface XmlDocument { + declaration?: { attributes: Record }; + root?: XmlNode; +} + +function parseString(xml: string): XmlDocument { + xml = xml.trim(); + + // strip comments + xml = xml.replace(//g, ""); + + return document(); + + /** + * XML document. + */ + function document(): XmlDocument { + return { + declaration: declaration(), + root: tag(), + }; + } + + /** + * Declaration. + */ + function declaration(): { attributes: Record } | undefined { + const m = match(/^<\?xml\s*/); + if (!m) return; + + // tag + const node: { attributes: Record } = { + attributes: {}, + }; + + // attributes + while (!(eos() || is("?>"))) { + const attr = attribute(); + if (!attr) return node; + node.attributes[attr.name] = attr.value; + } + + match(/\?>\s*/); + + return node; + } + + /** + * Tag. + */ + function tag(): XmlNode | undefined { + const m = match(/^<([\w-:.]+)\s*/); + if (!m) return; + + // name + const node: XmlNode = { + name: m[1], + attributes: {}, + children: [], + }; + + // attributes + while (!(eos() || is(">") || is("?>") || is("/>"))) { + const attr = attribute(); + if (!attr) return node; + node.attributes[attr.name] = attr.value; + } + + // self closing tag + if (match(/^\s*\/>\s*/)) { + return node; + } + + match(/\??>\s*/); + + // content + node.content = content(); + + // children + let child: XmlNode | undefined; + while ((child = tag())) { + node.children.push(child); + } + + // closing + match(/^<\/[\w-:.]+>\s*/); + + return node; + } + + /** + * Text content. + */ + function content(): string { + const m = match(/^([^<]*)/); + if (m) return m[1]; + return ""; + } + + /** + * Attribute. + */ + function attribute(): { name: string; value: string } | undefined { + const m = match(/([\w:-]+)\s*=\s*("[^"]*"|'[^']*'|\w+)\s*/); + if (!m) return; + return { name: m[1], value: strip(m[2]) }; + } + + /** + * Strip quotes from `val`. + */ + function strip(val: string): string { + return val.replace(/^['"]|['"]$/g, ""); + } + + /** + * Match `re` and advance the string. + * + * Upstream quirk: several of the patterns above are unanchored, yet the + * match length is always sliced off the *front* of the input. That only + * works because every unanchored call site is already positioned on the + * text it expects to consume. + */ + function match(re: RegExp): RegExpMatchArray | undefined { + const m = xml.match(re); + if (!m) return; + xml = xml.slice(m[0].length); + return m; + } + + /** + * End-of-source. + */ + function eos(): boolean { + return 0 == xml.length; + } + + /** + * Check for `prefix`. + */ + function is(prefix: string): boolean { + return 0 == xml.indexOf(prefix); + } +} + +// --------------------------------------------------------------------------- +// MathML → LaTeX +// --------------------------------------------------------------------------- + +class mmlToLatex { + // This is an awfully weak MathML parser, but it's good enough for what MathJax generates + parse(mml: XmlNode): string | undefined { + // math identifier + if (mml.name === "mi") { + if (entities[mml.content]) { + return entities[mml.content]; + } + + // Multi-letter identifiers are assumed to name a LaTeX macro (`sin`, + // `log`, …); single letters are variables and pass through as-is. + if (mml.content.length > 1) { + return "\\" + mml.content; + } else { + return mml.content; + } + } else if (mml.name === "mn") { + // math number + return mml.content; + } else if (mml.name === "msup") { + // superscript + return ( + this.parse(mml.children[0]) + "^{" + this.parse(mml.children[1]) + "}" + ); + } else if (mml.name === "mroot") { + // root + // + // Legacy bug preserved: the index and the radicand both read + // `children[1]`, so `x3` renders as + // `\sqrt[3]{3}`. Nothing in the suite pins `mroot`, and "correcting" it + // here would silently change output for any caller that already + // compensates. + return ( + "\\sqrt[" + + this.parse(mml.children[1]) + + "]{" + + this.parse(mml.children[1]) + + "}" + ); + } else if (mml.name === "mfrac") { + return ( + "\\frac{" + + this.parse(mml.children[0]) + + "}{" + + this.parse(mml.children[1]) + + "}" + ); + } else if (mml.name === "msqrt") { + // square root + return "\\sqrt{" + mml.children.map((v) => this.parse(v)).join(" ") + "}"; + } else if (mml.name === "mo") { + // math operator + if (entities[mml.content]) { + return entities[mml.content]; + } else if (mml.content === "⁡") { + // U+2061 FUNCTION APPLICATION: MathJax's invisible operator, which has + // no LaTeX spelling — a space is the closest equivalent. + return " "; + } else { + return mml.content; + } + } else if ( + mml.name === "mrow" && + mml.attributes.class === "MJX-TeXAtom-ORD" + ) { + // MathJax's "ordinary atom" wrapper carries no grouping of its own, so + // unlike a plain `mrow` it must not introduce parentheses. + return mml.children.map((v) => this.parse(v)).join(" "); + } else if (mml.name === "math" || mml.name === "mrow") { + return "(" + mml.children.map((v) => this.parse(v)).join(" ") + ")"; + } + + // Unrecognized element: legacy fell off the end of the chain and returned + // undefined, which the callers happily stringify into "undefined". + return undefined; + } + + convert(xml: string): string | undefined { + return this.parse(parseString(xml).root); } } + +export default mmlToLatex; diff --git a/packages/math-expressions-js-compat/lib/converters/render-options.ts b/packages/math-expressions-js-compat/lib/converters/render-options.ts new file mode 100644 index 00000000..bd53cb19 --- /dev/null +++ b/packages/math-expressions-js-compat/lib/converters/render-options.ts @@ -0,0 +1,32 @@ +// Translate a legacy converter's constructor params into the options JSON the +// wasm `to_text_with_options` / `to_latex_with_options` entry points read. +// +// Only the keys the Rust printers understand are forwarded — a converter's +// params object may carry parser-side settings (and, in DoenetML's use, +// non-serializable values) that have no business in a render call. + +/** Legacy spelling → the name the Rust side reads. */ +const RENAMED: Record = { + output_unicode: "unicode", +}; + +const FORWARDED = [ + "unicode", + "notation", + "padToDigits", + "padToDecimals", + "showBlanks", + "explicitMultiplicationSymbols", + "avoidScientificNotation", + "matrixEnvironment", +]; + +export function renderOptions(params: Record | undefined) { + const out: Record = {}; + for (const [k, v] of Object.entries(params || {})) { + if (v === undefined || v === null) continue; + const name = RENAMED[k] ?? k; + if (FORWARDED.includes(name)) out[name] = v; + } + return JSON.stringify(out); +} diff --git a/packages/math-expressions-js-compat/lib/converters/text-to-ast.ts b/packages/math-expressions-js-compat/lib/converters/text-to-ast.ts index 4cfe3f3e..6f71c45a 100644 --- a/packages/math-expressions-js-compat/lib/converters/text-to-ast.ts +++ b/packages/math-expressions-js-compat/lib/converters/text-to-ast.ts @@ -4,9 +4,12 @@ // operatorSymbols, allowSimplifiedFunctionApplication, parseLeibnizNotation, // parseScientificNotation). import wasm from "../_wasm"; +import { jsonToAst } from "./ast-json"; export default class TextToAst { - constructor(params) { + /** Parser options, passed through as JSON when non-empty. */ + params: Record; + constructor(params?: Record) { this.params = params || {}; } convert(text) { @@ -15,7 +18,9 @@ export default class TextToAst { ? wasm.parse_text_with_options(text, JSON.stringify(this.params)) : wasm.parse_text(text); try { - return JSON.parse(handle.tree_json()); + // `jsonToAst`, not bare `JSON.parse`: `oo` parses to the wire tag + // `{"$":"Inf"}` and callers expect the legacy scalar `Infinity`. + return jsonToAst(handle.tree_json()); } finally { handle.free(); // throwaway: created here, never handed to the caller } diff --git a/packages/math-expressions-js-compat/lib/expression/equality/discrete_infinite_set.ts b/packages/math-expressions-js-compat/lib/expression/equality/discrete_infinite_set.ts index 943a16bf..57e54a39 100644 --- a/packages/math-expressions-js-compat/lib/expression/equality/discrete_infinite_set.ts +++ b/packages/math-expressions-js-compat/lib/expression/equality/discrete_infinite_set.ts @@ -1,7 +1,54 @@ -// Discrete-infinite-set equality. The Rust core exposes this via -// `discrete_infinite_set(...)` free function; a faithful tree-level `equals` -// port is future work, so this throws when used (test fails; suite runs). -export function equals() { - throw new Error("math-expressions-js-compat: discrete_infinite_set.equals is not implemented"); +// Discrete-infinite-set equality (`{offset + k·period}`, e.g. `π/4 + nπ`). +// +// The interesting part of the legacy API is not the boolean — `expr.equals(…)` +// already routes through this stage inside the Rust `equals` chain — but the +// `match_partial` grading signal, which returns the *fraction* of residue +// classes the two sets have in common. Only this entry point can express it, +// so it is the reason the module exists separately from `Expression.equals`. +import wasm from "../../_wasm"; +import Context from "../../math-expressions"; +import { get_tree } from "../../trees/util"; +import { astToJson } from "../../converters/ast-json"; + +/** Expression-or-tree → a throwaway wasm handle the caller must `free()`. */ +function handle(value) { + return wasm.from_ast(astToJson(get_tree(value))); } + +/** + * Is `expr` (a discrete infinite set) equal to `other` (another set, or a list + * ending in `...`)? + * + * With `match_partial`, returns a number in `[0, 1]` — the fraction of `expr` + * matched, for partial credit — instead of a boolean; a pair with nothing in + * common scores `0` rather than a fraction, as in the legacy grader. + * + * `min_elements_match` is not accepted: the Rust stage fixes it at the legacy + * default of 3, and silently ignoring a caller's other value would grade a + * listed sequence against a rule they did not ask for. + * + * The context's assumptions are consulted (a symbolic period has to be known + * nonzero before its ratios mean anything), matching the legacy version, which + * pulled them off each argument's `.context`. This port has the one context, so + * it reads it directly rather than requiring the arguments to be Expressions. + */ +export function equals(expr, other, { match_partial = false } = {}) { + const a = handle(expr); + try { + const b = handle(other); + try { + const score = Context._assumptionsHandle.match_discrete_infinite_set( + a, + b, + match_partial, + ); + return match_partial ? score : score >= 1; + } finally { + b.free(); + } + } finally { + a.free(); + } +} + export default { equals }; diff --git a/packages/math-expressions-js-compat/lib/expression/matrix.ts b/packages/math-expressions-js-compat/lib/expression/matrix.ts index 6867b531..2b8ea17f 100644 --- a/packages/math-expressions-js-compat/lib/expression/matrix.ts +++ b/packages/math-expressions-js-compat/lib/expression/matrix.ts @@ -1,7 +1,7 @@ // Compat stub: 'matrix' has no Rust equivalent in the port (see // active-plans/JS_TEST_COVERAGE_AUDIT.md). The module loads so specs importing // it still run; any use throws, failing just those tests. -function unsupported() { +function unsupported(): never { throw new Error("math-expressions-js-compat: matrix is not implemented"); } export default new Proxy(function () {}, { diff --git a/packages/math-expressions-js-compat/lib/expression/pm.ts b/packages/math-expressions-js-compat/lib/expression/pm.ts index 4c2b7d82..f99988eb 100644 --- a/packages/math-expressions-js-compat/lib/expression/pm.ts +++ b/packages/math-expressions-js-compat/lib/expression/pm.ts @@ -1,11 +1,81 @@ -// Compat stub: 'pm' has no Rust equivalent in the port (see -// active-plans/JS_TEST_COVERAGE_AUDIT.md). The module loads so specs importing -// it still run; any use throws, failing just those tests. -function unsupported() { - throw new Error("math-expressions-js-compat: pm is not implemented"); -} -export default new Proxy(function () {}, { - get: () => unsupported, - apply: unsupported, - construct: unsupported, -}); +// Helpers for working with the `pm` (plus-minus) operator. +// +// AST shape: `pm` is unary, analogous to unary `-`. A pm-bearing expression +// like `5 \pm 3` is represented as `["+", 5, ["pm", 3]]`. Each `["pm", x]` +// denotes the set `{x, -x}` with an independent sign choice. +// +// These are tree-level operations with no math in them, so they stay in JS +// rather than crossing the wasm boundary: `expand_pm_signs` returns up to 1024 +// trees, and shipping each one through wasm would cost more than building it. +import type { Tree } from "../math-expressions"; + +/** + * Maximum number of `pm` operators allowed in a single expression for + * sign-expansion. `expand_pm_signs` produces 2^MAX_PM_COUNT variants, so + * raising this trades exponential work for the ability to handle more + * independent ± choices. + */ +const MAX_PM_COUNT = 10; +const MAX_PM_EXPANSIONS = 1 << MAX_PM_COUNT; // 1024 + +/** Whether `tree` contains any `pm` operator anywhere in its subtree. */ +export function contains_pm(tree: Tree): boolean { + if (!Array.isArray(tree)) return false; + if (tree[0] === "pm") return true; + for (let i = 1; i < tree.length; i++) { + if (contains_pm(tree[i])) return true; + } + return false; +} + +/** The number of `pm` operators anywhere in `tree`. */ +export function count_pm(tree: Tree): number { + if (!Array.isArray(tree)) return 0; + let n = tree[0] === "pm" ? 1 : 0; + for (let i = 1; i < tree.length; i++) { + n += count_pm(tree[i]); + } + return n; +} + +/** + * Enumerate all 2^n sign assignments for the `pm` operators in `tree`. Each + * `["pm", x]` is replaced either by `x` (sign = +) or by `["-", x]` (sign = −). + * Throws if the count would exceed `MAX_PM_COUNT`. + * + * The n-th `pm` in left-to-right order reads bit n of the mask, so every + * operator gets an *independent* sign — which is the whole point of the + * operator, and why `(±x)(±x)` is not `(±x)²`. + */ +export function expand_pm_signs(tree: Tree): Tree[] { + const n = count_pm(tree); + if (n === 0) return [tree]; + if (n > MAX_PM_COUNT) { + throw new Error( + `pm: cannot expand ${n} plus-minus operators (limit is ${MAX_PM_COUNT} → ${MAX_PM_EXPANSIONS} combinations)`, + ); + } + const total = 1 << n; + const results: Tree[] = []; + for (let mask = 0; mask < total; mask++) { + results.push(replace_pm(tree, mask, { idx: 0 })); + } + return results; +} + +function replace_pm(tree: Tree, mask: number, counter: { idx: number }): Tree { + if (!Array.isArray(tree)) return tree; + if (tree[0] === "pm") { + const bit = (mask >> counter.idx) & 1; + counter.idx += 1; + const inner = replace_pm(tree[1], mask, counter); + return bit === 0 ? inner : ["-", inner]; + } + const out: Tree[] = [tree[0]]; + for (let i = 1; i < tree.length; i++) { + out.push(replace_pm(tree[i], mask, counter)); + } + return out; +} + +export default { contains_pm, count_pm, expand_pm_signs }; diff --git a/packages/math-expressions-js-compat/lib/expression/rational.ts b/packages/math-expressions-js-compat/lib/expression/rational.ts index a268b9cc..12d57a28 100644 --- a/packages/math-expressions-js-compat/lib/expression/rational.ts +++ b/packages/math-expressions-js-compat/lib/expression/rational.ts @@ -1,7 +1,7 @@ // Compat stub: 'rational' has no Rust equivalent in the port (see // active-plans/JS_TEST_COVERAGE_AUDIT.md). The module loads so specs importing // it still run; any use throws, failing just those tests. -function unsupported() { +function unsupported(): never { throw new Error("math-expressions-js-compat: rational is not implemented"); } export default new Proxy(function () {}, { diff --git a/packages/math-expressions-js-compat/lib/expression/simplify.ts b/packages/math-expressions-js-compat/lib/expression/simplify.ts index 75650fbe..f4630fd5 100644 --- a/packages/math-expressions-js-compat/lib/expression/simplify.ts +++ b/packages/math-expressions-js-compat/lib/expression/simplify.ts @@ -3,14 +3,19 @@ // Ops with no Rust backing are omitted (calls throw a TypeError → test fails, // suite runs). import wasm from "../_wasm"; +import { get_tree } from "../trees/util"; +import { astToJson, jsonToAst } from "../converters/ast-json"; function op(method) { return (tree) => { - const src = wasm.from_ast(JSON.stringify(tree)); + // Legacy ops accepted an expression-or-tree; unwrap an Expression to its + // AST. Tag non-finite numbers so `from_ast` accepts NaN/±Infinity. + tree = get_tree(tree); + const src = wasm.from_ast(astToJson(tree)); try { const out = src[method](); try { - return JSON.parse(out.tree_json()); + return jsonToAst(out.tree_json()); } finally { out.free(); // throwaway: method result, never returned } @@ -27,4 +32,9 @@ export const collect_like_terms_and_factors = op("collect_like_terms_factors"); export const factor = op("factor"); export const together = op("together"); -export default { simplify, expand, evaluate_numbers, collect_like_terms_and_factors }; +export default { + simplify, + expand, + evaluate_numbers, + collect_like_terms_and_factors, +}; diff --git a/packages/math-expressions-js-compat/lib/math-expressions.ts b/packages/math-expressions-js-compat/lib/math-expressions.ts index f7189180..ea955a89 100644 --- a/packages/math-expressions-js-compat/lib/math-expressions.ts +++ b/packages/math-expressions-js-compat/lib/math-expressions.ts @@ -4,11 +4,35 @@ // Not every legacy method exists on the Rust side; those that don't are either // approximated, or throw a clear "not implemented in js-compat" so the calling // test fails cleanly (the suite still runs). See JS_TEST_COVERAGE_AUDIT.md. -import wasm from "./_wasm"; +import wasm, { onWasmModuleChange, setWasmModule } from "./_wasm"; import math from "./mathjs"; import { match, flatten, unflattenLeft, unflattenRight } from "./trees/flatten"; import * as converters from "./converters/index"; +import { jsonToAst, tagNonFinite } from "./converters/ast-json"; +import { renderOptions } from "./converters/render-options"; +import * as assumptionStore from "./assumptions/store"; +import { expression_to_polynomial } from "./polynomial/polynomial"; +import { get_tree } from "./trees/util"; +import { compileRustExpr } from "math-expressions-rs-wasm"; import type { WasmExpression } from "math-expressions-rs-wasm"; +import type { MathJsInstance } from "mathjs"; + +// `me.math.pow_strict` — the legacy library carried this on its bundled mathjs +// instance to switch `0^0` (and the other indeterminate `x^0` forms) between +// `NaN` (strict) and `1`. The Rust core keeps it as ambient policy rather than +// baking it into an instance, so intercept the property on the shared `math` +// object and route it there. Assignment is the shape the spec uses +// (`me.math.pow_strict = false`), so a getter/setter is required — a method +// would not answer it. +Object.defineProperty(math, "pow_strict", { + configurable: true, + get(): boolean { + return JSON.parse(wasm.get_constant_policy()).pow_strict; + }, + set(value: boolean) { + wasm.set_constant_policy(JSON.stringify({ pow_strict: Boolean(value) })); + }, +}); /** The JS AST tree encoding (`["+", 1, "x", 3]`). */ export type Tree = number | string | boolean | Tree[]; @@ -31,7 +55,11 @@ export function isTree(value: unknown): boolean { ) { return true; } - if (Array.isArray(value) && value.length > 0 && typeof value[0] === "string") { + if ( + Array.isArray(value) && + value.length > 0 && + typeof value[0] === "string" + ) { return value.slice(1).every((item) => isTree(item)); } return false; @@ -44,7 +72,10 @@ function notImplemented(name: string): (...args: unknown[]) => never { } /** Wrap a raw wasm Expression handle (or undefined) as a compat Expression. */ -function wrap(handle: WasmExpression | undefined, context: Ctx): Expression | undefined { +function wrap( + handle: WasmExpression | undefined, + context: Ctx, +): Expression | undefined { if (handle === undefined || handle === null) return undefined; return new Expression(handle, context); } @@ -60,6 +91,83 @@ function toExpr(x: ExpressionLike, context?: Ctx): Expression { return ctx.fromAst(x as Tree); // number or AST array } +/** + * A component index is either a bare index or a path of them — `get_component(2)` + * and `get_component([2, 1, 2])` are both legal, the first being the one-element + * path. Indices count operands of the tree spelling, 0-based. + */ +function componentPath(component: number | number[]): Uint32Array { + const path = Array.isArray(component) ? component : [component]; + return Uint32Array.from(path, (i) => Number(i)); +} + +/** + * `JSON.stringify` replacer that preserves the non-finite numbers JSON cannot + * hold. `JSON.stringify(NaN) === "null"` and likewise for `±Infinity`, so a + * `NaN` slope or an infinite bound would reach the Rust boundary as `null` and + * be rejected — the tree is serialized here on the way in, and this maps those + * three values to the `{"$":…}` specials the Rust `from_ast` already reads back. + * An already-special `{"$":"NaN"}` object passes through untouched. + * + * The *wire* format is tagged in both directions, because JSON cannot hold + * these three values in either one. The *values a caller sees* are not: `.tree` + * untags them back to JS scalars (see `untagNonFinite`), because `Infinity` is + * what legacy handed back and what `typeof x === "number"` and `x === -Infinity` + * consumers test against. `fromAst(x).tree` is still a fixpoint — this replacer + * re-tags on the way in — it just holds at the value level rather than the wire + * level. `{"$":"None"}` is the exception in both directions: it has no JS scalar + * to untag to, and DoenetML emits and reads it in that form already. + */ +function astReplacer(this: unknown, key: string, value: unknown): unknown { + // An `Expression` standing where a tree is expected — `fromAst(expr)`, or an + // `expr` nested inside one (`["+", someExpr, 2]`). A math-valued DoenetML + // state variable *holds* an Expression, so code that re-wraps one hands it + // straight back here; this makes that a no-op instead of a throw. + // + // Note this reads the *holder* rather than `value`: `JSON.stringify` calls + // `toJSON()` before consulting the replacer, so by the time `value` arrives an + // Expression has already become its `{objectType:"math-expression",tree:…}` + // envelope and `value instanceof Expression` is always false. That envelope is + // precisely the "object with no `$` key" the Rust side used to reject. + // + // Both unwrapped trees go back through `tagNonFinite`: `.tree` hands out the + // *untagged* scalars, so an `Expression` holding `NaN` or `±Infinity` would + // otherwise be returned as a bare JS non-finite and `JSON.stringify` would + // write `null` for it — the "unexpected value null" the Rust side rejects. + // (Nested ones are covered by the fall-through below, which `stringify` + // reaches when it walks into the value returned here.) + const held = (this as Record | undefined)?.[key]; + if (held instanceof Expression) return tagNonFinite(held.tree); + // The same envelope arriving as plain data — a `JSON.parse` of a persisted + // expression that never got run through `Context.reviver`. Keyed on the shape + // `reviver` itself recognizes. + if (isSerializedExpression(value)) return tagNonFinite(value.tree); + // Shared with the standalone converters, so the two cannot tag `Infinity` + // differently (see `converters/ast-json.ts`). + return tagNonFinite(value); +} + +/** The `toJSON()` envelope shape, as `Context.reviver` recognizes it. */ +function isSerializedExpression(v: unknown): v is { tree: unknown } { + return ( + !!v && + typeof v === "object" && + (v as { objectType?: unknown }).objectType === "math-expression" && + (v as { tree?: unknown }).tree !== undefined + ); +} + +/** + * Whether a call carries options worth forwarding to the wasm + * `*_with_options` entry points — render options (padToDigits, padToDecimals, + * showBlanks, explicitMultiplicationSymbols, notation, unicode) or parser + * options (splitSymbols, appliedFunctionSymbols, …). An empty/absent object + * takes the cheaper no-options path. + */ +function hasOptions(opts: unknown): opts is Record { + return !!opts && typeof opts === "object" && Object.keys(opts).length > 0; +} + /** A variable argument may be a string name or an Expression of a symbol. */ function varName(v: string | Expression): string { if (typeof v === "string") return v; @@ -67,11 +175,43 @@ function varName(v: string | Expression): string { return String(v); } +/** Whether a tree involves the imaginary unit `i` as a leaf — used to tell a + * complex NaN (`Infinity*i` → `{re:NaN, im:NaN}`) apart from a real NaN + * (`0/0` → scalar `NaN`), since both fold to a single `NaN`. */ +function treeHasImaginary(tree: Tree): boolean { + if (tree === "i") return true; + return Array.isArray(tree) && tree.some((t) => treeHasImaginary(t)); +} + +/** Whether a tree contains a `det`/`trace` application — the matrix reductions + * that only fold under `simplify`, so `evaluate_to_constant` retries them there + * (but nowhere else, to avoid simplifying an undefined leaf into a number). */ +function treeHasMatrixReduction(tree: Tree): boolean { + if (Array.isArray(tree)) { + if (tree[0] === "apply" && (tree[1] === "det" || tree[1] === "trace")) { + return true; + } + return tree.some((t) => treeHasMatrixReduction(t)); + } + return false; +} + +/** The tree heads `get_component` will index — the JS library's set. */ +const COMPONENT_CONTAINERS = new Set([ + "list", + "tuple", + "vector", + "altvector", + "array", +]); + /** The Context (`me`) shape, used for the back-reference on each Expression. */ type Ctx = typeof Context; -// Legacy `.equals` options are snake_case; the wasm `equals_with_options` takes -// camelCase JSON keys. Map the ones the Rust side understands; drop the rest. +// Legacy `.equals` options are snake_case; the wasm entry points that read them +// — `Assumptions#equals_expressions` and `Expression#structural_equality_with_options` +// — take camelCase JSON keys. Map the ones the Rust side understands; drop the +// rest. const EQ_OPTION_KEYS: Record = { relative_tolerance: "relativeTolerance", absolute_tolerance: "absoluteTolerance", @@ -80,6 +220,8 @@ const EQ_OPTION_KEYS: Record = { include_error_in_number_exponents: "includeErrorInNumberExponents", allowed_error_is_absolute: "allowedErrorIsAbsolute", allow_blanks: "allowBlanks", + coerce_tuples_arrays: "coerceTuplesArrays", + coerce_vectors: "coerceVectors", }; function mapEqOptions(opts: EqualityOptions): Record { const out: Record = {}; @@ -90,6 +232,81 @@ function mapEqOptions(opts: EqualityOptions): Record { return out; } +/** + * Handles for *recurring* atomic trees, and their (primitive) `.tree` readback. + * + * Evaluating a function over a domain drives `fromAst` in a tight loop, and + * overwhelmingly on an atom: in one DoenetML `` test, 2.13M of 2.14M + * calls were a bare number or the blank `"_"` a domain miss returns, and + * re-parsing those through `JSON.stringify` + `from_ast` was 40% of the run. + * The handles are immutable, so a repeated atom can be built once and shared. + * + * The catch is which atoms actually repeat. Symbols, the blank, and small + * integers do — 1.39M of those 2.13M calls were the single string `"_"`. A + * *sampled coordinate* does not: an interpolated function is evaluated at + * millions of distinct floats, and caching those turns every call into a miss + * plus table churn and holds a wasm handle per sample alive until the next + * sweep. That is not merely a wash, it is a large loss: caching every atom + * took the interpolated-function test from 77s to 153s while taking the + * blank-driven one from 173s to 105s. Restricting the cache to strings and + * small integers gives 78s and 4s — both faster than either. So an arbitrary + * float goes straight to `from_ast`. + * + * `MAX_ATOMS` still bounds the table, since symbol names are unbounded over a + * long session; on overflow it is dropped wholesale rather than evicted one at + * a time, the working set being small and an atom cheap to re-parse. + */ +const ATOM_HANDLES = new Map(); +const ATOM_TREES = new WeakMap(); +/** + * Handles the atom cache owns. A shared handle outlives any one wrapper, so + * `free()` on a wrapper around one must not release it — see `free`. + */ +const ATOM_SHARED = new WeakSet(); +/** + * Live wrappers per shared handle, and the key each was cached under. + * + * Together these let `free()` release a handle the cache has since dropped + * (`MAX_ATOMS` overflow, or a wasm-module swap) instead of leaving it to the + * GC. Counting happens in the `Expression` constructor rather than in + * `fromAst`, so a wrapper minted by any other route — `wrap`, the reviver, a + * wasm call that hands back the same handle — is counted too; miss one and + * `free()` would release a handle another live wrapper still points at. + * + * A wrapper that is garbage-collected without `free()` never decrements, which + * only ever *inhibits* the release. The FinalizationRegistry is still the + * backstop, so the failure direction is "freed late", never "freed early". + */ +const ATOM_REFS = new WeakMap(); +const ATOM_KEYS = new WeakMap(); +const MAX_ATOMS = 4096; + +// Handles belong to the module that minted them, so a swap invalidates every +// cached one — passing a stale handle to the new module's `from_ast` fails with +// "expected instance of Expression". Dropping the table is enough: wrappers +// already handed out keep working against their own module, and the orphaned +// handles are released by `free()` or the GC as usual. +onWasmModuleChange(() => ATOM_HANDLES.clear()); +/** Integers up to this magnitude are treated as recurring; see `atomKey`. */ +const MAX_CACHED_INT = 1024; + +/** Cache key for an atomic tree, or `undefined` if it is not worth caching. */ +function atomKey(ast: unknown): string | undefined { + if (typeof ast === "string") return "s" + ast; + if ( + typeof ast === "number" && + Number.isInteger(ast) && + Math.abs(ast) <= MAX_CACHED_INT && + // `-0` and `0` are distinct expressions (see `Number::NegZero`); rather + // than spell the sign into the key, leave `-0` uncached — it is rare, and + // an uncached atom is correct, just not free. + !Object.is(ast, -0) + ) { + return "n" + ast; + } + return undefined; +} + class Expression { _w: WasmExpression; context: Ctx; @@ -97,41 +314,116 @@ class Expression { constructor(handle: WasmExpression, context?: Ctx) { this._w = handle; this.context = context || Context; + if (ATOM_SHARED.has(handle)) { + ATOM_REFS.set(handle, (ATOM_REFS.get(handle) ?? 0) + 1); + } } // ---- inspection / rendering ---- + /** + * The AST as plain JS data. `±Infinity` and `NaN` read back as the JS + * scalars legacy handed out, not as their `{"$":…}` wire tags — see + * `untagNonFinite`. `{"$":"None"}` stays tagged, having no scalar to become. + */ get tree() { - return JSON.parse(this._w.tree_json()); - } - toString() { - return this._w.to_text(); - } - toText() { - return this._w.to_text(); - } - toLatex() { - return this._w.to_latex(); - } - tex() { - return this._w.to_latex(); + // Memoized only when the tree is a primitive (a number, or a symbol/blank + // string). A composite tree is handed out as a fresh array every read and + // callers are free to mutate what they get back, so those must not be + // shared; a primitive has nothing to mutate. Keyed on the wasm handle + // rather than the wrapper because handles are immutable and are shared by + // the atom cache below — `fromAst("_")` is the single hottest call in + // a function-evaluation loop, and this makes its `.tree` free after the + // first read. + const cached = ATOM_TREES.get(this._w); + if (cached !== undefined) return cached; + const tree = jsonToAst(this._w.tree_json()); + if (tree === null || typeof tree !== "object") + ATOM_TREES.set(this._w, tree); + return tree; + } + // Rendering honors the legacy render options (padToDigits, padToDecimals, + // showBlanks, explicitMultiplicationSymbols, notation/unicode) by forwarding + // a non-empty options object to the `*_with_options` wasm entry points. + // It goes through `renderOptions` rather than a bare `JSON.stringify` so the + // legacy spellings are translated, not silently dropped: callers pass + // `output_unicode`, which the Rust side reads as `unicode`. The no-arg path + // stays on the cheap no-options render — `toString()` is what JS coercion + // (`String(expr)`) calls. + toString(opts?) { + return hasOptions(opts) + ? this._w.to_text_with_options(renderOptions(opts)) + : this._w.to_text(); + } + toText(opts?) { + return hasOptions(opts) + ? this._w.to_text_with_options(renderOptions(opts)) + : this._w.to_text(); + } + toLatex(opts?) { + return hasOptions(opts) + ? this._w.to_latex_with_options(renderOptions(opts)) + : this._w.to_latex(); + } + tex(opts?) { + return hasOptions(opts) + ? this._w.to_latex_with_options(renderOptions(opts)) + : this._w.to_latex(); } toJSON() { return JSON.parse(this._w.to_serialized()); } - variables() { - return this._w.variables(); + /** + * The free variable names, in first-appearance order. + * + * `include_subscripts` reports a subscripted variable under its full name: + * `x_1 + y` gives `["x_1", "y"]` rather than `["x", "y"]`. The argument was + * dropped on the floor here, so a caller testing membership against a + * subscripted name — `Line.js` deciding whether a coefficient mentions the + * line's own variables — never found one. + * + * Implemented by flattening the subscript nodes into plain symbols first, + * which is the same spelling `subscripts_to_strings` produces and the one + * legacy's own `include_subscripts` pass builds. + */ + variables(include_subscripts?: boolean) { + const source = include_subscripts + ? this._w.subscripts_to_strings(false) + : this._w; + try { + return source.variables(); + } finally { + if (source !== this._w) source.free(); + } } functions() { return this._w.functions(); } + /** + * This expression read as a polynomial — `["polynomial", v, [[deg, coeff], …]]` + * — or `false` when it is not one. See `lib/polynomial/polynomial`. + */ + expression_to_polynomial() { + return expression_to_polynomial(this.tree); + } // ---- equality ---- - equals(other, options) { + equals(other, options?) { const o = toExpr(other, this.context); - if (options && Object.keys(options).length > 0) { - return this._w.equals_with_options(o._w, JSON.stringify(mapEqOptions(options))); - } - return this._w.equals(o._w); + // Routed through the context's assumption store, not `this._w.equals`. + // Discrete infinite sets are the one stage of the chain that needs it — the + // comparison divides by the period, so a symbolic period means nothing + // until it is known nonzero — and the legacy `equals` read the context's + // assumptions for exactly that stage. Every other stage is assumption-free + // and answers identically, so this costs a dispatch, not a second pass: + // the store's method falls straight through to the plain chain when neither + // side is a set. + return this.context._assumptionsHandle.equals_expressions( + this._w, + o._w, + options && Object.keys(options).length > 0 + ? JSON.stringify(mapEqOptions(options)) + : undefined, + ); } equalsViaReal(other) { return this._w.equals_via_real(toExpr(other, this.context)._w); @@ -144,15 +436,27 @@ class Expression { // through `structural_equality` with the `sameStructure` criterion, which // routes to Rust `equals_syntactic` (no sampling). This matches the original // `equalsViaSyntax` and never evaluates the expression at sample points. - equalsViaSyntax(other) { - return this._w.structural_equality(toExpr(other, this.context)._w, '"sameStructure"'); + equalsViaSyntax(other, options?) { + const o = toExpr(other, this.context)._w; + if (hasOptions(options)) { + return this._w.structural_equality_with_options( + o, + '"sameStructure"', + JSON.stringify(mapEqOptions(options)), + ); + } + return this._w.structural_equality(o, '"sameStructure"'); } is_zero() { return this._w.is_zero(); } isAnalytic(opts) { const o = opts || {}; - return this._w.is_analytic(!!o.allow_abs, !!o.allow_arg, !!o.allow_relation); + return this._w.is_analytic( + !!o.allow_abs, + !!o.allow_arg, + !!o.allow_relation, + ); } // ---- calculus ---- @@ -167,7 +471,11 @@ class Expression { // quadrature and returns `NaN` when the value cannot be certified — never a // silently-wrong number. integrateNumerically(v, lower, upper) { - const r = this._w.integrate_numerically(varName(v), Number(lower), Number(upper)); + const r = this._w.integrate_numerically( + varName(v), + Number(lower), + Number(upper), + ); return r === undefined ? NaN : r; } @@ -185,11 +493,137 @@ class Expression { expand() { return wrap(this._w.expand(), this.context); } + /** + * Sort into the default order without evaluating — DoenetML's + * `simplify="normalizeOrder"`. Unlike `simplify`, every term survives: + * `0x^2` stays, `7+4` stays two terms, `1x^2` keeps its coefficient. The + * ordering key is the JS library's, quirks included, because the term + * sequence it produces is what gets displayed. + */ + default_order() { + return wrap(this._w.default_order(), this.context); + } factor() { return wrap(this._w.factor(), this.context); } - evaluate_numbers(_opts) { - return wrap(this._w.evaluate_numbers(), this.context); + /** + * Push every unary minus into the numeric literal it negates, bottom-up: + * `-(3x)` → `(-3)x`, `-(3/y)` → `(-3)/y`, `-3` → the number `-3`. + * + * A normalization for pattern matching, not a simplification — `3x + 4y - 2x` + * has to become `3x + 4y + (-2)x` before a `n·x + m·x` rule can see `-2` as a + * coefficient. Nothing else changes, and a minus with no literal to fold into + * (`-x`) stays where it is. + * + * Implemented over the raw AST rather than through the core because that is + * what it is for: the trees it feeds are matched structurally, and + * canonicalizing would reorder and re-fold them out from under the pattern. + */ + collapse_unary_minus() { + const collapse = (tree) => { + if (!Array.isArray(tree)) return tree; + const [operator, ...operands] = tree.map((t, i) => + i === 0 ? t : collapse(t), + ); + if (operator === "-") { + const operand = operands[0]; + if (typeof operand === "number") return -operand; + if (Array.isArray(operand)) { + // A product whose leading factor is a literal: negate that factor. + if (operand[0] === "*" && typeof operand[1] === "number") + return ["*", -operand[1], ...operand.slice(2)]; + // A quotient: the numerator is either a literal itself or a product + // led by one. Only the numerator moves; negating a denominator would + // change the value's spelling for no gain. + if (operand[0] === "/") { + const [, numerator, denominator] = operand; + if (typeof numerator === "number") + return ["/", -numerator, denominator]; + if ( + Array.isArray(numerator) && + numerator[0] === "*" && + typeof numerator[1] === "number" + ) + return [ + "/", + ["*", -numerator[1], ...numerator.slice(2)], + denominator, + ]; + } + } + } + return [operator, ...operands]; + }; + return this.context.fromAst(collapse(this.tree)); + } + evaluate_numbers(opts) { + // `skip_ordering` (DoenetML's `simplify="numberspreserveorder"`) selects a + // genuinely different core pass: numbers fold only with *adjacent* numbers, + // so `1+x+2` stays `1+x+2` where the ordering form gives `x+3`. It used to + // throw here, which was worse than a missing feature — the Rust core calls + // this mode and is built `panic = "abort"`, so the exception unwound into + // it as a WASM trap and took the whole worker down. + // `max_digits` is how many significant digits the caller is willing to + // spend turning an exact value into a decimal. `Infinity` — spend as many + // as it takes — folds `π` and `1/3` too, which is what makes + // `2π + π + 6` comparable against a response typed as `15.42478`; it is + // what DoenetML's grading path passes. A *finite* cap converts only the + // rationals whose decimal fits that many significant figures: `1/2 → 0.5` + // at any budget ≥ 1, but `1/3` stays exact (its decimal never terminates) + // and `π` stays symbolic (an irrational is never captured by a finite + // count). Both go through the same digit-budget core; only `undefined` + // (omit) keeps every exact value. + const maxDigits = opts?.max_digits; + if ( + maxDigits !== undefined && + maxDigits !== Infinity && + !(Number.isInteger(maxDigits) && maxDigits >= 0) + ) { + throw new Error( + `evaluate_numbers: 'max_digits' must be a non-negative integer or Infinity (got ${maxDigits}).`, + ); + } + const skipOrdering = Boolean(opts?.skip_ordering); + // `evaluate_functions` additionally folds a function applied to a numeric + // argument (`sin(0)+2` → `2`), which is what `simplify="full"` needs. + const evaluateFunctions = Boolean(opts?.evaluate_functions); + let result; + if (maxDigits !== undefined) { + result = wrap( + this._w.evaluate_numbers_to_floats( + skipOrdering, + evaluateFunctions, + maxDigits, + ), + this.context, + ); + } else if (skipOrdering) { + result = wrap(this._w.evaluate_numbers_preserve_order(), this.context); + } else if (evaluateFunctions) { + result = wrap( + this._w.evaluate_numbers_evaluate_functions(), + this.context, + ); + } else { + result = wrap(this._w.evaluate_numbers(), this.context); + } + // `set_small_zero` drops residual round-off (`10x + 5e-15` → `10x`) after the + // numeric fold. `true` uses the default tolerance; a number sets it. Mirrors + // the standalone `set_small_zero()` method the legacy option delegated to. + const ssz = opts?.set_small_zero; + if (ssz) { + // `set_small_zero` leaves the zeroed term in place (`10x + 0`); re-fold to + // drop it (`10x`) and collapse `0·x → 0`. Same options minus `set_small_zero` + // so this does not recurse. + result = result + .set_small_zero(ssz === true ? undefined : ssz) + .evaluate_numbers({ + skip_ordering: skipOrdering, + evaluate_functions: evaluateFunctions, + max_digits: maxDigits, + }); + } + return result; } collect_like_terms_factors() { return wrap(this._w.collect_like_terms_factors(), this.context); @@ -206,10 +640,45 @@ class Expression { normalize_function_names() { return wrap(this._w.normalize_function_names(), this.context); } + normalize_applied_functions() { + return wrap(this._w.normalize_applied_functions(), this.context); + } + normalize_negative_numbers() { + return wrap(this._w.normalize_negative_numbers(), this.context); + } + expand_relations() { + return wrap(this._w.expand_relations(), this.context); + } constants_to_floats() { return wrap(this._w.constants_to_floats(), this.context); } + // ---- solving ---- + /** + * Restate a relation with `variable` alone on the left: `3x+4 = 2` in `x` + * becomes `x = -2/3`, and an inequality flips when the coefficient is + * negative (`2x-4 < 6+4x` → `x > -5`). + * + * Routed through the context's assumption store rather than the free + * `solve_linear_ast`, which is deliberately assumption-blind. The facts on + * file are what decide two of the three outcomes here: `2uv-v = 3u+q` has no + * answer in `u` until something makes `2v-3` nonzero, and an inequality's + * direction is unknowable until the coefficient's sign is. + * + * When there is no answer this returns the {@link ABSENT_EXPRESSION} + * stand-in, not `undefined` — legacy handed back an `Expression` whose `.tree` + * was `undefined`, and callers read `.tree` off the result unconditionally. + */ + solve_linear(variable) { + const solved = this.context._assumptionsHandle.solve_linear( + this._w, + varName(variable), + ); + return solved === undefined + ? ABSENT_EXPRESSION + : wrap(solved, this.context); + } + // ---- structural conversions ---- tuples_to_vectors() { return wrap(this._w.tuples_to_vectors(), this.context); @@ -220,8 +689,20 @@ class Expression { to_intervals() { return wrap(this._w.to_intervals(), this.context); } - subscripts_to_strings() { - return wrap(this._w.subscripts_to_strings(), this.context); + // Move `+`/scalar-`*` inside vector & matrix containers so grading can slice + // the result into components. Not arithmetic — it deliberately leaves `1+3` + // rather than folding to `4` (`checkEquality` compares components under + // tolerance). Mirrored onto `Context`, so `me.perform_…(expr)` works too. + perform_vector_matrix_additions_scalar_multiplications() { + return wrap( + this._w.perform_vector_matrix_additions_scalar_multiplications(), + this.context, + ); + } + // `force` also collapses a compound subscript, by its text spelling — + // `(x^3)_2` becomes that seven-character symbol name. + subscripts_to_strings(force = false) { + return wrap(this._w.subscripts_to_strings(force), this.context); } strings_to_subscripts() { return wrap(this._w.strings_to_subscripts(), this.context); @@ -230,18 +711,149 @@ class Expression { return wrap(this._w.copy(), this.context); } + // ---- lifetime ---- + // Every Expression owns a Rust/wasm handle that is otherwise only reclaimed by + // the JS GC's FinalizationRegistry — too late for DoenetML's long-lived worker, + // which mints a handle per state-variable eval and per state-JSON revive. `free` + // releases it eagerly. Idempotent: the handle is nulled, so freeing twice is a + // no-op rather than the wasm-memory corruption a double free would cause, and a + // later method call fails on the null handle (a TypeError naming the method) + // instead of reading through a dangling pointer. + free() { + const w = this._w as WasmExpression | undefined; + if (!w) return; + this._w = undefined as unknown as WasmExpression; + if (!ATOM_SHARED.has(w)) { + w.free(); + return; + } + // A handle from the atom cache is shared by every wrapper `fromAst` has + // handed out for that atom, so releasing it on the first `free()` would + // dangle the others. Release it only once this is the last live wrapper + // *and* the cache itself has let go — after a `MAX_ATOMS` sweep or a wasm + // swap the handle is an orphan nothing will hand out again, and leaving it + // to the GC is what made `free()` a silent no-op for atoms. While the + // handle is still cached it stays alive by design. + const refs = (ATOM_REFS.get(w) ?? 0) - 1; + ATOM_REFS.set(w, refs); + const key = ATOM_KEYS.get(w); + if (refs <= 0 && (key === undefined || ATOM_HANDLES.get(key) !== w)) { + ATOM_SHARED.delete(w); + w.free(); + } + } + // Aliases: `dispose()` and the `using`-statement protocol. + dispose() { + this.free(); + } + + // ---- component access ---- + // `component` is an operand index into the tree spelling, or a path of them + // for nested components. A matrix is `["matrix", ["tuple", rows, cols], + // ["tuple", ]]`, so an entry of one is `[1, row, col]`. + /** + * The `component`-th operand of a **container** — a list, tuple, vector, + * altvector or array. + * + * **Throws** for anything else, which is the legacy contract and what + * callers are written against: DoenetML wraps this in `try/catch` and reads + * the throw as "not a container, use the value whole". Two things went wrong + * without it. The wasm entry point indexes the operands of *any* operator + * (its paths are over the flattened JS tree, which is right for what it is + * used for internally), so `xyz` — a product — reported its first factor as + * `.x`, and a scalar reported `undefined`, which read as a container holding + * nothing. + */ + get_component(component) { + const t = this.tree; + if (!Array.isArray(t) || !COMPONENT_CONTAINERS.has(t[0])) { + throw Error( + "Invalid get_component: expected list, tuple, vector, or array", + ); + } + const got = this._w.get_component(componentPath(component)); + if (got === undefined) { + throw Error( + "Invalid get_component: expected list, tuple, vector, or array", + ); + } + return wrap(got, this.context); + } + substitute_component(component, value) { + return wrap( + this._w.substitute_component( + componentPath(component), + toExpr(value, this.context)._w, + ), + this.context, + ); + } + + // ---- numeric evaluator ---- + // The plotting / root-finding entry point: compile once through math.js, then + // evaluate per sample. `compileRustExpr` normalizes function names Rust-side + // and frees its own temporary handle; `this._w` is untouched. + f() { + // `./mathjs` re-exports either a created instance or the namespace itself, + // so its static type is a union; the runtime value is always an instance. + const compiled = compileRustExpr(math as MathJsInstance, this._w); + return (bindings = {}) => compiled.evaluate(bindings); + } + + /** + * The critical points with respect to `variable` — the real solutions of + * `d/dvariable = 0` — exactly, in increasing order. + * + * Three outcomes, and a caller that keeps a numerical fallback needs to tell + * them apart: an array of points; an **empty** array, meaning there are + * provably none; and `null`, meaning undecided — sample instead. Undecided + * is a derivative that is not a rational function of `variable` (`cos(x)`, + * which has infinitely many roots anyway), one carrying a free parameter + * (`d/dx a·x²`, whose roots depend on `a`), or a constant-zero derivative, + * where every point is critical and no finite list says so. + * + * Exact means exact: a rational root comes back as a number, an algebraic one + * as the `rootof` form carrying its defining polynomial, and a repeated root + * is listed once. Points where the derivative does not *exist* — the corner + * of `|x|` — are not reported; they are critical in the textbook sense, but + * finding them is not rational root-finding. + */ + critical_points(variable) { + // Through `varName`, like `derivative`/`integrate`/`solve_linear`: the + // wasm binding takes a `&str` and computes a length on whatever it is + // handed, so an `Expression` argument reads out of bounds rather than + // failing. + const pts = this._w.critical_points(varName(variable)); + return pts === undefined + ? null + : pts.map((p) => new Expression(p, this.context)); + } + // ---- units ---- remove_units(scaleBasedOnUnit) { - return wrap(this._w.remove_units(!!scaleBasedOnUnit), this.context); + // Legacy default scales (`50%` → `0.5`, `180deg` → `π`); pass `false` to + // keep the bare value (`50%` → `50`). + const scale = scaleBasedOnUnit === undefined ? true : !!scaleBasedOnUnit; + return wrap(this._w.remove_units(scale), this.context); } remove_scaling_units() { return wrap(this._w.remove_scaling_units(), this.context); } add_unit(unit) { - return wrap(this._w.add_unit(unit), this.context); + // `varName`, for the same reason `critical_points` uses it: the wasm entry + // point is `add_unit(unit: &str)`, and wasm-bindgen reads a non-string + // argument as a pointer/length pair. The published declaration invites an + // `Expression | Tree` here — legacy took one — and handing it either read + // out of bounds (`RuntimeError: memory access out of bounds`) or threw + // `arg.charCodeAt is not a function`. A unit is a symbol, so its name is + // all the Rust side wants. + return wrap(this._w.add_unit(varName(unit)), this.context); } set_small_zero(tolerance) { - return wrap(this._w.set_small_zero(tolerance === undefined ? 1e-14 : tolerance), this.context); + return wrap( + this._w.set_small_zero(tolerance === undefined ? 1e-14 : tolerance), + this.context, + ); } // ---- rounding ---- @@ -259,10 +871,92 @@ class Expression { } // ---- evaluation ---- - evaluate_to_constant() { - const v = this._w.evaluate_to_constant(); - return v === undefined ? null : v; - } + // Two return shapes, and neither is `null`: a `number` — where `NaN` is the + // "no numeric value" marker, as legacy's was — or a math.js `Complex` for a + // non-real value. The wasm entry point reports only the real case; the + // complex one comes back through `evaluate_to_complex`. + // + // Legacy returned a plain number for a real value and a complex value for a + // non-real one, so `fromText("i").evaluate_to_constant()` is `{re:0, im:1}`, + // not NaN. + // + // The complex value is a math.js `Complex`, as legacy's was: callers pass it + // straight into math.js functions (`divide(evaluate_to_constant(a), …)`), + // which reject a plain object. A consumer that puts one into a *state + // variable* should flatten it there — it is structured-cloned to the main + // thread and arrives prototype-stripped either way. + evaluate_to_constant(opts) { + // Units are scaled away first by default (`50%` → `0.5`, `180deg` → `π`): + // `remove_units_first` (default true) strips them, `scale_based_on_unit` + // (default true) applies the unit's factor. With `remove_units_first:false` + // a unit-bearing value has no numeric constant, so it falls through to NaN. + let e = this as unknown as Expression; + if (opts?.remove_units_first ?? true) { + e = e.remove_units(opts?.scale_based_on_unit ?? true); + } + const v = e._w.evaluate_to_constant(); + if (v !== undefined) { + // A non-finite value of a *complex* expression has no defined direction — + // `Infinity*i` and `Infinity*i + Infinity` are complex NaN + // (`{re:NaN, im:NaN}`), matching mathjs. A real non-finite value stays as + // it is (`Infinity`, or scalar `NaN` for `0/0` / `Infinity - Infinity`). + if (!Number.isFinite(v) && treeHasImaginary(e.tree as Tree)) { + return math.complex(NaN, NaN); + } + return v; + } + const c = e._w.evaluate_to_complex(); + if (c !== undefined) return math.complex(c[0], c[1]); + // `det`/`trace` of a literal matrix only reduce to a number under + // simplification (`\det[[1,2],[3,4]]` → −2), so retry once via the simplified + // form — but *only* for those, since simplification would also absorb an + // undefined leaf (`0·_` → `0`) and wrongly turn a `null` into a number. + if (treeHasMatrixReduction(e.tree as Tree)) { + const s = e.simplify(); + const sv = s._w.evaluate_to_constant(); + if (sv !== undefined) return sv; + const sc = s._w.evaluate_to_complex(); + if (sc !== undefined) return math.complex(sc[0], sc[1]); + } + // Not a constant: `NaN`, as legacy answered. Everything that reaches here — + // a free variable (`x+1`), a blank `_`, a placeholder hole (`0·_`, `_/_`), + // a matrix, a leftover unit — is "no numeric value", and legacy spelled all + // of them `NaN`. + // + // This used to be `null` for the free-variable and placeholder cases, on + // the grounds that "cannot be evaluated" is worth telling apart from + // "evaluates to NaN". The distinction is real, but `null` is the wrong way + // to carry it across into JavaScript, and it was carrying it into every + // consumer whether or not the consumer had asked. `null` is *anti*- + // poisoning: `Number(null)` is `0`, `null + 5` is `5`, `null <= 1` is + // `true`, `Number.isNaN(null)` is `false`. So an expression with no value + // silently behaved like zero — a rectangle 0 wide, a line with slope 1, a + // blank answer scoring full credit. `NaN` does the opposite: it propagates + // through arithmetic and falsifies every comparison, which is what a + // "no value" marker has to do to be safe by default. + // + // A caller that genuinely needs "unevaluable" apart from "evaluates to NaN" + // can still get it — `variables()` reports the free variables, and the tree + // is right there — but it has to ask, and the default is the safe one. + // + // Legacy's `nan_for_non_numeric` option is still *not* honored: this path + // always behaves as its `true` default, which is now also the only + // behavior. Passing `{nan_for_non_numeric: false}` does not produce `null`. + return NaN; + } + /** + * The complex half of `evaluate_to_constant`, on its own. + * + * This one *does* answer `null`, and deliberately, unlike + * `evaluate_to_constant`. Two reasons it is not the same hazard. It has no + * legacy counterpart, so there is no drop-in contract saying otherwise; and + * its range already contains `Complex(NaN, NaN)` as a genuine value + * (`Infinity*i`), so `NaN` cannot double as the "no value" marker here the + * way it can for a real result. A `Complex` never coerces silently either — + * math.js rejects `null` loudly rather than reading it as `0`. + * + * Not part of the published `types/math-expressions.d.ts` surface. + */ evaluate_to_complex() { const v = this._w.evaluate_to_complex(); return v === undefined ? null : math.complex(v[0], v[1]); @@ -273,16 +967,78 @@ class Expression { const r = this._w.evaluate(vars, vals); return r === undefined ? NaN : r; } + /** + * Evaluate at many values of one variable in a single crossing. + * + * `evaluate` marshals the variable names on every call, which costs far more + * than the arithmetic — measured at ~1.2µs a point against ~6ns of actual + * work on `x²−3x+1`. Sampling a curve, scanning for extremum brackets or + * hunting a root asks the same question thousands of times, and this pays + * that overhead once. + * + * Any other variable is left unbound; `substitute` it first. The result is a + * `Float64Array` the same length as `values`, with `NaN` wherever there is no + * finite real value — a pole, a complex branch, an unbound variable — so it + * lines up index-for-index with what was asked and the gaps carry the marker + * consumers already test for. + */ + evaluate_many(variable, values) { + // `varName` for the same reason as `critical_points` above. + return this._w.evaluate_many( + varName(variable), + values instanceof Float64Array ? values : Float64Array.from(values), + ); + } + /** + * Replace variables by their bindings, all at once. + * + * Simultaneous, as the JS library was: no binding sees another's + * replacement, so `a·x + b·y` with `{a: "b", b: "a"}` swaps the two + * coefficients rather than collapsing both to `a`. + * + * This *was* a left-to-right pass here, on the stated grounds that legacy + * was one too and that DoenetML relied on a substituted `` code + * expanding into further codes. Neither holds — legacy walks the tree once + * (`trees/basic.js`), and `{c1: "c2", c2: 5}` leaves `c2` standing there as + * well. A sequential pass silently captures instead: `sin(x+y)` with + * `{x: "10y", y: "-π"}` answered `sin(-10π − π)`, and DoenetML substitutes + * variable names into `a·x + b·y + c` in `Line.js`, where a document + * declaring `variables="y x"` put both coefficients on one variable. + * + * Differs from {@link substitute_all} only in coercing each binding the way + * the rest of this API does — a string is *parsed* (`{x: "2y"}` binds the + * product `2y`, not a symbol spelled `"2y"`), matching legacy. + */ substitute(bindings) { - let cur = this._w; - for (const k of Object.keys(bindings || {})) { - const next = cur.substitute_var(k, toExpr(bindings[k], this.context)._w); - // Free the prior intermediate handle (wrapper-owned); never `this._w` - // (caller's own) and never the final handle we hand back via `wrap`. - if (cur !== this._w) cur.free(); - cur = next; - } - return wrap(cur, this.context); + const keys = Object.keys(bindings || {}); + if (keys.length === 0) return this; + const map = {}; + for (const k of keys) map[k] = toExpr(bindings[k], this.context); + return wrap( + this._w.substitute_map(JSON.stringify(map, astReplacer)), + this.context, + ); + } + + /** + * Replace variables by their bindings **simultaneously**, taking each + * binding as the tree it already is. + * + * Same substitution as {@link substitute}; the difference is coercion. This + * one serializes the binding as given, so a string binds a *symbol* of that + * name (`{x: "2y"}` binds the single symbol `2y`), where `substitute` parses + * it into the product `2·y`. Reach for this when the bindings are trees or + * `Expression`s and there is nothing to parse. + */ + substitute_all(bindings) { + const keys = Object.keys(bindings || {}); + if (keys.length === 0) return this; + const map = {}; + for (const k of keys) map[k] = bindings[k]; + return wrap( + this._w.substitute_map(JSON.stringify(map, astReplacer)), + this.context, + ); } // ---- arithmetic ---- @@ -331,25 +1087,143 @@ class Expression { return wrap(this._w.dot_prod(toExpr(other, this.context)._w), this.context); } cross_prod(other) { - return wrap(this._w.cross_prod(toExpr(other, this.context)._w), this.context); + return wrap( + this._w.cross_prod(toExpr(other, this.context)._w), + this.context, + ); } vector_add(other) { - return wrap(this._w.vector_add(toExpr(other, this.context)._w), this.context); + return wrap( + this._w.vector_add(toExpr(other, this.context)._w), + this.context, + ); } vector_sub(other) { - return wrap(this._w.vector_sub(toExpr(other, this.context)._w), this.context); + return wrap( + this._w.vector_sub(toExpr(other, this.context)._w), + this.context, + ); + } + // `me.scalar_mul(scalar, vector)` mirrors to `toExpr(scalar).scalar_mul(vector)`, + // so `this` is the scalar and `other` the vector. + scalar_mul(other) { + return wrap( + this._w.scalar_mul(toExpr(other, this.context)._w), + this.context, + ); } // ---- pattern matching (default mode only) ---- - match(pattern, _options) { - const res = wasm.match_template( - this._w.tree_json(), - toExpr(pattern, this.context)._w.tree_json(), + /** + * Template match against `pattern`. Options: + * + * - `variables` — the declared parameters, as `{name: kind}` where kind is + * `true`/`"any"`, `"number"` or `"variable"`. Present-and-empty declares + * *no* parameters, so only an exact match succeeds; omitting the option + * keeps the legacy default where every string leaf in the pattern binds. + * - `allow_permutations` — match `+`/`*` operands in any order. + * - `allow_implicit_identities` — array of parameter names that may take the + * operator's identity, so `a x + b` matches `x` with `a = 1`, `b = 0`. + * - `allow_extended_match` — let a `+`/`*` pattern match a *subset* of a + * larger sum or product, reporting the untouched operands as `_skipped`. + * + * The kinds replace the JS predicates the legacy API took: a function cannot + * cross the wasm boundary, and these three are what the predicates expressed. + * A predicate is therefore rejected rather than ignored — silently treating + * one as "any" is what made `requireNumericMatches` a no-op. + */ + match(pattern, options?) { + // Delegated to the shared implementation, which is what makes the claim + // that this and `me.utils.match` cannot drift true. It used to share only + // `normalizeMatchOptions` and call the wasm matcher itself, and + // `allow_extended_match` is handled *outside* that matcher — so the two + // entry points answered differently for the same call: + // `("x+y+z").match("a+b", {variables: {a: true, b: true}, + // allow_extended_match: true})` bound `b` to `y+z` here and to `y`, with + // `_skipped: ["z"]`, through `me.utils.match`. Legacy's + // `Expression.prototype.match` delegated for the same reason. + // + // `.tree`, not `_w.tree_json()`, because the shared entry takes trees; and + // the pattern still goes through `toExpr` first, since a string pattern is + // a *parse* here and would be a bare leaf to `astToJson`. + // + // `hasOptions`, not the shared `hasParams`, so an empty options object + // keeps taking the cheaper no-options path — which is also the path whose + // legacy default lets every string leaf in the pattern bind. Bindings come + // back through `jsonToAst` in there, not bare `JSON.parse`: they are + // subtrees, and `.tree` hands subtrees out untagged, so returning + // `{a: {$: "Inf"}}` would contradict the convention the rest of the + // surface follows — and break the `typeof m.a === "number"` consumers + // legacy supported. + return match( + this.tree, + toExpr(pattern, this.context).tree, + hasOptions(options) ? options : undefined, ); - return res === undefined ? false : JSON.parse(res); } } +// The `using` protocol, attached only where the runtime actually has the symbol +// (Node ≥ 18.18, Chrome ≥ 125, Safari ≥ 18.4). Written as a class member, +// `[Symbol.dispose]() {}` on an engine without it would define a method keyed by +// the *string* "undefined" — silently useless rather than absent, and `free()` +// would never run. Feature-detecting keeps `using expr = me.fromText(…)` working +// where it is supported and simply unavailable where it is not. +if (typeof Symbol.dispose === "symbol") { + (Expression.prototype as unknown as Record)[Symbol.dispose] = function ( + this: Expression, + ) { + this.free(); + }; +} + +/** + * The "no answer" result from a method that can fail to produce an expression + * at all — currently only {@link Expression.solve_linear}. + * + * Legacy funnelled every tree-returning helper through `context.fromAst(...)` + * (`extend_prototype` in the old `math-expressions.js`), so a helper that + * returned `undefined` still handed back a real `Expression` — one whose `.tree` + * was `undefined`. Callers, the specs included, read `.tree` off the result + * without checking, so returning a bare `undefined` here would turn "unsolvable" + * into a `TypeError`. + * + * A js-compat `Expression` is always backed by a wasm handle and no handle + * spells "absent", so this is a separate object rather than an `Expression`. + * Its shape is what the live legacy oracle actually hands out for an unsolvable + * relation, checked case by case: `.tree` is `undefined`, `toString()` and + * `toLatex()` are `""`, `equals(…)` is `false`, `variables()` is `[]`. Every + * other `Expression` method returns the stand-in itself, so chaining off an + * unsolvable relation stays absent instead of throwing — legacy's own chained + * results were an artifact of wrapping `undefined` and not worth reproducing. + */ +const ABSENT_EXPRESSION = (() => { + const absent: Record = { + tree: undefined, + // A getter because `Context` is initialized further down this module and + // this runs during its evaluation. + get context() { + return Context; + }, + toString: () => "", + toLatex: () => "", + variables: () => [], + equals: () => false, + // Deliberately not self-returning: a `toJSON` handing back the stand-in + // makes `JSON.stringify` recurse until the stack goes, and there is no + // handle to free. + toJSON: () => undefined, + free: () => {}, + }; + for (const name of Object.getOwnPropertyNames(Expression.prototype)) { + const d = Object.getOwnPropertyDescriptor(Expression.prototype, name); + if (name === "constructor" || name in absent) continue; + if (typeof d?.value !== "function") continue; // a getter has no `value` + absent[name] = () => absent; + } + return Object.freeze(absent); +})(); + // Legacy methods with no Rust backing — defined so calls fail loudly, not as // "undefined is not a function" surprises. Tests using them fail; suite runs. for (const name of [ @@ -359,40 +1233,131 @@ for (const name of [ "toXML", "toGLSL", "toMathjs", - "f", - "solve_linear", - "substitute_component", - "get_component", - "create_discrete_infinite_set", - "expression_to_polynomial", "finite_field_evaluate", ]) { - (Expression.prototype as Record)[name] = notImplemented(name); + (Expression.prototype as unknown as Record)[name] = + notImplemented(name); } -// Normalization / transformation passes with no standalone Rust entry point -// (folded into `canonicalize`). Compat no-ops that return the expression -// unchanged, so method chains still resolve and specs collect + run. Cases that -// depended on the pass mismatch and fail — as expected (JS_TEST_COVERAGE_AUDIT). -for (const name of [ - "default_order", - "normalize_negative_numbers", - "normalize_applied_functions", - "expand_relations", - "applyAllTransformations", -]) { - (Expression.prototype as Record)[name] = function (this: Expression) { +// Normalization passes with no faithful Rust entry point (folded into +// `canonicalize`). Kept as no-ops returning `this` rather than throwing: a +// blanket throw here regressed ~170 idempotent-input specs that legitimately +// pass on the unchanged tree, and aborted whole spec files at collection. The +// real fix is implementing them; see DOENET_COMPAT_PLAN R7 and the follow-up note. +// `default_order` graduated out of this list — it has a real implementation +// now (`normalize::default_order`), carrying the JS ordering key rather than +// the Rust canonical `cmp`, because the order it produces is displayed. So did +// `normalize_negative_numbers` and `normalize_applied_functions`: the passes +// they name were already in the Rust core as `normalize_syntactic`'s second and +// third steps, and are now exported individually. And so did `expand_relations`, +// which the assumptions store had been using all along +// (`assumptions::expand::expand_relations`) — only the public binding was +// missing. +for (const name of ["applyAllTransformations"]) { + (Expression.prototype as unknown as Record)[name] = function ( + this: Expression, + ) { return this; }; } -function parseText(string) { - return new Expression(wasm.parse_text(string), Context); +// The parser options object is the legacy second argument (`splitSymbols`, +// `appliedFunctionSymbols`, `functionSymbols`, `operatorSymbols`, …). It was +// being dropped on the floor here, which mattered most for +// `appliedFunctionSymbols`: without it there is no way to get `sum(1,2,3)` to +// parse as an application rather than as `s·u·m·(1,2,3)`, since neither this +// library nor the legacy one lists the aggregates by default. +/** + * The legacy library threw a `ParseError` — an `Error` subclass whose `name` + * said so — and callers narrow on that name to tell "you typed something I + * cannot read", which is worth showing a student, from any other failure, which + * is not. `wasm-bindgen` throws a plain `Error`, so that name was lost and the + * narrowing silently stopped matching: DoenetML's `` has + * a slot for the parser's complaint and had been rendering nothing in it. + * + * The message is the engine's own and is already the useful part + * (`Expecting } (at 7)`, `Invalid symbol '@' (at 0)`); only the label was + * missing. `cause` keeps the original for anyone who wants the stack. + */ +function asParseError(e: unknown) { + if (e instanceof Error && e.name === "Error") { + e.name = "ParseError"; + return e; + } + if (e instanceof Error) { + return e; + } + // wasm-bindgen can reject with a bare string. + const wrapped = new Error(String(e), { cause: e }); + wrapped.name = "ParseError"; + return wrapped; } -function parseLatex(string) { - return new Expression(wasm.parse_latex(string), Context); + +/** + * Reject a non-string at the parser boundary, with a message that says so. + * + * `parse_text`/`parse_latex` are declared `(s: &str)` on the Rust side, and + * wasm-bindgen reads a non-string argument as a pointer/length pair into linear + * memory: `me.fromText(5)` and `me.fromText(anExpression)` both came out as + * `RuntimeError: memory access out of bounds`, and an array tree as + * `arg.charCodeAt is not a function`. These are the package's two most-used + * entry points, and the second message reaches a student — `` renders whatever the parser complains about. + * + * A *throw* is right here where `add_unit` takes a coercion: `add_unit`'s + * declaration invites an `Expression | Tree` and a unit is a symbol, so its + * name is a faithful reading; `fromText` is declared to take a `string` and + * there is no faithful reading of anything else. So this changes no call that + * used to succeed — it only replaces an engine-internal failure with a + * diagnosable one. + * + * `String` *objects* are accepted: they carry `.length` and `.charCodeAt`, so + * wasm-bindgen has always read them correctly and rejecting them here would be + * a new restriction rather than a clearer message. + */ +function parseInput(s: unknown, what: "fromText" | "fromLatex"): string { + if (typeof s === "string") return s; + if (s instanceof String) return String(s); + throw new TypeError( + `${what}: expected a string, got ${s === null ? "null" : typeof s}. ` + + "Use `me.fromAst` for an AST tree and `me.from` for an Expression.", + ); +} + +function parseText(string, opts?) { + const text = parseInput(string, "fromText"); + try { + return new Expression( + hasOptions(opts) + ? wasm.parse_text_with_options(text, JSON.stringify(opts)) + : wasm.parse_text(text), + Context, + ); + } catch (e) { + throw asParseError(e); + } +} +function parseLatex(string, opts?) { + const latex = parseInput(string, "fromLatex"); + try { + return new Expression( + hasOptions(opts) + ? wasm.parse_latex_with_options(latex, JSON.stringify(opts)) + : wasm.parse_latex(latex), + Context, + ); + } catch (e) { + throw asParseError(e); + } } function createFrom(expr) { + // "Nothing" converts to nothing. `fromAst(undefined)` reaches the core as a + // literal `undefined` string and dies inside the parser with a + // `Cannot read properties of undefined` — but callers do write + // `me.from(value)` over a table whose empty rows mean "no expression", and + // the legacy library handed those back an expression with an undefined tree + // that every consumer treated as absent. + if (expr === undefined || expr === null) return undefined; if (typeof expr === "string") { try { return parseText(expr); @@ -408,7 +1373,239 @@ function createFrom(expr) { return Context.fromAst(expr); // number or AST } +/** + * `numeric.dopri` drop-in — the Dormand-Prince ODE integrator DoenetML reached + * through the old bundled math.js (`me.math.dopri`). Since DoenetML is dropping + * mathjs, this is exported as a peer compat function (`me.dopri` / a named + * export) rather than under `me.math`; the call contract is unchanged: + * + * dopri(x0, x1, y0, f, tol?, maxit?) + * + * `f(x, y)` returns the derivative; `y0`, the states, and `f`'s return are + * arrays for a system or plain numbers for a scalar ODE. The result exposes + * `.at(x)` dense interpolation (a scalar/array x), and the `.x`/`.y` step + * arrays. Backed by the Rust `solve_ode` integrator (one boundary crossing per + * RK stage). numeric.js's `event` argument is not supported. + */ +function dopri( + x0: number, + x1: number, + y0: number | ArrayLike, + f: (x: number, y: number | number[]) => number | number[], + tol = 1e-6, + maxit = 1000, +) { + const scalar = typeof y0 === "number"; + const y0arr = scalar ? [y0 as number] : Array.from(y0 as ArrayLike); + const dim = y0arr.length; + // `f` is called from inside the integrator, across the wasm boundary, where + // an exception must not unwind — `panic = "abort"` makes that a module crash, + // so the Rust side treats a throwing stage as a failed step and stops early. + // Correct, but on its own it hands the caller a short, entirely + // plausible-looking trajectory with only `terminatedEarly` to hint at why: + // `dopri(0,1,1,()=>{throw …}).at(1)` returned the initial condition. Capture + // the first failure and rethrow it on this side once the integrator is done. + // A wrong-length derivative is caught here for the same reason — silently + // integrating one component of a two-component system is a wrong answer. + let failure: { error: unknown } | undefined; + const zeros = () => new Array(dim).fill(0); + const rhs = (x: number, y: Float64Array): number[] => { + if (failure) return zeros(); // already doomed; just let the solver wind down + let out: number | number[]; + try { + out = f(x, scalar ? y[0] : Array.from(y)); + } catch (error) { + failure = { error }; + return zeros(); + } + const arr = + typeof out === "number" + ? [out] + : Array.from(out as ArrayLike, Number); + if (arr.length !== dim) { + failure = { + error: new TypeError( + `dopri: the derivative returned ${arr.length} component(s) for a ${dim}-component state`, + ), + }; + return zeros(); + } + return arr; + }; + const sol = wasm.solve_ode(rhs, x0, x1, Float64Array.from(y0arr), tol, maxit); + if (failure) { + sol.free(); // nothing will read this solution; do not leak its handle + throw failure.error; + } + const n = sol.dim(); + const state = (flat: Float64Array, i: number) => { + const s = Array.from(flat.subarray(i * n, (i + 1) * n)); + return scalar ? s[0] : s; + }; + // Guarded so a second `free()` is a no-op rather than the "null pointer + // passed to rust" that a wasm-bindgen double free raises — same reasoning as + // `Expression.free`. + let freed = false; + const freeSolution = () => { + if (freed) return; + freed = true; + sol.free(); + }; + return { + /** Dense output: interpolated state at `x` (or one per element of an `x` array). */ + at(x: number | number[]): number | number[] | (number | number[])[] { + if (Array.isArray(x)) { + const flat = sol.at_many(Float64Array.from(x)); + return x.map((_, i) => state(flat, i)); + } + const s = Array.from(sol.at(x)); + return scalar ? s[0] : s; + }, + /** Accepted step abscissas. */ + get x(): number[] { + return Array.from(sol.times()); + }, + /** States at each step abscissa. */ + get y(): (number | number[])[] { + const ts = sol.times(); + const flat = sol.at_many(ts); + return Array.from(ts, (_v, i) => state(flat, i)); + }, + /** True when integration stopped before `x1` (blow-up / step budget). */ + get terminatedEarly(): boolean { + return sol.terminated_early(); + }, + // Same contract as `Expression.free`/`dispose`: the solution owns a wasm + // handle, and a worker that integrates in a loop leaks one per call + // otherwise. numeric.js had nothing to release, so this is additive — + // callers that never free behave exactly as before. + /** Release the underlying wasm handle. Idempotent. */ + free() { + freeSolution(); + }, + /** Alias of `free()`, and the `using`-statement protocol where supported. */ + dispose() { + freeSolution(); + }, + ...(typeof Symbol.dispose === "symbol" + ? { [Symbol.dispose]: freeSolution } + : {}), + }; +} + +/** + * Every tree obtainable from `tree` by negating exactly one of its nodes, + * itself included. Each node is negated once across the whole enumeration, so + * an n-node tree yields n variants. + */ +function* singleNegations(tree: Tree): Generator { + yield ["-", tree] as Tree; + if (Array.isArray(tree)) { + for (let i = 1; i < tree.length; i++) { + for (const variant of singleNegations(tree[i])) { + const copy = tree.slice() as Tree[]; + copy[i] = variant; + yield copy as Tree; + } + } + } +} + +/** + * Does `expr` equal `other` once **exactly** `n_sign_errors` of its parts have + * their sign flipped? Grading for "you had the right idea but dropped a minus + * sign" — DoenetML's `numSignErrorsMatched`. + * + * Port of the JS `equalSpecifiedSignErrors`. That version negated nodes in + * place, in the caller's tree, and relied on restoring them afterwards; this + * one enumerates variants instead, since a wasm-backed `Expression` has no + * mutable tree. Callers no longer need the defensive deep copy the old + * contract forced on them, though making one is harmless. + * + * `equalityFunction` receives the *negated* expression first, matching the JS + * argument order — DoenetML's normalizes that side before comparing. + */ +function equalSpecifiedSignErrors( + expr: ExpressionLike, + other: ExpressionLike, + { + equalityFunction, + n_sign_errors = 1, + }: { + equalityFunction?: (a: Expression, b: Expression) => boolean; + n_sign_errors?: number; + } = {}, +): boolean { + const e = toExpr(expr, Context); + const o = toExpr(other, Context); + const baseEquality = + equalityFunction ?? ((a: Expression, b: Expression) => a.equals(b)); + + if (n_sign_errors === 0) { + return baseEquality(e, o); + } + if (!(Number.isInteger(n_sign_errors) && n_sign_errors > 0)) { + throw Error( + `Have not implemented equality check with ${n_sign_errors} sign errors.`, + ); + } + + // More than one error: each variant is then checked for the remaining ones, + // so the negations compose without this function needing to enumerate + // combinations itself. + const compare = + n_sign_errors === 1 + ? baseEquality + : (a: Expression, b: Expression) => + equalSpecifiedSignErrors(a, b, { + equalityFunction: baseEquality, + n_sign_errors: n_sign_errors - 1, + }); + + const ctx = (e.context || Context) as Ctx; + for (const variant of singleNegations(e.tree as Tree)) { + if (compare(ctx.fromAst(variant) as Expression, o)) return true; + } + return false; +} + +/** + * Equal outright, or after up to `max_sign_errors` sign flips — reporting how + * many it took. Port of the JS `equalWithSignErrors`. + */ +function equalWithSignErrors( + expr: ExpressionLike, + other: ExpressionLike, + { + equalityFunction, + max_sign_errors = 1, + }: { + equalityFunction?: (a: Expression, b: Expression) => boolean; + max_sign_errors?: number; + } = {}, +): { matched: boolean; n_sign_errors?: number } { + const e = toExpr(expr, Context); + const o = toExpr(other, Context); + const compare = + equalityFunction ?? ((a: Expression, b: Expression) => a.equals(b)); + + if (compare(e, o)) return { matched: true, n_sign_errors: 0 }; + + for (let i = 1; i <= max_sign_errors; i++) { + if ( + equalSpecifiedSignErrors(e, o, { + equalityFunction: compare, + n_sign_errors: i, + }) + ) { + return { matched: true, n_sign_errors: i }; + } + } + return { matched: false }; +} + const Context = { + dopri, from: createFrom, fromText: parseText, parse: parseText, @@ -418,63 +1615,389 @@ const Context = { fromTex: parseLatex, parse_tex: parseLatex, fromMml: notImplemented("fromMml"), + /** + * `me.setConstantPolicy({define_e: false})` — declare which of `pi`, `e` and + * `i` denote mathematical constants here rather than ordinary variable names. + * + * The original library took this at construction time + * (`createInstance({define_e, define_pi, define_i})` in `lib/mathjs.js`); the + * Rust core keeps it as ambient state instead, so it is set rather than + * baked into an instance. Absent keys keep their current values. + * + * Turn `define_e` off for a document whose points are `(e, f)`: `e` then + * behaves as a variable everywhere — `e^x` stops folding to `exp(x)`, `e` is + * sampled as a free variable by `equals`, and it is an indeterminate rather + * than a coefficient to the polynomial code. Likewise `define_i` off stops + * `i·i` folding to `−1` in a document whose coordinates run `g, h, i`. + * + * `sort_constants_first` is the one display option here: off (the default), + * everything sorts alphabetically, which is what the original library does + * under every setting of `define_*`. On, declared constants lead a term, so + * `2 π i` reads that way rather than as the alphabetical `2 i π`. + * + * Ordering aside, this never changes a comparator: canonical trees stay + * comparable across policies, so a stored expression does not stop matching + * because a document later redeclared a name. + */ + setConstantPolicy(policy: Record) { + wasm.set_constant_policy(JSON.stringify(policy)); + }, + /** The constant policy currently in effect. */ + getConstantPolicy(): Record { + return JSON.parse(wasm.get_constant_policy()); + }, + // `me.matrix([[a,b],[c,d]])` — build a matrix literal from a 2-D array of + // Expressions (or ASTs). Not expression-first (the argument is an array, not + // an expression), so it lives on the Context directly rather than being + // mirrored from the `Expression` prototype. + matrix(rows: ExpressionLike[][]) { + const nr = rows.length; + const nc = nr > 0 ? rows[0].length : 0; + const body = [ + "tuple", + ...rows.map((row) => [ + "tuple", + ...row.map((e) => toExpr(e, Context).tree), + ]), + ]; + return Context.fromAst(["matrix", ["tuple", nr, nc], body]); + }, + /** + * `me.create_discrete_infinite_set({offsets, periods})` — a periodic solution + * set such as `π/4 + nπ`, written as the union of one arithmetic progression + * per offset. `offsets` may be a comma list; `periods` is then either a + * single shared period or a list of matching length. `min_index`/`max_index` + * bound the index `n` (default: all of ℤ). + * + * Like `matrix`, it takes a config object rather than an expression, so it + * lives on the Context directly. It used to be mirrored from the `Expression` + * prototype instead, which made the mirror wrapper run `toExpr` over the + * config object and reject it as a non-tree — the config is the argument, not + * the receiver. + * + * `undefined` (the legacy failure value) when an operand is missing or the + * offset/period list lengths disagree. + */ + create_discrete_infinite_set(config?: { + offsets?: ExpressionLike; + periods?: ExpressionLike; + min_index?: ExpressionLike; + max_index?: ExpressionLike; + }) { + // Read the fields one at a time rather than destructuring `config`. + // API Extractor — which `vite-plugin-dts`'s `rollupTypes` runs, and which + // therefore walks every declaration in this file for any consumer whose + // types reach it — cannot resolve an object binding pattern in this + // position, and aborts that consumer's build outright with "Unable to + // determine semantic information for declaration". A destructuring here is + // not worth costing downstream builds their d.ts rollup, so this file keeps + // to plain property reads. + const opts = config ?? {}; + const offsets = opts.offsets; + const periods = opts.periods; + const min_index = opts.min_index; + const max_index = opts.max_index; + if (offsets === undefined || periods === undefined) return undefined; + // The bounds cross as tree JSON because they are optional and wasm-bindgen + // has no by-reference `Option<&Expression>`; `tree_json()` is the same wire + // form `from_ast` reads, so no re-tagging is needed here. + const bound = (b: ExpressionLike | undefined) => + b === undefined ? undefined : toExpr(b, Context)._w.tree_json(); + return wrap( + wasm.discrete_infinite_set( + toExpr(offsets, Context)._w, + toExpr(periods, Context)._w, + bound(min_index), + bound(max_index), + ), + Context, + ); + }, fromAst(ast) { - return new Expression(wasm.from_ast(JSON.stringify(ast)), Context); + const key = atomKey(ast); + if (key === undefined) { + // A bare number skips JSON entirely. This is the sampled-coordinate + // case, which `atomKey` deliberately declines to cache (see + // `ATOM_HANDLES`: caching arbitrary floats is a measured loss), so it + // arrives here on every call — once per sample point when a function is + // evaluated over a domain. Going through `from_ast` meant a + // `JSON.stringify` here and a full JSON parse in wasm to move one f64. + // + // Finite only. The JSON path routes a non-finite through `astReplacer`'s + // `{"$":"Inf"}` / `{"$":"NaN"}` tags, which `from_ast` revives as the + // infinity *constant* — a different expression from a float that happens + // to be infinite, and one that `equals`, `simplify` and the interval + // endpoints all treat differently. Taking the shortcut there made + // `fromAst(Infinity)` stop comparing equal to `fromText("infinity")`. + if (typeof ast === "number" && Number.isFinite(ast)) { + return new Expression(wasm.from_number(ast), Context); + } + return new Expression( + wasm.from_ast(JSON.stringify(ast, astReplacer)), + Context, + ); + } + let handle = ATOM_HANDLES.get(key); + if (handle === undefined) { + handle = wasm.from_ast(JSON.stringify(ast, astReplacer)); + if (ATOM_HANDLES.size >= MAX_ATOMS) ATOM_HANDLES.clear(); + ATOM_HANDLES.set(key, handle); + ATOM_SHARED.add(handle); + ATOM_KEYS.set(handle, key); + } + // A fresh wrapper per call: the handle is immutable and safe to share, but + // the `Expression` around it carries a `context` and is what callers hold. + return new Expression(handle, Context); }, reviver(key, value) { - if (value && value.objectType === "math-expression" && value.tree !== undefined) { + if ( + value && + value.objectType === "math-expression" && + value.tree !== undefined + ) { return Context.fromAst(value.tree); } return value; }, + /** + * Distinct symbol names interned this session — a memory gauge for the + * long-lived worker. Append-only (see item 8); use it to measure symbol + * growth over a session. + */ + interner_size(): number { + return wasm.interner_size(); + }, isTree, math, converters, utils: { match, flatten, unflattenLeft, unflattenRight }, class: Expression, + // ---- sign-error grading (`lib/expression/sign_error.js`) ---- + equalSpecifiedSignErrors, + equalWithSignErrors, + // ---- assumptions (context-level) ---- - // Backed by a wasm `Assumptions` handle plus a parallel text list so - // `simplify_with_assumptions` can be fed. `get_assumptions` is best-effort — - // the original returned a richly-structured object this does not reproduce. - _assumptionsHandle: new wasm.Assumptions(), + // One handle, fed twice. The wasm `Assumptions` handle answers the predicates + // (`is_real`, `is_positive`, …) from the text spelling of every assumption, + // and holds the same facts as trees — filed per variable, so that + // `get_assumptions` can hand a fact *back*. The parallel text list is what + // `simplify_with_assumptions` takes. + // + // The handle is constructed lazily, and that is load-bearing. As a plain `new + // wasm.Assumptions()` in this literal it ran while *this module's body* was + // still evaluating, so any consumer importing `setWasmModule` from the package + // root forced the wasm load before it had a chance to inject — the injection + // could never win, and silently fell through to the node loader. Nothing here + // may touch `wasm` until someone actually calls a method. + _assumptionsHandleCache: undefined, + get _assumptionsHandle() { + return (this._assumptionsHandleCache ??= new wasm.Assumptions()); + }, + set _assumptionsHandle(h) { + this._assumptionsHandleCache = h; + }, _assumptionTexts: [], set_to_default() { + // A fresh handle is the reset: it carries the per-variable facts too. The + // one being replaced is wasm-side memory that nothing else holds, and + // `clear_assumptions` delegates here, so a worker that clears between + // problems would otherwise leak one `Assumptions` per clear. `free?.()` + // because a test may have injected a stand-in module. + this._assumptionsHandleCache?.free?.(); this._assumptionsHandle = new wasm.Assumptions(); this._assumptionTexts = []; }, clear_assumptions() { this.set_to_default(); }, - add_assumption(assumption) { - const text = toExpr(assumption, this).toString(); - this._assumptionsHandle.add(text); - this._assumptionTexts.push(text); - return true; + add_assumption(assumption, exclude_generic?) { + const tree = syncAssumptionText(this, assumption, "add"); + if (tree === undefined) return 0; + return assumptionStore.add_assumption( + this._assumptionsHandle, + tree, + exclude_generic, + ); }, add_generic_assumption(assumption) { - return this.add_assumption(assumption); + // A generic assumption is stated in terms of `x` and stands for every + // variable, which the wasm store cannot express; it gets the `x` spelling, + // which is at least right for `x` itself. + const tree = syncAssumptionText(this, assumption, "add"); + if (tree === undefined) return 0; + return assumptionStore.add_generic_assumption( + this._assumptionsHandle, + tree, + ); }, remove_assumption(assumption) { - const text = toExpr(assumption, this).toString(); - this._assumptionsHandle.remove(text); - this._assumptionTexts = this._assumptionTexts.filter((t) => t !== text); + const tree = syncAssumptionText(this, assumption, "remove"); + if (tree === undefined) return 0; + return assumptionStore.remove_assumption(this._assumptionsHandle, tree); }, remove_generic_assumption(assumption) { - return this.remove_assumption(assumption); + const tree = syncAssumptionText(this, assumption, "remove"); + if (tree === undefined) return 0; + return assumptionStore.remove_generic_assumption( + this._assumptionsHandle, + tree, + ); }, - get_assumptions() { - if (!this._assumptionTexts.length) return undefined; - try { - return Context.fromText(this._assumptionTexts.join(" and ")); - } catch { - return undefined; - } + get_assumptions(variables_or_expr, params?) { + return assumptionStore.get_assumptions( + this._assumptionsHandle, + variables_or_expr, + params, + ); }, + // `me.assumptions` was the assumptions object itself, carrying the same + // add/get methods as the context. This port also has to keep answering the + // wasm predicates through it, since `lib/assumptions/element_of_sets` reads + // `Context.assumptions` as its default source — so the facade forwards those + // to the handle rather than replacing it. + _assumptionsFacadeCache: undefined, get assumptions() { - return this._assumptionsHandle; + return (this._assumptionsFacadeCache ??= makeAssumptionsFacade()); }, }; -export { Expression }; +// The lazy handle above is a cached wasm object, so it belongs to whichever +// module minted it — see `onWasmModuleChange`. Without this, a host that calls +// `setWasmModule` after anything has touched an assumption keeps handing the +// old module's `Assumptions` the new module's `Expression`s, and every +// `equals`/`solve_linear` fails with "expected instance of Expression". Drop +// the texts too: they are the same facts in the other representation, and a +// handle rebuilt from an empty store must not claim to hold them. +onWasmModuleChange(() => { + Context._assumptionsHandleCache = undefined; + Context._assumptionTexts = []; +}); + +/** + * Mirror an assumption into the wasm handle and the `simplify_with_assumptions` + * text list, returning its tree for the JS store to file — or undefined when + * there is no assumption at all. + * + * An empty assumption is a no-op rather than an error: the spec tables drive + * `me.add_assumption(me.from(input))` over rows whose input is undefined, + * meaning "no assumptions for this row". + */ +function syncAssumptionText( + context: Ctx, + assumption: ExpressionLike, + action: "add" | "remove", +): Tree | undefined { + const tree = get_tree(assumption); + if (!Array.isArray(tree)) return undefined; + + const text = toExpr(assumption, context).toString(); + if (action === "add") { + context._assumptionsHandle.add(text); + context._assumptionTexts.push(text); + } else { + context._assumptionsHandle.remove(text); + context._assumptionTexts = context._assumptionTexts.filter( + (t) => t !== text, + ); + } + return tree; +} + +/** + * `me.assumptions`: the JS assumption API plus the wasm predicates, both + * pointing at the live context state (never a snapshot — the spec clears and + * re-adds assumptions between calls while holding the same object). + */ +function makeAssumptionsFacade() { + const facade: Record = { + get _assumptionsHandle() { + return Context._assumptionsHandle; + }, + get byvar() { + return assumptionStore.byvar(Context._assumptionsHandle); + }, + get derived() { + return assumptionStore.derived(Context._assumptionsHandle); + }, + get generic() { + return assumptionStore.generic(Context._assumptionsHandle); + }, + }; + for (const name of [ + "get_assumptions", + "add_assumption", + "add_generic_assumption", + "remove_assumption", + "remove_generic_assumption", + "clear_assumptions", + "set_to_default", + ]) { + facade[name] = (...args: unknown[]) => Context[name](...args); + } + // The three-valued predicates and the raw relation add/remove live on the + // wasm handle; keep them reachable so a caller holding `me.assumptions` can + // still use it as one. + for (const name of [ + "is_integer", + "is_real", + "is_complex", + "is_nonzero", + "is_nonnegative", + "is_nonpositive", + "is_positive", + "is_negative", + "add", + "remove", + ]) { + facade[name] = (...args: unknown[]) => + Context._assumptionsHandle[name](...args); + } + return facade; +} + +// The legacy library exposed every `Expression` method a second time as a free +// function on the context, expression-first: `me.simplify(expr)` alongside +// `expr.simplify()`. Mirror the prototype onto `Context` once both exist. +// +// Anything already reachable on `Context` wins, so the factories (`from`, +// `fromAst`, `fromText`, …) are never shadowed — and neither are the inherited +// `Object.prototype` members, which is why `toString`/`valueOf` stay put rather +// than becoming expression-first functions that would break `String(me)`. +// +// `NOT_EXPRESSION_FIRST` covers what that `in Context` test misses. A protocol +// method the *runtime* calls is not a candidate for the expression-first +// treatment, because the runtime supplies its own argument: `JSON.stringify` +// invokes `toJSON(key)`, so mirroring it made the property key the "expression", +// and `JSON.stringify({me})` emitted a `{objectType:"math-expression"}` envelope +// that `Context.reviver` would then revive the whole library context from — +// while `JSON.stringify({"(": me})` *threw* a parse error out of a plain +// stringify. `toJSON` is not on `Object.prototype`, so only naming it works. +// `free`/`dispose` are excluded for a milder reason: they manage this port's +// wasm handles, which legacy had no concept of, so there is no expression-first +// spelling of them to be compatible with — and `me.dispose()` reads like "tear +// down the context", which it would not do. +// +// Coercion goes through `toExpr`, not `Context.from`: the argument is usually +// an `Expression` already, and `from` would try to read that as an AST. +const NOT_EXPRESSION_FIRST = new Set([ + "constructor", + "toJSON", + "free", + "dispose", +]); +for (const name of Object.getOwnPropertyNames(Expression.prototype)) { + if (NOT_EXPRESSION_FIRST.has(name) || name in Context) continue; + const desc = Object.getOwnPropertyDescriptor(Expression.prototype, name); + if (typeof desc?.value !== "function") continue; // skip accessors such as `tree` + (Context as Record)[name] = ( + expr: ExpressionLike, + ...args: unknown[] + ) => + (toExpr(expr) as unknown as Record unknown>)[ + name + ](...args); +} + +export { Expression, dopri, setWasmModule }; export default Context; diff --git a/packages/math-expressions-js-compat/lib/polynomial/polynomial.ts b/packages/math-expressions-js-compat/lib/polynomial/polynomial.ts index f17db3bf..07d264c8 100644 --- a/packages/math-expressions-js-compat/lib/polynomial/polynomial.ts +++ b/packages/math-expressions-js-compat/lib/polynomial/polynomial.ts @@ -1,11 +1,136 @@ -// Compat stub: 'polynomial' has no Rust equivalent in the port (see -// active-plans/JS_TEST_COVERAGE_AUDIT.md). The module loads so specs importing -// it still run; any use throws, failing just those tests. -function unsupported() { - throw new Error("math-expressions-js-compat: polynomial is not implemented"); -} -export default new Proxy(function () {}, { - get: () => unsupported, - apply: unsupported, - construct: unsupported, -}); +// The compat polynomial / Gröbner API's JS side: marshalling, and nothing else. +// +// The engine is `polynomials::compat` in the Rust core. What crosses the +// boundary is the AST spelling the legacy API is written in — +// `["polynomial", v, [[deg, coeff], …]]` and `["monomial", c, [[v, deg], …]]` — +// because callers hand those in as literals and compare what comes back +// structurally. The wire is that same JSON, so nothing here has to know what a +// polynomial is; each export just names an operation and passes its arguments +// through. +// +// `undefined` comes back for an operation with no answer (`mono_div` on a +// non-divisor, `polynomial_pow` on a non-integer exponent), which is what the +// legacy engine returned too. +import wasm from "../_wasm"; +import { get_tree } from "../trees/util"; +import { astToJson, jsonToAst } from "../converters/ast-json"; + +/** Invoke one core polynomial operation on the given arguments. */ +function op(name: string, ...args: any[]): any { + const out = (wasm as any).poly_op(name, astToJson(args)); + return out === undefined ? undefined : jsonToAst(out); +} + +/** + * An expression, or a raw tree, read as a polynomial — `false` when it is not + * one. `pi`, `e` and `i` count as numbers, so `9x^(2/3) - pi*x` has the + * coefficient `["-", "pi"]`; anything the core cannot take apart (`sin(x)`, + * `x^(1/2)`, `x/y`) becomes an opaque polynomial variable. + */ +export function expression_to_polynomial(expr_or_tree: any): any { + return op("expression_to_polynomial", get_tree(expr_or_tree)); +} + +export function polynomial_to_expression(p: any): any { + return op("polynomial_to_expression", p); +} + +export function polynomial_add(p: any, q: any): any { + return op("polynomial_add", p, q); +} + +export function polynomial_sub(p: any, q: any): any { + return op("polynomial_sub", p, q); +} + +export function polynomial_mul(p: any, q: any): any { + return op("polynomial_mul", p, q); +} + +export function polynomial_neg(p: any): any { + return op("polynomial_neg", p); +} + +export function polynomial_pow(p: any, e: any): any { + return op("polynomial_pow", p, e); +} + +/** The leading term under the lexicographic order, as a monomial. */ +export function initial_term(p: any): any { + return op("initial_term", p); +} + +export function mono_less_than(left: any, right: any): boolean { + return op("mono_less_than", left, right); +} + +export function mono_gcd(left: any, right: any): any { + return op("mono_gcd", left, right); +} + +export function mono_div(top: any, bottom: any): any { + return op("mono_div", top, bottom); +} + +export function mono_is_div(top: any, bottom: any): boolean { + return op("mono_is_div", top, bottom); +} + +export function mono_to_poly(mono: any): any { + return op("mono_to_poly", mono); +} + +/** + * The largest term of `f` divisible by one of `monos`, with the index of the + * divisor, as `[monomial, index]` — or `0` when no term is. + */ +export function max_div_init(f: any, monos: any): any { + return op("max_div_init", f, monos); +} + +/** + * Division by a list: `[[[s1, m1], …], f']` with `f = m1·g_s1 + … + f'`. + */ +export function poly_div(f: any, divs: any): any { + return op("poly_div", f, divs); +} + +/** `polys[i]` reduced against every other polynomial in the list. */ +export function reduce_ith(i: any, polys: any): any { + return op("reduce_ith", i, polys); +} + +/** The list reduced against itself to a fixpoint, each made monic. */ +export function reduce(polys: any): any { + return op("reduce", polys); +} + +export function reduced_grobner(polys: any): any { + return op("reduced_grobner", polys); +} + +export function poly_gcd(f: any, g: any): any { + return op("poly_gcd", f, g); +} + +export function poly_lcm(f: any, g: any): any { + return op("poly_lcm", f, g); +} + +/** + * A rational expression's numerator and denominator with their common factor + * cancelled, and both scaled so the denominator's leading coefficient is 1. + */ +export function reduce_rational_expression(top: any, bottom: any): any { + return op("reduce_rational_expression", top, bottom); +} + +/** + * The legacy library carried a second, flat encoding of the same polynomials + * (`["polynomial_terms", ["monomial", …], …]`) and a duplicate of every + * algorithm written against it, as a faster path for single-variable inputs. + * The core's engine is sparse in both encodings' sense, so the fast path has + * nothing left to be faster than, and the name is kept only because callers + * use it. + */ +export const pt_reduce_rational_expression = reduce_rational_expression; diff --git a/packages/math-expressions-js-compat/lib/trees/basic.ts b/packages/math-expressions-js-compat/lib/trees/basic.ts index 713ba3ae..0d122832 100644 --- a/packages/math-expressions-js-compat/lib/trees/basic.ts +++ b/packages/math-expressions-js-compat/lib/trees/basic.ts @@ -1,5 +1,15 @@ -// `me.utils` tree basics: structural `equal`, `match`, `substitute`. +// `me.utils` tree basics: structural `equal`, `match`, `substitute`, and the +// pattern-rewriting layer built on them (`transform` / `replaceSubtree` / +// `applyAllTransformations` / …). +// +// Everything here is plain JS over the raw AST arrays, as in the legacy +// library. That is deliberate and not a shortcut: these operate on trees the +// caller assembled by hand (`["+", "a", "b"]`), the rewriting is driven by +// caller-supplied patterns, and `replaceSubtree` keys on *reference* identity — +// none of which survives a round trip through the canonical `Expr` layer. export { match } from "./flatten"; +import { match } from "./flatten"; +import me from "../math-expressions"; /** Structural tree equality (numbers compared by value, so 1 === 1.0). */ export function equal(a, b) { @@ -14,7 +24,9 @@ export function equal(a, b) { /** Substitute string-leaf variables with their bound subtrees. */ export function substitute(tree, bindings) { if (typeof tree === "string") { - return Object.prototype.hasOwnProperty.call(bindings, tree) ? bindings[tree] : tree; + return Object.prototype.hasOwnProperty.call(bindings, tree) + ? bindings[tree] + : tree; } if (Array.isArray(tree)) { // index 0 is the operator/head; never a substitutable variable @@ -22,3 +34,202 @@ export function substitute(tree, bindings) { } return tree; } + +/** + * Structural copy. + * + * Recursive rather than the legacy's `JSON.parse(JSON.stringify(...))` with its + * two reviver hooks: `.tree` hands out real `Infinity`/`NaN`, which + * `JSON.stringify` turns into `null`. Cloning by walking the array never sees + * the value as JSON and so cannot lose it. + */ +function deepClone(tree) { + return Array.isArray(tree) ? tree.map(deepClone) : tree; +} + +/** + * Call `callback(subtree, root)` bottom-up — children before their parent. + * + * `root` is threaded through unchanged so a callback can rebuild the whole tree + * around the subtree it was handed; that is what + * [`applyTransformationEachSubtree`] needs. + */ +export function traverse(tree, callback, root?) { + if (root === undefined) root = tree; + if (Array.isArray(tree)) { + for (let i = 1; i < tree.length; i++) traverse(tree[i], callback, root); + } + callback(tree, root); +} + +/** Rewrite every node bottom-up through `F`, which returns the new subtree. */ +export function transform(tree, F) { + if (Array.isArray(tree)) { + const rebuilt = [tree[0]]; + for (let i = 1; i < tree.length; i++) rebuilt.push(transform(tree[i], F)); + return F(rebuilt); + } + return F(tree); +} + +/** + * Replace the subtree `tree` of `root` with `replacement`. + * + * Matching is by `===`, so for an *array* subtree it is reference identity: + * `tree` has to be a node actually inside `root`, and an equal-looking tree + * built separately is left alone. That does not extend to leaves, where `===` + * is value equality — replacing `"x"` in `["+","x","x"]` rewrites both + * occurrences, not one. + */ +export function replaceSubtree(root, tree, replacement) { + if (root === tree) return deepClone(replacement); + if (Array.isArray(root)) + return root.map((c) => replaceSubtree(c, tree, replacement)); + return root; +} + +/** + * Fold numbers in a rewritten subtree, for `params.evaluate_numbers`. + * + * The only thing in this file that touches the core. `math-expressions` does + * not import this module, so the edge is one-way and there is no cycle. + * + * Legacy also forwards `params.assumptions` (substituted through the match + * bindings). The port's `evaluate_numbers` reads assumptions from the + * expression's context rather than taking them per call, so a transformation's + * own assumptions are not honored here. + */ +function evaluateNumbers(tree, params) { + return me.fromAst(tree).evaluate_numbers({ + max_digits: params.max_digits, + evaluate_functions: params.evaluate_functions, + }).tree; +} + +/** + * Rewrite `tree` by every `[pattern, replacement, params]` in `transformations`, + * repeating until nothing changes or `depth` rounds have passed. + * + * The depth bound is the termination guarantee, not an optimization: a rule set + * containing both `a+b → b+a` and its mirror never reaches a fixpoint, and the + * legacy callers lean on the cap (`simplify` passes 20 or 40). + */ +export function applyAllTransformations(tree, transformations, depth = 5) { + let newTree = tree; + for (; depth > 0; depth--) { + const oldTree = newTree; + for (const [pattern, replacement, rawParams] of transformations) { + const params = rawParams === undefined ? {} : rawParams; + newTree = transform(newTree, (subtree) => { + const m = match(subtree, pattern, params); + if (!m) return subtree; + let result = substitute(replacement, m); + + // An extended match consumed only part of an n-ary operand list; the + // operands it stepped over have to be spliced back around the rewritten + // part, or the transformation silently deletes them. + // + // `_skipped` is live: `allow_extended_match` is handled on the JS side + // (`trees/flatten.ts`, which sets it in `extendedMatch`) rather than in + // `js_match.rs`. `_skipped_before` is not — `extendedMatch` never + // reports operands to the *left* separately — so the `addLeft` half is + // dead for now. Kept because the alternative to writing it when + // extended match starts reporting both is a silent operand-dropping + // bug. + const skipped = m as { + _skipped?: unknown[]; + _skipped_before?: unknown[]; + }; + const addLeft = skipped._skipped_before ?? []; + const addRight = skipped._skipped ?? []; + if (addLeft.length > 0 || addRight.length > 0) { + if (Array.isArray(result)) { + result = result[0] === pattern[0] ? result.slice(1) : [result]; + } else { + result = [result]; + } + result = [pattern[0]].concat(addLeft, result, addRight); + } + + // Legacy folds both before *and* after the splice; this folds only + // after. The pre-fold's only observable effect is on the `result[0] + // === pattern[0]` test above — a replacement that folds to a bare + // number is wrapped rather than spliced flat — and the fold below then + // combines the same operands either way. Left as one pass because a + // second `fromAst` round-trip per rewrite, per round, is the hot loop + // of `simplify`. + if (params.evaluate_numbers) result = evaluateNumbers(result, params); + return result; + }); + } + if (equal(oldTree, newTree)) return newTree; + } + return newTree; +} + +/** + * Every one-step rewrite of `tree`: one result per subtree that matches + * `pattern`, each with only that subtree replaced. + */ +export function applyTransformationEachSubtree(tree, pattern, replacement) { + const results = []; + traverse(tree, (subtree, root) => { + const m = match(subtree, pattern); + if (m) + results.push(replaceSubtree(root, subtree, substitute(replacement, m))); + }); + return results; +} + +/** Curry a `[pattern, replacement]` rule into a one-step rewriter. */ +export function patternTransformer(pattern, replacement) { + return (tree) => applyTransformationEachSubtree(tree, pattern, replacement); +} + +/** + * Whether `left` and `right` meet under repeated rewriting by `transformers`. + * + * Grows both sides breadth-first and looks for a common form, rather than + * normalizing either one — the transformers are not confluent (commutativity + * alone is not), so there is no normal form to compare. + * + * Returns `true` on a meeting, `false` once both frontiers stop growing without + * one, and **`undefined`** when `depth` runs out first: the search was cut off, + * so "not equal" was never established. + */ +export function equalAfterTransformations( + left, + right, + transformers, + depth = 5, + comparer = equal, +) { + const leftQueue = [left]; + const rightQueue = [right]; + + // One breadth-first round: append every rewrite not already in the queue. + // Returns whether anything was added, i.e. whether this side is still moving. + const evolve = (queue) => { + const toAppend = []; + for (const item of queue) { + for (const transformer of transformers) { + for (const result of transformer(item)) { + if (queue.every((other) => !comparer(result, other))) + toAppend.push(result); + } + } + } + queue.push(...toAppend); + return toAppend.length > 0; + }; + + for (; depth > 0; depth--) { + const noMoreLeft = !evolve(leftQueue); + const noMoreRight = !evolve(rightQueue); + for (const a of leftQueue) { + for (const b of rightQueue) if (comparer(a, b)) return true; + } + if (noMoreLeft && noMoreRight) return false; + } + return undefined; +} diff --git a/packages/math-expressions-js-compat/lib/trees/default_order.ts b/packages/math-expressions-js-compat/lib/trees/default_order.ts index baf815b7..d72ee02f 100644 --- a/packages/math-expressions-js-compat/lib/trees/default_order.ts +++ b/packages/math-expressions-js-compat/lib/trees/default_order.ts @@ -1,9 +1,41 @@ -// `default_order` was a standalone per-tree ordering pass. The Rust core folds -// ordering into `canonicalize`, with no separate tree-level entry point, so this -// is a compat stub: it returns the tree unchanged. Specs asserting a specific -// re-ordering will fail here (see JS_TEST_COVERAGE_AUDIT.md); the suite runs. +// `default_order` is a standalone per-tree ordering pass: it sorts the operands +// of the commutative operators and states each relation in one canonical +// direction (`x > a` becomes `a < x`), so that two spellings of the same fact +// compare equal. +// +// It used to be a stub returning the tree unchanged, on the reading that the +// Rust core folded ordering into `canonicalize` with no separate entry point. +// It does have one — `Expression.default_order()`, backed by +// `normalize::default_order` — and the assumptions store needs it: an +// assumption is filed with the variable it is about on the left, so `a > b` and +// `b < a` reach the store as different trees and only this pass reconciles +// them. +import wasm from "../_wasm"; +import { astToJson, jsonToAst } from "../converters/ast-json"; + export function default_order(tree) { - return tree; + // Leaves have nothing to order, and routing one through the core would cost + // a wasm round trip to get it back unchanged. + if (!Array.isArray(tree)) return tree; + let src; + try { + src = wasm.from_ast(astToJson(tree)); + } catch { + // Callers hand this arbitrary trees, including partial ones built by tree + // surgery that the core cannot read back. Ordering is a normalization, so + // an unreadable tree is returned as it came rather than throwing. + return tree; + } + try { + const out = src.default_order(); + try { + return jsonToAst(out.tree_json()); + } finally { + out.free(); // throwaway: method result, never returned + } + } finally { + src.free(); // throwaway: parse source, never returned + } } export default default_order; diff --git a/packages/math-expressions-js-compat/lib/trees/flatten.ts b/packages/math-expressions-js-compat/lib/trees/flatten.ts index 999634dc..bd4922a2 100644 --- a/packages/math-expressions-js-compat/lib/trees/flatten.ts +++ b/packages/math-expressions-js-compat/lib/trees/flatten.ts @@ -1,11 +1,17 @@ // Raw JS-tree utilities (`me.utils.flatten` / `unflatten*` / `match`), backed by // the wasm ports which take/return the JSON tree encoding. import wasm from "../_wasm"; +import { astToJson, jsonToAst } from "../converters/ast-json"; +import { default_order } from "./default_order"; +// `astToJson`/`jsonToAst` rather than bare `JSON.stringify`/`JSON.parse`: these +// take trees straight from `.tree`, which hands out real `Infinity`/`NaN`, and +// `JSON.stringify(Infinity)` is `null`. That turned `me.utils.flatten(e.tree)` +// into a silently wrong tree — no throw, just a `null` where a value was. function viaWasm(fn, tree) { if (!Array.isArray(tree)) return tree; - const out = fn(JSON.stringify(tree)); - return out === undefined ? tree : JSON.parse(out); + const out = fn(astToJson(tree)); + return out === undefined ? tree : jsonToAst(out); } export function flatten(tree) { @@ -22,7 +28,9 @@ export function unflattenRight(tree) { export function allChildren(tree) { if (!Array.isArray(tree)) return tree; const op = tree[0]; - const associative = ["+", "*", "and", "or", "union", "intersect"].includes(op); + const associative = ["+", "*", "and", "or", "union", "intersect"].includes( + op, + ); const out = []; for (const operand of tree.slice(1)) { if (associative && Array.isArray(operand) && operand[0] === op) { @@ -34,8 +42,145 @@ export function allChildren(tree) { return out; } -/** Default-mode template match; `false` when it does not match. */ -export function match(tree, pattern) { - const res = wasm.match_template(JSON.stringify(tree), JSON.stringify(pattern)); - return res === undefined ? false : JSON.parse(res); +/** + * Normalize the JS `match` params into the JSON the wasm option decoder takes. + * + * Lives here rather than in `math-expressions.ts` because that module imports + * *this* one; both entry points share it so they cannot drift, which is what + * let `me.utils.match` keep dropping its params after `expr.match` learned to + * honor them. + * + * **Deprecated, wontfix:** legacy also accepted a predicate function or a + * `RegExp` as a parameter's condition. Neither is supported and neither will + * be. A function would have to be called back across the wasm boundary once per + * candidate binding, inside a matcher that backtracks — the cost is not the + * bridge, it is that the matcher stops being a pure Rust search. And the + * conditions callers actually write are two: "is a number" and "is a bare + * variable". DoenetML's ``, the only real consumer, passes + * exactly those two closures (`MatchesPattern.js`, under `requireNumericMatches` + * / `requireVariableMatches`), and they are already spelled `"number"` and + * `"variable"`. Declaring a kind is the supported replacement, and it is + * strictly better defined: `"number"` means "evaluates to a real numeric + * constant", where a caller's `typeof s === "number"` silently missed `π`. + */ +export function normalizeMatchOptions(options) { + const opts: Record = {}; + if (options.variables !== undefined) { + const vars: Record = {}; + for (const [name, kind] of Object.entries(options.variables)) { + const arbitrary = + typeof kind === "function" + ? "a predicate function" + : kind instanceof RegExp + ? "a regular expression" + : null; + if (arbitrary !== null) { + throw new Error( + `match: 'variables.${name}' is ${arbitrary}. Arbitrary per-parameter ` + + "conditions are deprecated and will not be supported — declare a " + + 'kind instead: "number", "variable", "any" (or true).', + ); + } + vars[name] = kind; + } + opts.variables = vars; + } + if (options.allow_permutations !== undefined) { + opts.allow_permutations = !!options.allow_permutations; + } + if (options.allow_implicit_identities !== undefined) { + const ii = options.allow_implicit_identities; + opts.allow_implicit_identities = Array.isArray(ii) ? ii : !!ii; + } + return opts; +} + +/** + * Template match; `false` when it does not match. + * + * `params` is honored rather than dropped. Ignoring it silently was the exact + * "confidently wrong bindings" failure the option decoder exists to prevent: + * with no params every string leaf in the pattern is a wildcard, so a caller + * who declared two parameters got a match on three. + */ +export function match(tree, pattern, params?) { + const hasParams = + params !== null && typeof params === "object" && !Array.isArray(params); + // `allow_extended_match` lets a sum/product pattern match a *subset* of a + // larger sum/product, leaving the rest. It is handled here rather than in the + // Rust matcher: enumerate operand subsets, match the (unextended) pattern + // against each with the core matcher, and report the untouched operands as + // `_skipped` for `applyAllTransformations` to splice back. See that function. + if ( + hasParams && + params.allow_extended_match && + Array.isArray(pattern) && + (pattern[0] === "+" || pattern[0] === "*") + ) { + return extendedMatch(tree, pattern, params); + } + const res = hasParams + ? wasm.match_template_with_options( + astToJson(tree), + astToJson(pattern), + JSON.stringify(normalizeMatchOptions(params)), + ) + : wasm.match_template(astToJson(tree), astToJson(pattern)); + return res === undefined ? false : jsonToAst(res); +} + +/** All size-`k` index subsets of `[0, n)`, in lexicographic order. */ +function combinations(n: number, k: number): number[][] { + const out: number[][] = []; + const pick = (start: number, chosen: number[]) => { + if (chosen.length === k) { + out.push(chosen.slice()); + return; + } + for (let i = start; i <= n - (k - chosen.length); i++) { + chosen.push(i); + pick(i + 1, chosen); + chosen.pop(); + } + }; + pick(0, []); + return out; +} + +/** + * Match a `+`/`*` pattern against a subset of a larger `+`/`*` tree. + * + * The tree's operands are put in canonical order first (`default_order`) so the + * skipped remainder comes out in the order the callers' expected results assume + * — legacy sorts before matching, and the splice appends `_skipped` verbatim. + * The pattern's own operands are matched against each candidate subset by the + * core matcher (honoring `allow_permutations`), so a coefficient like the `x` + * in `x·cos(b)² + x·sin(b)²` still binds. + */ +function extendedMatch(tree, pattern, params) { + const op = pattern[0]; + if (!Array.isArray(tree) || tree[0] !== op) return false; + const patOperands = pattern.slice(1); + const k = patOperands.length; + + const treeOperands = default_order(tree).slice(1); + const n = treeOperands.length; + // Guard the combinatorial search; a graded response never has this many terms. + if (n < k || n > 16) return false; + + // The subset is matched unextended; drop the flag so this does not recurse. + const subParams = { ...params }; + delete subParams.allow_extended_match; + + for (const idx of combinations(n, k)) { + const chosen = new Set(idx); + const candidate = [op, ...idx.map((i) => treeOperands[i])]; + const m = match(candidate, pattern, subParams); + if (m) { + const skipped = treeOperands.filter((_, i) => !chosen.has(i)); + if (skipped.length > 0) (m as { _skipped?: unknown[] })._skipped = skipped; + return m; + } + } + return false; } diff --git a/packages/math-expressions-js-compat/lib/trees/util.ts b/packages/math-expressions-js-compat/lib/trees/util.ts new file mode 100644 index 00000000..f961e3e7 --- /dev/null +++ b/packages/math-expressions-js-compat/lib/trees/util.ts @@ -0,0 +1,11 @@ +// `get_tree` unwraps an expression object to its raw AST, and is what every +// compat entry point that accepts "an Expression or a tree" calls first. +export const get_tree = function (expr_or_tree: any): any { + if (expr_or_tree === undefined || expr_or_tree === null) return undefined; + + var tree; + if (expr_or_tree.tree !== undefined) tree = expr_or_tree.tree; + else tree = expr_or_tree; + + return tree; +}; diff --git a/packages/math-expressions-js-compat/package.json b/packages/math-expressions-js-compat/package.json index 06d9aced..552ba32f 100644 --- a/packages/math-expressions-js-compat/package.json +++ b/packages/math-expressions-js-compat/package.json @@ -6,26 +6,39 @@ "type": "module", "main": "./dist/math-expressions.js", "module": "./dist/math-expressions.js", + "types": "./types/math-expressions.d.ts", "exports": { - ".": "./dist/math-expressions.js", - "./lib/*": "./lib/*" + ".": { + "types": "./types/math-expressions.d.ts", + "default": "./dist/math-expressions.js" + }, + "./lib/*": "./lib/*", + "./wasm-web/*": "./vendor/wasm-web/*", + "./package.json": "./package.json" }, "files": [ "dist", "lib", + "types/math-expressions.d.ts", "vendor" ], "scripts": { "build:wasm": "./build-wasm.sh", + "build:deps": "npm --prefix ../math-expressions-rs-wasm run build", "build": "vite build", + "build:package": "npm run build:wasm && npm run build:deps && npm run build", + "prepack": "npm run build:package", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "verify:package": "node scripts/verify-package.mjs", + "typecheck": "tsc -p tsconfig.lib.json && tsc -p types/tsconfig.json" }, "dependencies": { - "math-expressions-rs-wasm": "*", "mathjs": "^15.2.0" }, "devDependencies": { + "math-expressions-rs-wasm": "*", + "typescript": "^5.9.3", "underscore": "^1.13.6", "vite": "^8.0.16", "vitest": "^4.1.8" diff --git a/packages/math-expressions-js-compat/scripts/consumer/node-path.mjs b/packages/math-expressions-js-compat/scripts/consumer/node-path.mjs new file mode 100644 index 00000000..e699c953 --- /dev/null +++ b/packages/math-expressions-js-compat/scripts/consumer/node-path.mjs @@ -0,0 +1,36 @@ +/** + * Consumer smoke test #1: Node, no injection. + * + * Run from a scratch project that has installed the packed tarball, so every + * specifier here resolves the way a real consumer's would — through + * `exports`, out of `node_modules`. See `../verify-package.mjs`. + * + * This is the path that needs no host cooperation at all: the vendored + * *nodejs-target* wasm under `vendor/wasm` loads itself on first use. + */ +import assert from "node:assert/strict"; +import me, { dopri, isTree, setWasmModule } from "math-expressions"; + +assert.equal(me.fromText("x^2 + 2x + 1").toString(), "x^2 + 2 x + 1"); +assert.equal(me.fromLatex("\\frac{x+1}{2}").toString(), "(x + 1)/2"); +assert.equal(me.fromText("x^2").derivative("x").toString(), "2 x"); +assert.equal(me.fromText("sin^2 x + cos^2 x").equals(me.fromText("1")), true); + +// `fromAst` is how DoenetML builds every expression, and the round trip through +// it has to preserve the non-finite scalars the text parser cannot produce. +assert.ok(Number.isNaN(me.fromAst(["/", 0, 0]).simplify().tree)); + +// `f()` compiles through math.js, which is the one runtime dependency the +// bundle leaves external. If `dependencies` ever stops carrying it, this is +// where a consumer finds out. +assert.equal(me.fromText("x^2").f()({ x: 3 }), 9); + +// The ODE integrator, exported by name because it replaces `me.math.dopri`. +const solution = dopri(0, 1, 1, (_x, y) => y); +assert.ok(Math.abs(solution.at(1) - Math.E) < 1e-6); +solution.free(); + +assert.equal(isTree(["+", 1, "x"]), true); +assert.equal(typeof setWasmModule, "function"); + +console.log("node path: ok"); diff --git a/packages/math-expressions-js-compat/scripts/consumer/web-path.mjs b/packages/math-expressions-js-compat/scripts/consumer/web-path.mjs new file mode 100644 index 00000000..2c1d03c9 --- /dev/null +++ b/packages/math-expressions-js-compat/scripts/consumer/web-path.mjs @@ -0,0 +1,46 @@ +/** + * Consumer smoke test #2: the browser / Web Worker path, with no Rust toolchain. + * + * This is the one that decides whether publishing this package lets a host such + * as DoenetML drop its `vendor/math-expressions` submodule *and* its cargo + + * wasm-bindgen requirement, rather than only the submodule. It therefore uses + * nothing but what the tarball ships: + * + * 1. resolve the `--target web` wasm through the package's `./wasm-web/*` + * export — the nodejs-target build under `vendor/wasm` cannot be used off + * Node, and building a web one needs cargo; + * 2. instantiate it from *bytes*, never from a URL. `fetch` of a blob/data + * URL is blocked in the VS Code web-worker extension host, so hosts inline + * the binary; reading the file here is the same shape; + * 3. hand the initialized module to `setWasmModule` before parsing anything. + * + * Node stands in for the worker: it runs the same ESM glue and the same + * `initSync`. What it cannot check is that the browser main thread refuses a + * synchronous compile this large — that is the host's problem, and DoenetML's + * `wasm-loader.ts` handles it with an async init. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import * as glue from "math-expressions/wasm-web/math_expressions_wasm.js"; +import me, { setWasmModule } from "math-expressions"; + +const bytes = readFileSync( + fileURLToPath( + import.meta + .resolve("math-expressions/wasm-web/math_expressions_wasm_bg.wasm"), + ), +); +assert.ok(bytes.byteLength > 1_000_000, "the web wasm binary looks truncated"); +// The wasm magic number, so a text file renamed `.wasm` fails here rather than +// inside the instantiate call. +assert.deepEqual([...bytes.subarray(0, 4)], [0x00, 0x61, 0x73, 0x6d]); + +glue.initSync({ module: bytes }); +setWasmModule(glue); + +assert.equal(me.fromText("x^2 + 2x + 1").toString(), "x^2 + 2 x + 1"); +assert.equal(me.fromText("sin^2 x + cos^2 x").equals(me.fromText("1")), true); +assert.equal(me.fromAst(["*", 0.5, 2, "x"]).simplify().toLatex(), "x"); + +console.log("web path: ok"); diff --git a/packages/math-expressions-js-compat/scripts/verify-package.mjs b/packages/math-expressions-js-compat/scripts/verify-package.mjs new file mode 100644 index 00000000..d200c48b --- /dev/null +++ b/packages/math-expressions-js-compat/scripts/verify-package.mjs @@ -0,0 +1,71 @@ +/** + * Prove this package is publishable, by publishing it — as far as a tarball — + * and consuming it from outside the workspace. + * + * `npm test` and `npm run build` both run inside the monorepo, where every + * specifier resolves through workspace symlinks and every build output is + * simply present. Neither can see the three ways a package breaks only once + * installed, all of which this repo has had at once: + * + * - a `dependencies` entry that is not on the registry (`npm install` fails + * outright with a 404 — it never gets as far as importing anything); + * - `main`/`exports` pointing into a git-ignored `dist/` that nothing built, + * so the tarball's entry point does not exist; + * - a runtime asset that is not in `files`, or not reachable through + * `exports` — the `--target web` wasm a browser host has to inject. + * + * So: pack, install the tarball into a throwaway project in the OS temp + * directory, and run `scripts/consumer/*.mjs` there against the bare + * `math-expressions` specifier. + * + * Requires the same toolchain as a real publish, because `prepack` runs: cargo, + * the wasm32 target, and a matching wasm-bindgen-cli. + */ +import { execFileSync } from "node:child_process"; +import { + cpSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PKG = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const scratch = mkdtempSync(resolve(tmpdir(), "math-expressions-verify-")); + +const run = (cmd, args, cwd) => + execFileSync(cmd, args, { cwd, stdio: "inherit" }); + +try { + console.log(`packing ${PKG} -> ${scratch}`); + // The tarball name is derived from the manifest rather than parsed out of + // `npm pack --json`, so a rename or a version bump reads obviously here. + const { name, version } = JSON.parse( + readFileSync(resolve(PKG, "package.json"), "utf8"), + ); + run("npm", ["pack", "--pack-destination", scratch], PKG); + const tarball = resolve(scratch, `${name}-${version}.tgz`); + + const consumer = resolve(scratch, "consumer"); + cpSync(resolve(PKG, "scripts/consumer"), consumer, { recursive: true }); + writeFileSync( + resolve(consumer, "package.json"), + // No `dependencies` block: the install below adds one. `private` keeps + // a stray `npm publish` in this directory from doing anything. + `${JSON.stringify({ name: "consumer", private: true, type: "module", version: "0.0.0" }, null, 2)}\n`, + ); + + console.log(`installing ${tarball}`); + run("npm", ["install", "--no-audit", "--no-fund", tarball], consumer); + + for (const script of ["node-path.mjs", "web-path.mjs"]) { + console.log(`running ${script}`); + run("node", [script], consumer); + } + console.log("package verification: ok"); +} finally { + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/packages/math-expressions-js-compat/spec/build_esm.spec.ts b/packages/math-expressions-js-compat/spec/build_esm.spec.ts index c474a7d4..8b2a6ad1 100644 --- a/packages/math-expressions-js-compat/spec/build_esm.spec.ts +++ b/packages/math-expressions-js-compat/spec/build_esm.spec.ts @@ -1,58 +1,82 @@ +/** + * Smoke test of the *published* ES entry point — `dist/math-expressions.js`, + * what `exports["."]` resolves to — rather than of `lib/` the way every other + * spec here does. It is the only check that the bundle a consumer installs + * actually works: `lib/` passing says nothing about what Vite emitted. + * + * It degrades to a `todo` when `dist/` is absent, because `npm test` does not + * depend on `npm run build`. CI's packaging job builds first, so there it runs. + */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const BUILD_PATH = path.resolve(__dirname, "../build/math-expressions.js"); +const BUILD_PATH = path.resolve(__dirname, "../dist/math-expressions.js"); const buildExists = fs.existsSync(BUILD_PATH); let ME, lib; if (buildExists) { - lib = await import(/* @vite-ignore */ BUILD_PATH); - ME = lib?.default; + lib = await import(/* @vite-ignore */ BUILD_PATH); + ME = lib?.default; } describe("ESM build", () => { - if (!buildExists) { - it.todo("build not found — run `npm run build` first"); - return; - } - - it("exports a default MathExpression context", () => { - expect(typeof ME).toBe("object"); - expect(typeof ME.fromText).toBe("function"); - expect(typeof ME.fromLatex).toBe("function"); - }); - - it("exports isTree as a named export", () => { - expect(typeof lib.isTree).toBe("function"); - expect(lib.isTree("x")).toBe(true); - expect(lib.isTree(["+", 1, "x"])).toBe(true); - expect(lib.isTree(null)).toBe(false); - }); - - it("parses text expressions", () => { - expect(ME.fromText("x^2 + 2*x + 1").toString()).toBe("x^2 + 2 x + 1"); - }); - - it("parses LaTeX expressions", () => { - expect(ME.fromLatex("\\frac{x+1}{2}").toString()).toBe("(x + 1)/2"); - }); - - it("computes symbolic derivatives", () => { - expect(ME.fromText("x^2").derivative("x").toString()).toBe("2 x"); - expect(ME.fromText("sin(x)").derivative("x").toString()).toBe("cos(x)"); - }); - - it("tests expression equality", () => { - expect( - ME.fromText("sin^2(x) + cos^2(x)").equals(ME.fromText("1")) - ).toBe(true); - expect(ME.fromText("x^2").equals(ME.fromText("x^3"))).toBe(false); - }); - - it("converts to LaTeX", () => { - expect(ME.fromText("x^2").toLatex()).toBe("x^{2}"); - }); + if (!buildExists) { + it.todo("build not found — run `npm run build` first"); + return; + } + + it("exports a default MathExpression context", () => { + expect(typeof ME).toBe("object"); + expect(typeof ME.fromText).toBe("function"); + expect(typeof ME.fromLatex).toBe("function"); + }); + + it("exports isTree as a named export", () => { + expect(typeof lib.isTree).toBe("function"); + expect(lib.isTree("x")).toBe(true); + expect(lib.isTree(["+", 1, "x"])).toBe(true); + expect(lib.isTree(null)).toBe(false); + }); + + it("exports the v3 additions as named exports", () => { + // `dopri` and `setWasmModule` are what a host reaches for; both are + // declared in `types/math-expressions.d.ts` and neither existed in the + // legacy library's bundle. + expect(typeof lib.dopri).toBe("function"); + expect(typeof lib.setWasmModule).toBe("function"); + }); + + it("parses text expressions", () => { + expect(ME.fromText("x^2 + 2*x + 1").toString()).toBe("x^2 + 2 x + 1"); + }); + + it("parses LaTeX expressions", () => { + expect(ME.fromLatex("\\frac{x+1}{2}").toString()).toBe("(x + 1)/2"); + }); + + it("computes symbolic derivatives", () => { + expect(ME.fromText("x^2").derivative("x").toString()).toBe("2 x"); + expect(ME.fromText("sin(x)").derivative("x").toString()).toBe("cos(x)"); + }); + + it("tests expression equality", () => { + expect(ME.fromText("sin^2(x) + cos^2(x)").equals(ME.fromText("1"))).toBe( + true, + ); + expect(ME.fromText("x^2").equals(ME.fromText("x^3"))).toBe(false); + }); + + it("converts to LaTeX", () => { + expect(ME.fromText("x^2").toLatex()).toBe("x^{2}"); + }); + + it("reaches math.js through the external import", () => { + // `mathjs` is the one dependency left as a bare import in the bundle + // (it is 95% of the bytes when inlined). `f()` compiles through it, so + // this fails if the external ever stops resolving. + expect(ME.fromText("x^2").f()({ x: 3 })).toBe(9); + }); }); diff --git a/packages/math-expressions-js-compat/spec/build_umd.spec.ts b/packages/math-expressions-js-compat/spec/build_umd.spec.ts index 55598d9a..13001475 100644 --- a/packages/math-expressions-js-compat/spec/build_umd.spec.ts +++ b/packages/math-expressions-js-compat/spec/build_umd.spec.ts @@ -1,63 +1,85 @@ +/** + * Smoke test of the *published* UMD bundle, `dist/math-expressions_umd.js`, + * loaded the way a browser `