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

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

    iex> context = Predicator.Context.new(%{})
    iex> context = Predicator.Context.bind(context, "limit", 90)
    iex> Predicator.evaluate("limit", context)
    {:ok, 90}

# `function_entry`

```elixir
@type function_entry() :: {module(), atom()} | function()
```

A function entry the evaluator can dispatch: an MFA pair or a closure.

# `on_unbound`

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

# `t`

```elixir
@type t() :: %Predicator.Context{
  data: Predicator.Types.context(),
  functions: %{
    required(binary()) =&gt;
      {Predicator.Evaluator.function_arity(), function_entry()}
  },
  host: term(),
  on_unbound: on_unbound()
}
```

A bound evaluation context.

# `assign`

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

# `bind`

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

# `bound?`

```elixir
@spec bound?(t(), binary()) :: boolean()
```

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
`%{limit: 85}` example below is bound because of that normalization, not
because of an atom lookup here.

## Examples

    iex> context = Predicator.Context.new(%{"limit" => 85})
    iex> Predicator.Context.bound?(context, "limit")
    true
    iex> Predicator.Context.bound?(context, "missing")
    false

    iex> context = Predicator.Context.new(%{limit: 85})
    iex> Predicator.Context.bound?(context, "limit")
    true

# `new`

```elixir
@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; `false` drops them, leaving only `:providers` and
    `:functions`
  - `:providers` - a list of `Predicator.FunctionProvider` modules,
    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 export `functions/0`, or names an
    atom not exported at arity 2 raises `ArgumentError`, naming the module
    and the offending entry
  - `:functions` - an inline `%{name => {arity, fun}}` closure map, merged
    last (shadows both builtins and `:providers`) - same as
    `Predicator.evaluate/3`'s `:functions` option
  - `:on_unbound` - `:undefined` (default) | `:error` - see
    `t:on_unbound/0`; any other value raises `ArgumentError`
  - `:host` - default `nil` - see the "The `host` slot" section above
  - `:normalize` - `true` (default) | `false` - see the "The `:normalize`
    option" section below; any other value raises `ArgumentError`

`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
    %{}

# `put_host`

```elixir
@spec put_host(t(), term()) :: t()
```

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}

# `resolve_functions`

```elixir
@spec resolve_functions(keyword()) :: %{
  required(binary()) =&gt;
    {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).

---

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