Predicator.Evaluator (predicator v3.8.0)

Copy Markdown View Source

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 is false or :undefined, jump forward offset instructions leaving it on the stack; if true, pop and continue; any other type is a TypeMismatchError
  • ["jump_if_true_or_pop", offset] - If top of stack is exactly true, jump forward offset instructions leaving it on the stack; if false or :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

t()

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

function_arity()

@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

t()

@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

evaluate(instructions, context \\ %{}, opts \\ [])

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 execute
  • context - 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 by Predicator.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] whose name is not present in context returns {:error, %Predicator.Errors.UndefinedVariableError{}} instead of pushing :undefined. Unlike Predicator.Context.new/2, this option is not validated here: any value other than :error behaves 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

evaluate!(instructions, context \\ %{}, opts \\ [])

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

evaluate_prepared(evaluator)

@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.

merge_functions(opts \\ [])

@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.

resolve_key(data, 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

run(evaluator)

@spec run(t()) :: {:ok, t()} | {:error, struct()}

Runs the evaluator until it halts or encounters an error.

Returns {:ok, final_state} on success or {:error, reason} on failure.

run_prepared(evaluator)

@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).

step(evaluator)

@spec step(t()) :: {:ok, t()} | {:error, term()}

Executes a single instruction step.

Returns the updated evaluator state or an error.

types_match(a, b)

(macro)

unbound_loads(evaluator)

@spec unbound_loads(t()) :: [binary()]

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"]