Skip to content

Latest commit

 

History

129 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Solve, a natural language expression engine

A calculator that reads like a sentence.

Type what you mean. Units, currencies, percentages, dates, matrices and plain-English phrasing all work in the same expression, and the answer appears as you type.

npm CI Node License

Documentation  •  Playground  •  Syntax reference  •  Contributing


That's what a user sees. What you're looking at is the engine behind it: a lexer, parser, and evaluator, with units, currencies, percentages, dates and matrices already built in. There's no expression parser to write, no plumbing to wire up. Install it, point it at an input, and everything below is there out of the box.

Open source, sponsored by Oyren.

1024 * 8                      // 8,192
15% of 2400                   // 360
100 cm + 2 m                  // 300.00 cm
30 fps * 3 minutes            // 5400.00 frames
[1, 2; 3, 4] * [5, 6; 7, 8]   // [19, 22; 43, 50]
expand((x+1)*(x+2))           // x^2+3x+2

Every example in this file, and every example in the documentation, is executed by the test suite. If one of them stops being true, the build goes red.

Install

npm install solve-engine
import { ExpressionEngine } from "solve-engine";

const engine = new ExpressionEngine();
const [value] = engine.evaluateExpression("2 + 2 * 10");

console.log(value.toNumber()); // 22

No dependencies on a UI framework, a DOM, or an editor. It runs in Node, in a browser, and in a worker.

What it can do

Units, converted and carried through arithmetic. Not string matching on a suffix: units participate in the calculation and the result keeps the right one.

72F to C                      // 22.22 C
5 miles in km                 // 8.05 km
2 cups to ml                  // 473.18 ml
250 kg to pounds              // 551.16 pounds
1 GB in MB                    // 1000.00 MB
90 minutes in hours           // 1.50 hours
2 hours + 45 minutes          // 2.75 hours

Percentages, in the several different things people mean by them.

15% of 2400                   // 360
increase 100 by 10%           // 110.00
100 to 150                    // 50.00%
5% of what is 6               // 120

Named values, across lines. A document is a calculation, not a set of unrelated sums.

:subtotal = 240

Matrices, ranges, and symbolic algebra.

[1, 2; 3, 4] * [5, 6; 7, 8]   // [19, 22; 43, 50]
det([1, 2; 3, 4])             // -2

Everything else you reach for. Number bases, comparisons, conditionals, money, dice, live weather, and a function library.

0xFF + 0b1010                 // 265
255 as hex                    // 0xFF
2.5k * 4                      // 10,000
max(3, 9, 2)                  // 9
10 mod 3                      // 1
if 5 > 3 then 100 else 200    // 100
$100 + $250                   // $350.00

The syntax reference is the complete list. There is rather more of it than fits here.

How it works

Text goes through a lexer, a normaliser that fuses multi-word phrases into single tokens, a Pratt parser that emits bytecode, and a register-based virtual machine. Results are cached per line, and a dependency graph means editing one line re-evaluates only the lines that actually depended on it.

That is more machinery than a calculator strictly needs, and the reason for it is the typing. The engine is built to run on every keystroke, on a document rather than a single expression, where most lines have not changed and the one that did should not cost a full re-evaluation of the rest.

Everything above the pipeline is a package. All 22 of them, arithmetic included, register through the same public interface: token vocabulary, normaliser rules, parselets, and VM functions. There is no privileged built-in tier, which means an extension can do anything the built-ins can. Twenty register by default; stocks and knowledge stay out until a host supplies a data source.

Architecture covers this properly, including a candid list of what is not finished.

Design

Natural phrasing, without hijacking English. A calculator that understands sentences has an obvious failure mode: claim in, to, at and for as keywords and you break every line of prose that happens to contain one, and you make those words unusable as variable names. So bare common words are almost never keywords here. Multi-word phrases get fused by the normaliser only in positions where nothing else is plausible. Trigger words and fusion explains where the lines are drawn and why.

An error, never a guess. When a resolver has not returned yet the result is a pending value that resolves later. When a data source is not configured the result says so. The engine does not invent a plausible number, because a wrong answer that looks right is worse than no answer.

Bounded on untrusted input. Expression length, parse depth, instruction count and stack depth all have limits, and each produces a named error rather than hanging. Input arriving one keystroke at a time from a person who is still mid-thought is the normal case, not the edge case.

Non-goals

  • Not a general-purpose language. No loops, no I/O, no arbitrary code execution. Expressions compile to a fixed instruction set.
  • Not a full computer algebra system. There is a real one inside: exact rational arithmetic, expand, factor, solve, and symbolic der, integral, taylor and jacobian. It is deliberately bounded, and it says what it cannot do rather than approximating. Factoring works over the rationals, so x^2-2 comes back unfactored; solving goes further and works over the complex numbers, so it answers with every root an equation has or counts the ones it could not find; and integration reports when an expression has no elementary antiderivative instead of guessing.
  • Not a spreadsheet. Lines reference earlier lines. There are no sheets, no cells, and no circular references to resolve.
  • Not arbitrary-precision by default. Ordinary arithmetic uses doubles, and a big-integer type is available where exactness matters.

Security

This engine runs untrusted input by design: a calculator's whole job is to evaluate whatever someone typed. That shapes how it is built and how it is tested.

No dynamic code execution. There is no eval, no new Function, and no code generation anywhere in the source. An expression is lexed, parsed, and compiled to a fixed bytecode instruction set, then run on a VM that can only do what its opcodes do. There is no path from an expression to arbitrary JavaScript.

No I/O of its own. The engine reads no files and spawns no processes. It does make network requests, and the honest version of that is more specific than "opt in": two of the packages registered by default reach out on their own. 100 USD in GBP fetches an exchange rate, and weather in london calls a geocoder, with no host configuration at all. Two further packages (stocks, knowledge) need a host-supplied data source and do nothing without one.

If you need an engine that never touches the network, build the package list without those two rather than relying on a default:

import { ExpressionEngine } from "solve-engine";
import { BUILTIN_PACKAGES, CURRENCY_PACKAGE, WEATHER_PACKAGE } from "solve-engine/packages";

const offline = BUILTIN_PACKAGES.filter(
  (p) => p !== CURRENCY_PACKAGE && p !== WEATHER_PACKAGE,
);
const engine = new ExpressionEngine("en", false, { packages: offline });

One runtime dependency. @tanstack/query-core, for caching async resolution. Everything else, including the parser, the VM, the unit table and the computer algebra, is in this repository.

Bounded by construction. Untrusted input must not be able to hang or kill the host process, which for an editor plugin means the editor. Expression length, nesting depth, instruction count, stack depth, collection size, total allocation, function-call breadth and recursion depth are all capped. Most are configurable through EngineConfig; function recursion depth and the normaliser token cap are set elsewhere. Exceeding any of them raises a recoverable error naming what was refused rather than an unrecoverable one. The allocation budget exists because per-operation limits do not compose: two individually legal matrices can multiply into a fatal one.

Fuzzed, not just tested. A seeded fuzzer with automatic shrinking runs against both the expression grammar and the bytecode VM. It generates malformed programs, mutates valid ones, and asserts three invariants: the process never dies, nothing hangs, and every failure is a well-formed EngineError rather than a raw JavaScript exception. executeBytecode is a public export, so malformed bytecode is a real caller surface and is fuzzed as one. Findings are shrunk to a minimal reproducer and committed to a corpus that replays on every test run, so a fixed bug cannot come back quietly.

Run it yourself:

npm run fuzz                      # random seeds, both generators
npm run fuzz -- --minutes=10      # a longer soak

Verified against the previous release. tools/differential/ compares every expression it can find against the last published version and classifies every difference, so a behaviour change has to be deliberate rather than discovered afterwards.

To report a vulnerability, see SECURITY.md.

Repository

Path What is in it
packages/engine The published package
packages/playground-bridge Shared glue between the engine and the playground
playground The interactive playground, with every pipeline stage exposed
docs The documentation site
examples/osrs A worked example of a third-party package
npm install && npm run verify

verify is the whole gate: type check, test suite, and package build. See CONTRIBUTING.md.

Status

Working toward 1.0.0-beta. The engine is stable and heavily tested, but the API surface may still move before 1.0. Open items are tracked as issues rather than hidden, and the beta release notes will name the ones that matter.

Licence

MIT. See LICENSE.

If it is useful to you, sponsorship is welcome and never expected.

About

An embeddable expression engine for natural-language calculations: units, currencies, percentages, dates and matrices, with the parsing and evaluation plumbing already built.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages