This document is the specification of predicator's instruction set: the flat list of opcodes a compiled expression becomes and a stack VM runs. It is the artifact a sibling implementation (Ruby, JavaScript) implements against, and the one the conformance corpus is generated from.
It is not a syntax reference. For the expression grammar, operators, and builtin functions, see Language Reference. This document covers the instruction layer beneath that syntax - what an expression compiles to and how it runs, not how it is written.
Per ADR-0003, the Elixir implementation (this repository) is the reference implementation of the ISA. It moves when this library needs it to; sibling parity is a downstream obligation, not an upstream constraint.
1. Versioning
The versioning scheme is settled by ADR-0003; this section records it rather than re-arguing it.
- ISA versions are integers - v1, v2, v3 - with no correspondence to this
library's semver. All three ISA v2 opcodes shipped in 3.7.0; 3.8.0 then
refined v2 semantics without adding an opcode (it made every arithmetic and
legacy logical opcode report an unbound root rather than a type mismatch).
ISA v3 both retired
and/orand introducedstore/pop, the two opcodes the statement layer needed, shipping in 4.0.0. - An opcode's semantics never change under its own name. A change to what an opcode does is a new name at a new version. This is what makes "scan the opcode names in a list" a sound answer to "what version does this list require".
- Adding an operand form or widening an accepted type is a new version but
not a new name. This rule speaks to operand forms carried in the
instruction list - what a conformant implementation must accept and
execute. Widening §3's value domain at the host/context boundary moves
no version, because no opcode-name scan can express it and no existing
instruction list changes meaning (px-o9v's null widening is this case). A
later source spelling for a value that domain already admits likewise
moves no version, because a spelling is surface syntax (§6) and attaches to
no opcode name - the
undefinedliteral and thenullliteral are both this case. - An additive version - new opcodes only, every existing instruction list still valid - ships in a minor release.
- Retirement mints the next ISA integer, in addition to taking a major library release and an upgrade path. The integer moves because a build that no longer runs an opcode must be distinguishable from one that does - without it, the version stamp reports a list runnable that the build will refuse.
- A version's opcode set is a half-open interval. Each opcode is
introduced at one version and, if ever retired, removed at another;
version v's set is every opcode with
introduced <= v < removed_in. A version's set is fixed once minted - retiring at v3 does not change what v2 was - so a sibling declaring v2 claims v2's whole set, retired opcodes included. - The check a consumer performs is membership in
Predicator.Instructions.opcode_set/1for this build's version, not a bare comparison againstrequired_isa/1's result:required_isa/1still names the version an instruction list needs, andretired_in/1names the version that removed an opcode, and together they are what lets a refusal name a version rather than say "unknown opcode". - A sibling behind the current version is an expected, documented state. Each sibling publishes the version it supports in its own repository; this document maintains no support matrix.
Current version: ISA v6.
At runtime, Predicator.isa_version/0 returns this build's version as an
integer, and Predicator.Instructions.required_isa/1 returns the minimum
version a given instruction list needs.
2. Execution model
The rules that govern every opcode, stated once here rather than repeated in each row.
- A program is a flat list of instructions. An instruction is a JSON array
whose first element is the opcode name and whose remaining elements are
operands (
Predicator.Types.instruction/0). Nothing else is in the wire format - source positions and spans travel in an Elixir-side side table that is never serialized (Predicator.Types.position_table/0,Predicator.Types.span_table/0). - Execution is sequential from index 0. The program halts when the instruction pointer reaches or passes the end of the list, so a forward jump past the last instruction is a normal halt, not an error.
- In expression mode, the result is the top of the stack at halt. An empty
stack at halt is an
EvaluationErrorwith reason"empty_stack"; it is the one error that belongs to no instruction and therefore carries no source position. A deeper stack is not an error: the top is the result and anything beneath it is discarded. This rule,empty_stackincluded, is expression mode's alone - see "Two execution modes" below. - Opcodes validate, they do not coerce. There is no general truthiness
rule; a boolean-expecting opcode handed a non-boolean is a
TypeMismatchError. - What "falsy" means at a jump:
false,null, or:undefined, and nothing else. "True" means exactlytrue. This is ECMAScript-aligned, deliberately not symmetric-Kleene (ADR-0001). - Jumps are relative; the operand is always a positive integer.
jump,jump_if_falsy_or_pop, andjump_if_true_or_poptargetindex + offset;jump_backward(ISA v6) targetsindex - offset. There is no absolute jump in the ISA. - Execution of a list containing
jump_backwardmust be bounded. An implementation charges a per-execution budget on each back edge taken and, on exhaustion, stops with anEvaluationErrorwhose reason is"loop_budget_exceeded". The budget's default value and the option that configures it are implementation-local, ason_unboundis - only the bound's existence and the exhaustion reason are normative. The derived guarantee: between consecutive back edges the instruction pointer strictly increases, so total work is at most(budget + 1) * length(program)instructions. :undefinedis a first-class value, not an absence. Some opcodes propagate it (compareunder a non-strict operator,in,contains), some reject it (not, the arithmetic five,unary_minus,unary_bang, legacyand/or), and the jumps treat it as falsy. Each opcode's subsection below says which.nullis also a first-class value, and the two are not the same thing::undefinedis an absence (no value was ever supplied), whilenullis a value that is present and empty. The opcodes treat them alike at a jump (both falsy) but differently under===and membership (null === undefinedisfalse, andnullis only ever equal to itself) - see §3 and §5.- A malformed operand is an unknown instruction, not a bad operand.
Every opcode's clause is guarded on its operand's shape, so an
out-of-range or wrong-typed operand falls through to the catch-all clause
and returns an
EvaluationErrorwith reason"unknown_instruction". Examples:["make_list", -1],["jump_if_falsy_or_pop", 0],["compare", "FOO"],["load", 5]. - Error types are normative; error messages are not. A sibling wording a
message in its own idiom conforms. Three error types exist:
EvaluationError,TypeMismatchError,UndefinedVariableError(lib/predicator/errors/). - Insufficient operands is
EvaluationError, notTypeMismatchError, at every opcode that checks stack depth before checking types. - The Elixir-side
on_unboundpolicy (:undefinedversus:erroron aloadof an absent root) is an evaluation option, not part of the ISA: it changes what aloadof an absent root does but adds no opcode and no wire-format change. Seedocs/architecture.mdfor that option; it is not respecified here. - A host may name context roots a
storerefuses to write (the:protected_rootsevaluation option). This is likewise an evaluation option, not part of the ISA: it adds no opcode and no wire-format change, and a run with no such option behaves exactly as specified above. When configured, astorewhose path's root segment is protected returnsEvaluationErrorwith reason"protected_root"instead of writing. See §5'sstoresubsection anddocs/architecture.mdfor the option; it is not respecified here.
Two execution modes
A program is a flat instruction list with no header, so nothing in the wire
format says whether it is an expression or a statement program. The mode is
carried by the entry point, not by the artifact. In this implementation
Predicator.evaluate/2,3 is expression mode and Predicator.execute/2 - the
4.0 statement layer, §6 - is statement mode; a sibling exposes the same two
calls under whatever names it likes. The instruction set is identical in both:
no opcode is restricted to one mode, and no opcode means anything different in
the other. Only what "result" means differs.
- Expression mode - the result is the top of the stack at halt, and an
empty stack at halt is
empty_stack, as above. - Statement mode - the result is the context at halt, not a stack
value. The stack is scratch space between statement boundaries: a
well-formed statement program ends each statement with a
storeor apopand therefore halts with an empty stack by design. That is a normal halt.empty_stackis an expression-mode rule and is never raised in statement mode. A non-empty stack at halt is likewise not an error in statement mode; the residue is discarded, exactly as expression mode discards everything beneath the top.
A statement program that halts on an error has no result. Whether the partially applied context from the statements that completed before the error is kept or discarded is a property of the host API, not of the VM; the VM specifies only that execution stops at the failing instruction.
A host may additionally surface the value of the program's last expression
statement alongside the context. That is a host-API convenience, not an ISA
guarantee - the statement boundary's pop discards it as far as the VM is
concerned. Predicator.execute_value/2 is this implementation's answer: it
obtains the value by having the machine retain what pop discarded rather
than by compiling the program differently, so the compiled artifact is
identical either way.
Statement mode was specified here, ahead of the opcodes that reach it, so the
statement layer arrived as two opcodes plus an entry point rather than as a
change to this specification. Predicator.execute/2 is that entry point.
3. Value types
The value domain the opcodes operate over: integer, float, string, boolean,
list, map (object), Date, DateTime, duration, null, and :undefined.
null and :undefined are both first-class values and neither is the
other: :undefined is an absence - no value was ever supplied - while
null is a value that is present and empty. Both read symmetrically at the
surface: each has a literal keyword (undefined, null), and each compiles
to the corresponding lit operand (§5, §6). null additionally enters the
value domain through a host-supplied context, a load of a bound null, an
access/bracket_access result, or a function return.
A duration's shape is normative, because ["duration", units] produces one
and add/subtract consume it: a map with the seven keys years,
months, weeks, days, hours, minutes, seconds, all present and
defaulting to 0, plus an optional milliseconds key present only when a
ms-family unit was used.
How these values cross a language boundary in the conformance corpus's
tagged-value JSON encoding is px-35i.4's concern, not restated here.
Crossing a plain-JSON boundary
Seven of those eleven types are JSON's own - integer, float, string, boolean,
list, map, null - and survive a JSON round trip unchanged. The other four
do not, and because a lit operand may be any of them, an instruction list
written out as plain JSON and read back is not necessarily the list that was
written:
| Value | Plain-JSON encoding | What it decodes back as |
|---|---|---|
Date | "2026-08-06" | a string |
DateTime | "2026-08-06T12:00:00Z" | a string |
| duration | {"years":0,"days":3,...} | a map |
:undefined | "undefined" | a string |
Each right-hand value is itself a member of the domain above, so the loss
is silent in both directions: the encode succeeds, the decode yields a
well-formed instruction, and the program runs. It runs a different program.
["lit", :undefined] stored and reloaded is ["lit", "undefined"], which
turns the boundness test x === undefined into a string comparison that is
always false. Nothing raises, and no version check catches it - the list
requires the same ISA version either way, because no opcode changed.
This is not new with any particular version. Date, DateTime, and duration
have been in the domain since v1; :undefined joined the list of spellings
in 5.0.0 without joining the domain, which it was already in (§6).
The ISA defines no envelope for this, and does not intend to. Section 2's wire format is a plain JSON array and stays one; an envelope is something a consumer wraps around that array, and the right choice depends on the store - a consumer persisting to a JSON column has the problem, one persisting the source text or a language-native binary term does not have it at all. Two approaches work:
- The conformance corpus's tagged-value encoding, specified in
conformance/README.md, carries exactly these four as{"$type": ...}objects and is the encoding to reach for. It is recommended, not normative outside the corpus: it is corpus apparatus, not a published API, and a consumer adopting it copies it rather than calls it. Its one caveat travels with it - a genuine predicator map containing a literal"$type"key is ambiguous with the tag namespace, and the corpus codec rejects such a map rather than emit a tag that would decode wrong. - Persist the source and recompile on load. Recompiling the same source
is deterministic, so this loses nothing, and it is already the recommended
way to get a position table back after a round trip
(
Predicator.Compiled, ADR-0009). It costs a compile per load, which is the trade against storing a compiled artifact at all.
4. The opcode table
One row per opcode the ISA has ever contained, including opcodes the compiler no longer emits and opcodes a later version retired. A retired opcode keeps its row so the version scan stays total. Columns: Opcode, Operands, Pops, Pushes, ISA, Tier, Emitted by compiler, Removed in.
Error semantics are not a table column: several opcodes have three or four distinct error paths, more than a cell can carry cleanly. See the per-opcode subsections below the table.
Every opcode is ISA v1 except jump_if_falsy_or_pop,
jump_if_true_or_pop, and make_list, which are v2 (ADR-0001),
store and pop, which are v3, cast, which is v4 (ADR-0011),
jump and pop_jump_if_falsy, which are v5 (ADR-0013), and
jump_backward, which is v6 (ADR-0013).
Tiers
Tiers group opcodes for the conformance corpus (px-35i.4); a lower tier is
a smaller, more foundational surface. Tier is a function of opcode only - a
row's tier does not depend on the value types the expression happens to use
(see the note after the table).
| Tier | Name | Opcodes it unlocks |
|---|---|---|
| 1 | core | lit, load, compare, not, unary_bang, unary_minus, jump_if_falsy_or_pop, jump_if_true_or_pop, and, or |
| 2 | arithmetic | add, subtract, multiply, divide, modulo |
| 3 | access | in, contains, access, bracket_access, make_list |
| 4 | rich types | object_new, object_set, duration, relative_date |
| 5 | functions | call |
| 6 | statements | store, pop |
| 7 | casts | cast |
| 8 | control flow | jump, pop_jump_if_falsy |
| 9 | loops | jump_backward |
Tiers are defined by opcode, not by value. A date comparison
([["lit", Date], ["lit", Date], ["compare", "GT"]]) is tier 1 by opcode,
even though it needs Date support and chronological comparison. Value-level
requirements (dates, datetimes, durations) are carried by px-35i.4's
feature tags, not by tiers.
Opcodes
| Opcode | Operands | Pops | Pushes | ISA | Tier | Emitted by compiler | Removed in |
|---|---|---|---|---|---|---|---|
lit | value | 0 | 1 | v1 | 1 | yes | - |
load | name (string) | 0 | 1 | v1 | 1 | yes | - |
access | property (string) | 1 | 1 | v1 | 3 | yes | - |
compare | operator (string) | 2 | 1 | v1 | 1 | yes | - |
and | - | 2 | 1 | v1 | 1 | no | v3 |
or | - | 2 | 1 | v1 | 1 | no | v3 |
not | - | 1 | 1 | v1 | 1 | yes | - |
in | - | 2 | 1 | v1 | 3 | yes | - |
contains | - | 2 | 1 | v1 | 3 | yes | - |
add | - | 2 | 1 | v1 | 2 | yes | - |
subtract | - | 2 | 1 | v1 | 2 | yes | - |
multiply | - | 2 | 1 | v1 | 2 | yes | - |
divide | - | 2 | 1 | v1 | 2 | yes | - |
modulo | - | 2 | 1 | v1 | 2 | yes | - |
unary_minus | - | 1 | 1 | v1 | 1 | yes | - |
unary_bang | - | 1 | 1 | v1 | 1 | yes | - |
bracket_access | - | 2 | 1 | v1 | 3 | yes | - |
call | name (string), arg_count (int >= 0) | arg_count | 1 | v1 | 5 | yes | - |
object_new | - | 0 | 1 | v1 | 4 | yes | - |
object_set | key (string) | 2 | 1 | v1 | 4 | yes | - |
make_list | count (int >= 0) | count | 1 | v2 | 3 | yes | - |
jump_if_falsy_or_pop | offset (int > 0) | 0 or 1 | 0 | v2 | 1 | yes | - |
jump_if_true_or_pop | offset (int > 0) | 0 or 1 | 0 | v2 | 1 | yes | - |
duration | units (list of [int, string]) | 0 | 1 | v1 | 4 | yes | - |
relative_date | direction (string) | 1 | 1 | v1 | 4 | yes | - |
store | n (int >= 0) | n + 1 | 0 | v3 | 6 | yes | - |
pop | - | 1 | 0 | v3 | 6 | yes | - |
cast | type name (string) | 1 | 1 | v4 | 7 | yes | - |
jump | offset (int > 0) | 0 | 0 | v5 | 8 | yes | - |
pop_jump_if_falsy | offset (int > 0) | 1 | 0 | v5 | 8 | yes | - |
jump_backward | offset (int > 0) | 0 | 0 | v6 | 9 | yes | - |
jump_if_falsy_or_pop and jump_if_true_or_pop pop 0 or 1 values and push
0: on the taken branch they leave the value on the stack (net 0 change), and
on the fall-through branch they pop it and execution continues into the next
instruction, which pushes its own value. Neither jump itself ever pushes.
Retired opcodes
An opcode's Removed in cell holds - for a live opcode or vN for one
retired at ISA vN. That marker is the ISA version that removed the opcode,
not a library version (§1). A retired opcode keeps its row in this table, its
entry in Predicator.Instructions's opcode map, and its membership in the
tier-names table above; it loses exactly one thing, its
execute_instruction/2 clause in the evaluator. required_isa/1 and
tier/1 keep answering for it with a version rather than falling back to
unknown_opcode. The conformance corpus keeps a retired opcode's cases too,
with frozen expectations rather than ones the evaluator recomputes - see §8
and conformance/README.md. Retiring an opcode
still requires an upgrade path (ADR-0003).
5. Per-opcode semantics and errors
Each opcode names the private functions in lib/predicator/evaluator.ex that
implement it: the execute_instruction/2 clause matching its name, plus the
helper it delegates to. Function names rather than line numbers, so a
reference survives an edit above it.
lit- pushes the operand unchanged. No error path. The operand may be any value in §3's value domain,:undefinedandnullincluded, and a source spelling exists for both as of the changes that added them (theundefinedandnullliteral keywords, §6). Worth naming the one asymmetry:["lit", nil]survives a plain-JSON round trip, while["lit", :undefined]does not (§3's lossy table).load(load_from_context/2) - string-key lookup in the context; an absent key pushes:undefined. Atom keys are not read: the context is normalized to string keys before evaluation. This is the only opcode that reads a root variable;accessandbracket_accessoperate on a value already on the stack.access(execute_access/2,access_value/3) - pops the target, pushestarget[property]. A missing key, a property against a list (the compiler only ever emits a binary property, which a list never accepts as an index), or a target that is neither a map nor a list, all push:undefined- never an error. Anulltarget falls into that last case:null.foois:undefined, not a type error - indexing into a null is not a rule this opcode enforces. An empty stack isEvaluationErrorinsufficient operands.compare(execute_compare/2,compare_values/3) - operand is one ofGT,LT,EQ,GTE,LTE,NE,STRICT_EQ,STRICT_NE; any other string is an unknown instruction. Pops right then left (stack top is the right operand).:undefinedon either side under a non-strict operator (anything exceptSTRICT_EQ/STRICT_NE) pushes:undefined.STRICT_EQ/STRICT_NEare resolved before any type dispatch and work over every value including:undefined. ADateis never strictly equal to aDateTime, since strict equality is Elixir===and the two are different structs.Date/DateandDateTime/DateTimecompare chronologically, never by struct-key order; a mixedDate/DateTimepair coerces theDateto 00:00:00 UTC before comparing.- Every other pair must be type-matched, where "matched" means both
numbers, both booleans, both strings, both lists, or both plain maps
(
types_match/2). Integer and float are the same type for this purpose, so1 < 2.0compares and1 == 1.0istrue- whileSTRICT_EQon that same pair isfalse, because strict equality does not bridge integer and float. This is the one place the two equality families disagree on numbers, and it is worth a conformance case. - A type-mismatched pair under a non-strict operator pushes
:undefined- it is not an error.1 > "a"is:undefined, and so istrue == 1. null's four-row matrix:null === undefinedisfalse;null == undefinedis:undefined;null === nullistrue;null == nullis:undefined. No new clause implements this -nullis not admitted bytypes_match/2, so every non-strict comparison involving it falls to the type-mismatch catch-all above, andSTRICT_EQ/STRICT_NEalready work over every value. The rule in one sentence:==and the ordering operators are typed comparisons andnullhas no type peer, so every non-strict comparison involvingnullyields:undefined;===is the operator that answers a boolean aboutnull, exactly as it is for:undefined.- Ordering on a matched pair, by type:
- numbers - numeric order.
- strings - lexicographic by Unicode codepoint, comparing the UTF-8 bytes. Not a locale collation: no case folding, no accent folding.
- booleans -
false < true. - lists - element-wise, comparing the first position where the two differ; a proper prefix sorts before the longer list.
- maps - the Elixir reference implementation orders these by Erlang
term order (size first, then sorted keys, then values). A sibling
should treat ordering comparisons between two maps as unspecified
rather than reproduce that rule; the conformance corpus does not
exercise them.
EQ/NE/STRICT_EQ/STRICT_NEon two maps are well-defined and portable - onlyGT/LT/GTE/LTEare not.
- Fewer than two values on the stack is
EvaluationError.
and,or- retired at ISA v3; never emitted by the compiler. Both operands must be booleans; anything else, including:undefined, isTypeMismatchErrorwith operationlogical_and/logical_orand expected typeboolean. They do not short-circuit: both operands are already on the stack by the time either opcode runs. This is the semantics a v1 or v2 implementation still has to provide - a sibling declaring either version claimsand/orunder the interval rule (§1). This build has noexecute_instruction/2clause for them: evaluating a list containing either is refused with reason"retired_opcode", naming ISA v3 and pointing atPredicator.Instructions.upgrade/1, which is the migration ADR-0003 requires (px-tbv.9).not(execute_logical_not/1) - boolean required;:undefined,null, or any other type isTypeMismatchError(operationlogical_not, expectedboolean).in(execute_membership/2) -left in right. Either operand:undefinedpushes:undefined. The right operand must be a list, elseTypeMismatchError(operationin, expectedlist). Membership uses type-matched equality with chronological date comparison (values_equal?/2), and:undefinedis never equal to anything.nullis the one placenullbehaves as a value rather than an absence:values_equal?(nil, nil)istrue, sonull in [null]istrue(andnull in [1]isfalse, matched against nothing) -nulldoes not propagate the way:undefineddoes. Note the resulting surface tension, deliberate and documented rather than reconciled:null == nullis:undefined(above) whilenull in [null]istrue.==andinalready disagree about:undefinedin the opposite direction (undefined in [undefined]propagates:undefinedrather than answeringfalse), so the two operators were never one predicate.contains(execute_membership/2) -left contains right. Mirror ofin; the left operand must be the list, and the type mismatch is reported against the left operand's type.nullon the right behaves as it does underin: identity viavalues_equal?/2, not propagation.add(execute_arithmetic/2) - number+number is numeric addition; string+string, string+number, and number+string concatenate (numbers are stringified); list+list concatenates;Date/DateTime+ duration and duration +Date/DateTimedo date arithmetic. Anything else, including any:undefinedornulloperand, isTypeMismatchError(operationadd, expectednumber_or_string).subtract(execute_arithmetic/2) - number-number is numeric subtraction;Date-Dateyields a duration in days;DateTime-DateTimea duration in seconds; a mixedDate/DateTimepair coerces theDateto UTC midnight first;Date/DateTime- duration does date arithmetic. ElseTypeMismatchError(operationsubtract, expectednumber_or_date),nullincluded. Note the asymmetry withadd: subtraction does not concatenate strings or lists.multiply(execute_arithmetic/2) - numbers only, elseTypeMismatchError(expectednumber) -:undefinedandnullincluded.divide(execute_arithmetic/2) - the zero check runs before the type check, so a right operand of integer0or float0.0isEvaluationError"division_by_zero"regardless of the left operand's type. Two integers use truncating integer division; any float operand uses float division. OtherwiseTypeMismatchError(expectednumber),nullincluded.modulo(execute_arithmetic/2) - integers only, which is the one placemodulodiverges from the other four arithmetic opcodes: a float operand isTypeMismatchError(expectednumber). A right operand of integer0is checked before the type check, as individe, and isEvaluationError"modulo_by_zero". There is no float-zero clause - unlikedivide, a right operand of0.0is aTypeMismatchError, not"modulo_by_zero".nullisTypeMismatchError, same as any non-integer.unary_minus(execute_unary/2) - number required, elseTypeMismatchError(expectednumber) -:undefinedandnullincluded.unary_bang(execute_unary/2) - boolean required, elseTypeMismatchError(expectedboolean). Semantically identical tonot; the two differ only in which surface operator produced them and in the operation name carried on the error.bracket_access(execute_bracket_access/1,access_value/3) - pops the key (stack top), then the target.- Against a map: a string, an integer, a boolean, or
:undefinedindexes it. (The reference implementation writes this as one Elixir clause onis_atom/1, which is also true oftrue,false, and:undefined; a sibling with no atom type implements the clause as string, integer, boolean, and its own undefined value.) A key of any other type - float, list, map, date, duration - isTypeMismatchError(operationbracket_access, expectedstring). A missing key pushes:undefined, and an:undefinedkey is an ordinary miss on any map that does not carry one, not a type error - unlike the list case below. - Boolean keys are data, not a type error. A map may legitimately be
keyed by
true/false(config[true]); the reference implementation's context normalization preserves boolean keys for exactly this reason.m[true]against a map with notruekey is an ordinary miss and pushes:undefined, the same as any other missing key - it is not a type rejection that leaked through. - Against a list: only a non-negative integer indexes; out-of-range and
negative indices push
:undefined. Any non-integer key - string, boolean,:undefined, float - isTypeMismatchError(operationbracket_access, expectedinteger). - Against a target that is neither map nor list:
:undefined, whatever the key. Anulltarget takes this branch -null["x"]is:undefined, the same treatment asaccessabove. - Fewer than two values on the stack is
EvaluationError. - This bullet, not the ISA version, changed to state the above precisely: the list-with-a-non-integer-key case was previously unspecified here and the reference implementation crashed on it rather than returning an error value, and the boolean-map-key line corrects an earlier erratum in this document rather than describing a behavior change - see §1's versioning rules for why neither warrants a version bump.
- Against a map: a string, an integer, a boolean, or
call(execute_function_call/3,call_function/4) - popsarg_countvalues; the deepest (pushed first) is the first argument. Fewer thanarg_countvalues on the stack isEvaluationError"insufficient_arguments". An unknown function name, an arity mismatch, a function returning{:error, message}, or a function that raises are allEvaluationErrorwith operationfunction_call. The builtin function set is not part of the ISA table - see Language Reference. A sibling implementscallplus whatever functions it chooses to provide; the conformance corpus'sfunctionstier is where that set is pinned.object_new(execute_object_new/1) - pushes an empty map. No error path.object_set(execute_object_set/2) - pops the value (stack top) and the object beneath it, pushes the object withkeyset to that value. Fewer than two values on the stack isEvaluationErrorinsufficient operands. A non-map beneath the value isEvaluationError"invalid_stack_value", checked after the stack-depth check. The compiler only ever emitsobject_setimmediately afterobject_new, so the non-map case is reachable only from a hand-built instruction list - but it is specified, not undefined: a sibling implements it to claim tier 4.make_list(execute_make_list/2) - popscountvalues and pushes them as a list in source order: the stack holds them reversed, deepest first, and this opcode reverses them back. Fewer thancountvalues on the stack isEvaluationError.countof0pushes[]. The compiler only emits this opcode for a list with at least one non-literal element; an all-literal list compiles to a single["lit", [...]]instead, which is why v1 siblings can still run most list expressions.jump_if_falsy_or_pop(execute_jump_if_falsy_or_pop/2,jump_to/2) - if the stack top isfalse,null, or:undefined, jump toindex + offset, leaving the value on the stack as the result of the expression; if it is exactlytrue, pop it and fall through to the next instruction; any other value isTypeMismatchError(expectedboolean). An empty stack isEvaluationError. Worked example,a AND b:[["load","a"],["jump_if_falsy_or_pop",2],["load","b"]]- ifais falsy, the jump lands past["load","b"]anda's value is the result; ifaistrue, it is popped andb's value becomes the result.jump_if_true_or_pop(execute_jump_if_true_or_pop/2,jump_to/2) - mirror ofjump_if_falsy_or_pop: jump on exactlytrue, leaving the value on the stack; pop and fall through onfalse,null, or:undefined; anything else isTypeMismatchError. Worked example,a OR b:[["load","a"],["jump_if_true_or_pop",2],["load","b"]]. State the ECMAScript alignment explicitly:undefined AND xshort-circuits to:undefinedwithout evaluatingx, whileundefined OR xfalls through and takesx's value.duration(execute_duration/2) - pushes a duration map built from the operand's unit pairs. Accepted unit strings, all of them:y/year/years,mo/month/months,w/week/weeks,d/day/days,h/hour/hours,m/min/minute/minutes,s/sec/second/seconds,ms/millisecond/milliseconds. Later pairs overwrite earlier ones naming the same unit. An unrecognized unit string isEvaluationError"invalid_duration_unit"; a pair that is not[integer, string]isEvaluationError"invalid_duration_format".relative_date(execute_relative_date/2) - pops a duration map, pushes aDateTime."ago"and"last"subtract the duration from the current time;"future"and"next"add it. Any other direction string isEvaluationError"invalid_direction"; a non-map on top of the stack isEvaluationError"invalid_stack_value"; an empty stack isEvaluationErrorinsufficient operands. The result depends on the current time - it reads the system clock at evaluation time - so a conformance case exercising it cannot pin an exact expected value.store(execute_store/2) - pops the value from the stack top, thennlocation segments beneath it, and reverses thoseninto root-first path order: the compiler pushes segments root-to-leaf followed by the value, so the stack holds the segments deepest-first, exactly asmake_listdoes. Writespath -> valueinto the evaluator's context via the same write algorithmPredicator.ContextLocation.put/3implements: a missing,null, or:undefinedinterior slot is auto-vivified as a list when the next segment is an integer and a map otherwise - writing through anullintermediate vivifies it exactly as it would an:undefinedone, deliberately: "you cannot index into a null" is not a rule this ISA enforces anywhere else (access/bracket_accessabove agree - a null target is an ordinary miss, not a type error). An integer past a list's end pads with:undefined, and the leaf is always overwritten. Pushes nothing. Fewer thann + 1values on the stack isEvaluationErrorinsufficient operands. A segment that is neither a string nor an integer isTypeMismatchError(operationstore, expectedstring, the message naming string and integer as the accepted segment types), the mirror ofbracket_access's key rule. A write that cannot be performed isEvaluationErrorwith reasonnot_assignable(empty path, only reachable from a hand-built["store", 0]),not_a_container(an interior segment holds a scalar), orinvalid_index(a negative list index). This is the only opcode that writes the context. A host that supplied a protected-root list (the:protected_rootsevaluation option) getsEvaluationError"protected_root"when the path's root segment is in it, checked after segment-type validation and before the write, so a refused write leaves no partial write behind. This is host policy, not an ISA rule - see §2.pop(execute_pop/1) - discards the stack top and pushes nothing. The statement-boundary opcode: the compiler emits it after every expression statement so the next statement starts from a clean stack. An empty stack isEvaluationErrorinsufficient operands. It is unrelated tojump_if_falsy_or_pop/jump_if_true_or_pop, which pop conditionally as part of a jump. Non-normative: this implementation additionally retains the discarded value in the evaluator's own state, which is howPredicator.execute_value/2reports the last expression statement's value; the stack effect, the error, and everything else a conforming implementation must reproduce are unchanged, and a sibling need not retain anything.cast(execute_cast/2,Predicator.Cast.cast/2) - pops the stack top, converts it to the named type, pushes the result. The operand must be one of the seven scalar type names below; any other value isunknown_instruction, the standing malformed-operand rule (ADR-0011) -casthas no other error path. An empty stack isEvaluationErrorinsufficient operands. This subsection is the normative conversion matrix: a sibling claiming ISA v4 implements exactly it.Two rules generate every cell:
:undefinedpropagates:cast(:undefined, _type)is:undefinedfor every target.castis total over values: a conversion that cannot produce a value of the target type pushes:undefined, never an error. The type name itself already failed at parse time if it was invalid; what remains at evaluation time is the data's half, and data problems go soft here, as at acomparemismatch or anaccessmiss.
null::Tis:undefinedfor every targetT, via rule 2, not rule 1:nullis not:undefinedand does not propagate the way it does - anullsimply cannot produce a value of any target type, the same total failure a list or a map hits below.null::stringis:undefined, not"null". A cast is an explicit request for a typed value, and losing the null/undefined distinction across one is the correct, accepted cost.The seven scalar type names are
integer,float,string,boolean,date,datetime,duration(§3).=is identity, a word names a conversion,-is:undefined:source \ target integer float string boolean date datetime duration integer = widen format - - - - float truncate = format - - - - string parse parse = parse parse parse parse boolean - - format = - - - date - - format - = midnight UTC - datetime - - format - calendar date = - duration - - format - - - = list - - - - - - - map - - - - - - - null- - - - - - - :undefined- - - - - - - Numeric conversions.
integer::floatwidens to the nearest representable double, which is exact up to 2^53 and rounds beyond it:(2^53+1)::floatis2^53. Siblings agree, because a 53-bit mantissa is what their host float types have too, but the conversion is not lossless and a round trip throughfloatis not identity for a large integer.float::integertruncates toward zero. PostgreSQL rounds here; this diverges deliberately, because truncation is what Elixirtrunc, JavaScriptMath.trunc, and Rubyto_iall do natively, and matching the siblings' host languages is worth more than matching PG's tie-breaking.
String parses. Each parse accepts the whole string or yields
:undefined; no trimming, no partial consumption, no locale forms.::integer- an optionally-negated decimal integer (-?[0-9]+).::float- an optionally-negated decimal number with an optional fraction (-?[0-9]+(\.[0-9]+)?);"3"::floatis3.0. No exponent form, matching the language's own float literal grammar.::boolean- exactly"true"or"false", case-sensitive.::date- ISO 8601 calendar date (2026-08-09).::datetime- ISO 8601 datetime with a UTC offset, normalized to UTC. A date-only string is:undefinedhere; the supported spelling iss::date::datetime.::duration- the language's own duration-literal grammar, a sequence of[0-9]+(\.[0-9]+)?<unit>pairs ("1d2h30m","1.5s"). A number may carry a decimal fraction; there is no bare fraction (".5s") and no trailing dot ("1.s"). A fractional component must convert to an exact whole number of milliseconds or the whole string is:undefined- a sub-millisecond remainder ("0.5ms") is rejected, never rounded or truncated. A valid fraction expands to the integer part on its own unit plus a remainder decomposed largest-first throughd,h,m,s,msonly, never back intow,mo, ory. Fractions are permitted on every unit;moandyfractions commit this document's 30-day and 365-day approximations at parse time, so"0.5mo"::durationhasdays: 15and nomonthscomponent. Repeated units accumulate, expansions included. This bullet, not the ISA version, changed to state the widened grammar precisely - see §1's versioning rules for why: no operand form carried in the instruction list moves, and a string moving from the unparseable set to the parseable set is the monotone wideningcast's:undefinedfailure mode was built to absorb.
String formats (
::string).integer- decimal.float- the shortest round-trip decimal. Shortest-round-trip printing is a known cross-language hazard, so the conformance corpus pins values chosen to format identically across languages.boolean-"true"/"false".date- ISO 8601 calendar date (2026-08-09).datetime- ISO 8601 in UTC withZ, and the fractional-seconds field is normative: omitted entirely when the sub-second component is zero (2026-08-09T12:00:00Z), and exactly six digits when it is not (2026-08-09T12:00:00.500000Z). Never any other digit count, and never a zero fraction spelled out. Sub-second precision is microseconds - the::datetimeparse truncates digits past the sixth - so the field is stated in terms of the instant, not in terms of any host type's precision or scale field.dt::string::datetimepreserves the instant, buts::datetime::stringis a canonicalization rather than a string identity:"…:00.5Z"returns as"…:00.500000Z", and a seventh digit is gone.duration- the duration-literal grammar, largest unit first, zero components omitted,"0s"when all components are zero, sod::string::durationround-trips.- Lists and maps are
-: serialization isJSON.stringify's job, not a cast's.
The date/datetime bridge.
date::datetimeis midnight UTC - the same coercioncompareandsubtractalready apply to a mixed pair.datetime::dateis the datetime's calendar date.
No boolean/number bridge.
1::booleanandtrue::integerare:undefined. The ISA has no truthiness rule (§2) andcomparerefuses to bridge booleans and numbers; casts do not open a side door.jump(jump_to/2) - unconditional jump toindex + offset. Pops nothing, pushes nothing. No error path beyond the standing malformed-operand rule (a non-integer or non-positive offset falls through tounknown_instruction). Statement-level control flow's only unconditional jump -if/elseuses it to skip theelsebranch once theifbranch has run. Worked example,if c { A } else { B }:c; ["pop_jump_if_falsy", lenA + 2]; A; ["jump", lenB + 1]; B- afterAruns,jumpskips pastBto the instruction following it.pop_jump_if_falsy(execute_pop_jump_if_falsy/2,jump_to/2) - pops the stack top always. If the popped value isfalse,null, or:undefined, jump toindex + offset; if it is exactlytrue, fall through to the next instruction; any other value isTypeMismatchError(expectedboolean). An empty stack isEvaluationErrorinsufficient operands.Contrast with
jump_if_falsy_or_pop: that opcode pops only on the fall-through branch and leaves the value on the stack on the taken branch, because it exists to makea AND byieldaas the expression's result.pop_jump_if_falsypops unconditionally - a statement's condition is never a result, so there is nothing to preserve, and leaving it behind would strand a value on the stack for the following statement to trip over. CPython draws the same distinction betweenJUMP_IF_FALSE_OR_POPandPOP_JUMP_IF_FALSE, and this opcode is the latter's counterpart, not the former's.Worked example,
if c { A }(noelse):c; ["pop_jump_if_falsy", lenA + 1]; A- a falsycjumps pastAentirely;A's length plus one lands on the instruction afterA. Worked example,if c { A } else { B }(continuing thejumpexample above): a falsycjumps over bothAand the trailingjump, landing onB's first instruction,lenA + 2past thepop_jump_if_falsyitself.jump_backward(execute_jump_backward/2,jump_backward_to/2) - the one back edge in the ISA (ISA v6, ADR-0013). Unconditional jump toindex - offset. Pops nothing, pushes nothing. A non-integer offset, a non-positive offset, or a target before index 0 isunknown_instruction, the standing malformed-operand rule (§2).The opcode has its own name rather than being a negative
jumpoffset deliberately: it keeps opcode-name scanning a sound version check, and the absence ofjump_backwardin a list remains a termination proof - a list without it still halts in at mostlength(program)steps, since every other jump is forward-only (§2).Every execution of a back edge charges a per-execution loop budget (§2). On exhaustion, the instruction returns an
EvaluationErrorwith reason"loop_budget_exceeded", carrying thejump_backwardinstruction's own source position - the same position-attachment every other opcode's error gets, with no special-case plumbing.Worked example,
while c { A }:c; ["pop_jump_if_falsy", lenA + 2]; A; ["jump_backward", lenC + lenA + 1]- the
pop_jump_if_falsyskips pastAand the trailingjump_backwardoncecgoes falsy (the+2); thejump_backwardat the end targets the condition's first instruction,lenCinstructions beforeAbegan, so its offset counts bothlenCandlenA.
- the
6. Not in the ISA
What a reader might expect to find here and will not:
- Source positions and spans - these travel in an Elixir-side side table,
never serialized as part of the instruction list. See
docs/reference/ast.mdfor the blame-token and span tables, anddocs/reference/language.md's "Error Shapes" for how they surface on an error. - Surface syntax, including the
=grammar break (ADR-0002). Both=and==compile to["compare", "EQ"], so no instruction-level divergence exists between them: the difference is a parse error in expression position and an assignment in statement position, entirely at the parser layer, and it is outside the conformance corpus's scope. Theundefinedliteral keyword is the same shape: it compiles to the existing["lit", :undefined], an instruction this ISA's value domain (§3) already admitted, so the new spelling attaches to no opcode name and moves no version. Thenullliteral keyword is the same shape again:nullcompiles to the existing["lit", nil], an operand §3's value domain already admitted, so this spelling too attaches to no opcode name and moves no version. - The builtin function set - see Language Reference.
- A serialization envelope. Four of §3's value types cannot round-trip through plain JSON, and choosing what to do about that is the consumer's call, not the ISA's - see "Crossing a plain-JSON boundary" in §3 for the four types and for the two approaches that work.
- Absolute jumps. Every jump in the ISA is relative. Backward jumps entered
at v6 as exactly one opcode,
jump_backward- see §5.
7. Version history
| ISA | Opcodes introduced | Opcodes retired | Shipped in |
|---|---|---|---|
| v1 | everything not listed below | - | up to 3.6.x |
| v2 | jump_if_falsy_or_pop, jump_if_true_or_pop, make_list | - | 3.7.0 |
| v3 | store, pop | and, or | 4.0.0 |
| v4 | cast | - | 5.0.0 |
| v5 | jump, pop_jump_if_falsy | - | 5.0.0 |
| v6 | jump_backward | - | 5.0.0 |
This table records the release each opcode was introduced in, and, now that an opcode has been retired, the release each was removed in - both ends of the interval §1 defines. A version whose only change is a retirement still gets a row here. A version's semantics can also be refined in a later release without a new opcode and without a new ISA version - 3.8.0 did exactly that to v2, as noted in §1.
ISA v1 is defined as the full opcode set the Elixir evaluator accepted before ADR-0001. A sibling declaring v1 support is claiming that whole set; where a sibling falls short of it, the sibling publishes that fact in its own repository. No support matrix is maintained here (ADR-0003).
8. Conformance corpus
This document specifies the ISA in prose; conformance/README.md
is its executable form - a checked-in, language-neutral JSON corpus a
sibling runs its compiler and evaluator against, tier by tier, without an
Elixir toolchain (px-35i.4, ADR-0003). Read it before implementing against
a tier or adding a case. conformance/RATCHET.md
is the companion format a sibling uses to record and defend its conformance
claim over time (px-35i.8).
The corpus's scope boundary matches this document's: it does not cover
surface syntax (§6 - = and == compile identically, so a source-level test
would encode a divergence that does not exist) and it does not cover
parse or lexer errors (every authored case's source, when present, compiles
successfully). Coverage here means the instruction layer this document
specifies, nothing above it.