Version and tier queries over a compiled instruction list.
The instruction set is versioned (ADR-0003): there is a current ISA version this build emits and can run, and any instruction list can be asked what version it requires. A consumer holding a stored artifact, or a sibling handed a list, compares the two and refuses up front rather than failing partway through a run.
Every opcode also carries a conformance tier (px-35i.4): a
conformance-corpus grouping, a function of opcode only, that never depends
on the value types an expression happens to use. A lower tier is a smaller,
more foundational surface, so an implementation that has only tier 1 can
run only tier 1's cases and get a green.
The opcode set, the ISA version each opcode was introduced at, and each
opcode's tier are specified in docs/isa.md section 4;
the table below is its executable form - one table with two columns, not
two tables - and a test asserts the two agree.
A retired opcode keeps its row rather than losing it: the row gains an
optional :removed_in key naming the ISA version that retired it, so
required_isa/1 and tier/1 keep answering with a version instead of
falling back to unknown_opcode for an artifact that predates the
retirement. A version's opcode set is therefore an interval - introduced at
one version and, if ever retired, removed at another - and is fixed once
minted: retiring an opcode at a later version does not change what an
earlier version's set was. in_isa?/2, opcode_set/1, and retired_in/1
are the queries built on that interval.
Migration
Retiring an opcode requires an upgrade path (ADR-0003), so a stored
artifact holding a retired opcode is never simply stranded: upgrade/1
rewrites it onto the current instruction set. See its @doc for the
guarantee, the divergences it documents, and worked examples.
Summary
Types
One opcode's table entry: the ISA version that introduced it, its conformance
tier, and - only when it has been retired - the ISA version that removed it.
An opcode is in ISA version v iff isa <= v < removed_in (docs/isa.md §1);
an entry with no :removed_in key has never been retired.
Functions
Returns whether an opcode's table entry is a member of version's opcode set.
Returns the ISA version this build emits and can run.
Returns the set of opcode names that are members of version's opcode set.
Returns the full opcode table: every known opcode mapped to the ISA version
that introduced it and its conformance tier (docs/isa.md section 4).
Returns the minimum ISA version required to run instructions.
Returns the ISA version that retired a single opcode, or nil if it has
never been retired.
Returns the conformance tier for a single opcode.
Rewrites a pre-3.7 instruction list containing the retired "and"/"or"
opcodes into current jump form.
Types
@type opcode_info() :: %{ :isa => pos_integer(), :tier => pos_integer(), optional(:removed_in) => pos_integer() }
One opcode's table entry: the ISA version that introduced it, its conformance
tier, and - only when it has been retired - the ISA version that removed it.
An opcode is in ISA version v iff isa <= v < removed_in (docs/isa.md §1);
an entry with no :removed_in key has never been retired.
Functions
@spec in_isa?(opcode_info(), pos_integer()) :: boolean()
Returns whether an opcode's table entry is a member of version's opcode set.
A live entry (no :removed_in key) is in every version at or after the one
that introduced it. A retired entry is in every version from the one that
introduced it up to, but not including, the one that removed it - the
half-open interval [isa, removed_in) (docs/isa.md §1). This is the
membership test opcode_set/1 is built from, and it is what a consumer
should apply instead of a bare required_isa(list) <= isa_version()
comparison once any opcode is retired: a retired opcode still reports the
version that introduced it, so the <= comparison alone cannot see that a
later build no longer runs it.
Takes an explicit opcode_info() rather than an opcode name so the retired
branch is doctestable and unit-testable today, before any real opcode
carries a :removed_in value.
Examples
iex> Predicator.Instructions.in_isa?(%{isa: 1, tier: 1}, 1)
true
iex> Predicator.Instructions.in_isa?(%{isa: 2, tier: 3}, 1)
false
iex> Predicator.Instructions.in_isa?(%{isa: 1, tier: 1, removed_in: 3}, 2)
true
iex> Predicator.Instructions.in_isa?(%{isa: 1, tier: 1, removed_in: 3}, 3)
false
iex> Predicator.Instructions.in_isa?(%{isa: 1, tier: 1, removed_in: 3}, 4)
false
@spec isa_version() :: pos_integer()
Returns the ISA version this build emits and can run.
@spec opcode_set(pos_integer()) :: MapSet.t(String.t())
Returns the set of opcode names that are members of version's opcode set.
A build running ISA version v can run an instruction list iff every
opcode it uses is a member of opcode_set(v). This is the check that
supersedes a bare required_isa(list) <= isa_version() comparison once any
opcode is retired - required_isa/1 alone stops being sufficient because a
retired opcode still reports the version that introduced it, not the
version that removed it.
Examples
iex> Predicator.Instructions.opcode_set(1) |> MapSet.member?("make_list")
false
iex> Predicator.Instructions.opcode_set(2) |> MapSet.member?("make_list")
true
@spec opcodes() :: %{optional(String.t()) => opcode_info()}
Returns the full opcode table: every known opcode mapped to the ISA version
that introduced it and its conformance tier (docs/isa.md section 4).
Examples
iex> Predicator.Instructions.opcodes()["lit"]
%{isa: 1, tier: 1}
@spec required_isa(Predicator.Types.instruction_list()) :: {:ok, pos_integer()} | {:error, Predicator.Errors.EvaluationError.t()}
Returns the minimum ISA version required to run instructions.
Scans only the opcode - the head of each top-level element - and never
recurses into operands. That is deliberate, not incomplete: a list literal
compiles to a nested list that looks like an instruction, e.g.
['load', 'x'] compiles to [["lit", ["load", "x"]]]
(lib/predicator/visitors/instructions_visitor.ex:223). Recursing into
operands would read "load" there and report a version the program does
not actually require. Every real operand the compiler emits is a value or
an integer, never a nested instruction (docs/isa.md sections 2 and 5), so
a flat scan is sufficient as well as safe.
Returns {:ok, 1} for an empty list - there is no v0, and the floor keeps
required_isa(list) <= isa_version() correct without a :none case.
Returns {:error, %EvaluationError{reason: "unknown_opcode"}} when an
element's head is a binary not in the opcode table, and
{:error, %EvaluationError{reason: "malformed_instruction"}} when an
element is not a non-empty list headed by a binary. Both carry
operation: :required_isa, distinct from the evaluator's
"unknown_instruction", which covers malformed operands too - this
function does not look at operands.
Examples
iex> {:ok, instructions} = Predicator.compile("a > 1")
iex> Predicator.Instructions.required_isa(instructions)
{:ok, 1}
iex> {:ok, instructions} = Predicator.compile("a and b")
iex> Predicator.Instructions.required_isa(instructions)
{:ok, 2}
iex> Predicator.Instructions.required_isa([])
{:ok, 1}
iex> Predicator.Instructions.required_isa([["nope"]])
{:error, %Predicator.Errors.EvaluationError{reason: "unknown_opcode", message: "Unknown opcode \"nope\"; this build supports ISA v3", operation: :required_isa}}
@spec retired_in(String.t()) :: {:ok, pos_integer() | nil} | {:error, Predicator.Errors.EvaluationError.t()}
Returns the ISA version that retired a single opcode, or nil if it has
never been retired.
Mirrors tier/1's shape, including its unknown_opcode error for an
opcode not in the table. Together with required_isa/1, this is what lets
a refusal name both halves of ADR-0003's promised message: the version an
instruction list needs, and, when that opcode has since been retired, the
version that removed it.
Examples
iex> Predicator.Instructions.retired_in("lit")
{:ok, nil}
iex> Predicator.Instructions.retired_in("nope")
{:error, %Predicator.Errors.EvaluationError{reason: "unknown_opcode", message: "Unknown opcode: \"nope\"", operation: :retired_in}}
@spec tier(String.t()) :: {:ok, pos_integer()} | {:error, Predicator.Errors.EvaluationError.t()}
Returns the conformance tier for a single opcode.
Tier is a conformance-corpus grouping (px-35i.4), a function of opcode
only - it never depends on the value types an expression happens to use
(docs/isa.md section 4). Returns
{:error, %EvaluationError{reason: "unknown_opcode"}} for an opcode not in
the table, the same reason required_isa/1 uses.
Examples
iex> Predicator.Instructions.tier("lit")
{:ok, 1}
iex> Predicator.Instructions.tier("make_list")
{:ok, 3}
iex> Predicator.Instructions.tier("nope")
{:error, %Predicator.Errors.EvaluationError{reason: "unknown_opcode", message: "Unknown opcode: \"nope\"", operation: :tier}}
@spec upgrade(Predicator.Types.instruction_list()) :: {:ok, Predicator.Types.instruction_list()} | {:error, Predicator.Errors.EvaluationError.t()}
Rewrites a pre-3.7 instruction list containing the retired "and"/"or"
opcodes into current jump form.
Identity guarantee: a list containing neither retired opcode is
returned unchanged. A consumer can therefore call upgrade/1
unconditionally over every stored artifact instead of pre-filtering for
the ones that need it.
Returns {:error, %EvaluationError{reason: "unsupported_upgrade"}},
operation: :upgrade, rather than a wrong answer, when the list is not
something upgrade/1 can safely rewrite: it mixes a v2 opcode with a
retired opcode (a genuine pre-3.7 artifact cannot be in that state), it
underflows its own stack, it contains an opcode upgrade/1 does not
recognize (including a "call" whose count operand is not a
non-negative integer), or it contains a malformed element.
Semantic divergences
The rewritten list is not answer-preserving against the legacy opcodes -
by design, per ADR-0001's Consequences, which already calls the
short-circuit change on 3.7.0's compiler-emitted jumps "a bugfix" that
breaks a consumer relying on the old behavior. upgrade/1 moves a stored
artifact onto the same semantics every source-compiled expression has had
since 3.7.0. Against the legacy opcodes, the upgraded list differs when
and only when:
- Short-circuiting. The right operand is no longer evaluated once the left operand decides the result. Observable when the right operand would have errored or loaded an unbound variable: legacy raised: the upgraded form returns the left value without touching the right.
:undefinedoperands. Legacy raises aTypeMismatchError. Upgraded:undefined and xis:undefined;undefined or xisx's value (ECMAScript-aligned, ADR-0001).- A non-boolean right operand the left operand did not decide.
true and 1was aTypeMismatchErrorand is now1. A non-boolean left operand still errors, since the guard sits on the operand the expression actually depends on - but the error moves from the retired opcode to the jump, so a consumer matching on it seesoperation: :jump_if_falsy_or_pop/:jump_if_true_or_popwhere it saw:logical_and/:logical_or, and a correspondingly reworded message. The struct is aTypeMismatchErroreither way.
The upgraded list requires ISA v2
Jumps are ISA v2 opcodes (docs/isa.md section 4), so upgrading raises a
list's required_isa/1 answer from 1 to 2. This matters only where a
stored artifact is shared with another implementation: both the Ruby and
JavaScript siblings claim ISA v1 today, and a v1 implementation that ran
the legacy list will refuse the upgraded one. Upgrade in step with the
consumers of the artifact, not ahead of them.
Examples
iex> Predicator.Instructions.upgrade([["lit", 1], ["lit", 2], ["add"]])
{:ok, [["lit", 1], ["lit", 2], ["add"]]}
iex> Predicator.Instructions.upgrade([["lit", true], ["lit", false], ["and"]])
{:ok, [["lit", true], ["jump_if_falsy_or_pop", 2], ["lit", false]]}
iex> instructions = [
...> ["lit", true], ["lit", false], ["and"],
...> ["lit", true], ["or"]
...> ]
iex> Predicator.Instructions.upgrade(instructions)
{:ok,
[
["lit", true],
["jump_if_falsy_or_pop", 2],
["lit", false],
["jump_if_true_or_pop", 2],
["lit", true]
]}
iex> Predicator.Instructions.upgrade([["and"]])
{:error, %Predicator.Errors.EvaluationError{reason: "unsupported_upgrade", message: "Cannot upgrade: stack underflow at index 0 ([\"and\"]) - needs 2 value(s), only 0 available", operation: :upgrade}}