# Traffical SDK Specification

Language-agnostic specifications for implementing Traffical SDKs.

## Overview

This repository contains:

- **JSON Schemas** - Type definitions for configuration bundles and events
- **Test Vectors** - Deterministic test fixtures for validating SDK implementations

All Traffical SDKs must implement these specifications to ensure consistent behavior across languages.

## Schemas

### `config-bundle.schema.json`

The complete configuration bundle that SDKs fetch and cache. Contains:

- Organization and project identifiers
- Hashing configuration (unit key, bucket count)
- Parameters with defaults and layer membership
- Layers with policies and allocations
- Conditions for targeting

### `events.schema.json`

Event payloads sent to the Traffical API:

- Exposure events (when a user is assigned to a variant)
- Decision events (parameter resolutions, with per-layer metadata)
- Track events (custom user actions)

Exposure and decision events carry an optional top-level `configVersion` — the
config bundle `version` the SDK evaluated against. Per-layer entries
(`ExposureLayerInfo`) carry optional propensity fields:

- `probability` — propensity of the CHOSEN allocation at decision time, in
  `(0, 1]`. For `linear_contextual` policies this is the floored-softmax
  probability of the chosen allocation; for other adaptive policies
  (`thompson_bernoulli` / `epsilon_greedy` / `ucb1`) it is the chosen
  allocation's bucket-range share
  (`(bucketRange[1] - bucketRange[0] + 1) / bucketCount`); for per-entity
  dynamic allocations it is the weight the SDK actually used. Static policies
  omit the field entirely.
- `modelVersion` — only for `linear_contextual`: the model timestamp of the
  coefficients used (`trainingSummary.generatedAt` or the bundle model's
  equivalent).

See `test-vectors/fixtures/events_conformance.json` for payload conformance
vectors covering these fields.

### `traffical-config.schema.json`

Local project configuration file (`.traffical/config.yaml`) schema.

## Test Vectors

The `test-vectors/` directory contains deterministic test fixtures for validating SDK implementations.

### Purpose

All Traffical SDKs must produce identical results for the same inputs:

1. **Hashing consistency** - The SHA-256 v2 assignment hash produces the same bucket across all implementations
2. **Resolution correctness** - Parameter resolution follows the layered priority system
3. **Condition evaluation** - Context predicates are evaluated identically

### Fixture Structure

```
test-vectors/
├── fixtures/
│   ├── bundle_*.json        # Config bundles to test against
│   └── expected_*.json      # Expected outputs for each bundle
└── README.md
```

### Running Tests

Each SDK implementation should:

1. Load the bundle JSON
2. For each test case:
   - Compute buckets and verify against `expectedHashing`
   - Resolve parameters and verify against `expectedAssignments`

## Hash Function Reference

Traffical uses the **SHA-256 v2 assignment hash** for bucket computation. This
replaced FNV-1a, which passed single-layer uniformity but failed
cross-experiment independence with realistic UUID/ULID unit keys and `lay_*`
layer IDs (assignment in one layer could predict assignment in another).

### Bucket assignment contract

```
input  = "traffical:assignment:v2|u:<unitLen>:<unitKeyValue>|l:<layerLen>:<layerId>"
digest = SHA256(UTF-8 bytes of input)
hashInt = first 64 bits of digest, unsigned big-endian
bucket  = hashInt % bucketCount
```

- **Versioned** (`v2`) and **domain-separated** (`traffical:assignment`) so the
  contract is explicit and cannot collide with other hashes.
- **Length-framed**: `<unitLen>` and `<layerLen>` are the **number of UTF-8
  bytes** of each field value (not UTF-16 code units or grapheme clusters), so
  values containing `:` or `|` cannot create ambiguous inputs and every SDK
  frames identically across languages.
- **UTF-8 byte domain**: the digest is computed over the UTF-8 bytes of the
  framed input. See `test-vectors/fixtures/bundle_unicode.json` for conformance
  fixtures that lock UTF-8 byte framing of both unit keys and layer IDs.

### Example

- Input: `"traffical:assignment:v2|u:8:user-abc|l:8:layer_ui"`
- SHA-256 first 64 bits (big-endian): `3044655943265667177`
- Bucket (mod 1000): `177`

### Weighted selection

Per-entity and contextual selection use the same SHA-256 primitive on a seed
string (`"<entityId>:<unitKeyValue>:<policyId>"` or
`"ctx:<unitKeyValue>:<policyId>"`): the first 64 bits of the digest are reduced
to a uniform value in `[0, 1)` via `(hashInt mod 2^53) / 2^53`, which is then
used to walk the cumulative weights.

## Normative behavior (0.7.0)

The sections below are the **normative** cross-SDK behavior contract introduced
in spec 0.7.0 (drift-remediation). Every Traffical SDK MUST implement them
exactly; they are testable against the conformance vectors in `test-vectors/`.
The key words MUST, MUST NOT, SHOULD, and MAY are used per RFC 2119.

A few of these rules deliberately **contradict the current reference JS engine**
(`js-sdk/packages/core`), which is the 1-of-4 outlier on those points; the
engine is aligned to this spec in a follow-up ("Phase 2"). Such cases are called
out inline so implementers do not "match JS" where JS is wrong.

## Allocation identity (`key` vs `name`)

An allocation carries two labels, and they are **not interchangeable**:

| Field | Role | Rule |
|-------|------|------|
| `key` | **The identifier.** | Stable, machine-facing. The warehouse `allocation_name` column, the `layers[].allocationKey` event field, and every allocation-keyed map in the bundle are keyed by it. |
| `name` | **Display only.** | Author-editable, may contain spaces, capitals, and punctuation (`"Streak + Confetti"`). Emitted as `layers[].allocationName` for readability. |

They are frequently equal — which is exactly what makes this dangerous, because an implementation that keys on `name` passes every test where an author happened to write a slug-shaped name and then fails silently on the first allocation that does not.

**SDKs MUST resolve allocation identity as `key ?? name`.** `key` is optional in the schema only so that bundles produced before it existed keep resolving; it is present on every bundle a current control plane emits. An SDK MUST NOT key on `name` alone, and MUST NOT key on `id` (which is a control-plane-internal identifier that never appears in warehouse data).

This applies to **every** allocation-keyed lookup. Today that is exactly one: `BundleContextualModel.coefficients` (see [Contextual Model Resolution](#contextual-model-resolution)). Any future allocation-keyed map inherits the same rule, and the fixture below is expected to grow with it.

> **Why this is a MUST, not a SHOULD.** The failure is silent and self-concealing. Coefficient lookup by `name` misses, the arm falls back to `defaultAllocationScore`, and the softmax degrades toward uniform. Nothing errors; the policy keeps serving; the dashboard keeps showing a trained model. When *every* allocation's name differs from its key, all arms miss equally, the distribution is exactly uniform, and even a sample-ratio check passes — the bandit is inert and nothing anywhere reports it.

> **Note on per-entity weights.** `entityState[policyId].weights` is a **positional array** indexed by allocation order, not an allocation-keyed map, so it is not affected by this rule. Ordering is the bundle's allocation order and MUST be preserved.

Conformance: `bundle_contextual_key_differs` / `expected_contextual_key_differs`, in which every allocation's `name` differs from its `key`, plus a second policy carrying no `key` at all to pin the `?? name` fallback.

## Condition evaluation

A policy's `conditions` are context predicates that gate eligibility. All
conditions in the array are AND-ed: a policy is eligible only when **every**
condition matches. An empty (or absent) `conditions` array always matches.

### Field lookup (dot-notation, nested)

`condition.field` is resolved against the evaluation context using
**dot-notation** nested lookup:

- The field is split on `.` into path segments.
- Each segment indexes into the current value **only when that value is a
  (non-null) object**; array elements are addressed by their numeric-string
  index (`tags.0`).
- If any segment is reached on a value that is `null`, `undefined`, or a
  non-object primitive, the lookup yields **`undefined`** (the "field is
  absent" signal). SDKs MUST NOT throw on a missing path.

Examples: `user.plan` → `context.user.plan`; `tags.0` → the first element of
`context.tags`.

> iOS today does flat lookup only; it is aligned to nested lookup in Phase 2,
> and iOS device-info fields will be emitted as typed values.

### Strict typing (no coercion)

Comparisons are **strictly typed**. There is no `"42" == 42` coercion anywhere.

- **`eq` / `neq`** — strict identity. The types must match: `"42"` does **not**
  equal `42`. `neq` is the exact negation.
- **`gt` / `gte` / `lt` / `lte`** (relational) — match **only when the resolved
  context value is a number** *and* the condition's `value` is a number.
  If the context value is not numeric (missing, string, boolean, object), the
  condition does **not** match.
- **`contains` / `startsWith` / `endsWith` / `regex`** (string ops) — match
  **only when both the context value and `value` are strings**. `regex` compiles
  `value` as a regular expression and matches against the context string; an
  invalid pattern does **not** match (SDKs MUST NOT throw). Regex dialect is the
  host platform's (ECMAScript / PCRE / ICU / Python `re`); authors SHOULD keep
  patterns to the common subset.
- **`in` / `nin`** — membership by strict equality against `condition.values`.
  If `values` is not an array, `in` does not match and `nin` does match.
- **`exists` / `notExists`** — `exists` matches when the resolved value is
  neither `undefined` nor `null`; `notExists` is its negation. No `value` is
  read.

> iOS today uses loose coercion (e.g. string device-info `appBuildNumber`
> matching a numeric `gte 500`). It is aligned to strict typing in Phase 2.

### Omitted condition value (never-match)

For the relational operators `gt`, `gte`, `lt`, and `lte`, if the condition
carries **no `value`** (the key is absent), the condition **MUST NEVER match**,
regardless of the context value. SDKs MUST NOT coerce a missing threshold to
`0` (or any other default).

> Python currently coerces a missing threshold to `0.0`; it is the outlier and
> is fixed in Phase 2.

Conformance vectors: `test-vectors/fixtures/bundle_conditions_omitted.json` /
`expected_conditions_omitted.json` place an omitted-value `gte` policy ahead of a
valid `gte 100` policy and prove the omitted policy is always skipped.

### Operator reference

`eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `contains`, `startsWith`,
`endsWith`, `regex`, `exists`, `notExists`. An unknown operator MUST fail safe
(no match).

### Known gap: version-string comparison

Comparing **version strings** (semver, e.g. `appVersion gte "2.10.0"`) is **not
yet specified**. Under the strict rules above, a relational op against a string
context value does not match, so semver comparison currently has no defined
operator. A dedicated version-comparison operator is a **future** addition and
is intentionally out of scope for 0.7.0; do not emulate it via lexical string
comparison.

## Unit key values

The unit key value is the context field named by `hashing.unitKey` (or a
layer's `unitKey` override), read from the evaluation context and fed to the
[assignment hash](#bucket-assignment-contract).

### Canonical stringification (S2)

A unit key value read from context MUST be stringified with a **single canonical
rule: ECMAScript `Number::toString`** (equivalently, `String()` applied to the
value parsed from JSON). This guarantees a numeric unit key produces the **same
string — and therefore the same bucket — on every SDK**.

- Integers render without a decimal point or exponent within their integer
  range: `42` → `"42"`, `100` → `"100"`. A JSON `42.0` parses to the number
  `42` and renders `"42"`.
- Non-integers render minimally: `1.5` → `"1.5"`, `0.1` → `"0.1"`.
- Very large / very small magnitudes use exponential form exactly as
  `Number::toString` specifies: `1e21` → `"1e+21"`, `1e-7` → `"1e-7"`.
- `-0` renders `"0"`. Booleans render `"true"` / `"false"`.

Python's `engine/strings.py` is the reference port of this rule for non-JS
SDKs; other SDKs MUST match its output byte-for-byte.

> iOS today stringifies numeric keys via `String(Int64(n))`, which is
> **trapping** — it can crash on values ≥ 2^63 and truncates fractionals. It is
> replaced with the canonical rule in Phase 2.

Conformance vectors: `test-vectors/fixtures/bundle_numeric_unit_key.json` /
`expected_numeric_unit_key.json` lock the canonical form for `9007199254740993`
(2^53 + 1 → `"9007199254740992"`) and `1e21` (→ `"1e+21"`).

### Empty layer unit-key override (skip the layer) (S1)

A layer MAY carry a `unitKey` override to bucket on a different context field
(multi-entity randomization). If a layer's `unitKey` override is **empty or
whitespace-only**, the override is **invalid** and the SDK MUST:

1. **Skip the layer** — emit its resolution row with `bucket = -1`, match no
   policy, and leave the layer's parameters at their defaults.
2. Record **no exposure** for that layer.
3. **NOT fall back** to the project-level `hashing.unitKey` for that layer.
4. **NOT crash** and **NOT reject** the whole bundle — every other layer
   resolves normally.

This is distinct from a *valid* override naming a field that happens to be
absent from the context: that layer is likewise skipped (bucket `-1`), but for
the missing-value reason. The empty/whitespace **override string itself** is the
invalid-configuration case S1 governs.

> The current reference JS engine treats an empty-string layer override as
> falsy and **falls back to the project unit key** — this is WRONG per this
> spec. JS is the 1-of-4 outlier and is corrected in Phase 2.

Conformance vectors: `test-vectors/fixtures/bundle_empty_unit_key.json` /
`expected_empty_unit_key.json` (hand-authored to the decided skip semantics —
both an empty `""` and a whitespace `"   "` override are skipped with bucket
`-1`, while the project-keyed layer resolves normally).

## Contextual Model Resolution

Policies with `algorithm: "linear_contextual"` ship a trained model in the bundle via the `contextualModel` field on `BundlePolicy`. When present, the SDK uses this model to compute personalized allocation probabilities instead of the standard bucket-based assignment.

> **Discriminator note.** The `algorithm` field (`linear_contextual`, `thompson_bernoulli`, `epsilon_greedy`, `ucb1`) is an **optional, server-provided informational label** — it is defined on `BundlePolicy` in `config-bundle.schema.json` purely as telemetry/metadata and carries no resolution semantics. The SDK-side discriminator for contextual scoring is the **presence of `contextualModel`**: if a policy carries a trained `contextualModel`, the SDK runs the scoring pipeline below; otherwise it falls through to bucket-based resolution (see [Graceful Degradation](#graceful-degradation)). An SDK never needs to read `algorithm` to resolve a policy.

### Scoring Pipeline

1. **Linear score per allocation**: `score = intercept + SUM(coef_i * feature_i)`. For each allocation, look up its `BundleAllocationCoefficients` in `coefficients` **by `key ?? name`** — never by `name` alone (see [Allocation identity](#allocation-identity-key-vs-name)). If an allocation has no coefficients, use `defaultAllocationScore`.
2. **Softmax**: Convert raw scores to probabilities using temperature `gamma`. Lower gamma is more deterministic (exploitative), higher gamma is more uniform (explorative).
3. **Probability floor**: Enforce `actionProbabilityFloor` as a minimum probability for any allocation to ensure continued exploration. Clamp below-floor entries and renormalize.
4. **Deterministic selection**: Use `weightedSelection(probabilities, seed)` with seed `"ctx:" + unitKeyValue + ":" + policyId` to deterministically select an allocation via the SHA-256 v2 hash.

### Feature Types

- **Numeric**: `score += coef * contextValue`. When the context field is missing or non-numeric, `missing` is added instead.
- **Categorical**: `score += values[contextValue]`. When the context field is missing or the value is not in the `values` map, `missing` is added instead.

### Graceful Degradation

If `contextualModel` is absent on a policy (no trained model yet), the SDK falls through to standard bucket-based resolution using the allocation bucket ranges. This means newly-created contextual policies serve uniform traffic until the first training run publishes coefficients.

### Numerical guards (S6)

The softmax and floor steps have two mandatory guards so that all SDKs produce
the same probabilities at the edges of the parameter space:

- **`safeGamma = max(gamma, 1e-10)`** — the softmax temperature used for scaling
  is `safeGamma`, never the raw `gamma`. A `gamma` of `0` (or any value below
  `1e-10`) is clamped to `1e-10`, yielding a near-deterministic-but-defined
  distribution. SDKs MUST NOT divide by a zero temperature, and MUST NOT
  substitute an argmax shortcut or reset the temperature to `1`.
- **`effectiveFloor = min(floor, 1/n)`** — where `floor` is
  `actionProbabilityFloor` and `n` is the number of allocations. The floor
  applied per allocation is `effectiveFloor`, so flooring can never demand more
  than 100% of the probability mass. Each probability is raised to
  `effectiveFloor`, then the vector is renormalized. When `floor <= 0`, flooring
  is skipped.

> iOS today maps `gamma = 0` to temperature `1` (then argmax) and omits the
> `min(floor, 1/n)` cap. It is aligned to these guards in Phase 2.

Conformance vectors: `bundle_contextual_gamma_zero.json` /
`expected_contextual_gamma_zero.json` lock the `gamma = 0` near-argmax
distribution (`[0.909091, 0.045455, 0.045455]`), and
`bundle_contextual_high_floor.json` / `expected_contextual_high_floor.json` lock
the `floor = 0.5`, `n = 3` capped distribution (`effectiveFloor = 1/3` →
`[0.524953, 0.237524, 0.237524]`, NOT a raw-0.5 floor).

### Model version sourcing (S7)

When an SDK emits an exposure/decision event for a `linear_contextual`
selection, `layers[].modelVersion` MUST be sourced as:

```
modelVersion = contextualModel.generatedAt ?? contextualModel.modelVersion
```

`generatedAt` (an ISO 8601 date-time, added to `BundleContextualModel` in Part
A) is the canonical source; the legacy `modelVersion` label is the only
fallback. There is **no further fallback to `policy.stateVersion`** — if both
`generatedAt` and `modelVersion` are absent, the SDK MUST **omit**
`modelVersion` entirely rather than emit a wrong label.

> The current reference JS engine still falls back to `policy.stateVersion`
> (`resolution/engine.ts`); that fallback is removed in Phase 2.

### Test Vectors

See `test-vectors/fixtures/bundle_contextual.json` and `expected_contextual.json` for conformance test cases covering numeric features, categorical features, missing context fields, and unknown categorical values. Near-gridline softmax behavior is locked by `bundle_contextual_boundary.json` / `expected_contextual_boundary.json`, and the S6 numerical guards (`safeGamma`, `effectiveFloor`) by `bundle_contextual_gamma_zero.json` and `bundle_contextual_high_floor.json`.

## Exposure event contract

`trackExposure()` records that a unit **actually saw** the parameters resolved
by a `decide()`. The exposure stream feeds treatment-on-the-treated metrics and
carries the 0.6.0 propensity rows (`layers[].probability` / `modelVersion`), so
its shape MUST be identical across SDKs.

### Canonical shape (S4)

- **One exposure event per `trackExposure()` call.** SDKs MUST NOT emit one
  event per layer.
- The event's `layers[]` contains **only newly-exposed, non-`attributionOnly`
  layers**:
  - `attributionOnly` layers — layers resolved for attribution but whose
    parameters were not requested by this decision — MUST be excluded (they
    would inflate exposure for experiments the unit never saw).
  - Layers already exposed for this unit in the current session (see dedup
    below) MUST be excluded.
- If, after that filtering, no layers remain, the SDK MUST emit **no event**.
- Each surviving layer row carries its propensity fields (`probability`,
  `modelVersion`) exactly as produced by resolution.

This is the shape the Node SDK emits today. `js-client` and iOS currently emit
one event **per newly-deduped layer**, each carrying the **full unfiltered**
`layers` array (including `attributionOnly`); `php` and `python` emit a single
event with **no dedup**. All four are aligned to the canonical shape in Phase 2.

Conformance vectors: `test-vectors/fixtures/exposure_shape.json` gives, for each
decision (resolved layers + `alreadyExposed` dedup state), the exact
`expectedEvents` trackExposure() must emit — one event with attributionOnly and
already-exposed layers filtered out, or zero events when nothing survives.

### Session deduplication (on by default)

Exposure dedup is **on by default**. A `(unit, layer, allocation)` combination
already exposed within the current session is suppressed from subsequent
events.

- **Client SDKs** (browser / mobile): a **persisted 30-minute session** window
  (default TTL), surviving across `decide()`/`trackExposure()` calls and page
  loads, cleared when the unit changes (`identify`).
- **Server SDKs**: a **bounded in-memory LRU with a TTL** (never an unbounded
  map), so long-lived processes cannot leak memory.
- Dedup is configurable: an opt-out flag (`deduplicateExposures`) and a TTL
  knob (`exposureSessionTtlMs`) MUST exist on every SDK.

SDK-side dedup is a **volume optimization only**. The **ingestion pipeline is
authoritative** for final deduplication and counting; SDKs MUST NOT assume their
local dedup is exact across processes or devices.

## SDK runtime behavior

The spec defines data and math; this section defines the **network and
lifecycle behavior** every SDK MUST implement so runtime semantics don't drift.

### Fail-open (including malformed bundles) (S8)

An SDK MUST **never crash or throw out of `decide()` / `getParams()`** because
configuration is unavailable or bad. It degrades to the best available source in
this order: last-good cached bundle → `localConfig` (build-time baked bundle, if
provided) → caller-supplied defaults.

Fail-open applies to **both**:

- **Fetch failures** — HTTP 404, network error, or timeout on the config
  request.
- **Malformed bundles** — a fetched bundle that fails schema validation, has
  `bucketCount < 1`, or is missing `hashing.unitKey`. A malformed bundle MUST be
  discarded (keep the last-good bundle, or fall through to `localConfig` /
  defaults); it MUST NOT replace a good cached bundle and MUST NOT crash the SDK.

The empty layer `unitKey` override (S1) is handled at the layer level (skip that
layer), not by failing the whole bundle.

### Caching, refresh, and jitter

- The SDK caches the last-good bundle and refreshes on an interval (default
  **60s**).
- The refresh interval MUST be applied with **jitter** (±10% recommended) to
  avoid synchronized thundering-herd refetches across clients.
- When the config response carries **`suggestedRefreshMs`**, the SDK MUST honor
  it in place of the default interval. (PHP/iOS currently parse-and-ignore it;
  aligned in Phase 2.)

### Event delivery

Event delivery standardizes on the Python SDK's model:

- **Bounded queue** — events are buffered in a bounded in-memory queue. When the
  queue is full the SDK drops (oldest-first) rather than growing without limit.
  Unbounded requeue-on-failure is forbidden.
- **Batching** — flush when the batch reaches the batch size (default **10**) or
  when the flush interval (default **30s**) elapses, whichever comes first.
- **Retry with backoff** — transient delivery failures (network, 5xx, timeout)
  are retried with **exponential backoff**, bounded in attempts; events stay in
  the bounded queue between attempts.
- **Auth kill-switch** — on an HTTP **401**, the SDK MUST **permanently disable**
  event delivery (stop retrying, stop buffering) for the process lifetime rather
  than spin on a credential that will never succeed.

### Mandatory timeouts

SDKs MUST set explicit request timeouts and MUST NOT rely on platform defaults:

| Request           | Default timeout |
|-------------------|-----------------|
| Config fetch      | 10s             |
| Event delivery    | 10s             |
| Server resolve    | 5s              |

### Server evaluation mode

In server-evaluated mode the SDK MUST resolve each call with the **per-call
context** passed to that `decide()` / `getParams()` / resolve call — **not** a
snapshot captured at initialization, and not an empty context. A unit key is
required per call. (JS server-mode currently resolves once with an empty
context; corrected in Phase 2.)

## SDK Implementations

| Language | Repository |
|----------|------------|
| JavaScript/TypeScript | [traffical/js-sdk](https://github.com/traffical/js-sdk) |
| Go | Coming soon |
| Java | Coming soon |

## License

MIT License - see [LICENSE](LICENSE) for details.

