Lucerna.Gates.Evaluate (Lucerna v0.0.1-alpha.0)

The Gates evaluation engine — pure functions over a compiled runtime, no I/O and no processes. Ported from @lucerna/gates-core (sdks/core/src/evaluate.ts) onto the frozen bucketing contract in Lucerna.Gates.Hashing; the golden vectors in sdks/core/src/vectors.json pin parity.

The runtime document keeps its wire shape (string keys, camelCase); decisions come out as atom-keyed maps with atom reasons matching the wire codes (:flag_off, :rollout, :assigned, …).

Fail-safe rule: a gate carrying any rule element this engine version doesn't recognize (unknown condition operator, unknown override kind) is answered whole with :unsupported_rule — flag off, experiment unassigned. An engine never half-evaluates a document it doesn't fully understand.

Summary

Functions

One identity's decisions over the entire runtime.

Experiment assignment, sticky per (salt, user): eligibility gates first (running, environment, audience), then the holdout and traffic dice, then an allocation-weighted walk over the variants. The salt is per iteration, so restarting an iteration reshuffles every bucket by design.

Flag precedence, first hit wins

experiment/3 with the decision trace: {decision, trace_entries}.

flag/2 with the decision trace: {decision, trace_entries}.

Types

experiment_decision()

@type experiment_decision() :: %{
  variant: variant() | nil,
  iteration: non_neg_integer(),
  reason: atom()
}

flag_decision()

@type flag_decision() :: %{on: boolean(), reason: atom()}

trace_entry()

@type trace_entry() :: %{step: String.t(), detail: String.t()}

variant()

@type variant() :: %{id: String.t(), name: String.t(), is_control: boolean()}

Functions

all(runtime, opts \\ [])

@spec all(
  map(),
  keyword()
) :: %{kills: map(), flags: map(), experiments: map()}

One identity's decisions over the entire runtime.

experiment(experiment, audiences, opts \\ [])

@spec experiment(map(), map(), keyword()) :: experiment_decision()

Experiment assignment, sticky per (salt, user): eligibility gates first (running, environment, audience), then the holdout and traffic dice, then an allocation-weighted walk over the variants. The salt is per iteration, so restarting an iteration reshuffles every bucket by design.

flag(flag, opts \\ [])

@spec flag(
  map(),
  keyword()
) :: flag_decision()

Flag precedence, first hit wins:

  1. environment master switch off -> off
  2. overrides, in stored order (by user id, email domain, or trait)
  3. conditions — all must match (a flag's rule is a conjunction)
  4. percentage rollout, sticky by user id via the stored salt

A rollout between 0 and 100 with no user id serves off — there is nothing to be sticky by.

Options: :user_id, :traits (string-keyed, string-valued map), :audiences — the runtime's audience table, resolving in_audience / not_in_audience conditions; a referenced id that is absent evaluates the condition to no-match, never to a wider serve.

trace_experiment(experiment, audiences, opts \\ [])

@spec trace_experiment(map(), map(), keyword()) ::
  {experiment_decision(), [trace_entry()]}

experiment/3 with the decision trace: {decision, trace_entries}.

trace_flag(flag, opts \\ [])

@spec trace_flag(
  map(),
  keyword()
) :: {flag_decision(), [trace_entry()]}

flag/2 with the decision trace: {decision, trace_entries}.