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 and nil
values 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.
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) and nil becomes
:undefined, recursing through nested maps and lists; structs pass through
unchanged.
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" => :undefined}}
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) and nil
values become the :undefined sentinel, recursing through nested maps and
lists. Date, DateTime, and any other struct pass through unchanged.
host, by contrast, is stored exactly as given - no normalization, atom
keys and nils intact.
Examples
iex> Predicator.Context.new(%{"x" => 1}).data
%{"x" => 1}
iex> Predicator.Context.new(%{user: %{name: nil}}).data
%{"user" => %{"name" => :undefined}}
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).