Skip to content

Releases: LiamRiddell/solve-engine

solve-engine@2.2.0

Choose a tag to compare

@LiamRiddell LiamRiddell released this 26 Aug 10:47
f556923

Add evaluateDocument, a whole-document entry point that resolves goal seek.

The engine already had two ways to read a document, and they were not interchangeable. parseDocument is the batch pass: it reads earlier lines' results and skips markdown, which is everything line references, category tags and table columns need. What it cannot do is re-run an earlier line with a variable bound to a trial value, which is exactly what goal seek is, so solve line N for x = target came back as an error there.

evaluateDocument(engine, text), on the solve-engine/engine subpath, runs the incremental engine for one pass and returns the same ParsingResult shape parseDocument does, with the re-run primitive wired in:

:deposit = 100000
:rate = 4%
monthly repayment on deposit over 25 years at rate
solve line 3 for deposit = 900
entry point line 4 (solve line 3 for deposit = 900)
parseDocument error: goal seek has no document to solve against
evaluateDocument 170,507.23

On every form both passes support (line references, category tags, table columns) they agree value for value; goal seek is the one evaluateDocument adds. It restores the engine's document model before returning, so a caller can borrow an engine for a single pass and leave it as it was.

The boundary, deliberate: evaluateDocument does not skip a markdown table's own rows, where parseDocument does, so a document that mixes a raw table with goal seek reads the table through parseDocument and the goal seek through evaluateDocument. It also builds a fresh model per call, which suits occasional evaluation (a documentation notepad, a test) rather than the keystroke loop a live editor runs against one long-lived evaluator.

With this in place, the documentation's whole-document examples, line references, category tags, table columns and goal seek, are now live, editable notepads whose results the build proves, rather than static listings.

Verification

npm run verify (typecheck, the test suite, build, and the single-file and bundled smoke consumers): 7,835 tests across 346 suites pass. The documentation example suite now evaluates every whole-document block the same way a notepad renders it and asserts each documented result, and a new cross-path suite pins each whole-document form through all three entry points at once: the single-line path must refuse with a structured error, and the two document passes must agree. The bundled-consumer contract (test:consumer) re-proves all 521 documented examples against the installed package.

solve-engine 2.1.0

Choose a tag to compare

@LiamRiddell LiamRiddell released this 25 Aug 22:33
68fc547

A focused release: three fixes so a category tag is recognised the same way everywhere, whatever it is named and wherever the # sits.

A tag glued to a word or number is no longer half-recognised

The lexer tagged any # followed by a letter, ignoring the character before it, while the aggregate scanner only counts a tag at a word boundary. So 100#food was stripped from its own line as if tagged, yet left out of total of #food: a line that looked tagged but did not count.

line before now
100#food stripped to 100, but excluded from the total left whole (#food reads as a comment), and excluded
100 #food tagged and counted tagged and counted

A # glued to the end of a word or number is now not a tag in either half, so 100#food and a#food stay whole and only 100 #food, with a space, tags the line.

A tag named after a grammar word is no longer swallowed

The phrase trie fuses multi-word phrases by their written value, ahead of the tag rules, so a tag whose name completed a phrase was consumed as the bare word. total of #column errored ("expected a column name"), and 1200 #assuming errored ("unexpected token"), instead of tagging the line.

expression            result
40 #column            = 40      (tagged #column)
55 #column            = 55
total of #column      = 95
1200 #assuming        = 1,200

The trie now skips a tag token, so a typed #tag never starts or completes a phrase, and a category can be named after a word wherever that word appears in the grammar.

An aggregate of a keyword-named tag no longer errors

total of #tag fuses to an internal aggregate token whose value is the tag name. When that name was also a lexer keyword, the trie re-read the fused token's value on the next pass and turned it back into the keyword, so total of #assuming (with assuming a finance keyword) collapsed to a bare ASSUMING and errored, while total of #column and a data-line 1200 #assuming were fine.

1200 #assuming        = 1,200
800 #assuming         = 800
total of #assuming    = 2,000

The trie's guard now covers the fused aggregate tokens as well as the raw tag, so a tag name is never re-interpreted as a keyword once the aggregate has claimed it.

Verification

  • Three regression specs pin the fixes: Issue197 (a tag named after a phrase word survives, with a property test over every built-in phrase word), Issue198 (the lexer now agrees with the scanner on every boundary case, and a glued tag is neither shown as tagged nor counted), and Issue213 (an aggregate of a keyword-named tag sums through total / sum / count / average, and fuses to a TAG_SUM rather than the bare keyword).
  • 7,803 tests across 345 suites, no failures. npm run verify (typecheck, test:ci, build, smoke, the bundled-consumer contract) is green, and the publish job runs assert-release-tag, verify and test:consumer against the packed tarball before anything reaches the registry.

solve-engine 2.0.0

Choose a tag to compare

@LiamRiddell LiamRiddell released this 25 Aug 16:40
51201d0

The first major release. The API is reshaped around three things a 1.x caller kept having to work around: a constructor that made you spell out every slot, a result that could not tell a fault from a real zero, and an engine that pulled every package into your bundle whether you used it or not. Each change names the boundary it deliberately does not cross, and the whole surface is smaller to parse than 1.x was.

The constructor takes an options object

ExpressionEngine had five positional parameters, so a call that only wanted to pass a package list still had to spell out every slot before it. It now takes a single EngineOptions object, and every field is optional.

// before
new ExpressionEngine("en", false, undefined, undefined, [ARITHMETIC_PACKAGE]);
// now
new ExpressionEngine({ packages: [ARITHMETIC_PACKAGE] });

The fields are locale, packages, config and diagnostics. config takes an EngineConfigOverride, a per-section partial merged over the defaults, so overriding one validation limit no longer means restating a whole config section. The fourth positional slot, an internal diagnostic-pipeline injection point no consumer set, is gone. createEngine, fromJSON and the worker runtime take the same shape.

A result is a Value, and a fault says so

evaluateLine and evaluateExpression returned a single-element Value[], an array kept only for API stability. They now return the Value itself.

// before
const [value] = engine.evaluateExpression("2 + 2 * 10");
// now
const value = engine.evaluateExpression("2 + 2 * 10"); // value.toNumber() === 22

An Error or a Pending value used to read as the number 0 through toNumber(), so a caller that reached for the number could not tell a fault apart from a real zero. Value now carries the guards the engine already used internally.

expression            result
5 kg to m             isError() → true, errorCode → the conversion error
live price of silver  isPending() → true
2 + 2                 isFault() → false

isError(), isPending(), isFault() and the errorCode / errorMessage accessors make the distinction the engine makes, and evaluateNumber applies it too: an impossible conversion returns NaN rather than the 0 that toNumber() would have handed back. evaluateLineDetailed, LineEvaluation and EvalResults are removed, and the off-thread worker client's evaluateExpression collapses the same way, from Promise<SerializedValue[]> to Promise<SerializedValue>.

Packages are explicit, and the engine tree-shakes

The constructor registered all built-in packages by default, so importing the engine pulled every package into a consumer's bundle whether they used it or not. It now registers only the packages it is given, so a bundler drops every built-in a consumer never imports.

a consumer importing and constructing the engine parsed
before 475 KB (all 25 packages, always)
now, arithmetic only 352 KB

This is a breaking change: new ExpressionEngine() with no packages now registers nothing, so 2 + 2 on a bare engine is an undefined-token parse error rather than 4. For the common "I want everything" case, createEngine() is batteries-included; for a slimmer engine, pass the packages you want and import them from solve-engine/packages.

import { createEngine } from "solve-engine";
const engine = createEngine();                       // the full built-in set

import { ExpressionEngine } from "solve-engine";
import { ARITHMETIC_PACKAGE, UOM_PACKAGE } from "solve-engine/packages";
const slim = new ExpressionEngine({ packages: [ARITHMETIC_PACKAGE, UOM_PACKAGE] });

fromJSON takes the same packages argument and must be given the set the snapshot was taken with, since a snapshot's compiled bytecode only lines up against the packages present when it was written.

Package descriptors are keyed by token and name

If you author a package, its parselets and plugin functions were declared as arrays of wrapper objects, and every plugin function carried a numeric index you minted by hand and threaded into both the descriptor and the parselet that emitted it. Both are now keyed records, and the engine owns the index.

// before
const LIGHTEN_FN_IDX = allocatePluginFunctionIndex();
prefixParselets: [{ tokenType: "COLOUR_CALL", parselet: new ColourCallParselet() }],
pluginFunctions: [{ index: LIGHTEN_FN_IDX, handler: lightenHandler }],
// in the parselet: emitOpcode(CALL_PLUGIN); emitIndex(LIGHTEN_FN_IDX); emitIndex(argCount)

// now
prefixParselets: { COLOUR_CALL: new ColourCallParselet() },
pluginFunctions: { lighten: lightenHandler },
// in the parselet: builder.emitPluginCall("lighten", argCount)

The old shape leaked a process-global index counter into every author's code and made a class of silent mistakes possible: two functions sharing an index, a parselet emitting an index its descriptor never registered, an index registered but never emitted. Naming the function once and letting the engine own the index removes them; a name a parselet emits that no descriptor declares is now a registration-time error, not a silent mis-dispatch. A plugin-function name two packages share is a checkPackageCompatibility warning, the later registration winning, exactly as the other cross-package collisions already are.

The boundary: an async resolver that scans compiled bytecode still works in numeric indices, because that is what bytecode is. It recovers its own function's index by the qualified name the engine files it under, pluginFunctionIndexFor("<package>:<name>"), rather than owning a constant. The examples/osrs Grand Exchange resolver is the worked example.

Removed long-dead surface

Three groups of exports that registered into state nothing evaluated against, or duplicated a canonical one, are gone. IEnginePackage.variableSources (with IVariableSource, VariableResolver, IPackageRegistry.registerVariableSource and the solve-engine/variables subpath): a source's variables were registered into a resolver no evaluation path ever queried, so they were never found; a package that needs to expose a value contributes a pluginFunctions entry instead. The PackageRegistry class, its packageRegistry singleton and the IPackageRegistry interface: they wrote into process-wide state no engine reads, so register on an engine instead. And symbolToCurrency, a backward-compatibility re-export of the currency-symbol alias table that lives in uom/CurrencyAliases.ts.

A smaller bundle

The build shipped unminified, so a consumer without their own bundler parsed the full source, whitespace and all, on every load. The build now minifies, the unit table is stored packed and decoded once at load rather than as 1,456 repeating entries, and the bundled semver (pulled in whole for a single compatibility check) is replaced by a small internal range checker covering the grammar a package's engineVersion actually uses.

importing the whole engine parsed, minified
1.x 1,263 KB
2.0 480 KB

Nothing a consumer computes changes. Source maps stay on, so a production stack trace still points at real source; the packed unit table is asserted at generation time to decode to exactly the source table, so a packing bug fails the build rather than altering a conversion; and package gating is unchanged, a 0.x caret still narrows to the minor and a malformed range is still a distinct invalid-range error.

Verification

  • The whole engine suite runs against the new API: 7,790 tests in 342 suites, no failures, including the options-object construction, the bare-value return, the fault guards, the explicit-packages tree-shaking contract (a bare engine registers nothing, and createEngine has its own spec), and every built-in package's descriptor and parselet-emit migrated to the keyed-record shape.
  • Tree-shaking is measured directly: a consumer importing ExpressionEngine plus one package bundles 123 KB smaller than one importing BUILTIN_PACKAGES, and the bundled-consumer contract confirms no semver identifier reaches the shipped bundle.
  • npm run verify (typecheck, test:ci, build, smoke, the bundled-consumer contract) is green, alongside lint, lint:docs, lint:comments and lint:size. The publish itself runs assert-release-tag, verify and test:consumer against the packed, minified tarball, on both the ESM and CJS builds, before anything reaches the registry.

solve-engine 1.2.0

Choose a tag to compare

@LiamRiddell LiamRiddell released this 24 Aug 17:49
633b4d6

A minor release. Four things a running note of numbers can now do in place: split a bill, keep a running total, work a savings goal backwards, and total lines by category tag. Every addition is a phrase that reads the way the note is already written, and each names the boundary it deliberately does not cross.

Splitting a bill

split <amount> between <N> and <amount> split <N> ways answer "X each", in either spelling. A tip written as a percentage composes on the same line, since $120 + 18% is already an exact $141.60 before the split divides it.

split $120 between 3         $40.00 each
$120 split 3 ways            $40.00 each
$120 + 18% split 3 ways      $47.20 each

The boundary is the odd penny. split $100 between 3 is not a bare $33.33 each that quietly loses a penny: the extra penny is named, and the shares add back to the total to the cent.

split $100 between 3         $33.33 each, with 1 share paying $33.34

Money stays exact, a bare number splits to a bare number, and split, ways and people are read as the split grammar only inside the full shape, so :split = 5 and a variable named split keep working.

Running totals

A named variable could be assigned but not updated: each new total had to be written out in full. += and -= turn a note into a live ledger, where each line adjusts a balance in place.

:budget = 500
budget -= 120     380
budget -= 63      317
budget            317

A first += or -= on a name not yet set starts it at zero, so a ledger can open straight into spent += 10 rather than an undefined-variable error. The accumulation runs through the engine's own arithmetic, so money stays money and a unit stays its unit, and the right-hand side keeps its own precedence (budget -= 1 + 2 subtracts three).

A running total is re-seeded on every re-evaluation, so a note that opens spent += 10 reads the same total no matter how many times the document is re-parsed (a host re-parses on each keystroke) or a line is edited: the total is reset to its seed at the start of each pass and rebuilt from the ledger, rather than reading its own previous value and growing without bound. The boundary is the colon grammars: the compound forms apply to bare names, not :name or global :name, and += and -= are punctuation, so they never shadow an ordinary word.

Savings goals

The saving maths already ran forwards. It now runs backwards too, answering the two questions a savings note actually asks: how long a goal takes at a given contribution, and what contribution a deadline needs.

how long to save $10,000 at $500 monthly        20 months
how much per month to save $12,000 in 2 years   $500.00

An optional annual rate compounds over the term, so how much per month to save $12,000 in 2 years at 6% is $471.85, less than the flat $500.00, because the interest does part of the work. The period reads in the natural place (monthly, a week, in 2 years), and the words stay ordinary everywhere else.

Category tags

A running note often groups its numbers by hand, a shopping list or a set of expenses scattered down the page. A mid-line #tag labels a line's category and is dropped from that line's own result, and the aggregates gather every line carrying the tag, wherever they sit.

40 + 15 #grocery      55
petrol this week
30 #transport         30

12.50 #grocery        12.50
total of #grocery     67.50

sum of is a synonym for total of, and average of and count of read the same set. The boundaries are deliberate: a tag that is a line's first token is a heading, not a figure; the match is on the whole tag, so #housing does not gather #housingcost; total and average need numbers, while count is about presence, so it counts a non-numeric tagged line too. A tag name starts with a letter, which keeps it clear of the colour literals (#c0ffee is a colour, not a tag). Like line references, these forms only work inside a document, since they read other lines.

Verification

  • One regression spec per feature: bill split (16 cases), running totals (11 for the grammar, 9 at the lexer, and 5 for re-evaluation stability across re-parses and an in-place edit, on both the batch and the incremental evaluators), savings goals (12), and category tags (21, plus 7 for the pure tag scanner).
  • A cross-feature review of the merged surface before this release caught one blocker, the running total reading its own previous value on re-evaluation, now fixed and pinned by the stability spec above. Two narrower findings are tracked for a later release (#197, #198).
  • 7,784 tests across 343 suites, no failures.
  • npm run verify green, and the publish itself runs assert-release-tag, verify and test:consumer against the packed tarball before anything reaches the registry.

solve-engine 1.1.1

Choose a tag to compare

@LiamRiddell LiamRiddell released this 23 Aug 22:26
b25964e

A patch release: a line of only backslashes, or any run of characters the lexer discards, no longer evaluates to 0.

The bug

\, \\ and \\\\ showed a result of 0 in the notepad and the playground, a number on screen for a line that holds no expression, where a blank line, a heading and a prose line all correctly showed nothing.

\           was 0, now no result
\\          was 0, now no result
\\\\        was 0, now no result

The cause

The lexer discards an unknown ASCII character, a backslash falls through to the same skip path as whitespace, so a line built only from them tokenises to an empty token stream. The line was still classified as an expression and evaluated, and the engine reports an empty token stream as the number 0.

The fix

One change, at the shared classifier: a line whose every character is discarded by the tokeniser (ASCII whitespace or a skip character) is now classified as empty, the same as a blank line. That single classification is read by the batch parse, the incremental evaluator, and the playground's prose gate, so every surface skips such a line rather than answering 0.

A backslash next to real content is unchanged: \1 is still 1 (the backslash is skipped), and 1 \ 2 still errors on the trailing 2. A non-ASCII code point stays real content (it lexes as an identifier), so accented or CJK text is untouched.

Verification

  • A regression spec (11 cases) covers the classification, the document result, and the boundary cases (\1, 1 \ 2, a blank line, a real expression).
  • 7,694 tests across 336 suites, no failures.
  • npm run verify green, and the publish itself runs assert-release-tag, verify and test:consumer against the packed tarball before anything reaches the registry.

solve-engine 1.1.0

Choose a tag to compare

@LiamRiddell LiamRiddell released this 23 Aug 08:43
f73f228

A feature release. Colours become values you can compute with, money and fractions compute exactly, measurements carry a tolerance through the arithmetic, and evaluation can leave the main thread. Twenty-three additions and seventeen correctness fixes, with no breaking API change.

Colours are values

Write a colour and the engine treats it as a value, not as text. All four CSS hex forms are literals (#f00 expands to #ff0000, #ff0000ff carries alpha), alongside rgb()/rgba()/hsl()/hsla(), hsv/hwb, and every CSS colour name through color("...") (including transparent and rebeccapurple):

#ff0000                     #ff0000
rgb(255, 128, 0)            rgb(255, 128, 0)
color("rebeccapurple")      rebeccapurple

A DevTools-style function set adjusts them (lighten/darken, saturate/desaturate, rotate, complement, mix, tint/shade/tone, grayscale, invert, alpha), and the amount reads the same whether written 0.2, 20% or 20. Channel readouts (red, hue, lightness, and the rest) and the WCAG helpers return plain numbers, so they compose with everything else:

lighten(#3366cc, 20%)         #85a3e0
mix(#ff0000, #0000ff)         #800080
contrast(#ffffff, #767676)    4.54
readable(#3366cc)             #ffffff
wcagLevel(#ffffff, #767676)   AA

Every colour result carries its channels, a hex string and a ready CSS string across the worker boundary, so a frontend can render an inline swatch beside the answer without recomputing anything.

One behaviour to note: a bare # sequence that is exactly 3, 4, 6 or 8 hex digits now reads as a colour rather than a markdown heading or tag, so #face, #c0ffee and #deadbeef evaluate to colours. A # followed by anything else, # Heading or #todo, is unchanged.

Exactness where it counts

Money and fractions were IEEE doubles underneath, so representation error reached a user who had only typed two prices or written a third with /. Each now carries an exact value alongside the double (a base-ten decimal for money, a reduced rational for fractions), and same-kind arithmetic reads it:

Expression Before Now
$0.10 + $0.20 0.30000000000000004 $0.30
$1.005 $1.00 $1.01
$19.99 * 3 a drifting double $59.97
1/49 * 49 0.9999999999999999 1
1/3 + 1/3 + 1/3 approximately 1 exactly 1
(1/3 + 1/7) as fraction approximated 10/21

The boundary is deliberate: a bare decimal is unchanged, so 0.1 + 0.2 is still 0.30000000000000004, and a cross-currency conversion through a live rate stays a double.

Precision is now something you can set and see. A place count given with <x> to N dp or round(x, N) is carried on the value, so it shows exactly that many places and travels into the next line:

3.14159 to 4 dp    3.1416
100 to 2 dp        100.00
round(1.5, 2)      1.50
1.005 to 2 dp      1.01

Uncertainty travels through the arithmetic

Write 12.3 ± 0.5 (or the ASCII 12.3 +/- 0.5) and the number carries a one-sigma tolerance. +, -, * and / propagate it, combining independent errors in quadrature:

12.3 +/- 0.5              12.3 ± 0.5
(12.3 +/- 0.5) * 4        49.2 ± 2.0
(10 +/- 1) + (20 +/- 2)   30 ± 2.24

A value with no tolerance behaves exactly as a plain number always did.

Off the main thread

Parsing is synchronous and lands on whichever thread calls it, which is fine once and janky on every keystroke. A new solve-engine/worker entry wraps the core evaluate methods behind a postMessage boundary, so a host can move that work to a Web Worker or a Node worker_threads thread without hand-rolling the protocol:

import { createWorkerEngine, eventTargetTransport } from "solve-engine/worker";

const engine = await createWorkerEngine({ transport: eventTargetTransport(worker) });
const result = await engine.parseDocument(text); // Promise<SerializedParsingResult>

Results cross as a clone-safe DTO (never a raw Value), an AbortSignal cancels a superseded keystroke, and a worker-side throw is rebuilt as the same EngineError a caller would have seen in-process. Live data streams back too: onResolved delivers a batch of re-evaluated lines when a currency rate or a weather reading settles a moment later, and onAsyncError carries the structured failure. The entry is side-effect-free, so a bundle that never imports it pays nothing.

The document reads itself

  • Line references and table aggregates in one pass. total above, line 3, sum(line 1 : line 4) and the table-column aggregates resolved live but errored through the batch parseDocument/evaluateLines APIs. They now wire a document model for the pass, so both paths read the same.
  • Markdown table columns as data. sum of column "cost" in table above reads and totals a column in place; min, max, count, median and average of column read the same one.
  • Aligned matrices. formatMatrixAligned renders a matrix as a stacked, column-aligned grid, while the compact [1, 2; 3, 4] stays the stable API and worker text.
  • Explain a line. explainLine(expression) returns a readable derivation, each operation in evaluation order with the value it reaches, for the reader rather than the diagnostic pipeline.
  • Goal seek. solve line 4 for rate = 900 inverts a line against a target: exactly where the relationship is closed form, and through a fenced numeric search where it is not.

Units, dates and finance

Expression Before Now
5 working days after 20/12/2024 not recognised 27/12/2024
working days between 01/01/2024 and 31/01/2024 not recognised 23
100 km/h in mph INCOMPATIBLE_UNITS 62.14 mph
-40 C in F -104 F -40 F
1 sprint = 2 weeks, then 6 sprints in days parse error 84 days
5 kg + 3 m INCOMPATIBLE_UNITS mass and length cannot be added
interest on 1000 at 5% over 3 years parse error 157.63
100 USD in GBP on 2024-01-15 not recognised the rate on that day
450 monthly for 18 months worked out by hand 8,100
120 up 10% then down 10% misread as 120 118.80

Business-day arithmetic reads the deadline phrasing people write, and excludes public holidays when the host supplies a calendar. A slash between two units is now one rate that converts. A document can define its own units (1 story point = 4 hours) the way it already defines a function. Unit mismatches read as sentences that name the dimensions instead of a bare code. Interest and repayment take the term and the rate in either order. Currency conversion can name the day it happened, with historical rates supplied by the host the same way stocks and weather are. And a recurring schedule adds itself up.

For package and host authors

  • defineFunction derives the plugin index, the parselet and the bytecode from a declaration, so adding vat(x) no longer means learning the parser and the VM by hand. It raises the engine's own arity and type errors for free.
  • A test kit under solve-engine/testing: createTestEngine and expectExpression assert on results and failure codes in expressions, not on emitted bytecode, and expectPackage catches shadowing, vocabulary collisions and unresolvable version ranges from the descriptor alone.
  • Snapshot and restore. engine.toJSON() and ExpressionEngine.fromJSON(state, { packages }) persist a session (variables, user-defined functions, and the line and bytecode caches) as plain JSON, so a document warm-starts rather than re-evaluating. Point-in-time async values are dropped rather than restored stale.

Correctness fixes

Seventeen fixes, most of them extending a guarantee to every operator and every path it should already have covered.

  • Percentage, money and tax stay exact and keep uncertainty across all four operators. $0.10 + 15% is $0.12, 15% of $0.10 is $0.02, tax on $0.10 at 15% is $0.02, tax off and tax in round the half-cent like a till, and (100 ± 5) / 10% keeps its spread. A chained 50% of 1% of $3 no longer depends on grouping.
  • Comma grouping versus argument separators. rgb(255,255,255) and [100,200,300] read without spaces, while (1,000), 2 * (1,000), (2)(1,000) and 100 mod (1,000) keep the thousands comma. The lexer decides by what precedes the (: a call target opens a separator context, a grouping keeps the thousands group.
  • Dated currency conversion fetches exactly one rate from the true source, whether the amount is a literal, a variable, or a subexpression in which a currency cancels out.
  • Non-finite worker results (1/0, 0/0) survive the DTO's JSON round-trip, both as a scalar and one container deeper inside a matrix cell.
  • explainLine reports the answer with no steps when a line mixes arithmetic with an operator it does not model, rather than a misleading step.
  • Batch cross-line reads no longer build a document model per pass, so a document that uses no cross-line feature pays nothing.

Compatibility

No breaking API change. The engine stays tree-shakeable and dependency-light: a single runtime dependency, 18 subpath exports, and a side-effect-free worker entry. Several answers that were wrong are now right, and money and fractions that drifted now display exactly. The one intentional behavioural flip is that a bare # of exactly 3, 4, 6 or 8 hex digits now evaluates as a colour rather than a heading or tag.

Verification

  • 7,683 tests across 335 suites, no failures, including a regression spec for every fix above.
  • npm run verify green: typecheck, the full suite, the build, and the bundled-consumer and sideEffects contracts.
  • The publish itself runs assert-release-tag, verify and test:consumer against the packed tarball before anything reaches the registry.

solve-engine 1.0.2

Choose a tag to compare

@LiamRiddell LiamRiddell released this 12 Aug 12:07
1a8f85a

Markdown list markers are no longer evaluated as arithmetic.

The bug

- 100 + 20 in a document answered -80.

The - is a bullet, but it is also a prefix operator, and nothing stripped the marker before evaluating, so the line was read as negative one hundred plus twenty. That is the worst shape a bug can take: a plausible number where a correct one was expected, with nothing on screen to say it went wrong.

The tell is that the three unordered markers disagreed with each other about the same document:

Line Before Now
- 100 + 20 -80 120
* 100 + 20 error 120
+ 100 + 20 120, by luck 120, by rule
1. 100 + 20 error 120
- [ ] 100 + 20 "a matrix literal cannot be empty" 120
- 100 + 20 -80 120

Only - is also a valid prefix operator, so it was the one marker that could silently produce a number rather than declining.

This is not a regression. It answered -80 in 1.0.0-beta.2, beta.6, 1.0.0 and 1.0.1 alike, checked against all four published versions rather than assumed.

The fix

The lexer already classified these lines as list, and always had. Nothing consumed that classification to trim the marker before evaluating, so the information needed to get this right was sitting there unused.

LineClassification now carries a contentOffset, and both the token stream and the expression text are sliced from it. Deriving them from a single offset is the point rather than an implementation detail: the previous code passed pre-lexed tokens and the raw line text onward, so fixing only one would have left the two describing different lines.

Task-item checkboxes are skipped as well, guarded so that a real matrix literal is never mistaken for one.

What is unchanged

The discriminator is the space, which CommonMark requires after a list marker for exactly this reason.

  • -100 + 20 is still -80. No space, so it is arithmetic.
  • 2 * -3 is still -6
  • [1,2] + [3,4] is still a matrix
  • --- is still a horizontal rule, and still skipped

Behavioural change

A bulleted line that previously showed a negative number, or an error, now shows the result of the expression after the marker. That is the intended reading of a bulleted calculation, and the reason the bug was reported.

Verification

  • A 16-case regression spec covering both the fix and the things that had to keep working
  • 6,792 tests across 285 suites, no failures
  • npm run verify green, including the bundled-consumer and sideEffects contracts

solve-engine 1.0.1

Choose a tag to compare

@LiamRiddell LiamRiddell released this 11 Aug 20:26
e6458bc

A crash fix. One method, two paths into it, and a reason the fuzzer could not see either.

What was happening

EngineError: Unexpected end of input escaping into Obsidian and breaking the editor mid-edit, reported after clearing a document that contained hello =.

tryCompileExpression() answers "does this compile" with a boolean, and LanguageService calls it for every visible line on every keystroke to decide what to highlight. A throw from it does not land in a caller that is looking for one; it reached CodeMirror's transaction dispatch.

The trigger is not an edge case. total = is what every assignment looks like for the moment between typing the = and typing the value, so this was reachable by typing an assignment at ordinary speed.

Input Before Now
total = throws false
hello = throws false
" throws false
der( throws false
hello = 5 true true

Two paths, not one

The symbolic grammar parses its own operand sub-ranges, and ran ahead of the try/catch guarding the main parse, so an assignment with an empty right-hand side threw straight out of prepareExpression(). Every other failure mode in that method already returned a structured { kind: 'error' }; this one alone could throw. It now returns the same 'parse' result, which makes compileExpression() consistent as well.

The lexer throws on an unterminated string, before the parser is reached at all. Fixing the grammar alone left " still throwing, so tryCompileExpression() now enforces its own published contract rather than trusting every stage below it to agree. A catch costs nothing on the path that does not throw, so the cheap "no" answer the method exists to give stays cheap.

evaluateExpression() is unchanged and still throws. It is documented @throws {EngineError}, and only the boolean probe was wrong.

Why 2.6 million fuzz cases missed it

The oracle counts a thrown EngineError as a pass. That is correct for the @throws API it drives, and tryCompileExpression() is the one entry point with a stricter contract, so the invariant being asserted was weaker than the contract being published. A green fuzz run meant the oracle's invariant held, not that the engine was right.

The expression oracle now asserts that contract on every case, so the whole existing corpus exercises it with no new generator, CLI surface or corpus of its own. Adding it fired immediately and shrank two further reproducers, " and der(, out of inputs that looked nothing like the reported one. Both are fixed here and committed to the corpus, which replays on every test run.

Verification

  • The regression spec fails 6 of 23 without the fix and passes 27 of 27 with it
  • 6,776 tests across 284 suites, no failures
  • npm run verify green, including the bundled-consumer and sideEffects contracts
  • 250,000 fuzz cases at seed 20260811 with zero findings; the seed that previously produced findings in every block now produces none

Also in this release

RELEASING.md documents the path from pull request to registry: why a merge cannot publish, why publishing a GitHub Release is the deliberate irreversible act, and the failures this repository has actually hit rather than the ones it might.

A changeset consumed at 1.0.0-beta.7 had been left on disk and would have folded an already-published entry into this changelog. Removed, and the check for it is now written down.

solve-engine 1.0.0

Choose a tag to compare

@LiamRiddell LiamRiddell released this 11 Aug 15:44
247b081

The first stable release.

The work that got here was not adding features. It was nine agents trying to break the engine, a fuzzer, and a differential harness comparing every expression we could find against the previous release. They found sixty-odd defects. The ones that mattered were not gaps: they were confident wrong answers, and five inputs that killed the host process.

Wrong answers, now right

Expression Before Now
1 km > 500 m false true
2024-01-01 + 1 year Dec 31, 2024 Jan 1, 2025
-40 C in F -104 F -40 F
1 cup in fl oz 2.36e14 8
true == false true false
5 > 3 and 2 > 1 false true
sum(x*10, [5]) 5 50
1.5 in French or Spanish 150 1,50
12 - 25 - 2023 Christmas 2023 -2036
$490 rounded to nearest hundred 500 $500.00
24:00 0 an error
[1,2;3,4]^2 0 [7,10;15,22]

Most of these were one decision applied incompletely, with the correct pattern already sitting next door. EQ/NEQ unified units while the four ordering operators did not, under a comment claiming they did. addBusinessDays walked the calendar correctly while + N days added milliseconds and landed on the wrong day across a daylight-saving boundary. fact refused past 170 while permutation, immediately beside it, would loop a trillion times.

It cannot take your process down

Five inputs used to kill the host outright. Each had a different mechanism, and none was catchable by the existing limits, because every one of those is checked between bytecode instructions while all of this work happened inside one:

sum(x, 1:100000000)          heap fatal
transpose(a) * a             heap fatal, from two individually legal operands
today + 100000000 workdays   13 seconds, then "Invalid Date"
gcd(4, arccos(2))            nine characters, an uninterruptible infinite loop

There is now an allocation budget that bounds user-controlled allocation in one place. Matrices are charged at birth, so instructions not yet written inherit the bound, and a matrix product is refused from its shape before anything is allocated. It costs 3.5% on the hot path.

Eight configuration fields that were declared as safety limits and read nowhere are now wired up or deleted. A limit that does nothing is worse than no limit, because it invites false confidence.

Precedence follows mathematics and C

2 ^ 3 ^ 2 is 512. Exponentiation is right-associative, which both BindingPower.ts and PrecedenceParser.ts already claimed in their comments while the code did the opposite.

Bitwise and shift operators take their C and JavaScript levels, so 1 + 2 << 3 is 24 rather than 17. Both parse tiers now read the same named constants, so they cannot silently diverge again.

This changes results in existing documents. It is the largest behavioural change in the release.

How this was verified

A fuzzer, seeded and shrinking, against both the expression grammar and the bytecode VM. 2.6 million cases, zero process deaths. It found what review had not: an instruction missing from the operand-width table, which desynchronised every bytecode scanner after a date literal and let a raw TypeError escape to the host; nine raw exceptions reachable through the public vm export; and a nine-character infinite loop. Every finding is shrunk to a minimal reproducer and committed to a corpus that replays on every test run. Run it with npm run fuzz.

A differential harness comparing 40,368 expressions against 1.0.0-beta.6, drawn from the documented examples, every string literal in the test suite, the parity corpus, and generated expressions. Zero process deaths against the previous release's 68. Of 1,942 differences, every one was classified; five were a regression, and it was fixed.

That regression is worth naming, because the test suite had concealed it. A conversion error was being swallowed by a following conversion, so 60 km/h in m/s answered 0.00 /s, a plausible number in a unit with no numerator. Value.toNumber() returns 0 for errors, so 52 sites computed with a zero they could not tell from a real one. A test.failing covering that exact expression had an assertion quietly flip from failing to passing while the test stayed red on a later line. Every such test is now a single assertion, with a scanner that fails the run if a new one is not.

6,733 tests across 283 suites.

Breaking changes

  • Operator precedence and associativity changed as described above.
  • unitOfMeasurementResult.unitNames is removed. Both branches of its ternary were identical, so it never did anything. Deriving unit names automatically is not sound: C and K share a ratio and are distinguished only by an offset table, so a reverse index renders 20 C as "20 kelvins".
  • parseTimeoutMs, executionTimeoutMs and the dice configuration section are removed. A wall-clock timeout is undeliverable in a synchronous engine, and the dice fields described notation the engine does not implement.
  • maxDocumentLines now defaults to 100,000, raised from a declared-but-unenforced 10,000, because the repository's own tests already exceed that.
  • String literals no longer keep their quote characters in their value.
  • Unpadded dates parse again, and a date is now distinguished from a subtraction chain by whether it is written as one run: 2024-5-3 is a date, 2024 - 5 - 3 is 2016.

Security

The README now documents the posture, and every claim in it was checked against the source. There is no eval, no Function constructor and no code generation anywhere. The engine reads no files and spawns no processes. It does make network requests, and the honest version is more specific than "opt in": two packages registered by default reach out on their own, so 100 USD in GBP fetches a rate and weather in london calls a geocoder with no host configuration. The docs show how to build a package list that really is offline. There is one runtime dependency.

Also in this release

The symbolic solver was reworked while this release was being prepared.
solve(x^5-x=0, x) now returns all five roots, [-1, 0, 1, -i, i], where it
previously invented duplicates and dropped the complex pair; solve(x^3-x=0, x)
is exactly [-1, 0, 1] rather than carrying floating-point noise.

Known limits

  • Two projected years in the inflation table await a real data source rather than invented numbers.
  • Stock and knowledge data require a host-supplied provider. Currency and weather reach the network on their own unless you exclude them.

solve-engine@1.0.0-beta.7

Pre-release

Choose a tag to compare

@LiamRiddell LiamRiddell released this 10 Aug 20:48
4b650ca

Moves `latest` forward for the first time since it was accidentally published to `1.0.0-beta.2`. This is what finally makes npm's package page and a bare `npm install solve-engine` show the current README (the library/engine positioning, embed-in-any-site framing) and pick up the two worker origin-check fixes and the control-character regex fix from beta.6.

See `packages/engine/CHANGELOG.md` for the full per-version notes.