Skip to content

improve solution generation on road to v3 - #3

Open
henryhale wants to merge 25 commits into
masterfrom
feat/v3-solution-generation
Open

improve solution generation on road to v3#3
henryhale wants to merge 25 commits into
masterfrom
feat/v3-solution-generation

Conversation

@henryhale

Copy link
Copy Markdown
Member

No description provided.

henryhale and others added 25 commits August 29, 2026 11:22
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>
@henryhale henryhale changed the title feat - v3 solution generation improve solution generation on road to v3 Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant