# `Predicator`
[🔗](https://github.com/riddler/predicator-ex/blob/v9.4.1/lib/predicator.ex#L1)

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", "limit"]]
    iex> context = %{"limit" => 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`](../docs/isa.md).

## Context

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

    %{"limit" => 85, "name" => "Alice"}
    %{limit: 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(%{"limit" => 85})
    iex> Predicator.evaluate("limit > 80", context)
    {:ok, true}
    iex> context = Predicator.Context.bind(context, "limit", 90)
    iex> Predicator.evaluate("limit > 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

# `compile`

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

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, error}` - A `%Predicator.Errors.ParseError{}` describing the
  failure

## Examples

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

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

    iex> {:error, error} = Predicator.compile("limit >")
    iex> {error.message, error.position, error.span}
    {"Expected number, string, boolean, date, datetime, identifier, function call, list, object, or '(' but found end of input", {1, 8}, {{1, 8}, {1, 8}}}

The error arm is a `%Predicator.Errors.ParseError{}` carrying the parser's
bare message in `:message` and `{line, column}` in `:position` - the same
struct `evaluate/3` returns for the same source. It also carries `:span` -
the failing token's source extent - in every mode, not only when the caller
asked for spans. `parse/2` remains available for a caller that wants the raw
5-tuple instead.

# `compile!`

```elixir
@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!("limit > 85")
    [["load", "limit"], ["lit", 85], ["compare", "GT"]]

# `compile_program`

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

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 `t:Predicator.Parser.program/0`
with `Compiler.to_instructions/2`, which already accepts one. Returns the
same `{:error, struct()}` shape `compile/1` does - not `parse_program/2`'s
raw 5-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> {:error, error} = Predicator.compile_program("x =")
    iex> {error.message, error.position}
    {"Expected number, string, boolean, date, datetime, identifier, function call, list, object, or '(' but found end of input", {1, 4}}

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

The error arm is a `%Predicator.Errors.ParseError{}`, for uniformity with the
expression family above. It carries the parser's bare message in `:message`
and `{line, column}` in `:position`, and now also `:span` - the failing
token's source extent - in every mode. `parse_program/2` remains available
for a caller that wants the raw 5-tuple instead.

# `compile_program_with_positions`

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

Compiles a statement-sequence string to a `t: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]]

The error arm is a `%Predicator.Errors.ParseError{}`, for uniformity with the
expression family above. It carries the parser's bare message in `:message`
and `{line, column}` in `:position`, and now also `:span` - the failing
token's source extent - in every mode. `parse_program/2` remains available
for a caller that wants the raw 5-tuple instead.

# `compile_program_with_spans`

```elixir
@spec compile_program_with_spans(binary()) ::
  {:ok, Predicator.Compiled.t()} | {:error, struct()}
```

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

The program-shaped echo of `compile_with_spans/1`. `compiled.instructions` is
identical to `compile_program/1`'s output; `compiled.positions` maps each
instruction's 0-based index to the `t:Predicator.Types.span/0` of the AST node
that emitted it, and `compiled.segment_positions` holds spans too. The
instruction that terminates a statement - `store` for an assignment, `pop` for
a bare expression statement - carries that statement's own source extent, so a
host can underline the failing statement inside a multi-statement body rather
than the whole program.

Pass the struct straight to `execute/3`, which threads the table itself; an
error it returns then carries `:span` as well as `:position`.

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

## Examples

    iex> {:ok, compiled} = Predicator.compile_program_with_spans("x = 1")
    iex> compiled.instructions
    [["lit", "x"], ["lit", 1], ["store", 1]]
    iex> compiled.positions
    %{0 => {{1, 1}, {1, 2}}, 1 => {{1, 5}, {1, 6}}, 2 => {{1, 1}, {1, 6}}}

The error arm is a `%Predicator.Errors.ParseError{}`, for uniformity with the
expression family above. It carries the parser's bare message in `:message`
and `{line, column}` in `:position`, and now also `:span` - the failing
token's source extent - in every mode. `parse_program/2` remains available
for a caller that wants the raw 5-tuple instead.

Spans describe AST nodes, but a parse error's span comes from the token
stream, not from AST metadata: the struct carries a span in point mode just
as much as in span mode.

# `compile_with_positions`

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

Compiles a string expression to a `t: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("limit > 85")
    iex> compiled.instructions
    [["load", "limit"], ["lit", 85], ["compare", "GT"]]
    iex> compiled.positions
    %{0 => {1, 1}, 1 => {1, 9}, 2 => {1, 7}}

The error arm is a `%Predicator.Errors.ParseError{}` carrying the parser's
bare message in `:message` and `{line, column}` in `:position` - the same
struct `evaluate/3` returns for the same source. It also carries `:span` -
the failing token's source extent - in every mode, not only when the caller
asked for spans. `parse/2` remains available for a caller that wants the raw
5-tuple instead.

# `compile_with_spans`

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

Compiles a string expression to a `t: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
`t: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("limit > 85")
    iex> compiled.instructions
    [["load", "limit"], ["lit", 85], ["compare", "GT"]]
    iex> compiled.positions
    %{0 => {{1, 1}, {1, 6}}, 1 => {{1, 9}, {1, 11}}, 2 => {{1, 1}, {1, 11}}}

The error arm is a `%Predicator.Errors.ParseError{}` carrying the parser's
bare message in `:message` and `{line, column}` in `:position` - the same
struct `evaluate/3` returns for the same source. It also carries `:span` -
the failing token's source extent - in every mode, not only when the caller
asked for spans. `parse/2` remains available for a caller that wants the raw
5-tuple instead.

# `context_assign`

```elixir
@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 {: .info}
>
> `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;
  the struct carries both `:position` and `:span`

## 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`

```elixir
@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`

```elixir
@spec decompile(
  Predicator.Parser.visitable(),
  keyword()
) :: binary()
```

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
  `t: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, "limit", nil}, {:literal, 85, nil}, nil}
    iex> Predicator.decompile(ast)
    "limit > 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)"

    iex> {:ok, ast} = Predicator.parse_program("if a { x = 1 } else { x = 2 }")
    iex> Predicator.decompile(ast)
    "if a { x = 1 } else { x = 2 }"

# `evaluate`

```elixir
@spec evaluate(
  binary() | Predicator.Types.instruction_list() | Predicator.Compiled.t(),
  Predicator.Types.context() | Predicator.Context.t(),
  keyword()
) :: {:ok, Predicator.Types.value()} | {:error, struct()}
```

Evaluates a predicate expression or instruction list.

This is the main entry point for Predicator evaluation. It accepts either:
- A string expression (e.g., "limit > 85") which gets compiled automatically
- A pre-compiled instruction list for maximum performance

## Parameters

- `input` - String expression, instruction list, or a `t: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, `%{name => {arity, fun}}`, called as `(args, context)`
  - `:providers` - a list of `Predicator.FunctionProvider` modules,
    resolved into the dispatch map alongside the builtins and `:functions`
    - see `Predicator.Context.new/2`
  - `:builtins` - `false` drops the four default builtin functions
    (default `true`)
  - `:host` - an opaque term threaded to every function call's context as
    `context.host` (default `nil`) - see `Predicator.Context.put_host/2`
  - `: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`.
  - `:loop_budget` - the number of back edges (`jump_backward`, ISA v6) a
    single execution may take, default
    `Predicator.Evaluator.default_loop_budget/0` (`10_000`), 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`.

## Returns

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

## Error Types

- `Predicator.Errors.TypeMismatchError` - Type mismatch in operation
- `Predicator.Errors.UndefinedVariableError` - Variable not found in context
- `Predicator.Errors.EvaluationError` - General evaluation error (division by zero, etc.)
- `Predicator.Errors.ParseError` - Expression parsing error, carrying both
  `:position` and `:span`

## Examples

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

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

    # With context
    iex> Predicator.evaluate("limit > 85", %{"limit" => 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("limit + 'hello'", %{"limit" => 5})
    {:ok, "5hello"}

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

# `evaluate!`

```elixir
@spec evaluate!(
  binary() | Predicator.Types.instruction_list() | Predicator.Compiled.t(),
  Predicator.Types.context(),
  keyword()
) :: Predicator.Types.value()
```

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!("limit > 85", %{"limit" => 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("limit > 85")
    iex> Predicator.evaluate!(compiled, %{"limit" => 90})
    true

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

# `evaluator`

```elixir
@spec evaluator(Predicator.Types.instruction_list(), Predicator.Types.context()) ::
  Predicator.Evaluator.t()
```

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`

```elixir
@spec execute(
  binary() | Predicator.Types.instruction_list() | Predicator.Compiled.t(),
  Predicator.Types.context() | Predicator.Context.t(),
  keyword()
) :: {:ok, Predicator.Context.t()} | {:error, struct(), Predicator.Context.t()}
```

Runs a statement program - a source string, an instruction list, or a
`t: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`:

- `program_or_source` - a source string (parsed with `parse_program/2`, not
  `parse/2`), a pre-compiled instruction list, or a `t:Predicator.Compiled.t/0`
  from `compile_program_with_positions/1`
- `context` - a bare map or a `t:Predicator.Context.t/0` (default `%{}`)
- `opts` - same options `evaluate/3` accepts (`:functions`, `:providers`,
  `:builtins`, `:host`, `:positions`, `:segment_positions`, `:on_unbound`,
  `:loop_budget`, `:protected_roots`); `:positions` or `:segment_positions`
  alongside a `%Compiled{}` raises `ArgumentError`, same as `evaluate/3`.
  Unlike `evaluate/3`, a program can compile a `store`, so
  `:segment_positions` - the table from `compiled.segment_positions` - can
  change a store failure's reported position here, and `:protected_roots`
  - a list of context root names a `store` may not write - only matters
  here, since `evaluate/3` never runs a `store`; see
  `Predicator.Evaluator.evaluate/3`'s option list for the fuller entry.

## Protected context roots

Passing `protected_roots: ["_event", ...]` refuses any `store` whose path's
root segment is in the list, instead of performing the write: the run stops
at that statement with `{:error, %Predicator.Errors.EvaluationError{reason:
"protected_root"}, context}`, where `error.details.root` names the offending
root. Protection is per-root, not per-path - `_event.name = 1` is refused
because `_event` is protected, and there is no way to protect `_event.name`
while leaving `_event.other` writable. The error arm's partial context is
unchanged by this option: it still carries every write made before the
refused statement, exactly as any other `store` failure's does. Absent the
option, behavior is byte-identical to today.

## 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}

    iex> {:error, %Predicator.Errors.EvaluationError{reason: "protected_root"} = error, ctx} =
    ...>   Predicator.execute("x = 1; _event = 2", %{}, protected_roots: ["_event"])
    iex> {error.details.root, ctx.data}
    {"_event", %{"x" => 1}}

# `execute_value`

```elixir
@spec execute_value(
  binary() | Predicator.Types.instruction_list() | Predicator.Compiled.t(),
  Predicator.Types.context() | Predicator.Context.t(),
  keyword()
) ::
  {:ok, Predicator.Types.value(), Predicator.Context.t()}
  | {:error, struct(), Predicator.Context.t()}
```

Runs a statement program - a source string, an instruction list, or a
`t: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`, and `:protected_roots`
(see `execute/3`'s "Protected context roots" section).

## 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}

    iex> {:error, %Predicator.Errors.EvaluationError{reason: "protected_root"} = error, ctx} =
    ...>   Predicator.execute_value("x = 1; _event = 2", %{}, protected_roots: ["_event"])
    iex> {error.details.root, ctx.data}
    {"_event", %{"x" => 1}}

# `isa_version`

```elixir
@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`](../docs/isa.md) for the full versioning scheme.

## Examples

    iex> Predicator.isa_version()
    6

# `parse`

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

Parses an expression string into an Abstract Syntax Tree.

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

## Examples

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

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

# `parse_program`

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

Parses a statement-sequence string into a `t: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, {{1, 4}, {1, 5}}}

# `run_evaluator`

```elixir
@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]

---

*Consult [api-reference.md](api-reference.md) for complete listing*
