solve-engine 1.1.0
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 batchparseDocument/evaluateLinesAPIs. 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 abovereads and totals a column in place;min,max,count,medianandaverage of columnread the same one. - Aligned matrices.
formatMatrixAlignedrenders 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 = 900inverts 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
defineFunctionderives the plugin index, the parselet and the bytecode from a declaration, so addingvat(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:createTestEngineandexpectExpressionassert on results and failure codes in expressions, not on emitted bytecode, andexpectPackagecatches shadowing, vocabulary collisions and unresolvable version ranges from the descriptor alone. - Snapshot and restore.
engine.toJSON()andExpressionEngine.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.10is$0.02,tax on $0.10 at 15%is$0.02,tax offandtax inround the half-cent like a till, and(100 ± 5) / 10%keeps its spread. A chained50% of 1% of $3no 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)and100 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. explainLinereports 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 verifygreen: typecheck, the full suite, the build, and the bundled-consumer andsideEffectscontracts.- The publish itself runs
assert-release-tag,verifyandtest:consumeragainst the packed tarball before anything reaches the registry.