Predicator (predicator v4.0.0)

Copy Markdown View Source

A secure, non-evaluative condition engine for processing end-user boolean predicates.

Predicator transforms string conditions into executable instructions that can be safely evaluated without direct code execution. It uses a stack-based virtual machine to process instructions and supports flexible context-based condition checking.

Basic Usage

The simplest way to use Predicator is with the evaluate/2 function:

iex> instructions = [["lit", 42]]
iex> Predicator.evaluate(instructions)
{:ok, 42}

iex> instructions = [["load", "score"]]
iex> context = %{"score" => 85}
iex> Predicator.evaluate(instructions, context)
{:ok, 85}

Instruction Format

Instructions are lists where:

  • First element is the operation name (string)
  • Remaining elements are operation arguments

The full opcode set is specified in docs/isa.md.

Context

The context is a map containing variable bindings. Both string and atom keys are supported for flexibility:

%{"score" => 85, "name" => "Alice"}
%{score: 85, name: "Alice"}

A bare map is merged with the builtin functions on every evaluate/3 call - fine for one-off evaluation, wasteful when the same bindings are evaluated against repeatedly. Predicator.Context.new/2 builds that merge once into a %Predicator.Context{}, which evaluate/3 also accepts directly:

iex> context = Predicator.Context.new(%{"score" => 85})
iex> Predicator.evaluate("score > 80", context)
{:ok, true}
iex> context = Predicator.Context.bind(context, "score", 90)
iex> Predicator.evaluate("score > 80", context)
{:ok, true}

A context also carries the unbound-variable policy. By default a load of an unbound root pushes the :undefined sentinel, which three-valued logic can absorb into a defined result; under on_unbound: :error the load fails instead, naming the variable:

iex> context = Predicator.Context.new(%{}, on_unbound: :error)
iex> {:error, error} = Predicator.evaluate("missing OR true", context)
iex> error.variable
"missing"

The same option works on a bare map, which evaluate/3 routes through Predicator.Context.new/2:

iex> Predicator.evaluate("missing OR true", %{})
{:ok, true}
iex> {:error, error} = Predicator.evaluate("missing OR true", %{}, on_unbound: :error)
iex> error.variable
"missing"

Architecture

Predicator uses a stack-based evaluation model:

  1. Instructions are processed sequentially
  2. Each instruction manipulates a stack
  3. The final result is the top value on the stack when execution completes

Summary

Functions

Compiles a string expression to instruction list.

Compiles a string expression to instruction list, raising on errors.

Compiles a statement-sequence string to an instruction list.

Compiles a statement-sequence string to a Predicator.Compiled.t/0 - the instruction list plus a source-position side table, as one value.

Compiles a string expression to a Predicator.Compiled.t/0 - the instruction list plus a source-position side table, as one value.

Compiles a string expression to a Predicator.Compiled.t/0 - the instruction list plus a source-span side table, as one value.

Assigns value at the location expression names within context.

Resolves a location path for assignment operations in SCXML datamodel expressions.

Converts an AST back to a string representation.

Evaluates a predicate expression or instruction list.

Evaluates a predicate expression or instruction list, raising on errors.

Creates a new evaluator state for low-level instruction processing.

Runs a statement program - a source string, an instruction list, or a Predicator.Compiled.t/0 - and returns the resulting context.

Runs a statement program - a source string, an instruction list, or a Predicator.Compiled.t/0 - and returns the resulting context plus the value the program's last expression statement produced.

Returns the ISA version this build emits and can run.

Parses an expression string into an Abstract Syntax Tree.

Parses a statement-sequence string into a Predicator.Parser.program/0.

Runs an evaluator until completion.

Functions

compile(expression)

@spec compile(binary()) ::
  {:ok, Predicator.Types.instruction_list()} | {:error, binary()}

Compiles a string expression to instruction list.

This function allows you to pre-compile expressions for maximum performance when evaluating the same expression multiple times with different contexts.

Parameters

  • expression - String expression to compile

Returns

  • {:ok, instructions} - Successfully compiled instructions
  • {:error, message} - Parse error with details

Examples

iex> {:ok, instructions} = Predicator.compile("score > 85")
iex> instructions
[["load", "score"], ["lit", 85], ["compare", "GT"]]

iex> Predicator.compile("score >")
{:error, "Expected number, string, boolean, date, datetime, identifier, function call, list, object, or '(' but found end of input at line 1, column 8"}

compile!(expression)

@spec compile!(binary()) :: Predicator.Types.instruction_list()

Compiles a string expression to instruction list, raising on errors.

Similar to compile/1 but raises an exception for parse errors.

Examples

iex> Predicator.compile!("score > 85")
[["load", "score"], ["lit", 85], ["compare", "GT"]]

compile_program(source)

@spec compile_program(binary()) ::
  {:ok, Predicator.Types.instruction_list()} | {:error, binary()}

Compiles a statement-sequence string to an instruction list.

The program-shaped echo of compile/1: parses with parse_program/2 instead of parse/2, then compiles the resulting Predicator.Parser.program/0 with Compiler.to_instructions/2, which already accepts one. Returns the same {:error, binary()} shape compile/1 does - not parse_program/2's raw 4-tuple.

Examples

iex> {:ok, instructions} = Predicator.compile_program("x = 1; x + 1")
iex> instructions
[["lit", "x"], ["lit", 1], ["store", 1], ["load", "x"], ["lit", 1], ["add"], ["pop"]]

iex> Predicator.compile_program("x =")
{:error, "Expected number, string, boolean, date, datetime, identifier, function call, list, object, or '(' but found end of input at line 1, column 4"}

compile_program_with_positions(source)

@spec compile_program_with_positions(binary()) ::
  {:ok, Predicator.Compiled.t()} | {:error, binary()}

Compiles a statement-sequence string to a Predicator.Compiled.t/0 - the instruction list plus a source-position side table, as one value.

The program-shaped echo of compile_with_positions/1. Pass the struct straight to execute/3, which threads the table itself.

Examples

iex> {:ok, compiled} = Predicator.compile_program_with_positions("x = 1")
iex> compiled.instructions
[["lit", "x"], ["lit", 1], ["store", 1]]

compile_with_positions(expression)

@spec compile_with_positions(binary()) ::
  {:ok, Predicator.Compiled.t()} | {:error, binary()}

Compiles a string expression to a Predicator.Compiled.t/0 - the instruction list plus a source-position side table, as one value.

compiled.instructions is identical to compile/1's output; compiled.positions maps each instruction's 0-based index to the {line, column} of the AST node that emitted it. Pass the struct straight to evaluate/3, which threads the table itself.

Store compiled.instructions, not the struct - see Predicator.Compiled.

Examples

iex> {:ok, compiled} = Predicator.compile_with_positions("score > 85")
iex> compiled.instructions
[["load", "score"], ["lit", 85], ["compare", "GT"]]
iex> compiled.positions
%{0 => {1, 1}, 1 => {1, 9}, 2 => {1, 7}}

compile_with_spans(expression)

@spec compile_with_spans(binary()) ::
  {:ok, Predicator.Compiled.t()} | {:error, binary()}

Compiles a string expression to a Predicator.Compiled.t/0 - the instruction list plus a source-span side table, as one value.

compiled.instructions is identical to compile/1's output; compiled.positions maps each instruction's 0-based index to the Predicator.Types.span/0 of the AST node that emitted it. Pass the struct straight to evaluate/3, which threads the table itself.

Store compiled.instructions, not the struct - see Predicator.Compiled.

Examples

iex> {:ok, compiled} = Predicator.compile_with_spans("score > 85")
iex> compiled.instructions
[["load", "score"], ["lit", 85], ["compare", "GT"]]
iex> compiled.positions
%{0 => {{1, 1}, {1, 6}}, 1 => {{1, 9}, {1, 11}}, 2 => {{1, 1}, {1, 11}}}

context_assign(context, expression, value, opts \\ [])

@spec context_assign(Predicator.Types.context(), binary(), term(), keyword()) ::
  {:ok, Predicator.Types.context()} | {:error, struct()}

Assigns value at the location expression names within context.

Resolves the location expression the same way context_location/3 does, then writes through Predicator.ContextLocation.put/3, creating any missing intermediate maps and lists. See Predicator.ContextLocation.put/3 for the full auto-vivification and collision rules.

The expression is resolved against the pre-assignment context, which matches SCXML: a variable bracket key such as items[index] reads index as it stands before the write.

Argument order

context comes first because this function transforms a context and returns a new one, which makes it pipeline-friendly, whereas context_location/3 merely inspects one.

Parameters

  • context - The context map to write into
  • expression - The location expression naming where to write
  • value - The value to write
  • opts - Reserved for future options

Returns

  • {:ok, context} - The updated context
  • {:error, %Predicator.Errors.LocationError{}} - The expression is not an assignable location, or the write collided with existing data
  • {:error, %Predicator.Errors.ParseError{}} - The expression did not parse

Examples

iex> Predicator.context_assign(%{}, "user.profile.name", "Ada")
{:ok, %{"user" => %{"profile" => %{"name" => "Ada"}}}}

iex> Predicator.context_assign(%{"items" => [1, 2, 3]}, "items[1]", "x")
{:ok, %{"items" => [1, "x", 3]}}

iex> {:error, error} = Predicator.context_assign(%{}, "len(items)", 1)
iex> error.type
:not_assignable

context_location(expression, context \\ %{}, opts \\ [])

@spec context_location(binary(), Predicator.Types.context(), keyword()) ::
  {:ok, Predicator.ContextLocation.location_path()} | {:error, struct()}

Resolves a location path for assignment operations in SCXML datamodel expressions.

Takes an expression string and returns a location path that can be used for assignment operations. Validates that the expression represents an assignable location (l-value) rather than a computed value.

This function is specifically designed for SCXML <assign location="..."> operations where the location attribute must specify where to assign a value in the datamodel.

Location Path Format

Location paths are returned as lists of keys/indices that represent the path to a specific location in the context data structure:

  • ["user"] - top-level variable user
  • ["user", "name"] - property access user.name
  • ["items", 0] - array access items[0]
  • ["user", "profile", "settings", "theme"] - nested user.profile.settings.theme

Assignable vs Non-Assignable

Assignable (valid locations):

  • Simple identifiers: user
  • Property access: user.name, obj.prop
  • Bracket access: items[0], obj["key"]
  • Mixed notation: user.items[0].name

Non-Assignable (invalid locations):

  • Literals: 42, "string", true
  • Function calls: len(items), upper(name)
  • Arithmetic expressions: user.age + 1
  • Any computed values

Parameters

  • expression - The location expression string to resolve
  • context - The evaluation context (used for resolving variable keys)
  • opts - Options (currently unused, reserved for future extensions)

Examples

# Simple identifier
iex> Predicator.context_location("user", %{"user" => %{"name" => "John"}})
{:ok, ["user"]}

# Property access
iex> Predicator.context_location("user.name", %{"user" => %{"name" => "John"}})
{:ok, ["user", "name"]}

# Bracket access with literal key
iex> Predicator.context_location("items[0]", %{"items" => [1, 2, 3]})
{:ok, ["items", 0]}

# Bracket access with string key
iex> Predicator.context_location("user['profile']", %{"user" => %{"profile" => %{}}})
{:ok, ["user", "profile"]}

# Mixed notation
iex> Predicator.context_location("data.users[0]['name']", %{"data" => %{"users" => [%{"name" => "Alice"}]}})
{:ok, ["data", "users", 0, "name"]}

# Variable as bracket key
iex> Predicator.context_location("items[index]", %{"items" => [1, 2, 3], "index" => 1})
{:ok, ["items", 1]}

# Error: cannot assign to literal
iex> {:error, %Predicator.Errors.LocationError{type: :not_assignable}} =
...>   Predicator.context_location("42", %{})

# Error: cannot assign to function call
iex> {:error, %Predicator.Errors.LocationError{type: :not_assignable}} =
...>   Predicator.context_location("len(items)", %{"items" => [1, 2, 3]})

# Error: cannot assign to computed expression
iex> {:error, %Predicator.Errors.LocationError{type: :not_assignable}} =
...>   Predicator.context_location("user.age + 1", %{"user" => %{"age" => 30}})

SCXML Usage Example

# In an SCXML state machine
location_expr = "user.profile.settings['theme']"

case Predicator.context_location(location_expr, datamodel_context) do
  {:ok, path} ->
    # Use path for assignment: ["user", "profile", "settings", "theme"]
    update_datamodel_at_path(datamodel, path, new_value)

  {:error, %Predicator.Errors.LocationError{} = error} ->
    # Handle invalid assignment target
    {:error, "Invalid assignment location: #{error.message}"}
end

decompile(ast, opts \\ [])

Converts an AST back to a string representation.

This function takes an Abstract Syntax Tree and generates a readable string representation. This is useful for debugging, displaying expressions to users, and documentation purposes.

Parameters

  • ast - The Abstract Syntax Tree to convert - a bare expression, or a Predicator.Parser.program/0, which renders as its statements joined by "; "
  • opts - Optional formatting options:
    • :parentheses - :minimal (default) | :explicit | :none

    • :spacing - :normal (default) | :compact | :verbose

Returns

String representation of the AST

Examples

iex> ast = {:comparison, :gt, {:identifier, "score", nil}, {:literal, 85, nil}, nil}
iex> Predicator.decompile(ast)
"score > 85"

iex> ast = {:literal, 42, nil}
iex> Predicator.decompile(ast)
"42"

iex> ast = {:comparison, :eq, {:identifier, "active", nil}, {:literal, true, nil}, nil}
iex> Predicator.decompile(ast, parentheses: :explicit, spacing: :verbose)
"(active  ==  true)"

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

Evaluates a predicate expression or instruction list.

This is the main entry point for Predicator evaluation. It accepts either:

  • A string expression (e.g., "score > 85") which gets compiled automatically
  • A pre-compiled instruction list for maximum performance

Parameters

  • input - String expression, instruction list, or a Predicator.Compiled.t/0 from compile_with_positions/1 or compile_with_spans/1, whose table is threaded automatically
  • context - Optional context map with variable bindings (default: %{})
  • opts - Optional keyword list of options:
    • :functions - Map of custom functions to make available during evaluation
    • :positions - the table from compiled.positions, for a caller passing a bare instruction list, used to populate :position (and :span) on runtime errors. String input threads its own table automatically; an instruction-list caller who omits this sees position: nil. Passing it alongside a %Compiled{} raises ArgumentError. evaluate/3 compiles no store, so the companion :segment_positions table (see execute/3) is always empty here and has no observable effect.
    • :spans - when true, string input compiles with spans instead of point positions, so runtime errors carry :span and :position names the span's start. Ignored for instruction-list input, which has no source; such a caller passes compile_with_spans/1's struct, or positions: from compiled.positions.

Returns

  • {:ok, result} on successful evaluation
  • {:error, error_struct} if parsing or execution fails

Error Types

Examples

# Simple expressions
iex> Predicator.evaluate("true")
{:ok, true}

iex> Predicator.evaluate("2 + 3")
{:ok, 5}

# With context
iex> Predicator.evaluate("score > 85", %{"score" => 90})
{:ok, true}

# With custom functions
iex> custom_functions = %{"double" => {1, fn [n], _context -> {:ok, n * 2} end}}
iex> Predicator.evaluate("double(21)", %{}, functions: custom_functions)
{:ok, 42}

# Pre-compiled instruction lists
iex> Predicator.evaluate([["lit", 42]])
{:ok, 42}

# Type coercion with + operator (string concatenation)
iex> Predicator.evaluate("score + 'hello'", %{"score" => 5})
{:ok, "5hello"}

# Error handling for incompatible types
iex> {:error, error} = Predicator.evaluate("score * true", %{"score" => 5})
iex> String.contains?(error.message, "multiply requires")
true

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

Evaluates a predicate expression or instruction list, 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.evaluate!("score > 85", %{"score" => 90})
true

iex> Predicator.evaluate!([["lit", 42]])
42

# With custom functions
iex> custom_functions = %{"double" => {1, fn [n], _context -> {:ok, n * 2} end}}
iex> Predicator.evaluate!("double(21)", %{}, functions: custom_functions)
42

# A pre-compiled %Predicator.Compiled{}
iex> {:ok, compiled} = Predicator.compile_with_positions("score > 85")
iex> Predicator.evaluate!(compiled, %{"score" => 90})
true

# This would raise an exception:
# Predicator.evaluate!("score >", %{})

evaluator(instructions, context \\ %{})

Creates a new evaluator state for low-level instruction processing.

This function is useful when you need fine-grained control over the evaluation process or want to inspect the evaluator state.

Parameters

  • instructions - List of instructions to prepare for execution
  • context - Optional context map with variable bindings (default: %{})

Returns

An %Predicator.Evaluator{} struct ready for execution.

Examples

iex> evaluator = Predicator.evaluator([["lit", 42]])
iex> evaluator.instructions
[["lit", 42]]

iex> evaluator = Predicator.evaluator([["load", "x"]], %{"x" => 10})
iex> evaluator.context
%{"x" => 10}

execute(program_or_source, context \\ %{}, opts \\ [])

Runs a statement program - a source string, an instruction list, or a Predicator.Compiled.t/0 - and returns the resulting context.

This is statement mode's entry point (docs/isa.md section 2), the sibling of evaluate/3 for the {:program, ...} / {:assignment, ...} grammar parse_program/2 produces. Where evaluate/3 returns the value an expression reduces to, execute/3 returns the context a program's writes produced - a program halts with an empty stack by design, so there is no expression result to return.

Parameters

Same shape as evaluate/3:

Returns

  • {:ok, context} - every statement ran; context.data holds every write

  • {:error, error_struct, context} - execution stopped at the failing statement. context is the context as of the last successfully completed statement - every earlier write survives, the failing statement's write does not happen, and no later statement runs. Handing back this partial context costs nothing (contexts are immutable) and commit-or-discard is left to the caller: a caller wanting all-or-nothing drops the third element and keeps the context it already had.

    case Predicator.execute(program, ctx) do
      {:ok, ctx} -> ctx
      {:error, _error, _partial} -> ctx    # all-or-nothing is a caller policy
    end

A caller who also wants the program's last expression statement's value calls execute_value/3 instead.

Examples

iex> {:ok, ctx} = Predicator.execute("x = 1; y = x + 2", %{})
iex> ctx.data
%{"x" => 1, "y" => 3}

iex> {:error, %Predicator.Errors.EvaluationError{reason: "not_a_container"}, ctx} =
...>   Predicator.execute("a = 1; a.b = 2; c = 3", %{})
iex> ctx.data
%{"a" => 1}

execute_value(program_or_source, context \\ %{}, opts \\ [])

Runs a statement program - a source string, an instruction list, or a Predicator.Compiled.t/0 - and returns the resulting context plus the value the program's last expression statement produced.

This is execute/3 plus the last expression statement's value; call execute/3 instead when the value is not wanted.

The value is the value of the program's last expression statement: :undefined when the program has none (a program of assignments only), and :undefined for a hand-built instruction list that pops nothing. See Predicator.Evaluator.last_value/1 for the exact definition, which this function projects.

Parameters

Same as execute/3: program_or_source, context (default %{}), and opts (default []), including :positions or :segment_positions alongside a %Compiled{} raising ArgumentError.

Returns

  • {:ok, value, context} - every statement ran; value is the last expression statement's value (or :undefined), context.data holds every write
  • {:error, error_struct, context} - identical to execute/3's error arm: no value is reported for a run that stopped early. A caller who wants the partial run's last value has Evaluator.run_state/1 and Evaluator.last_value/1 directly.

This is a host-API convenience, not an ISA guarantee - see docs/isa.md section 2's host-convenience paragraph. A sibling implementation need not offer it.

Examples

iex> {:ok, value, ctx} = Predicator.execute_value("x = 2; x * 10", %{})
iex> value
20
iex> ctx.data
%{"x" => 2}

iex> {:ok, value, _ctx} = Predicator.execute_value("x = 1", %{})
iex> value
:undefined

iex> {:error, %Predicator.Errors.EvaluationError{reason: "not_a_container"}, ctx} =
...>   Predicator.execute_value("a = 1; a.b = 2; c = 3", %{})
iex> ctx.data
%{"a" => 1}

isa_version()

@spec isa_version() :: pos_integer()

Returns the ISA version this build emits and can run.

ISA versions are integers, independent of this library's semantic version (ADR-0003). Compare it against Predicator.Instructions.required_isa/1 before running a stored instruction list, so a version mismatch is caught up front instead of failing partway through evaluation. See docs/isa.md for the full versioning scheme.

Examples

iex> Predicator.isa_version()
3

parse(expression, opts \\ [])

@spec parse(
  binary(),
  keyword()
) ::
  {:ok, Predicator.Parser.ast()}
  | {:error, binary(), pos_integer(), pos_integer()}

Parses an expression string into an Abstract Syntax Tree.

Every node carries a trailing {line, column} source position. Pass spans: true for a Predicator.Types.span/0 in that slot instead - the source text the node covers, which is what a diagnostic underlines.

Examples

iex> Predicator.parse("score > 85")
{:ok, {:comparison, :gt, {:identifier, "score", {1, 1}}, {:literal, 85, {1, 9}}, {1, 7}}}

iex> Predicator.parse("score > 85", spans: true)
{:ok, {:comparison, :gt, {:identifier, "score", {{1, 1}, {1, 6}}}, {:literal, 85, {{1, 9}, {1, 11}}}, {{1, 1}, {1, 11}}}}

parse_program(source, opts \\ [])

@spec parse_program(
  binary(),
  keyword()
) :: Predicator.Parser.program_result()

Parses a statement-sequence string into a Predicator.Parser.program/0.

Implements program := statement (";" statement)* [";"], where a statement is either an assignment (location "=" expression) or an ordinary expression. This is a separate entry point from parse/2, not an option on it: parse/2 never returns a program, and parse_program/2 always returns one, even for a single statement.

Examples

iex> Predicator.parse_program("a = 1; b = a + 1")
{:ok,
 {:program,
  [
    {:assignment, {:identifier, "a", {1, 1}}, {:literal, 1, {1, 5}}, {1, 3}},
    {:assignment, {:identifier, "b", {1, 8}},
     {:arithmetic, :add, {:identifier, "a", {1, 12}}, {:literal, 1, {1, 16}}, {1, 14}},
     {1, 10}}
  ], {1, 1}}}

iex> Predicator.parse_program("42 = 1")
{:error, "Left side of '=' must be an assignable location - an identifier, a property access, or a bracket access.", 1, 4}

run_evaluator(evaluator)

@spec run_evaluator(Predicator.Evaluator.t()) ::
  {:ok, Predicator.Evaluator.t()} | {:error, term()}

Runs an evaluator until completion.

This provides direct access to the low-level evaluator API for cases where you need more control than the execute/2 function provides.

Parameters

  • evaluator - An %Predicator.Evaluator{} struct

Returns

  • {:ok, final_evaluator_state} on success
  • {:error, reason} on failure

Examples

iex> evaluator = Predicator.evaluator([["lit", 42]])
iex> {:ok, final_state} = Predicator.run_evaluator(evaluator)
iex> final_state.stack
[42]