Predicator is a secure, non-evaluative condition engine for end-user boolean
predicates. A user-authored expression like score > 85 AND active compiles
to a flat instruction list run by a small stack VM - there is no eval, no
Code.eval_string, and no dynamic code execution anywhere in the pipeline, so
untrusted input can never become code.
The language covers comparisons, arithmetic, logical operators, dates and durations, lists and objects, nested data access, and both builtin and custom functions.
Add predicator to your list of dependencies in mix.exs:
def deps do
[
{:predicator, "~> 5.0"}
]
endPredicator requires Elixir 1.18 or later and has no runtime dependencies.
iex> Predicator.evaluate!("score > 85 AND active", %{"score" => 92, "active" => true})
true
iex> {:ok, instructions} = Predicator.compile("score > threshold")
iex> Predicator.evaluate!(instructions, %{"score" => 95, "threshold" => 80})
true
iex> Predicator.evaluate("score > 85", %{"score" => 92})
{:ok, true}
iex> {:ok, compiled} = Predicator.compile_with_positions("score > threshold")
iex> Predicator.evaluate(compiled, %{"score" => 95, "threshold" => 80})
{:ok, true}compile_with_positions/1 and compile_with_spans/1 return a
%Predicator.Compiled{} carrying the instructions and their source-location
table as one value, so runtime errors keep their positions without the caller
re-attaching anything.
Persist compiled.instructions, not the struct - the instruction list is the
portable artifact; the table holds offsets into the source string and is
meaningless without it. Want positions back after a round trip? Persist the
source too and recompile with compile_with_positions/1 on load, rather than
storing the table - a table compiled from one source silently mismatches a
different source's instructions. See
Embedding compiled programs for the full
store/check/run lifecycle, including what to do when a stored artifact
predates a retired opcode.
The pattern most host applications need: a function provider that reads state
from the evaluation context's host slot, and a caller that swaps that slot
in O(1) as its own state changes. This mirrors how a state machine library
wires an SCXML In(stateId) guard into a running machine - the guard reads
current state through context.host rather than the caller re-threading it
as an ordinary predicate argument.
Define a provider module implementing Predicator.FunctionProvider, whose
callback reads context.host:
defmodule MyApp.StateFunctions do
@behaviour Predicator.FunctionProvider
@impl Predicator.FunctionProvider
def functions, do: %{"In" => {1, :call_in}}
def call_in([state_id], context), do: {:ok, context.host.current_state == state_id}
endBuild the context once, wiring the provider in with providers:, and store
it in the embedder's own struct:
defmodule MyApp.Machine do
defstruct [:context]
def new(initial_state) do
context =
Predicator.Context.new(%{},
providers: [MyApp.StateFunctions],
host: %{current_state: initial_state}
)
%__MODULE__{context: context}
end
def transition(%__MODULE__{context: context} = machine, new_state) do
%{machine | context: Predicator.Context.put_host(context, %{current_state: new_state})}
end
endEach state change calls put_host/2 - a single Map.put/3, not a context
rebuild - leaving data and the resolved function dispatch map untouched:
iex> context = Predicator.Context.new(%{}, host: %{current_state: :idle})
iex> context = Predicator.Context.put_host(context, %{current_state: :running})
iex> context.host
%{current_state: :running}A context built only from providers: is the form worth persisting or
handing to another process: a provider is a module atom and host is plain
data, so the whole context is ordinary Erlang term data. An inline
functions: closure works the same way at evaluation time but cannot be
serialized - :erlang.term_to_binary/1 has no way to hand a fun back to a
different run. See Custom functions for
the full provider API and the host slot, and Embedding compiled
programs
for storing a context alongside a compiled program.
- Language reference - operators, builtin functions, data types, and error shapes
- ISA reference - the instruction set specification: opcodes, stack effects, error semantics, and versioning
- Nested data access - dot and bracket notation over deep contexts
- Custom functions - extending the function set per evaluation
- Location expressions - SCXML assignment targets and writing into a context
- Embedding compiled programs - storing an instruction list and checking its ISA version before running it
- Porting Predicator - implementing the instruction set in another language and verifying it against the conformance corpus
- Architecture and language reference - the grammar with precedence, the compilation pipeline, and the component map
- Architecture decision records - the reasoning behind the design
= is no longer an equality operator. It is assignment, valid only at the
start of a statement (Predicator.parse_program/2) and only with an
assignable left side; a bare = in expression position - through
Predicator.parse/2 or Predicator.evaluate/3 - is a parse error naming ==
as the fix, never a silent reinterpretation. == and === are the only
equality operators. See
ADR-0002 for the reasoning.
Predicator's Elixir implementation is the reference implementation of the instruction set (the ISA), which is versioned. Ruby and JavaScript siblings, in the riddler/predicator monorepo, adopt each ISA version on their own schedule; a sibling running behind the current version is an expected, documented state, not a defect. See ADR-0003 for the reasoning and docs/architecture.md for what each sibling currently supports. docs/isa.md is the specification a sibling implements against, and conformance/ is how a sibling verifies a claim of support against it: a checked-in, language-neutral JSON corpus, tiered so a v1-only implementation runs a smaller, complete slice rather than skipping its way through the whole thing. This is the versioned-contract framing ADR-0003 asks for - a sibling behind the current ISA version is expected and documented, not a parity deficit to apologize for. If you are that implementer, Porting Predicator walks the path from picking a version to recording a conformance claim.
See CLAUDE.md for the contributor workflow and
docs/contributing.md for the quality-check commands
and the checklists for adding operators and data types.
MIT - see LICENSE.