This site describes solve-engine as it is on main: 2.43.0, which npm does not have yet. npm installs 2.40.0, so a page may show an answer that version does not give yet.
Design decisions
Bytecode rather than tree walking
Section titled “Bytecode rather than tree walking”Re-evaluating on every keystroke makes interpretation speed the dominant cost. Compiling once and executing repeatedly is the standard answer, and it also makes bounding execution straightforward.
Precedence climbing rather than a generated parser
Section titled “Precedence climbing rather than a generated parser”Adding an operator should be registering a parselet, not regenerating a grammar. Precedence climbing keeps the extension point small and the parser readable, and it handles associativity without special cases.
Normalisation as its own stage
Section titled “Normalisation as its own stage”Natural phrasing could have been handled in the parser with lookahead. Making it a separate token-rewriting stage keeps the parser simple and makes phrase fusion something a package can contribute declaratively.
It is also what makes prose safety achievable. Words are recognised in context rather than reserved globally.
Units are case-sensitive and are never remapped
Section titled “Units are case-sensitive and are never remapped”m is metres and M is a millions suffix; the suffix attaches to a number, so
5M is five million while 5 M with a space reads M as an ordinary name.
Accepting both cases, or guessing
between plausible aliases, produces confidently wrong answers. Refusing to guess
is the safer default for a tool doing arithmetic on someone’s real numbers.
“No aliases” means no remapping: mt is not silently read as t, and floz is
not silently read as a US fluid ounce. It does not mean a unit gets only one
spelling. The conversion table carries several spellings for most units, and all
of them are accepted, so lb, lbs and pounds all work. Those are the unit’s
own names, not guesses about what you meant.
The same principle removed an inherited behaviour where m was read as minutes
whenever the other side of a conversion was a time unit. It made today + 5 m
add five minutes to someone who wrote metres, which is precisely the confidently
wrong answer this rule exists to prevent.
Errors and pending are values
Section titled “Errors and pending are values”Both could have been exceptions or nulls. Making them value types means they propagate through arithmetic and arrive with their cause intact, rather than being coerced to zero and producing a plausible but wrong result.
flowchart TD price["AAPL price<br/><i>pending</i>"] --> mul["× 100"] mul --> pendingOut["<i>pending</i><br/>shown as waiting"] bad["A rate that failed to fetch<br/><i>error: no such symbol</i>"] --> add["+ 50"] add --> errOut["<i>error: no such symbol</i><br/>the cause survives the arithmetic"] coerce["If either were coerced to 0"] --> wrong["0 and 50<br/>plausible, authoritative, wrong"]
A recoverable error carries no stack trace
Section titled “A recoverable error carries no stack trace”Because a recoverable error is a value, it does not capture a JavaScript stack trace. A line of prose in a notepad is not an expression, so parsing it fails, and that failure is the answer for that line rather than a bug to investigate.
The cost of doing otherwise is not small. Capturing a stack grows more expensive the deeper the stack is at the time, and the engine reaches the throw about a dozen frames down inside a document pass. A 250-line document builds one such error per non-expression line, and a CPU profile put the error constructor at 46% of the whole pipeline, more than lexing, normalising, parsing and executing together. A prose-heavy document now parses in under a quarter of the time it used to.
The boundary: an error that is not recoverable is a genuine fault and still
captures a full stack. Set EngineError.captureRecoverableStacks = true to get
them back for the recoverable ones while debugging.
No public holidays in working-day arithmetic
Section titled “No public holidays in working-day arithmetic”Correct holiday handling needs a region-specific, continuously updated calendar. Offering an approximation would be worse than not offering it, because the answer would look authoritative while being wrong for most users.
One calendar backend, with Date as the default
Section titled “One calendar backend, with Date as the default”Every date the engine holds is an instant: epoch milliseconds, with no time
zone attached. Every date question is a calendar question about that instant:
which day it falls on, what the same time a month later is, whether it is a
Saturday, how it is written out. Answering needs a time zone and a set of
calendar rules, and the engine used to take both from the JavaScript Date
object, read in the host process’s zone, at some twenty separate places.
Those places now go through one interface, CalendarBackend, chosen per engine
with the calendar option. Its methods take and return plain numbers and
strings, never a Date or a Temporal object, so a date value’s payload stays
a number and nothing a worker sends or a snapshot stores changes shape. The
arithmetic that does not depend on a zone once the calendar fields are known (a
day number, the ISO week) is shared by every backend rather than reimplemented
behind each one, which is what keeps two backends from disagreeing about it.
The default is the Date backend, and it stays the default. It is the same
code that ran before the interface existed, moved rather than rewritten, so an
engine that configures nothing computes exactly what it did. It is also the one
calendar every supported runtime has. Temporal, the API that makes a backend
with a zone of its own possible, is not in Safari, nor in Node 22 and 24, which
the engine supports, and the smallest polyfill adds about twenty kilobytes to a
bundle that a host doing plain arithmetic never needs. The engine therefore
never imports one. A Temporal backend is planned as a separate entry point
that takes the host’s own Temporal implementation, native or polyfilled, so a
host that wants it opts in and pays for it then.
The boundary: results never depend on which backend is in use, except where a
backend adds a capability the Date one lacks, a per-engine time zone being
the one in view. Every site the engine owns reads its backend: the VM’s date
opcodes, the plugin functions and as converters, the rules that fuse a date
literal while normalising, and the parser for the forms that read a literal
while parsing (days in <period>, the stocks and historical-currency date
phrases). Two sites sit outside the engine and are told separately.
formatValue is a free function with no engine in hand, so the backend a date
is written out through is a field on its settings, calendar, and a host
passes the same backend it gave the engine. A worker runtime takes it on
WorkerRuntimeOptions.calendar: a backend is an object of functions and does
not cross the message boundary, so a host with its own bakes it into its worker
entry, as it does for a custom package. The inline offload worker computes with
the Date backend.