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).
Supported instruction types:
["lit", value]- Push literal value onto stack["load", variable_name]- Load variable from context onto stack["compare", operator]- Compare top two stack values with operator["and"]- Logical AND of top two boolean values["or"]- Logical OR of top two boolean values["jump_if_falsy_or_pop", offset]- If top of stack isfalseor:undefined, jump forwardoffsetinstructions leaving it on the stack; iftrue, pop and continue; any other type is a TypeMismatchError["jump_if_true_or_pop", offset]- If top of stack is exactlytrue, jump forwardoffsetinstructions leaving it on the stack; iffalseor:undefined, pop and continue; any other type is a TypeMismatchError["not"]- Logical NOT of top boolean value["in"]- Membership test (element in collection)["contains"]- Membership test (collection contains element)["add"]- Add top two integer values["subtract"]- Subtract top two integer values["multiply"]- Multiply top two integer values["divide"]- Divide top two integer values (integer division)["modulo"]- Modulo operation on top two integer values["unary_minus"]- Negate top integer value["unary_bang"]- Logical NOT of top boolean value["bracket_access"]- Pop key and object, push object[key] result["call", function_name, arg_count]- Call function with arguments from stack["duration", units]- Create duration value from unit list["relative_date", direction]- Calculate relative date from duration and direction
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
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.
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.
Executes a single instruction step.
The root variables this run loaded and did not find bound, in execution order, without repeats.
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(), on_unbound: Predicator.Context.on_unbound(), positions: Predicator.Types.position_table(), size: non_neg_integer() | nil, stack: [Predicator.Types.value()], unbound_loads: [binary()] }
Internal evaluator state
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/2. Runtime errors raised by an instruction with a table entry carry it as:position.: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 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).
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.
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"]