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:normalize-true(default) |false- see the "The:normalizeoption" section below; any other value raisesArgumentError
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.
Passing normalize: false skips this walk entirely and stores data
exactly as given - the caller vouches that the invariant it would have
established already holds.
host, by contrast, is stored exactly as given - no normalization, atom
keys intact.
The :normalize option
:normalize is true by default, which is what runs the data walk
described above. Passing normalize: false skips it and stores data
exactly as given - a caller-vouches option: the caller asserts that data
already satisfies the normalization invariant (string keys throughout, at
every level, with any nested map or list already normalized the same way)
and accepts that violating it is the caller's own bug, not a defect here. A
context built this way behaves identically to a normalized one as long as
the invariant actually holds; if it does not, lookups keyed on a string
that was never converted from an atom simply miss, the same silent
:undefined a genuinely-unbound name produces.
This is safe to offer because it asks for nothing bind/3 does not already
assume: bind/3's O(1) claim rests on "data is already normalized from
construction" (see bind/3 below), so a caller vouching for that at
construction time is upholding the same invariant bind/3 has relied on
all along, just one call earlier.
The walk this skips is the entire size-scaling term of a build, so what it
is worth depends on the shape of data: over a handful of flat scalar roots
it is a small slice of the total, and over a large nested datamodel it is
very nearly all of it. bench/results/260814-context-build.md carries the
measured decomposition across three sizes; it is not transcribed here,
because a figure in a moduledoc goes stale and the results file does not
(px-10u; see also px-rnc for the fixed-term counterpart, memoized as of that
bead - which also means the walk is a larger share of what remains). A
caller that
already guarantees the invariant - for example because its data model uses
string keys natively and its own preprocessing already normalized nested
structures - pays that cost for no benefit.
Performance
A build pays two costs, and they scale differently. The normalization walk
described above is proportional to the size of data - normalize: false
removes it (px-10u; see the :normalize section above). The other cost is
fixed: resolving :builtins and :providers into the dispatch map, which
does not shrink as data gets smaller and does not grow as it gets larger.
That fixed term is memoized as of px-rnc: a new/2 call against a provider
list already seen in this process reuses the cached resolution instead of
re-validating it, so repeat builds against the same provider list no longer
re-pay validation. A build is still a build, though - the memo removes
re-validation, not the allocation and struct construction new/2 does on
every call.
The anti-pattern this leaves is calling new/2 once per evaluation. Hold
one context and rebind instead: bind/3 is O(1) in the size of data, and
put_host/2 is a plain struct field update - both exist so a caller never
has to pay new/2's cost per evaluation, which is what ADR-0014's design is
for. See bench/context_build.exs and its results file,
bench/results/260814-context-build.md, for the actual numbers, rather than
a figure transcribed here that would go stale.
Examples
iex> Predicator.Context.new(%{"x" => 1}).data
%{"x" => 1}
iex> Predicator.Context.new(%{user: %{name: nil}}).data
%{"user" => %{"name" => nil}}
iex> Predicator.Context.new(%{"user" => %{name: "Ada"}}, normalize: false).data
%{"user" => %{name: "Ada"}}
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).