Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
8ccd98d
Changes needed for doenet integration
siefkenj Jul 31, 2026
cdc5343
Changes needed for Doenet
siefkenj Aug 1, 2026
02293bf
Updates for Doenet
siefkenj Aug 4, 2026
640d32c
More soundness fixes
siefkenj Aug 4, 2026
08bd4dc
Trig identity fixes
siefkenj Aug 4, 2026
537eca3
Formatting + fixes
siefkenj Aug 4, 2026
970c1c3
Update plans
siefkenj Aug 4, 2026
7a18c9c
Fixes for Doenet
siefkenj Aug 4, 2026
264be80
Root fixes
siefkenj Aug 5, 2026
593dbe6
Doenet fixes
siefkenj Aug 5, 2026
7f8365c
Prevent overflow on bad input
siefkenj Aug 5, 2026
9aedf60
Updates
siefkenj Aug 6, 2026
348a603
Updates
siefkenj Aug 6, 2026
1d8fe27
Merge branch 'doenet' of github.com:siefkenj/math-expressions into do…
siefkenj Aug 6, 2026
e783efc
Improve simplification
siefkenj Aug 6, 2026
66a3bc1
Fixes
siefkenj Aug 6, 2026
69bdc22
Added eval many
siefkenj Aug 6, 2026
0fd94a2
Minor fixes
siefkenj Aug 6, 2026
db8de8e
evaluate fixes
siefkenj Aug 6, 2026
2d4f1f7
canonicalize order before evaluation for consistent float behaviour
siefkenj Aug 6, 2026
bba9cb9
Better folding
siefkenj Aug 6, 2026
10488e9
Rename function to be more explifit about appraoch
siefkenj Aug 6, 2026
ad05f87
Fixes for doenet
siefkenj Aug 6, 2026
ae13c2d
Minor fixes
siefkenj Aug 7, 2026
d6a394b
Updates
siefkenj Aug 7, 2026
c473321
Merge branch 'doenet' of https://github.com/siefkenj/math-expressions…
siefkenj Aug 7, 2026
9c4704f
Fix rounding
siefkenj Aug 7, 2026
8d2d9fd
Fixes
siefkenj Aug 7, 2026
d0737c3
Fixes
siefkenj Aug 7, 2026
8fa8c9b
Changes for doenet
siefkenj Aug 7, 2026
e39fcff
Changes for doenet
siefkenj Aug 8, 2026
d96465d
Fix floor function
siefkenj Aug 8, 2026
ae7da6d
Review edits
siefkenj Aug 8, 2026
f71c4a0
Test fixes
siefkenj Aug 8, 2026
a139491
Minor updates
siefkenj Aug 8, 2026
2be5d93
Adopt new printer conventions in latex round-trip test expectations
siefkenj Aug 8, 2026
cd72603
Complete the assumptions engine
siefkenj Aug 8, 2026
033d9ae
Merge branch 'doenet' of github.com:siefkenj/math-expressions into do…
siefkenj Aug 8, 2026
42aa602
Add js-compat failure summary
siefkenj Aug 8, 2026
708bb57
Small fix
siefkenj Aug 9, 2026
a5d1867
Move JS tree/assumption math into Rust bindings (Phase A)
siefkenj Aug 9, 2026
133ce6f
Move the assumptions logic into the Rust core (Phase B)
siefkenj Aug 9, 2026
087c3ab
Move the polynomial / Groebner engine into Rust (Phase C)
siefkenj Aug 9, 2026
4192de7
Record the outcome of the JS-to-Rust migration
siefkenj Aug 9, 2026
d75c045
Updates
siefkenj Aug 9, 2026
0cd36fa
Matrix normalizations
siefkenj Aug 9, 2026
386d57e
Ast fixes
siefkenj Aug 9, 2026
d294d00
Fixes
siefkenj Aug 9, 2026
4fc358a
More compat
siefkenj Aug 9, 2026
faa7c09
Simplify fixes
siefkenj Aug 9, 2026
615d3be
Minor fix
siefkenj Aug 9, 2026
a9f0991
Updates for tests
siefkenj Aug 9, 2026
c64881f
Merge branch 'doenet' of github.com:siefkenj/math-expressions into do…
siefkenj Aug 9, 2026
680d6ad
Formatting
siefkenj Aug 9, 2026
0155902
Fix some failing tests
siefkenj Aug 10, 2026
f84577c
Review
siefkenj Aug 10, 2026
0dfcd24
Test fixes
siefkenj Aug 10, 2026
685c0be
Updates
siefkenj Aug 10, 2026
6914f0c
Test fixes
siefkenj Aug 10, 2026
0f41710
Minor fixes
siefkenj Aug 10, 2026
08472b6
Merge branch 'doenet' of github.com:siefkenj/math-expressions into do…
siefkenj Aug 10, 2026
62c5f20
Test fixes
siefkenj Aug 10, 2026
6831717
More fixes
siefkenj Aug 11, 2026
7082f8a
Improve simplify routine
siefkenj Aug 11, 2026
d7da6d8
Reduce failures
siefkenj Aug 11, 2026
ddb91b9
Review pass: satisfy this PR's own fmt gate, fix 0·(−∞), correct a st…
dqnykamp Aug 14, 2026
ba9fa39
Review cycle 2: correctness fixes in the compat polynomial engine and…
dqnykamp Aug 14, 2026
06742b5
Review cycle 3: restore four legacy contracts in the compat layer
dqnykamp Aug 14, 2026
eeebe67
Review cycle 3: a latex-printer abort, and 0^0 on the skip_ordering path
dqnykamp Aug 14, 2026
1385dac
Review cycle 4: seven confirmed defects from the simplify/print backlog
dqnykamp Aug 14, 2026
612ab81
Review cycle 6: two engine defects the first full CI run exposed
dqnykamp Aug 14, 2026
ce683cb
Review cycle 6b: a literal semicolon was translated to sigma, and thr…
dqnykamp Aug 14, 2026
67e99ee
Review cycle 7: make the package publishable, and enforce the wasm co…
dqnykamp Aug 14, 2026
c4ae2e4
Review cycle 8: `is_real(i^2)` was false, and four stale claims in th…
dqnykamp Aug 14, 2026
1f543c5
Review cycle 9: the non-realness gap was load-bearing after all
dqnykamp Aug 14, 2026
d385291
Review cycle 10: repair the tests this review wrote, and unbreak the …
dqnykamp Aug 14, 2026
41b9cb4
Review cycle 11: read odd roots of negatives on the real branch every…
dqnykamp Aug 14, 2026
5e25e26
Review cycle 11: move the durable findings ledger out of the PR body
dqnykamp Aug 15, 2026
bb0503d
Review cycle 11: reconcile two plan docs with the odd-root fix
dqnykamp Aug 15, 2026
a7d9a76
Review cycle 12: repair two entries cycle 11 damaged in the move
dqnykamp Aug 15, 2026
8e62b75
Review cycle 12: f() could not compile nthroot at all
dqnykamp Aug 15, 2026
bb8f691
Review cycle 13: a function that plotted perfectly and evaluated to NaN
dqnykamp Aug 15, 2026
5ba6721
Review cycle 13: reconcile the known-issues ledger with the code
dqnykamp Aug 15, 2026
3e31576
Review cycle 14: a seventh grading defect, on the path the sweep missed
dqnykamp Aug 15, 2026
a2c468a
Review cycle 15: a determinant that was not equal to itself
dqnykamp Aug 15, 2026
3b27de9
Review cycle 16: f((a,b)) split the folder from the sampler, and root…
dqnykamp Aug 15, 2026
e2b3bfd
Review cycle 17: rootof read only one spelling of its own polynomial
dqnykamp Aug 15, 2026
10fdf8a
Review cycle 17: the published declaration still said `number | null`
dqnykamp Aug 15, 2026
6a39cc3
Review cycle 17: file the half of f((a,b)) the sampler fix does not r…
dqnykamp Aug 15, 2026
ea3e378
Review cycle 18: make `f((a, b))` and `f(a, b)` one tree, in the parsers
dqnykamp Aug 15, 2026
3335f38
Review cycle 19: the rest of the bracket-notation fix, and a ledger t…
dqnykamp Aug 15, 2026
ca5168d
Review cycle 20: NaN, not null, when there is no numeric value
dqnykamp Aug 17, 2026
46b04c4
Review cycle 21: the declarations audited by looking, not by accident
dqnykamp Aug 17, 2026
46f4c49
Review cycle 22: the sweep that was asserted, and the two decisions
dqnykamp Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
80 changes: 71 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
182 changes: 182 additions & 0 deletions active-plans/ASSUMPTIONS_ENGINE_PLAN.md
Original file line number Diff line number Diff line change
@@ -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
(`x<a` stored ⇒ `get_assumptions("a")` yields `a>x`).
- **Transitive closure** over `=` and `<`/`≤` (`x<a, a<b, b<c` ⇒ for `x`:
`x<a and x<b and x<c`), including the mixed strict/non-strict case.
- **Interval membership expansion**: `x ∈ (a,b)` ⇒ `x>a and x<b`; all four
bracket forms, `containselement`, and the negated forms as `or`-disjunctions.
- **Subset/superset expansion**: `(a,b) subset (c,d)` ⇒ `a>=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.
Loading