Stack-based evaluator for predicator instructions.
The evaluator executes a list of instructions using a stack machine approach. Instructions operate on a stack, with the most recent values at the top (head of list).
The instruction set - every opcode this module accepts, its operands, stack
effect, and error semantics - is specified in
docs/isa.md, not here. and and or were retired at
ISA v3: a stored pre-3.7 artifact containing either is refused with a
message naming the removing version and pointing at
Predicator.Instructions.upgrade/1, though the ISA table (docs/isa.md §4)
keeps their rows.
Summary
Types
A function's accepted argument count(s): a fixed arity, or a set of arities for optional/variadic-style args
Internal evaluator state
An executed load of a root that was not bound, paired with the source location
of the ["load", _] instruction that read it.
Functions
Evaluates a list of instructions with the given context and options.
Evaluates a list of instructions with the given context and options, raising on errors.
Runs an already-built evaluator to completion and extracts its result.
The value the most recently executed ["pop"] instruction discarded, or
:undefined if the run executed none.
Merges the builtin function maps with opts[:functions].
Resolves name to the key it is bound under in data, or :unbound.
Runs the evaluator until it halts or encounters an error.
Runs an already-built evaluator to completion, returning its result and its final state - on the error path too.
Runs an already-built evaluator to halt, returning the final state on both paths.
Executes a single instruction step.
The root variables this run loaded and did not find bound, in execution order, without repeats.
The same loads unbound_loads/1 reports, each paired with the source location
of the ["load", _] instruction that read it.
Types
@type function_arity() :: non_neg_integer() | [non_neg_integer()]
A function's accepted argument count(s): a fixed arity, or a set of arities for optional/variadic-style args
@type t() :: %Predicator.Evaluator{ context: Predicator.Types.context(), functions: %{required(binary()) => {function_arity(), function()}}, halted: boolean(), instruction_pointer: non_neg_integer(), instructions: Predicator.Types.instruction_list() | tuple(), last_value: Predicator.Types.value(), on_unbound: Predicator.Context.on_unbound(), positions: Predicator.Types.position_table() | Predicator.Types.span_table(), segment_positions: Predicator.Types.segment_position_table(), size: non_neg_integer() | nil, stack: [Predicator.Types.value()], unbound_loads: [unbound_load()] }
Internal evaluator state
@type unbound_load() :: {binary(), Predicator.Types.position() | Predicator.Types.span() | nil}
An executed load of a root that was not bound, paired with the source location
of the ["load", _] instruction that read it.
The location is the raw positions table entry: nil when the run carried no
table or the index was uncovered, a Predicator.Types.position/0 under point
positions, a Predicator.Types.span/0 under spans. Hand it to
Predicator.Errors.put_position/2, which discriminates the three.
Functions
@spec evaluate( Predicator.Types.instruction_list(), Predicator.Types.context(), keyword() ) :: Predicator.Types.internal_result()
Evaluates a list of instructions with the given context and options.
Returns the top value on the stack when evaluation completes, or an error if something goes wrong.
Parameters
instructions- List of instructions to executecontext- Context map with variable bindings (default:%{})opts- Options keyword list::functions- Map of custom functions%{name => {arity, function}}:positions- Side table mapping a 0-based instruction index to the{line, column}of the AST node that emitted it, as produced byPredicator.Compiler.to_instructions_with_positions/2and carried in aPredicator.Compiled.t/0'spositionsfield. Runtime errors raised by an instruction with a table entry carry it as:position. A span table fromcompiled.positions, wherecompiledis whatPredicator.compile_with_spans/1returned, works here too: such an error carries the span as:spanand the span's start as:position.:segment_positions- the per-store segment-position side table fromcompiled.segment_positions, as produced byPredicator.Compiler.to_instructions_with_segment_positions/2. Read only bystore, to blame the exact failing location segment rather than the store instruction's ownpositionsentry (the lhs root). A run carrying no table - or none for the failing store - positions a store failure exactly as:positionsalone would; passing this option changes no other opcode's behavior.:on_unbound-:undefined(default) or:error. Under:error, a["load", name]whosenameis not present incontextreturns{:error, %Predicator.Errors.UndefinedVariableError{}}instead of pushing:undefined. UnlikePredicator.Context.new/2, this option is not validated here: any value other than:errorbehaves as:undefined.
Examples
iex> Predicator.Evaluator.evaluate([["lit", 42]], %{})
42
iex> Predicator.Evaluator.evaluate([["load", "score"]], %{"score" => 85})
85
# With custom functions
iex> custom_functions = %{"double" => {1, fn [n], _context -> {:ok, n * 2} end}}
iex> instructions = [["lit", 21], ["call", "double", 1]]
iex> Predicator.Evaluator.evaluate(instructions, %{}, functions: custom_functions)
42
@spec evaluate!( Predicator.Types.instruction_list(), Predicator.Types.context(), keyword() ) :: Predicator.Types.value()
Evaluates a list of instructions with the given context and options, raising on errors.
Similar to evaluate/3 but raises an exception for error results instead
of returning error tuples. Follows the Elixir convention of bang functions.
Examples
iex> Predicator.Evaluator.evaluate!([["lit", 42]], %{})
42
iex> Predicator.Evaluator.evaluate!([["load", "score"]], %{"score" => 85})
85
# With custom functions
iex> custom_functions = %{"double" => {1, fn [n], _context -> {:ok, n * 2} end}}
iex> instructions = [["lit", 21], ["call", "double", 1]]
iex> Predicator.Evaluator.evaluate!(instructions, %{}, functions: custom_functions)
42
@spec evaluate_prepared(t()) :: Predicator.Types.internal_result()
Runs an already-built evaluator to completion and extracts its result.
Unlike evaluate/3, this does no function merging - evaluator.functions
is used as given. This is what evaluate/3 uses internally, and what
Predicator.Context-based evaluation uses to skip the per-call merge.
@spec last_value(t()) :: Predicator.Types.value()
The value the most recently executed ["pop"] instruction discarded, or
:undefined if the run executed none.
For a program the compiler produced this is the last expression statement's
value: pop is emitted after every expression statement and after nothing
else, so the last one to run carries the last statement's value. It is defined
over the instruction list rather than over the AST, so it answers for a
hand-built list too.
Two things it deliberately is not:
- Not the stack top at halt. A list that leaves a residue on the stack
without popping it reports
:undefined;docs/isa.mdsection 2 discards the residue. - Not written by
jump_if_falsy_or_pop/jump_if_true_or_pop. Those pop as part of a jump, mid-expression, and their operand is not a statement's value.
Predicator.execute_value/2 is the façade over this.
Examples
iex> instructions = [["lit", 1], ["pop"], ["lit", 2], ["pop"]]
iex> {:ok, final} = Predicator.Evaluator.run_state(%Predicator.Evaluator{instructions: instructions})
iex> Predicator.Evaluator.last_value(final)
2
iex> {:ok, final} = Predicator.Evaluator.run_state(%Predicator.Evaluator{instructions: [["lit", 1]]})
iex> Predicator.Evaluator.last_value(final)
:undefined
@spec merge_functions(keyword()) :: %{ required(binary()) => {function_arity(), function()} }
Merges the builtin function maps with opts[:functions].
Builtins are SystemFunctions, DateFunctions, JSONFunctions, and
MathFunctions, in that order; opts[:functions] is merged last, so
custom functions can shadow a builtin of the same name.
@spec resolve_key(Predicator.Types.context(), binary()) :: {:ok, binary() | atom()} | :unbound
Resolves name to the key it is bound under in data, or :unbound.
A root variable may be stored under a string key or the equivalent atom key;
this answers which. Predicator.Context.bound?/2 and the evaluator's own
unbound-load recording both go through here, so they cannot drift apart from
each other.
Note this asks about presence, not value: a name bound to :undefined (or
to nil) resolves to {:ok, key}, because it is bound.
Atom keys resolve here but no longer load
This is deliberately wider than what a load instruction reads. Since
px-8um.2, load does a plain string-key lookup: Predicator.Context.new/2
and bind/3 normalize atom keys away at the edge, so no Context-routed
data can still carry one. The atom branch below survives only for a caller
who bypasses that edge - Predicator.Evaluator.evaluate/3 or
Predicator.evaluator/2 handed a hand-built atom-keyed map.
For that caller the two answers are asymmetric: %{score: 85} loads as
:undefined (no string key) while resolving as {:ok, :score} (present),
so the load is not recorded in unbound_loads. Bound-but-undefined is a
state this API already models, and treating a present atom key as unbound
bookkeeping would be the worse answer. Nothing reachable through
Predicator.evaluate/3 can observe the asymmetry.
Examples
iex> Predicator.Evaluator.resolve_key(%{"score" => 85}, "score")
{:ok, "score"}
iex> Predicator.Evaluator.resolve_key(%{score: 85}, "score")
{:ok, :score}
iex> Predicator.Evaluator.resolve_key(%{"score" => 85}, "missing")
:unbound
Runs the evaluator until it halts or encounters an error.
Returns {:ok, final_state} on success or {:error, reason} on failure.
@spec run_prepared(t()) :: {:ok, Predicator.Types.value(), t()} | {:error, struct(), t()}
Runs an already-built evaluator to completion, returning its result and its final state - on the error path too.
Same as evaluate_prepared/1 except that the caller keeps the state the run
produced - unbound_loads/1 in particular, which Predicator.evaluate/3
reads to name the variable behind an :undefined result and behind a
TypeMismatchError that rejected an :undefined operand (px-8um.7).
Runs an already-built evaluator to halt, returning the final state on both paths.
This is statement mode's entry point (docs/isa.md section 2): unlike
run_prepared/1 it applies no expression-mode result rule, so an empty
stack at halt is a normal halt rather than empty_stack. On error the state
returned is the pre-step one, holding every write the statements before the
failing instruction performed - a ["load", _] instruction never errors,
so the pre-step state holds every unbound load recorded before the failing
instruction too, which is all of them.
Executes a single instruction step.
Returns the updated evaluator state or an error.
The root variables this run loaded and did not find bound, in execution order, without repeats.
This is what the run actually read, not what the program contains: a load
inside a branch jump_if_falsy_or_pop/jump_if_true_or_pop skipped never
executes and never appears here. Predicator.evaluate/3 uses it to name the
variable behind an :undefined result.
It records executed loads, not provenance - a run that executes two unbound
loads lists both, even if only the second one's :undefined reached the
result. Deciding that would require tracking which stack value descends from
which load.
unbound_loads_with_locations/1 returns the same loads with the source
location of each.
Examples
iex> instructions = [["load", "a"], ["load", "b"]]
iex> {:ok, _result, evaluator} =
...> Predicator.Evaluator.run_prepared(%Predicator.Evaluator{
...> instructions: instructions,
...> context: %{"a" => 1}
...> })
iex> Predicator.Evaluator.unbound_loads(evaluator)
["b"]
@spec unbound_loads_with_locations(t()) :: [unbound_load()]
The same loads unbound_loads/1 reports, each paired with the source location
of the ["load", _] instruction that read it.
The location comes from the run's positions table, read at the load itself, so
it names the variable's token - not the operator that later rejected its
:undefined. It is nil when the run carried no table (an instruction-list
caller who passed no positions:), a {line, column} under point positions, and
a span under a table from compiled.positions, where compiled is the
Predicator.Compiled.t/0 Predicator.compile_with_spans/1 returned.
Predicator.evaluate/3 uses this to position the UndefinedVariableError it
builds after the run.
A name loaded more than once appears once, with the location of its first executed load.
Examples
iex> instructions = [["load", "a"], ["load", "b"]]
iex> {:ok, _result, evaluator} =
...> Predicator.Evaluator.run_prepared(%Predicator.Evaluator{
...> instructions: instructions,
...> context: %{},
...> positions: %{1 => {1, 5}}
...> })
iex> Predicator.Evaluator.unbound_loads_with_locations(evaluator)
[{"a", nil}, {"b", {1, 5}}]