improve solution generation on road to v3 - #3
Open
henryhale wants to merge 25 commits into
Open
Conversation
Add a precedence-aware `stringify` that renders an AST node back into mathematical notation. Parentheses are derived from operator precedence and associativity rather than patched in afterwards with a regex. Not wired up yet - the existing solution stack still drives `solve`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Solutions were built from a flat string log: `evaluate` pushed partial text
and `#N` markers into a WeakMap, and `buildSolution` rebuilt the steps by
assuming `raw[i-2]` was an expression and `raw[i-1]` its result. That
encoding was positional, so any node the evaluator did not explicitly
instrument vanished from the output:
add(1,2,3) -> ["6"]
pow(2,3)+1 -> ["8 + 1", "9"]
-(3+4) -> ["3 + 4", "-7"]
Reduce the tree instead. Each step collapses every sub-expression whose
operands are already values, and the resulting tree is printed with
`stringify`. This is uniform over every node type, so calls of any arity and
unary expressions can no longer drop out, and parentheses come from
precedence rather than a regex.
add(1,2,3) -> ["add(1, 2, 3)", "6"]
pow(2,3)+1 -> ["pow(2, 3) + 1", "8 + 1", "9"]
-(3+4) -> ["-(3 + 4)", "-7"]
Assignments no longer repeat their value on a final bare line, which drops
the `steps.pop()` workaround in the demo.
Names are resolved to values up front, which is also where an unknown
variable is now reported instead of silently evaluating to 0.
BREAKING CHANGE: `createSolutionStack` and `ISolution` are removed, and
`evaluate` no longer takes a solution argument. Use `explain(ctx, node)` to
get `{ value, solution }` for a single node; `solve` and `solveBatch` are
unchanged. Unknown variables now throw a RuntimeError.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One case per node shape, so a step that silently drops out of a solution fails a test instead of going unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`2^3^2` threw a syntax error: `parsePower` matched a single `^` and left the
second one for `parseProgram`, which had no rule for a bare operator. And
because unary was handled inside `parseFactor`, `-2^2` parsed as `(-2)^2` and
returned 4.
Give unary its own level between `*` and `^`:
parseTerm -> parseUnary -> parsePower -> parseFactor
`parsePower` now takes its exponent from `parseUnary`, which makes `^`
right-associative and keeps `2^-1` working.
2^3^2 512 (was a syntax error)
-2^2 -4 (was 4)
BREAKING CHANGE: `-2^2` is now -4, following standard mathematical
precedence. Write `(-2)^2` for the old result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both the group and the call-argument branches assumed the next token was a
closing paren and skipped it unchecked, so a stray token was silently
swallowed and surfaced later as a confusing error somewhere else:
(1,2) near ')' at 1:18
sin(1 2) parsed as sin(1), then failed on the leftover ')'
Consume it with an `expect` helper that names what was missing instead:
(1,2) near ',' at 1:3 (expecting ')')
sin(1 2) near '2' at 1:8 (expecting ')')
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`1e-3` was a lexical error: extraction stopped at the sign and then rejected
the number for ending in `e`. So was `2e`, which should read as 2 times the
constant `e`.
Only treat `e` as an exponent marker when digits follow it, optionally
signed; otherwise leave it for the identifier scanner, which already inserts
the implicit multiplication.
1e-3 0.001 (was a lexical error)
2e 5.43656... (was a lexical error)
This also matters for solutions: a step is re-tokenized to be rendered, and
small values stringify to exactly this form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five builtins were wrong or incomplete: - `cbrt(-8)` returned NaN. `Math.pow(x, 1/3)` cannot take a cube root of a negative number; `Math.cbrt` can. - `root(-32, 5)` had the same problem. An odd root of a negative number is real, an even one is not. - `log(8)` returned NaN - the base was required. It now defaults to 10, which is what the single-argument form means everywhere else. - `hypot` took exactly two arguments and squared them by hand, despite TODO.md advertising it as variadic. `Math.hypot` is variadic and avoids overflow. - `coversin` computed `(1 - cos x) / 2`, which is the haversine. The coversine is `1 - sin x`. BREAKING CHANGE: `coversin` returns the coversine rather than the haversine, and negative arguments to `cbrt`/`root` now return a real root instead of NaN. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the number and exponent entries left open in TODO.md, all
number-in/number-out:
sign round fix clamp roundToNearest precision sigFigs
gcd lcm modExp lerp hermite
pow10 pow2 expm1 log1p
`sign` was already ticked in TODO.md but had never been implemented.
`modExp` runs in BigInt so that a modulus above ~9.5e7 cannot overflow the
intermediate product. `hermite` takes the standard five-argument cubic form -
the `hermite(p0, p1, t)` in TODO.md is missing its tangents.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the entries TODO.md already ticked but never implemented - `sind`, `cosd`, `tand`, `versind`, `coversind` - plus `atan2`. Also fixes which arguments the angle preference applies to. It was applied to every argument and every result, but an inverse function takes a *ratio* and returns an angle, and a hyperbolic function takes and returns a plain real. In degree mode `asin(0.5)` returned 0.5 instead of 30. BREAKING CHANGE: in degree mode, `asin`, `acos` and `atan` now take a plain ratio, and the hyperbolic functions no longer convert their argument or result. Radian mode is unaffected, since the conversions were identities there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sum prod min max mean median mode quantile
variance std mad entropy
geometricMean harmonicMean skewness kurtosis
All variadic, so a data set is written `mean(1, 2, 3)` and no array value type
is needed. `min` and `max` were already mapped by the LaTeX renderer despite
never having been implemented, so `max(1, 2)` was a syntax error.
Conventions worth knowing, since TODO.md leaves them open:
- `variance` and `std` use the sample (n - 1) normalization, as mathjs does.
TODO.md's `normalization?` argument cannot be expressed variadically.
- `mode` returns the smallest value when several tie.
- `quantile(p, ...values)` takes the probability first so the data can be
variadic, and interpolates as numpy does by default.
- `kurtosis` is excess kurtosis, 0 for a normal distribution.
- `entropy` is in nats, over the distribution the values describe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
factorial combinations permutations stirlingApproximation
isPrime fibonacci birthdayProblem
random randomInt pickRandom
`combinations` uses the multiplicative form, which stays exact far longer than
n! / (k! (n - k)!) - combinations(52, 5) is exact where the factorial form has
already lost precision.
Predicates return 1 or 0, since every mathflow value is a number.
`random` and `randomInt` follow mathjs: no arguments, an upper bound, or a
pair of bounds. TODO.md's `rand`/`randi` are the same functions under another
name and are not implemented twice.
This also needed a parser fix: a call with an empty argument list was a syntax
error, so `random()` could not be written at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gamma lngamma digamma beta gammaIncomplete
erf erfc zeta lambertW sinc heaviside
Implementations follow the standard numerical recipes: Lanczos for gamma,
series below a+1 and a continued fraction above it for the incomplete gamma,
a reflection plus an asymptotic series for digamma, the accelerated
alternating series for zeta with the functional equation covering s < 1/2,
and Halley iteration for the Lambert W principal branch.
`erf` is derived from the regularized incomplete gamma rather than a rational
approximation, which is accurate to machine precision instead of ~1e-7.
Each is pinned in the tests against a known closed form - gamma(1/2) = sqrt(pi),
zeta(2) = pi^2/6, zeta(-1) = -1/12, digamma(1) = -gamma, and the identity
W(x)e^W(x) = x.
`gammaIncomplete` is the lower incomplete gamma, unregularized. `sinc` is
unnormalized and always in radians.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
futureValue presentValue compoundInterest annuityPayment
totient mobius isPerfectSquare
`compoundInterest` returns the accrued amount, the A of A = P(1 + r/n)^(nt).
`annuityPayment` falls back to an even split when the rate is zero, where the
usual formula divides by zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite runs on vitest. `jest`, `ts-jest` and `@types/jest` appear in no source, test or config file - there is no jest.config, no ts-jest transform, and no script that invokes them. Removing them also empties `minimumReleaseAgeExclude`, which existed only to whitelist the 45 jest packages the bump pulled in. This commit also carries the dependency version bumps that were already sitting uncommitted in the tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stylesheet was a 115-line template literal rebuilt on every call, with
`${prefix}` interpolated ~60 times for a `classPrefix` option no caller ever
set. It is now a module constant with the prefix written out.
The light, dark and prefers-color-scheme blocks declared the same eight custom
properties three times over; CSS `light-dark()` states each once.
Also drops a `.mf-position` rule that nothing ever emitted, and a `...options`
spread that could overwrite a default with undefined.
BREAKING CHANGE: `IHTMLRenderOptions.classPrefix` is gone; the class prefix is
always `mf`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both branches ran the same 18-line paren-matching loop, differing only in the delimiters they wrapped the result in. Extract `collectGroup` and drive the two cases from a table. Adds the regression test that path never had - output is byte-identical before and after, including its existing quirks on nested calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`createParenStack` was a factory over a `boolean[]` whose every element was literally `true` - only the depth was ever read, through `!!values.at(-1)`. A single counter says the same thing. Also inlines `matchType`, a generic wrapper around `Array.includes` with two callers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight lines and two `as Record<string, string | number>` casts to copy fields onto an object. `Object.assign` is the same thing, typed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`isReducible(x) ? reduceOnce(ctx, x) : x` - `reduceOnce` already returns a value node untouched through its default case, so the guard only restates what the call does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`src/safe.ts` held six exported functions that each did nothing but
`safeExecutor(() => fn(...args))`. Every new throwing API had to grow a
seventh, an eighth, and so on, for no behaviour.
Export the executor itself as `safe`. One name covers every current and
future API:
safe(() => ctx.solve('2+')) // { data: undefined, error }
BREAKING CHANGE: `safeTokenize`, `safeParse`, `safeEvaluate`, `safeExplain`,
`safeSolve` and `safeSolveBatch` are removed. Wrap the call in `safe(...)`
instead. `ISafeResult` is unchanged and now comes from the root export.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`fix` was `Math.trunc` a second time and `sigFigs` was `precision` a second time. TODO.md already stated the rule for `rand`/`randi` and `binomialCoefficient` - one name per behaviour - so apply it here too rather than carry two entries that do the same thing. BREAKING CHANGE: `fix(x)` and `sigFigs(x, n)` are removed. Use `trunc(x)` and `precision(x, n)`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two dead render calls and an unused import in the demo, a fully commented-out
`batch script` test, and an empty `esbuild: {}` block in the build config that
only held a `// minify: true` line - `package.json` already passes `--minify`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README gains the step-by-step solution as a headline feature with a worked example, and states the one-value-type rule that decides what mathflow will and will not grow. CLAUDE.md is rewritten around the reduction model that replaced the solution stack, and adds the scope rule and the lexer's registration trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The demo showed the token-rendered HTML but nothing for `renderAsLaTeX`, so the LaTeX renderer could only be checked by reading its output string. Add a LaTeX pane that typesets the same solution, with the raw source in a collapsible block beside it. KaTeX is all-or-nothing per call: a single construct it rejects renders the whole input as red error text. Each step is therefore typeset on its own, so a bad line shows up as one red row and the other 38 still render - which is the point, since it makes the renderer's gaps visible during development. Dev-only. `files` is `dist`, the library still has no runtime dependencies, and the demo is not part of the build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three faults in the token walker, all of which produced LaTeX that a renderer
rejects rather than merely typeset oddly:
- `^` emitted its operand as-is, so `2^(5-1)` became
`2^\left(5-1\right)`. A superscript takes a single token or a braced group,
so the exponent now has to be read as a unit: `2^{5-1}`. That includes its
sign, a function call, and a nested `^`, which is right-associative -
`2^3^2` is `2^{3^{2}}`, not `2^{3}^{2}`, which is a double superscript.
- `ceil` and `floor` mapped to `\lceil` and `\lfloor`, opening a delimiter
that nothing closed. They join `sqrt` and `abs` in the table that has to
name both halves of the pair.
- A group's contents were copied out token by token instead of going through
these rules, so anything structural inside a call broke:
`sqrt(abs(-16))` gave `\sqrt{\left|\left(-16\right)}`. A group is an
expression like any other, so it renders through the same path.
The fraction rule also had to be tightened. It tests token types but pops a
rendered string, which was only ever safe because every value happened to be
one entry. With an exponent now rendering as a unit, `y^2 / 6` would have
popped `^{2}` as the numerator; it now fires only when the previous token was
emitted as a lone value.
The demo's sample program went from one red line to none, all 39 steps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.