A bound evaluation context: data, functions, and the unbound-variable policy.
Build one with new/2, evaluate Predicator.evaluate/3 against it many
times, and rebind cheaply with bind/3 between evaluations - functions
resolve once, at construction, not on every evaluate call.
Functions
functions is a closed dispatch map, resolved once by new/2 from three
sources, folded left so a later one shadows an earlier same-named entry:
the four builtin provider modules (:builtins, default true), then
:providers - a list of Predicator.FunctionProvider modules, left to
right - then :functions, an inline %{name => {arity, fun}} closure map
merged last. A provider module is validated at construction
(ArgumentError on a bad one - host API misuse, not a predicate-derived
failure); a resolved entry is either {arity, {module, atom}} or
{arity, fun}, and the evaluator dispatches both the same way, handing the
function (args, context).
The host slot
host is an opaque carrier for whatever a function provider needs at call
time - a database connection, a request struct, a tenant id. It is stored
exactly as given, with no normalization: unlike data, atom keys inside a
host term are never touched. It is never readable from
predicate text - there is no syntax that reaches it - and it is never
merged into data. Set it with new/2's :host option or put_host/2.
Examples
iex> context = Predicator.Context.new(%{"score" => 85})
iex> Predicator.evaluate("score > 80", context)
{:ok, true}
iex> context = Predicator.Context.new(%{})
iex> context = Predicator.Context.bind(context, "score", 90)
iex> Predicator.evaluate("score", context)
{:ok, 90}
Summary
Types
A function entry the evaluator can dispatch: an MFA pair or a closure.
Policy for a load of an unbound root variable.
A bound evaluation context.
Functions
Assigns value at path_or_expression, returning the rebound context.
Answers whether name is bound in context's data, resolved by
Predicator.Evaluator.resolve_key/2 - the same lookup the evaluator uses
when a load instruction records an unbound read, so the two cannot
disagree. Presence, not definedness: a name bound to :undefined is bound.
Builds a context, resolving its function dispatch map once.
Replaces context's host term, leaving data, functions, and
on_unbound untouched. The new host is stored exactly as given - no
normalization, same as new/2's :host option.
Resolves opts's :builtins, :providers, and :functions into a single
dispatch map - the same resolution new/2 performs internally, exposed so
Predicator.Evaluator.evaluate/3 can route its own :functions/
:providers/:builtins opts through it without going through the rest of
new/2 (data normalization, :on_unbound validation, which evaluate/3
deliberately does not enforce).
Types
A function entry the evaluator can dispatch: an MFA pair or a closure.
@type on_unbound() :: :undefined | :error
Policy for a load of an unbound root variable.
:undefined (the default) pushes the :undefined sentinel and lets
three-valued logic absorb it. :error makes the load fail with
Predicator.Errors.UndefinedVariableError, halting the run.
Roots only, under either policy: a missing key on a bound map
(user.nope), a missing nested path, and an out-of-range index all stay
:undefined. This mirrors ECMAScript - a ReferenceError for an
undeclared variable, a silent undefined for a missing property - and
keeps guards over sparse data usable.
@type t() :: %Predicator.Context{ data: Predicator.Types.context(), functions: %{ required(binary()) => {Predicator.Evaluator.function_arity(), function_entry()} }, host: term(), on_unbound: on_unbound() }
A bound evaluation context.
Functions
@spec assign( t(), binary() | Predicator.ContextLocation.location_path(), Predicator.Types.value() ) :: {:ok, t()} | {:error, struct()}
Assigns value at path_or_expression, returning the rebound context.
path_or_expression is either a location expression string (resolved
against the context's current data, then written) or an
already-resolved Predicator.ContextLocation.location_path/0. Writes
through Predicator.ContextLocation.put/3 - the same auto-vivifying write
algorithm as Predicator.context_assign/4 and the future store opcode
(px-tbv.2). functions, on_unbound, and host are carried over
unchanged. value is stored verbatim, the same as bind/3 stores a
scalar.
Examples
iex> context = Predicator.Context.new(%{})
iex> {:ok, context} = Predicator.Context.assign(context, "user.name", "Ada")
iex> context.data
%{"user" => %{"name" => "Ada"}}
iex> context = Predicator.Context.new(%{"items" => [1, 2, 3]})
iex> {:ok, context} = Predicator.Context.assign(context, ["items", 1], "x")
iex> context.data
%{"items" => [1, "x", 3]}
@spec bind(t(), binary(), Predicator.Types.value()) :: t()
Binds name to value in data. O(1): a single Map.put/3, plus
normalizing value itself (O(size of value), not O(size of data) -
data is already normalized from construction or a prior bind/3).
value is normalized the same way new/2 normalizes data: atom keys
become string keys (string keys win on collision), recursing through
nested maps and lists; structs pass through unchanged. nil is preserved
as-is - it is the null value, distinct from the :undefined sentinel; see
docs/reference/language.md.
functions, on_unbound, and host are carried over unchanged.
Examples
iex> context = Predicator.Context.new(%{"a" => 1})
iex> Predicator.Context.bind(context, "b", 2).data
%{"a" => 1, "b" => 2}
iex> context = Predicator.Context.new(%{})
iex> Predicator.Context.bind(context, "user", %{name: nil}).data
%{"user" => %{"name" => nil}}
Answers whether name is bound in context's data, resolved by
Predicator.Evaluator.resolve_key/2 - the same lookup the evaluator uses
when a load instruction records an unbound read, so the two cannot
disagree. Presence, not definedness: a name bound to :undefined is bound.
resolve_key/2 also accepts an atom key, but a Context's data never has
one: new/2 and bind/3 normalized them to string keys already. The
%{score: 85} example below is bound because of that normalization, not
because of an atom lookup here.
Examples
iex> context = Predicator.Context.new(%{"score" => 85})
iex> Predicator.Context.bound?(context, "score")
true
iex> Predicator.Context.bound?(context, "missing")
false
iex> context = Predicator.Context.new(%{score: 85})
iex> Predicator.Context.bound?(context, "score")
true
@spec new( Predicator.Types.context(), keyword() ) :: t()
Builds a context, resolving its function dispatch map once.
Parameters
data- the bound-variable map (default%{})opts::builtins-true(default) includes the four builtin provider modules;falsedrops them, leaving only:providersand:functions:providers- a list ofPredicator.FunctionProvidermodules, resolved left to right (a later module shadows an earlier one's same-named entry), after the builtins and before:functions. A module that fails to load, does not exportfunctions/0, or names an atom not exported at arity 2 raisesArgumentError, naming the module and the offending entry:functions- an inline%{name => {arity, fun}}closure map, merged last (shadows both builtins and:providers) - same asPredicator.evaluate/3's:functionsoption:on_unbound-:undefined(default) |:error- seeon_unbound/0; any other value raisesArgumentError:host- defaultnil- see the "Thehostslot" section above
data is normalized deeply before it is stored: atom keys become string
keys (a string key wins if both are present at the same level), recursing
through nested maps and lists. Date, DateTime, and any other struct
pass through unchanged. nil is preserved as-is - it is the null value,
distinct from the :undefined sentinel; see docs/reference/language.md.
host, by contrast, is stored exactly as given - no normalization, atom
keys intact.
Examples
iex> Predicator.Context.new(%{"x" => 1}).data
%{"x" => 1}
iex> Predicator.Context.new(%{user: %{name: nil}}).data
%{"user" => %{"name" => nil}}
iex> context = Predicator.Context.new(%{"x" => nil})
iex> {Predicator.Context.bound?(context, "x"), Predicator.evaluate("x === undefined", context)}
{true, {:ok, false}}
iex> Predicator.Context.new().on_unbound
:undefined
iex> context = Predicator.Context.new(%{}, on_unbound: :error)
iex> {:error, error} = Predicator.evaluate("missing OR true", context)
iex> {error.variable, error.position}
{"missing", {1, 1}}
iex> Predicator.Context.new(%{}, builtins: false).functions
%{}
Replaces context's host term, leaving data, functions, and
on_unbound untouched. The new host is stored exactly as given - no
normalization, same as new/2's :host option.
Examples
iex> context = Predicator.Context.new(%{})
iex> Predicator.Context.put_host(context, %{conn: :db}).host
%{conn: :db}
@spec resolve_functions(keyword()) :: %{ required(binary()) => {Predicator.Evaluator.function_arity(), function_entry()} }
Resolves opts's :builtins, :providers, and :functions into a single
dispatch map - the same resolution new/2 performs internally, exposed so
Predicator.Evaluator.evaluate/3 can route its own :functions/
:providers/:builtins opts through it without going through the rest of
new/2 (data normalization, :on_unbound validation, which evaluate/3
deliberately does not enforce).
Order: the builtin providers (Predicator.FunctionProvider.builtin_providers/0,
unless builtins: false), then opts[:providers] left to right, then
opts[:functions] merged last - each later source shadows a same-named
entry from an earlier one.
Raises ArgumentError under the same conditions new/2 does, for the same
reason: a bad provider module is host API misuse, not a predicate-derived
failure (ADR-0004).