Predicator.Evaluator (predicator v5.0.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).

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

t()

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

The default number of back edges a single execution may take.

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.

Reads and validates the :loop_budget evaluation option.

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

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(), Predicator.Context.function_entry()}
  },
  halted: boolean(),
  host: term(),
  instruction_pointer: non_neg_integer(),
  instructions: Predicator.Types.instruction_list() | tuple(),
  last_value: Predicator.Types.value(),
  loop_budget: non_neg_integer(),
  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

unbound_load()

@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

default_loop_budget()

@spec default_loop_budget() :: non_neg_integer()

The default number of back edges a single execution may take.

ADR-0013: a program with no jump_backward never touches this counter, so the v5 termination-by-construction property is unaffected. The value is implementation-local, exactly as :on_unbound is - ISA v6 makes only the existence of a bound and the "loop_budget_exceeded" reason normative.

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, :providers, :builtins - resolved into the dispatch map the same way Predicator.Context.new/2 resolves them (see Predicator.Context.resolve_functions/1): the builtin providers (unless builtins: false), then :providers left to right, then the inline :functions closure map %{name => {arity, function}}, each shadowing a same-named entry from an earlier source
    • :host - opaque term threaded to every function call's %Context{} as context.host (default nil)
    • :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 and carried in a Predicator.Compiled.t/0's positions field. Runtime errors raised by an instruction with a table entry carry it as :position. A span table from compiled.positions, where compiled is what Predicator.compile_with_spans/1 returned, works here too: such an error carries the span as :span and the span's start as :position.
    • :segment_positions - the per-store segment-position side table from compiled.segment_positions, as produced by Predicator.Compiler.to_instructions_with_segment_positions/2. Read only by store, to blame the exact failing location segment rather than the store instruction's own positions entry (the lhs root). A run carrying no table - or none for the failing store - positions a store failure exactly as :positions alone would; passing this option changes no other opcode's behavior.
    • :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.
    • :loop_budget - the number of back edges (jump_backward, ISA v6) a single execution may take, default 10000, shared across every loop in the program. Exhaustion returns {:error, %Predicator.Errors.EvaluationError{reason: "loop_budget_exceeded"}}. Must be a non-negative integer; anything else raises ArgumentError.

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.

last_value(evaluator)

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

loop_budget_from_opts(opts)

@spec loop_budget_from_opts(keyword()) :: non_neg_integer()

Reads and validates the :loop_budget evaluation option.

Raises ArgumentError for anything that is not a non-negative integer - host-API misuse, not a predicate-derived failure (ADR-0004), the same line Predicator.Context.new/2 draws for a malformed :on_unbound.

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

run_state(evaluator)

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

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.

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.

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

unbound_loads_with_locations(evaluator)

@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}}]