Skip to content

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

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.

  1. Keystrokethe document is evaluated again
  2. Walk the treea virtual call and a branch at every node
  3. Walk it againnext keystroke, same work

The structure is convenient to build and expensive to run. Every evaluation pays the cost of navigating the shape as well as the cost of doing the arithmetic.

The trade

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.

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.

  1. 15m5.00 m
  2. 25M5,000,000

Lower case m is metres. Upper case M is the millions suffix. These are the values the engine returns, and they differ by six orders of magnitude and a dimension.

Refusing to guess

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"]
The same document, with a live price that has not arrived yet.

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.