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.
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.
pnpm add @riddler/predicator@^0.7.0The package has no runtime dependencies. The version is named on purpose: Installing, in full says why, and what the package has been run on.
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.
- 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
- How to compile and evaluate a rule: compile a rule's text, keep the instruction list, and decide the rule for one loan.
- Rendering a rule back: show an authored rule back to its author in a normalized form.
- How to run a statement program: execute a short script that changes a loan's fields, read back the loan it leaves, and handle a script that fails.
- Writing a location: write an assignment's location into your own data without running a program.
- How to add host functions: let a rule call a function your application supplies, and handle a call that fails.
- Look up
- The API reference: every exported function and type, built into
docs/api/bymise exec -- pnpm run docsin 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
executeandexecuteValueanswer, 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.
- The API reference: every exported function and type, built into
- 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.
- 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.nodeinpackage.jsonis>=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,nodenextandbundlerreach both entry points;node10reaches the main entry point only, through the top-levelmainandtypes. - 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.
The sections below are the package's reference by example. Every TypeScript example in them is run by this repository's test suite.
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.
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/predicatoris 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/taggedis 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.
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.
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.
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.
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.
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.
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.
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.
Moved to The value domain and evaluation options.
Moved to The value domain and evaluation options.
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.
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.
"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 onlyThe 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.
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 toomise.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.
MIT. See LICENSE.