Skip to content

About

Predicator in TypeScript: a conformant sibling of the Elixir reference implementation (the ISA and the corpus live in predicator-ex)

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@riddler/predicator

The same predicate language in TypeScript, corpus-identical to the Elixir evaluator. Predicator is a small, safe expression language a host embeds so that a non-programmer can author a condition - may this loan be renewed, is this copy overdue - and the host can decide it without running arbitrary code. An expression compiles to a flat instruction list that an evaluator runs.

Why

A rule a librarian writes - how many times a loan may be renewed, when an overdue copy counts as lost - has to be decided in more than one place: on the server that keeps the loan, and in the browser or React Native app that shows the patron what they may do. Without a shared language each place re-implements the rule, and the copies drift until the desk and the app give a patron two different answers. With this package the rule is authored once, as an expression, and decided the same way everywhere: the same language, the same instruction set and the same answers as the reference implementation, predicator-ex, held to a shared conformance corpus. An expression compiled on a server can be evaluated in a browser or an app without a round trip, and an authored rule never runs as code.

Install

pnpm add @riddler/predicator@^0.7.0

The package has no runtime dependencies. The version is named on purpose: Installing, in full says why, and what the package has been run on.

Basic usage

import { compile, evaluate } from "@riddler/predicator";

// The branch's renewal rule, as a librarian writes it.
const rule = "loan.renewals < 2 AND NOT copy.on_hold";

const compiled = compile(rule);

if (!compiled.ok) {
  throw new Error("a well-formed rule compiles");
}

// The instruction list is plain data: store it, send it, and run it anywhere.
const renewable = evaluate(compiled.instructions, {
  loan: { renewals: 1 },
  copy: { on_hold: false },
});

if (!renewable.ok || renewable.value !== true) {
  throw new Error("a loan renewed once, on a copy nobody holds, may be renewed");
}

const wanted = evaluate(compiled.instructions, {
  loan: { renewals: 1 },
  copy: { on_hold: true },
});

if (!wanted.ok || wanted.value !== false) {
  throw new Error("a copy another patron holds may not be renewed");
}

A rule that does not compile, and an evaluation that fails, come back as the failing arm of the result rather than as a throw; ok tells the two apart.

Documentation

  • Learn
    • Basic usage: a rule compiled once and evaluated against two loans.
    • Compiling a rule: a rule compiled, run and refused, with each result checked.
  • Do
  • Look up
    • The API reference: every exported function and type, built into docs/api/ by mise exec -- pnpm run docs in a clone of this repository.
    • The entry points: the two import paths and what each one exports.
    • Statement programs: what a program's statements can be, what execute and executeValue answer, and what a failing execution hands back.
    • The value domain and evaluation options: each value type and its shape in TypeScript, what crosses the host boundary and what it refuses, and every option an evaluation takes with its default.
    • Host functions: how a host function's arguments and answer cross into the language's values, and how its name shadows a builtin.
    • The tagged subpath: the encoding that keeps what a JSON round trip loses.
    • The changelog: what changed in each version.
  • Understand
    • Conformance: what "conformant" means here, and what this build claims.
    • The decision records: what this package decided for itself, and why.
    • The language reference: the grammar, the operators, the function set, the value space and the refusals, kept with the reference implementation.

Compatibility

  • Runtimes: a server runtime, a browser, and React Native's JavaScript engine. Nothing under src/ imports a Node built-in or touches a DOM, and a gate stage checks that rather than leaving it to review.
  • Node: engines.node in package.json is >=20, the floor a consumer's runtime has to clear.
  • Module formats: ESM and CommonJS, each with type declarations, for both entry points.
  • TypeScript resolution: node16, nodenext and bundler reach both entry points; node10 reaches the main entry point only, through the top-level main and types.
  • Instruction set: version 6, answered by isaVersion().

Installing, in full and The entry points below carry the detail, including what has been run on React Native's engine and how.

Reference, in full

The sections below are the package's reference by example. Every TypeScript example in them is run by this repository's test suite.

Installing, in full

The name carries an earlier generation of this project. @riddler/predicator on npm was first a 2019 package, built from github.com/riddler/predicator-js rather than from this repository. Read from the live registry on 2026-09-30: npm view @riddler/predicator versions answered 0.1.1, 0.2.0 and 0.2.1, and its dist-tags were { latest: '0.2.1' }. The two 0.2.x versions were published from this repository. 0.1.1 is the earlier generation's: its description is "Safe predicate engine", it declares a runtime dependency on chevrotain, and it carries a deprecation message naming 0.2.0 as where the current line starts. The name's time metadata also records a publish of 0.1.0 on 2019-08-08Z, beside 0.1.1's the same day, but the registry no longer offers 0.1.0 as a version. No version of that 0.1.x line is this code. It was retired by deprecation rather than removal, because a published version can be deprecated but not recalled. A registry is live: read it yourself rather than trusting this paragraph's date.

The version is named because a bare install resolves to whatever the registry offers as latest under the name, and nothing but a publish from this repository moves that pointer to a build of this code. Naming it asks for a build of this code whatever latest points at.

The package has no runtime dependencies - there is no dependencies key in its package.json at all - and assumes no host environment. It imports no Node built-in and touches no DOM, so nothing under src/ reaches for anything a server runtime, a browser or React Native's JavaScript engine does not offer. A gate stage checks src/ for those constructs rather than leaving the rule to review.

That is a check on the text. On the last of those three there is also a check on a run: scripts/hermes-conformance.mjs bundles both conformance surfaces, the vendored corpus and the two runs beside it (the location transcript and the authored statement programs) into one self-contained file, runs it on the JavaScript engine React Native uses, and diffs the four reports against a run of the same inputs on the server runtime in the same invocation. It is run by hand and is not a stage of the gate; conformance/README.md says what it needs and what it bounds.

Run on 2026-10-02, on this source as it stood just before the version moved to 0.5.0: the vendored corpus at tier 9, 262 cases. The evaluator surface answered 257 rows on the server runtime and 257 on the engine, the compiler surface 215 and 215, and both surfaces diffed clean, row for row, with zero differences. What answered is the standalone command-line build of that engine from the archive published with its v0.12.0 release, which reports release 0.12.0 at bytecode version 89. That is an older release than the one a current React Native ships, so the run is evidence about that engine family and about this package's use of the language, rather than a run on the exact build an application ships.

The 2026-10-02 run covered what the corpus covers. On the evaluator surface each case is an instruction list run through evaluateTagged, the corpus's statement instruction lists (store and pop) among them; on the compiler surface each case is expression source run through compile. No corpus case compiles statement source or runs execute, and the location surface (contextLocation, contextPut, contextAssign) was not in the bundle the engine ran, so that run is no evidence about those functions on that engine.

The earlier run behind this paragraph, on 2026-09-20 before the first publish, also diffed clean on both surfaces, and it reported release 0.12.0 at bytecode version 96. The run above reports 89 from the v0.12.0 archive, so the earlier run answered from a different build that names itself by the same release; the bytecode version is what tells them apart.

From 2026-10-04 the bundle carries two more reports beside the corpus's: every row of the location transcript, handed to the location function the row names with the row's own inputs, and every authored statement program, compiled and run through executeTagged against one context. Each is diffed between the two engines row by row, as a corpus surface is. Run on 2026-10-04, on the source at c901cc0 with that change and on the same engine build (release 0.12.0, bytecode version 89): the evaluator surface answered 257 rows on the server runtime and 257 on the engine, the compiler surface 215 and 215, the location run 112 and 112 and the statement run 64 and 64, all four with zero differences. That run compares the two engines; that the location rows answer as the reference did, and that the statement programs compile as it compiled them, is what the suite checks on the server runtime.

Run again on 2026-10-04, on this source as it stood just before the version moved to 0.6.0, at 9a2fb80: the source of the run above plus the two source changes made after it, the fraction expander's bound computed from the unit table and the exported name for evaluateTagged's result type. The same four reports on the same engine build (release 0.12.0, bytecode version 89): the evaluator surface answered 257 rows on the server runtime and 257 on the engine, the compiler surface 215 and 215, the location run 112 and 112 and the statement run 64 and 64, all four with zero differences. The corpus is the same tier 9, 262 cases, as in the runs above. What it covers is what the four reports run: the corpus cases as the 2026-10-02 paragraph describes them, the three location functions with each transcript row's inputs, and the statement programs through executeTagged. Like every run here, it is a run on the standalone build named above, not on the engine an application ships.

Run again on 2026-10-10, on this source as it stood just before the version moved to 0.7.0: the source of the run above plus every change made after it, the corpus among them, re-vendored at the reference's v9.4.4. The run was made on a branch head whose bundle inputs (the source, the vendored corpus, the location transcript and the statement programs) are byte for byte those of f78683b, so it is recorded against f78683b. The tier stays 9, and the case count grew from 262 to 267. The same four reports on the same engine build (release 0.12.0, bytecode version 89): the evaluator surface answered 262 rows on the server runtime and 262 on the engine, the compiler surface 220 and 220, the location run 112 and 112 and the statement run 64 and 64, all four with zero differences. What it covers is what the four reports run, as the paragraph above says, over the larger corpus. Like every run here, it is a run on the standalone build named above, not on the engine an application ships.

engines.node in package.json is >=20, and that is the floor a consumer's runtime has to clear. It is not the toolchain: what builds and gates this repository is the one node and the one pnpm mise.toml pins, and the Development section below is how to provision them.

The entry points

import { compile, contextAssign, contextLocation, contextPut, decompile, durationToMilliseconds, evaluate, execute, executeValue, float, isaVersion, parse, parseDuration, toHost } from "@riddler/predicator";
import { decodeTagged, encodeTagged, evaluateTagged, executeTagged } from "@riddler/predicator/tagged";
  • @riddler/predicator is the main entry point: the value domain, the host boundary, the compilation of an expression's or a statement program's source text into an instruction list, the evaluation and the run of such a list, the rendering of a parsed expression back to source text, the reading of a duration from its literal spelling and its length in milliseconds, the resolution and writing of an assignment's location in a host's own data, and the version of the instruction set this build implements.
  • @riddler/predicator/tagged is the tagged-value subpath: a codec for the conformance corpus's tagged encoding, and the one evaluation and the one statement run that speak it. That encoding carries the members a plain JSON round trip loses - a date, a datetime, a duration, an absence, and the difference between an integer and an integral float. The main entry point neither emits nor requires it.

package.json declares those two under exports, each shipped as ESM and CommonJS with type declarations.

A consumer that reads that map - TypeScript's node16, nodenext or bundler resolution, and every bundler that honors conditional exports - reaches both entry points in both formats. TypeScript's older node10 resolution reads no exports map at all, so the manifest also carries a top-level main and types naming the main entry point's CommonJS build and its declarations. That fallback covers the main entry point only: the ./tagged subpath needs a resolution mode that reads exports. A gate stage compiles a consumer of the main entry point under node10, and under nodenext from both an ES module and a CommonJS file, and reads the compiler's own resolution trace: the node10 run has to resolve through the top-level types without entering exports, and each nodenext run through exports under the condition its format matches. It compiles no consumer under node16 or bundler resolution, and none of the ./tagged subpath.

Compiling an expression from its source text is compile, and compiling a statement program from its source text is compileProgram. Each has two siblings that hand back the same instruction list with a table beside it: compileWithPositions and compileWithSpans for an expression, compileProgramWithPositions and compileProgramWithSpans for a program. evaluate also takes an expression's source text and compiles it as compile does, and execute and executeValue take a source string and run it as a statement program, compiled as compileProgram compiles it. An instruction list may also reach this package already compiled - from the reference implementation, or hand-built, as several of the examples below are.

The TypeScript examples in this file are executed by this repository's test suite, which also asserts that every name they import is bound, and the ones that show a result check it; the JSON quoted further down is compared against the files it quotes. So an example whose result changes, or which imports a name this package stops exporting, fails the gate. What the suite runs is pointed at this repository's own source rather than at the installed package, so package.json's exports map is not exercised by it; a specifier of this package that the suite has no rewrite for fails there rather than resolving. Each example is also typechecked, against the source module each entry of exports is built from rather than the built declarations, so an example that would not compile fails the gate too. Not caught there: a defect only the declaration build would introduce, and a type error only a consumer's different compiler options would raise.

The ISA version

import { isaVersion } from "@riddler/predicator";

if (isaVersion() !== 6) {
  throw new Error("this build implements version 6 of the instruction set");
}

The instruction set architecture is the contract between a compiler and every evaluator that runs its output. A host holding a compiled instruction list can ask an evaluator whether it is new enough to run it, and refuse the list itself if it is not: that comparison and that refusal are the host's to perform. This package performs neither. A compiled list is a flat list with no header, so it states no version of its own for anything to check it against, and the one place isaVersion() is read inside src/ runs the other direction - it refuses an opcode that this version of the set has retired, with retired_opcode. The number here is re-derived from the reference implementation's ISA document, not chosen independently.

Reading a duration

import { parseDuration } from "@riddler/predicator";

// A host holding a duration as text reads it with the parse `::duration` runs.
const read = parseDuration("3d8h30m");

if (!read.ok || read.value.days !== 3 || read.value.hours !== 8 || read.value.minutes !== 30) {
  throw new Error("a duration's literal spelling reads back as its parts");
}

// A text that is not a duration answers the failing arm rather than a throw.
const refused = parseDuration("3 days");

if (refused.ok || refused.reason !== "invalid_duration_format") {
  throw new Error("a text that is not a duration is refused with its reason");
}

parseDuration reads the spelling ::duration reads and ::string writes: a run of components, each a whole number with an optional decimal fraction and one of the units y, mo, w, d, h, m, s and ms, with no whitespace and no sign. The value on the succeeding arm is a Duration, and the failing arm carries the one reason invalid_duration_format for every text it refuses.

import { durationToMilliseconds, parseDuration } from "@riddler/predicator";

// A host that schedules by the clock holds the delay as text.
const delay = parseDuration("1d12h");

if (!delay.ok || durationToMilliseconds(delay.value) !== 129_600_000) {
  throw new Error("a duration's length is its components by their weights");
}

durationToMilliseconds answers a duration's length in milliseconds by the weights the reference converts by: a week of seven days, a month of thirty and a year of three hundred and sixty five, so a month or a year is an approximation with no calendar behind it. A component is weighed as it stands, so one a host built with a fraction contributes an unrounded product, and a sum past the largest safe integer is the nearest double rather than the exact count. It never throws: an argument that is not an object answers NaN.

Compiling a rule

compile takes the source text of an expression and answers the instruction list evaluate runs. The operands are this package's own domain values rather than the corpus's encoding of them, so what comes back is passed straight to evaluate and stored as the plain list it is.

import { compile, compileWithSpans, evaluate } from "@riddler/predicator";

// A librarian authors this rule in the branch's circulation settings.
const rule = "days_overdue > 30 AND status == 'checked_out'";

const compiled = compile(rule);

if (!compiled.ok) {
  throw new Error("a well-formed rule compiles");
}

const lost = evaluate(compiled.instructions, { days_overdue: 45, status: "checked_out" });

if (!lost.ok || lost.value !== true) {
  throw new Error("a copy still out a month and a half past due matches the rule");
}

// A circulation editor hands over a rule the librarian is still typing, so
// the failing arm is routine rather than exceptional. It is a value: the
// reason names the family, the message is the one the reference gives, and the
// span is what an editor underlines.
const draft = "status == 'checked_out' and renewals >= ";

const refused = compile(draft);

if (refused.ok) {
  throw new Error("a rule that stops mid-comparison does not compile");
}

if (refused.error.reason !== "expected_primary") {
  throw new Error("the refusal names the grammar family it belongs to");
}

if (refused.error.position.column !== 41) {
  throw new Error("the refusal points at the place the source ran out");
}

// `compileWithSpans` adds the extent of the node each instruction came from,
// keyed by the instruction's own index; `compileWithPositions` adds a point
// instead. Either way the instruction list is the one `compile` answers.
const underlined = compileWithSpans("score > 85");

if (!underlined.ok || underlined.spans.get(2)?.end.column !== 11) {
  throw new Error("the comparison's span covers the whole expression");
}

Every refusal the scanner, the grammar or the emitter produces comes back on the failing arm rather than as a throw, carrying the refusing stage's own reason, message, position and span. The reason is a member of a closed union, which is what a caller switches on; the message is text for a human - for most members the reference implementation's for that site, reproduced verbatim - and is not something to match on. docs/adr/0004-the-compiler-surface.md is the record, and it enumerates the union.

compile never throws for any string input, and no input class is reserved for a throw. What that took is a declared bound on how deep a source may nest: the grammar is a recursive descent and the emitter a recursive walk, so a source nesting deeper than either would follow used to exhaust the host's stack and raise where the contract said it answered. The bound is 256 levels, the whole expression counting as the first, and a source past it is refused as a value under the reason nesting_depth_exceeded. Declaring the bound is what makes that refusal a property of the source rather than of the machine that compiled it.

evaluate, execute and executeValue each take that source text directly as well, in place of the instruction list, and compile it before running it, as the reference implementation's three do. evaluate compiles the string as an EXPRESSION, so a source that needs the statement grammar is refused there. execute and executeValue compile it as a statement program, the compilation compileProgram performs under Statement programs below, so the same source runs there, and an expression's source runs as a program of one statement.

import { evaluate, execute } from "@riddler/predicator";

// The circulation rule, run straight from its text. What the string
// form skips is the storage step, not the compilation: the same compiler runs
// underneath, under the same context and the same options.
const lost = evaluate("days_overdue > 30 AND status == 'checked_out'", {
  days_overdue: 45,
  status: "checked_out",
});

if (!lost.ok || lost.value !== true) {
  throw new Error("a copy still out a month and a half past due matches the rule");
}

// A source that does not compile comes back on the failing arm these three
// already had, carrying the compiler's own refusal rather than a rewrapping of
// it. `evaluate` compiles an expression, so an assignment is refused there.
const assigned = evaluate("x = 1");

if (assigned.ok) {
  throw new Error("an assignment is not an expression");
}

if (assigned.error.type !== "ParseError") {
  throw new Error("a source that does not compile fails with a ParseError");
}

if (assigned.error.reason !== "assignment_in_expression") {
  throw new Error("the refusal names the grammar family it belongs to");
}

if (assigned.error.position.column !== 3) {
  throw new Error("the refusal points at the `=` the grammar had no room for");
}

// `execute` compiles the same text as a statement program, and runs it.
const bound = execute("x = 1");

if (!bound.ok || bound.context.x !== 1) {
  throw new Error("an assignment is a statement, and execute runs it");
}

The cost is on the failing arm's type: it now admits a ParseError for every caller of the three, including one that never passes a string. A caller that wants the narrower set back narrows on error.type, which is what the example above does.

Rendering a rule back

decompile writes an expression back out as source text, and parse is how you get it something to write: it reads the source into the syntax tree the renderer takes. The two are a pair, and an editor that lets someone author a rule and then shows it back in a normalized form is what they are for.

The rendering is the reference implementation's. The parentheses option is minimal (the default), explicit or none, the spacing option is normal (the default), compact or verbose, and the word operators come out uppercase whatever case the author typed.

import { compile, decompile, parse } from "@riddler/predicator";

// A circulation editor holds the rule the librarian typed, lowercase `and`
// and all.
const authored = "status == 'checked_out' and renewals >= 3";

const read = parse(authored);

if (!read.ok) {
  throw new Error("a well-formed rule parses");
}

// Written back at the defaults: the word operator is uppercase, each literal
// keeps the quote character it was written with, and no parenthesis is added
// that precedence does not need.
const written = decompile(read.ast);

if (!written.ok || written.source !== "status == 'checked_out' AND renewals >= 3") {
  throw new Error("the defaults are minimal parentheses and normal spacing");
}

// The editor offers a view that makes every grouping visible.
const grouped = decompile(read.ast, { parentheses: "explicit" });

if (!grouped.ok || grouped.source !== "((status == 'checked_out') AND (renewals >= 3))") {
  throw new Error("explicit parentheses wrap every operator application");
}

// `compact` reaches the infix operators and nothing else: the separators
// inside a list, an object or a call stay a fixed `", "`.
const tight = decompile(read.ast, { spacing: "compact" });

if (!tight.ok || tight.source !== "status=='checked_out'ANDrenewals>=3") {
  throw new Error("compact spacing removes the spaces around the operators");
}

// At the defaults, a rendering compiles back to the program the source
// itself compiles to.
const first = compile(authored);
const second = compile(written.source);

if (!first.ok || !second.ok) {
  throw new Error("both the source and its rendering compile");
}

if (JSON.stringify(first.instructions) !== JSON.stringify(second.instructions)) {
  throw new Error("rendering and recompiling answers the same program");
}

// `parse` refuses every source whose scan or grammar fails, on the same arm
// and with the refusal `compile` answers for that source - there is no second
// error shape to handle.
const draft = parse("status == 'checked_out' and renewals >= ");

if (draft.ok) {
  throw new Error("a rule that stops mid-comparison does not parse");
}

if (draft.error.reason !== "expected_primary") {
  throw new Error("the refusal names the grammar family it belongs to");
}

// The converse does not hold: `compile` can still refuse a source `parse`
// accepts. `compile` runs an emitter after the grammar and `parse` does not,
// and the emitter refuses two things on its own account - a numeric literal
// the value domain cannot hold, and a source its walk counts past the depth
// limit where the grammar's count stops short. A library's hold rule written
// with such a literal is a tree to `parse` and a refusal from `compile`.
const oversized = "holds_placed > 99999999999999999999";

const tree = parse(oversized);
const refusedByEmitter = compile(oversized);

if (!tree.ok) {
  throw new Error("the literal scans and the grammar reads it");
}

if (refusedByEmitter.ok || refusedByEmitter.error.reason !== "number_out_of_range") {
  throw new Error("the emitter refuses a literal outside the value domain");
}

decompile answers a result rather than a bare string, the way parse and compile do: source on the succeeding arm, and on the failing arm the ParseError compile answers. The one refusal is a tree nesting past the depth limit this package declares for a source, under nesting_depth_exceeded. The walk counts its depth the way the compiler does, so a tree parse answers is refused here exactly when compile refuses the same source for its depth. The reference renders such a tree, so past the limit decompile diverges from it at the same place compile does.

Two values are worth care, and they are not both on the same option. none writes no parentheses at all, not merely the redundant ones, so a rendering under it can read back as a different expression. compact closes the space around the word operators, so a AND b renders as aANDb, a single identifier. On the expression above, none renders exactly what the defaults render and recompiles to the same program, while the compact rendering the block above pins does not compile at all. Which value bites depends on the expression, so neither of them is the one to watch. Both are what the reference does and this matches it.

The tree parse answers and decompile takes is exported as Ast, and it is opaque rather than a promise. It carries no member a caller can read and none a caller can write, so there is nothing on it to switch on and no way to build one: hand it back to decompile and that is all it is for. The node shapes behind it are internal and may change without a major version, and what holds across such a change is that, for a source compile accepts, decompile(parse(source).ast) keeps answering what the reference answers for that source. A source compile refuses carries no such promise: past the depth limit decompile refuses a tree the reference renders, the divergence described above. Not being able to walk the tree is the cost of promising nothing about it, and the direction is the reversible one: publishing the shapes later would break nobody, while taking them back once they were public would. docs/adr/0004-the-compiler-surface.md is the record, and it says why the renderer takes the tree rather than a compiled program.

An instant literal renders with the fraction digits it was written with, as the reference renders it: a datetime carries its precision, the number of fractional-second digits it was written with, so #2026-08-09T10:30:00.500Z# renders as written. The tagged encoding and the ::string cast do not follow it; they write the canonical form, no fraction for a whole second and six digits otherwise.

Evaluating a rule

evaluate runs an instruction list in expression mode: the result is the value on top of the stack when the program halts.

import { evaluate } from "@riddler/predicator";

// The instruction list for: patron.fines_owed > 10 and not patron.staff
const blockedFromBorrowing = [
  ["load", "patron"],
  ["access", "fines_owed"],
  ["lit", 10],
  ["compare", "GT"],
  ["jump_if_falsy_or_pop", 4],
  ["load", "patron"],
  ["access", "staff"],
  ["unary_bang"],
];

const decision = evaluate(blockedFromBorrowing, {
  patron: { fines_owed: 12, staff: false },
});

if (!decision.ok || decision.value !== true) {
  throw new Error("a patron owing more than ten in fines is blocked from borrowing");
}

const staff = evaluate(blockedFromBorrowing, {
  patron: { fines_owed: 12, staff: true },
});

if (!staff.ok || staff.value !== false) {
  throw new Error("a member of staff is not blocked");
}

Failure is a value. A context the boundary refuses, an instruction the evaluator does not recognize, an operand of the wrong type, a variable the context did not bind: each of those comes back as the failing arm of the result rather than as a throw, and ok is what tells the two arms apart. The failing arm carries an error type the corpus's cases match on - EvaluationError, TypeMismatchError or UndefinedVariableError - with a reason token those cases match on too and a message they do not.

The shape of the context is covered too. evaluate normalizes the whole context before it runs a program, so a context that contains itself anywhere - any object graph with a back-reference - is refused with the reason cyclic_value, whether or not the program loads the root it sits under. A context whose lists and maps nest past the depth limit is refused with depth_limit_exceeded. The limit is 256 levels, the context itself counting as the first, and it is a constant this package declares rather than whatever the host's stack happens to allow, so where the refusal falls is a property of the context rather than of the engine running it. A literal in the instruction list is held to the same limit, and so is a value the program builds for itself: a comparison or a membership test whose operand is nested past the limit, a store that would nest the context past it, a value handed to the JSON.stringify builtin nested past it, and a result nested past it are each refused at that point rather than walked or handed over. A value reached by two paths without a cycle - one copy object under two keys, say - is not refused: it is normalized at each place it appears, as a copy of its own. Because that copy grows with the paths rather than with the objects, the places are counted, once for each path that reaches them, and a context of more than a million places is refused with place_budget_exceeded rather than copied; a place is the context itself and every member of every list and map under it.

import { evaluate } from "@riddler/predicator";

const patron: Record<string, unknown> = { name: "Ada" };
patron.loan = { copy: "atlas", patron };

const refused = evaluate([["lit", true]], { patron });

if (refused.ok || refused.error.reason !== "cyclic_value") {
  throw new Error("a context that contains itself is refused rather than raised");
}

Outside the promise is host code that throws while the evaluation reads what the host handed it: a getter or a proxy trap on a value the evaluation walks, such as the context, and the now option when a relative date reads the clock. Its error propagates out of evaluate unchanged, because that is the host failing rather than an outcome of the evaluation. A function the host registers under functions is different: if it throws, the failing arm carries its message. A host whose context carries code like that, and that wants a result rather than a throw, wraps the call.

import { evaluate } from "@riddler/predicator";

const missing = evaluate([["load", "loan"], ["access", "due"]], {});

if (missing.ok || missing.error.reason !== "unbound_variable") {
  throw new Error("a load of a root the context did not bind is reported");
}

An absence that a jump absorbs is not reported that way: under the default policy a load of an unbound root pushes the absence and execution continues, and the error above is the halt rewriting an absence result that an executed unbound load put there.

Statement programs

The same instruction list runs in either mode, because a program is a flat list with no header: what differs is the entry point and what comes back. execute answers the context the program halted with, and executeValue answers the last expression statement's value alongside that context.

import { executeValue } from "@riddler/predicator";

// decision = if loan.renewals < 2 { "renewed" } else { "refused" }; decision
const decideRenewal = [
  ["load", "loan"],
  ["access", "renewals"],
  ["lit", 2],
  ["compare", "LT"],
  ["pop_jump_if_falsy", 5],
  ["lit", "decision"],
  ["lit", "renewed"],
  ["store", 1],
  ["jump", 4],
  ["lit", "decision"],
  ["lit", "refused"],
  ["store", 1],
  ["load", "decision"],
  ["pop"],
];

const run = executeValue(decideRenewal, { loan: { renewals: 1 } });

if (!run.ok || run.value !== "renewed" || run.context.decision !== "renewed") {
  throw new Error("a loan renewed once may be renewed again");
}

compileProgram compiles that list from source text. A program is one or more statements separated by ;: an assignment to a name, a property or an index, an if with an optional else or else if, a while, or a bare expression, whose value is what executeValue answers when it is the last one to run. A statement that ends in } needs no ; after it, and a block opens no scope of its own.

import { compileProgram, compileProgramWithSpans, executeValue } from "@riddler/predicator";

// A branch's renewal script, as a librarian writes it in the editor.
const script = "if loan.renewals < 2 { decision = 'renewed' } else { decision = 'refused' }; decision";

const compiled = compileProgram(script);

if (!compiled.ok) {
  throw new Error("a well-formed script compiles");
}

const run = executeValue(compiled.instructions, { loan: { renewals: 1 } });

if (!run.ok || run.value !== "renewed" || run.context.decision !== "renewed") {
  throw new Error("the compiled script runs as the hand-written list above does");
}

// A refusal is a value here as it is at `compile`, and the statement grammar
// brings reasons of its own: a left side that is not a location, a block
// with no opening brace, and an `else` with no `if` before it.
const misplaced = compileProgram("loan.period + 1 = 14");

if (misplaced.ok || misplaced.error.reason !== "unassignable_location") {
  throw new Error("only a name, a property or an index can be assigned");
}

// `compileProgramWithSpans` carries the spans table `compileWithSpans` does,
// where the instruction ending each statement spans that whole statement,
// and a second table keyed by each `store`, with one span per segment of the
// location it writes. `compileProgramWithPositions` carries points instead.
const located = compileProgramWithSpans("loan.period = 14");

if (!located.ok || located.segmentSpans.get(3)?.length !== 2) {
  throw new Error("a store into loan.period writes a location of two segments");
}

Three properties of a statement run are worth knowing before a host relies on one.

A run answers a new context rather than writing into the caller's, so a host that wants all-or-nothing on failure ignores what comes back and keeps what it had. On the failing arm the context is the writes that completed before the failing statement, handed back rather than dropped; it is absent from that arm in one case, a context the value boundary refused, which is answered before any program runs. And executeValue answers the absence both when the program had no expression statement and when the last one's own value was an absence, which the result does not distinguish.

Writing a location

A host that keeps its own data - a statechart's datamodel, say - writes an assignment's location into it without running a program. contextLocation resolves a location's source text to the path it names, contextPut writes a value at a path, and contextAssign does both. The two that answer a context take it first, and contextLocation, which only reads it, takes the source first.

import { contextAssign, contextLocation, contextPut, Float, float, Undefined } from "@riddler/predicator";

// A library's datamodel: the patron's holds, and which hold the step is on.
const datamodel = { i: 0, patron: { holds: ["atlas"], fines: float(2) } };

const located = contextLocation("patron.holds[i]", datamodel);

if (!located.ok || located.path.join("/") !== "patron/holds/0") {
  throw new Error("a bracket key's variable is read from the context");
}

// The location is resolved against the context before the write, and the
// answer is a new context; the caller's is never written into.
const moved = contextAssign(datamodel, "patron.holds[i]", "codex");

if (!moved.ok) {
  throw new Error("a hold that exists can be replaced");
}

// The answered context holds this package's own values rather than their
// plain projection, so a float keeps its brand when the datamodel threads it
// back in, and a list padded past its end holds the absence.
const patron = moved.context.patron as { holds: unknown[]; fines: unknown };

if (!(patron.fines instanceof Float)) {
  throw new Error("an integral float keeps its brand through a write");
}

const padded = contextPut(moved.context, ["patron", "holds", 2], "ledger");

if (!padded.ok || (padded.context.patron as { holds: unknown[] }).holds[1] !== Undefined) {
  throw new Error("a list written past its end is padded with the absence");
}

// A refusal is a value: a `LocationError` with a closed reason and details a
// host reads without parsing the message.
const through = contextAssign(datamodel, "patron.fines.total", 3);

if (through.ok || through.error.type !== "LocationError" || through.error.reason !== "not_a_container") {
  throw new Error("a path cannot pass through a float");
}

A missing, null or absent slot on the way is created - a list when the next segment is an integer, a map otherwise - and the leaf is always overwritten. Nothing else is destroyed to make room: a path through a scalar, a string key against a list, a negative index, and a number in a hand-built path that is not a safe integer are refused. The context and the value go in through the same host boundary as execute's context, so a value the domain has no member for answers the EvaluationError that boundary answers. docs/adr/0005-the-location-surface.md is the record, with the reasons, the details each one carries, and the places this surface declares it differs from the reference.

The value domain

Moved to The value domain and evaluation options.

Evaluation options

Moved to The value domain and evaluation options.

Host functions

A host supplies functions by name, and one arrives with its arguments already normalized into the domain and has its answer normalized on the way back. Builtins go down first and a host's functions over them, so a host name shadows a builtin of the same name rather than merging with it.

import { evaluate } from "@riddler/predicator";

const branchLends = [
  ["load", "copy"],
  ["access", "branch"],
  ["call", "branch_lends", 1],
];

const answer = evaluate(
  branchLends,
  { copy: { branch: "central" } },
  { functions: { branch_lends: (args) => args[0] === "central" } },
);

if (!answer.ok || answer.value !== true) {
  throw new Error("the host decides which branches lend their copies");
}

The builtins are the closed set the reference implementation defines, including len, upper, lower, trim, substring, concat and the Math., Date. and JSON. families. conformance/corpus/tier-5.json pins the deterministic ones case by case. No case in the corpus calls Date.now or Math.random, whose answers depend on the now and random options (The value domain and evaluation options) rather than on their arguments.

The tagged subpath

import { evaluateTagged } from "@riddler/predicator/tagged";

const borrowedAt = evaluateTagged(
  [["load", "borrowed_at"]],
  { borrowed_at: new Date("2026-03-01T09:30:00Z") },
  { tagged: true },
);

if (!borrowedAt.ok || borrowedAt.value !== '{"$type":"datetime","value":"2026-03-01T09:30:00Z"}') {
  throw new Error("asked for the encoding, a datetime result comes back as its tag");
}

executeTagged is the statement run beside it. execute hands the context a program halted with back under the plain projection, which drops a float's brand; executeTagged answers that context as the encoding's text on both arms, so decodeTagged reads back every value the program bound - a float the program stored stays a float, and a partial context on the failing arm reads back the same way. It takes a compiled program or source text, which it compiles as compileProgram does, and the main entry point's options: the encoding is the only form it answers in, so there is no tagged request to make. A context the encoding cannot carry is a failure rather than a throw: on the successful arm it is the encoder's reason, and on the failing arm the run's own error stays the answer and the partial context is left off.

import { isFloat } from "@riddler/predicator";
import { decodeTagged, executeTagged } from "@riddler/predicator/tagged";

// A library loan's overdue script that sets a flat late fee.
const run = executeTagged("late_fee = 2.0", { loan: { days_late: 3 } });

if (!run.ok || run.context !== '{"loan":{"days_late":3},"late_fee":2.0}') {
  throw new Error("the halt context comes back as the encoding's text");
}

const back = decodeTagged(run.context);

if (!back.ok || !isFloat((back.value as { late_fee: unknown }).late_fee)) {
  throw new Error("the late fee reads back as the float the script stored");
}

decodeTagged and encodeTagged are the codec itself, for a host that persists a value rather than evaluating one. Every failure the codec names a reason for is answered as the failing arm of a result rather than thrown, and a decode's failing arm also carries the offset in the text it went wrong at. The shape of the input is among those failures: a value that contains itself is refused by encodeTagged with cyclic_value, and both directions refuse nesting past the same declared limit of 256 levels with depth_limit_exceeded - a decode at the offset of the first bracket or brace past it. In the text every bracket and brace counts, a tag's own included, so whatever encodeTagged writes, decodeTagged reads back. A value reached by two paths is written at each place it appears, and encodeTagged refuses a value of more than a million places, counted as at evaluate, with place_budget_exceeded. A getter or a proxy trap on a value handed to encodeTagged is host code running inside the walk, and an error it throws propagates unchanged, as it does at evaluate.

encodeTagged writes a float from the field the class's instanceof test checked rather than from the instance's valueOf, and refuses a float whose field is not a finite number with non_finite_number. Every float this package builds carries a finite field, because Float's constructor refuses anything else, so what that refusal answers is an object a host built to the shape the test admits.

The encoding is the corpus's apparatus rather than a published serialization format: predicator-ex's conformance/README.md specifies it, it is revised by regenerating the corpus, and offering a codec for it here does not make it part of what conformance means.

Conformance

"Conformant" is not a claim, it is a corpus. The Predicator family shares one language-neutral conformance corpus - cases, their expected results, and the schemas over them - vendored here byte for byte from the reference implementation at a named tag. conformance/SOURCE.json records which:

{
  "repo": "riddler/predicator-ex",
  "tag": "v9.4.4",
  "sha": "ecff778ef5469a6b062968fc04e948614d97e9be",
  "corpus_hash": "sha256:ddc5cf824b06b4899e1bd4166263a1ee07016ff239216e029b9276ac17243975",
  "isa_version": 6
}

A case is never edited, added or re-expected here to make a local run go green. A disagreement between this package and the corpus is this package's bug; a disagreement between the corpus and the reference implementation is raised in predicator-ex and arrives here as a re-vendoring.

What this build claims is in conformance/registry.json, which is written only by the ratchet script from an observed run and never by hand. It carries one entry per case the runner watched pass, and it records two claims, one per surface. On the evaluator:

{"surface":"evaluator","tier":9}

And on the compiler:

{"surface":"compiler","tier":7}

Tiers are cumulative, so the first covers tiers 1 through 9 on the evaluator surface and the second covers tiers 1 through 7 on the compiler surface. A claim is written only when every case the claimed ISA version runs in those tiers has an entry - the ratchet refuses to write it otherwise. A case the claimed version retired is filtered out of the run rather than reported, and a case result is pass or fail with no third value, so nothing is skipped into looking finished.

The two claims reach different tiers because the two surfaces run different case sets. The evaluator's set is every case; the compiler's is the source-bearing ones, and a case carrying no source is absent from that set rather than skipped by it. So a tier whose cases all lack a source contributes no compiler entry, and the completeness rule behind the compiler's claim holds there over nothing: tier 6 is such a tier, which is why a reader counting compiler entries by tier finds none for it. Tiers 8 and 9 are the same, so tier 7 is the last tier at which a compiler entry exists at all, and the claim is stated there rather than reaching past the evidence into tiers that could only satisfy it by being empty.

Running it:

mise exec -- pnpm run test           # runs the corpus against this build and writes reports/
mise exec -- pnpm run corpus:check   # the vendored corpus is the one SOURCE.json says it is
mise exec -- pnpm run ratchet        # rewrites the registry from the reports, growing only

The suite that runs the corpus is test/conformance/, and the registry's own checks live beside it: the pin against the vendored corpus, each entry's membership and tier, a byte comparison of the file against a re-encoding, a fresh run in which each recorded entry still passes, and the completeness rule behind a claim. The full gate, mise exec -- pnpm run gate, runs that suite and the hash check together.

Development

mise install                                  # the pinned node and pnpm
mise exec -- pnpm install --frozen-lockfile
mise exec -- pnpm run gate:loop               # typecheck, lint, the suite
mise exec -- pnpm run gate                    # the full gate, which CI runs too

mise.toml carries the toolchain versions. The gate commands run through mise exec -- so that they run on the pinned node whatever node a shell's PATH resolves to, and CI installs mise and provisions from the same file rather than duplicating the versions into the workflow. The pnpm version is the one exact version written in two places: mise.toml pins it for mise install and package.json's packageManager pins it for corepack. Nothing checks that the two agree, so a bump has to move both. engines.node in package.json is not a pin of the toolchain either; the Compatibility section says what it is.

License

MIT. See LICENSE.

About

Predicator in TypeScript: a conformant sibling of the Elixir reference implementation (the ISA and the corpus live in predicator-ex)

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages