> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trevosdk.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Bucketing spec

> The normative definition of how an identity maps to a variant. Frozen.

Status: **frozen**. Normative for every Trevo SDK.

This defines how an identity is mapped to a variant. It is the one piece of logic that must be
byte-identical on every platform: given the same `bucketingId`, `experimentKey`, and variant
list, a conforming implementation returns the same variant name as every other conforming
implementation.

You only need this page if you are implementing assignment in a language Trevo does not ship
an SDK for. If you are only recording conversions, use [REST](/install/rest) — no
assignment logic required.

## Why it is frozen

Assignment is deterministic and stateless. Nothing is stored, so a change to the arithmetic
silently re-buckets every user in flight.

If two implementations disagree, the same visitor gets one variant in the browser and another
from the backend, their exposures collide, and the statistics stop meaning anything. Nothing
errors and nothing alerts — the numbers are just quietly wrong.

## Terminology

| Term | Meaning |
| - | - |
| `bucketingId` | The identity assignment is keyed on. A stable opaque string |
| `experimentKey` | The experiment's stable key |
| `variants` | Ordered list of `{ name, trafficSplit }`. Order is significant |
| `trafficSplit` | Whole percent, `0`–`100`. The list sums to `100` |
| bucket | Integer in `[0, 10000)` derived from the identity and key |

## 1. Identity resolution

Resolved in this order, first non-empty value wins:

1. The identified user id, if the host application has called `identify()`
2. The anonymous id — a v4 UUID minted on first visit and persisted by the platform

If neither is available, do **not** assign: return the control variant and record no exposure.

Switching from an anonymous id to a user id changes the bucketing basis and may therefore
change the assigned variant. That is intended — it is the point at which a visitor becomes a
known user.

Server-side callers supply `bucketingId` explicitly and are responsible for passing the same
value the browser would use, in practice the `trevo_id` cookie.

The `bucketingId` is used **verbatim**. Do not trim, case-fold, or Unicode-normalise it.
`café` (NFC) and `café` (NFD) are different identities and bucket differently.

## 2. Hash

FNV-1a, 32-bit, over **UTF-16 code units** — not over UTF-8 bytes.

```
FNV_OFFSET_BASIS_32 = 0x811C9DC5
FNV_PRIME_32        = 0x01000193

fnv1a32(s):
    hash = FNV_OFFSET_BASIS_32
    for each UTF-16 code unit u in s:      # 0..0xFFFF
        hash = hash XOR u                  # 16-bit XOR, not 8-bit
        hash = (hash * FNV_PRIME_32) mod 2^32
    return hash as unsigned 32-bit
```

Two properties differ from textbook FNV-1a and are the most likely source of a divergent port:

* **The unit is a UTF-16 code unit, not a byte.** Textbook FNV-1a consumes one byte at a time;
  this consumes one 16-bit unit, XORing all 16 bits. ASCII inputs agree with a byte-wise
  implementation by coincidence — every ASCII character is one code unit whose high byte is
  zero — and diverge for everything else. An implementation validated only against ASCII
  identifiers will pass its own tests and corrupt data for any customer with non-ASCII ids.
* **Characters outside the BMP are two units.** `🙂` (U+1F642) is the surrogate pair
  `0xD83D 0xDE42` and contributes two iterations — not one code point, and not four UTF-8
  bytes.

Non-JavaScript implementations must encode to UTF-16 and iterate 16-bit units. Unpaired
surrogates are hashed as-is; the algorithm is defined over code units and is total over any
UTF-16 sequence.

Arithmetic is unsigned 32-bit with wraparound.

## 3. Bucket derivation

```
bucket = fnv1a32(bucketingId + ":" + experimentKey) mod 10000
```

The separator is a single ASCII colon (`U+003A`), and the operands are concatenated in that
order — identity first.

**Known property:** the concatenation is not injective when an operand contains a colon.
`("a:b", "c")` and `("a", "b:c")` both hash `"a:b:c"` and share a bucket. This is unreachable
in practice — bucketing ids are UUIDs, experiment keys are slugs — and is frozen as-is rather
than fixed, because adding escaping would re-bucket every existing user.

## 4. Bucket walk

Variants are walked **in list order**, accumulating traffic in basis points:

```
cumulative = 0
for variant in variants:
    cumulative = cumulative + variant.trafficSplit * 100
    if bucket < cumulative:
        return variant.name
return last variant's name
```

* `trafficSplit` is a whole percent, scaled to basis points by `* 100`, giving a `[0, 10000)`
  space that matches the bucket space exactly. All arithmetic is exact in integers; no rounding
  is performed and none is permitted.
* The comparison is **strictly less than**. A variant owns `[cumulative_before,
  cumulative_after)`. With a 50/50 split, bucket `4999` is the first variant and `5000` is the
  second.
* **List order is part of the contract.** Reordering variants with identical splits reassigns
  users.
* The trailing return is reachable only when splits sum to less than 100. Configs are
  schema-validated to sum to exactly 100, so it is a guard, not a behaviour to rely on.

## 5. Degenerate configurations

| Condition | Required behaviour |
| - | - |
| `variants` is empty | Return `"control"`. Warn (the reference implementation warns on every call). Do not throw |
| Splits do not sum to 100 (±0.01 tolerance) | Assign anyway, using the walk above. Warn (the reference implementation warns on every call) |
| No resolvable identity | Return `"control"`, record no exposure |
| Experiment key absent from loaded config | Return `"control"`, record no exposure. Paused, ended, rejected and guardrail-stopped experiments leave the config, so this is how every visitor reverts. A config read from the SDK's own cache counts as loaded. Leave any cached assignment in place, so a resumed experiment restores the same arm. Exception: a key passed as a server-resolved assignment (the browser's `bootstrap`) keeps that assignment, unassigned, until the first config fetch succeeds, because the cache can predate the experiment's launch or resume |
| No config loaded yet (first fetch pending or failed) | Return the last cached assignment for that identity if one exists, otherwise `"control"`. Record no exposure. Never assign against a config the SDK has not seen. Because a cached config counts as loaded, a visitor keeps the cached arm only until any config loads. React Native does not follow this row yet: before its config loads it returns `"control"` for a key outside the app's bundled `bootstrapConfig`, even when it holds a sticky assignment |

Note: the reference config parser rejects any experiment with fewer than two variants — and
one invalid experiment invalidates the whole config response — so the empty- and
single-variant rows describe the assignment function's contract in isolation, not states
reachable through the config endpoint.

An SDK must **never throw** out of variant resolution. Failing to assign degrades to control,
because a thrown error in a host application's render path is a worse outcome than an
unbucketed user.

## 6. Override precedence

Overrides are a QA affordance. Resolution order, highest first:

1. **Explicit override** — `?trevo_force=<key>:<variant>` in the browser, or a programmatic /
   environment `forceVariants` map in a server SDK. An override applies **only if the named
   variant exists in that experiment's loaded config**; an unknown variant name is ignored and
   resolution continues. This is what stops a crafted URL from making `getVariant()` return an
   arbitrary string. Ignoring is never silent: the SDK warns through its error channel
   (`force_ignored` in the browser, `onError` on a server), naming the force and the variants
   the experiment has.
2. **Deterministic assignment** — sections 2 to 4.

A forced variant **must not** record an exposure and **must not** be written to the assignment
cache. Preview traffic that reaches the results pipeline is indistinguishable from real traffic
and biases the experiment it was previewing.

While any override is active, every event the SDK sends is marked `preview: true`. Ingestion
records no exposure for a preview event and every conversion read drops one, so a tester's
conversions never move an arm — not even the control arm an earlier, unforced visit put them
in.

A server SDK accepts a per-request override (the map `?trevo_force=key:variant` parses to)
with precedence over a process-wide one, so a render for a browser that is previewing serves
the variant being previewed. `@trevosdk/nextjs` does this for you: the Next.js request hook
(`proxy` on Next 16+, `middleware` on Next 14/15) forwards `?trevo_force=` to the render and
`getTrevoBootstrap()` honours it.

In the browser, `?trevo_force=` persists for the tab session, and `?trevo_force=clear` drops
it. Overrides are per experiment key; comma-separate to force several.

## 7. Conformance

The conformance vectors live at `packages/contracts/src/corpus/vectors.json` in the Trevo
monorepo — ask us for a copy if you're implementing outside it:

* **`hash`** — `{ name, input, hash }` entries exercising the hash directly, including ASCII,
  accented Latin in both NFC and NFD, Cyrillic, CJK, emoji, and astral-plane input.
* **`assignment`** — `{ name, experimentKey, bucketingId, variants, hash, bucket, expected }`.
  The `hash` and `bucket` fields are diagnostics: they tell a failing implementation whether
  it diverged in the hash or in the walk.

The same file also carries `configParse`, `buildEvent` and `exposureDedup` categories, plus
a semver `specVersion`.

Every Trevo SDK runs these vectors in its own test suite, and CI runs every SDK's suite. **A
new language binding is not conformant until it does.** If you are writing one, pass every
vector before sending a single exposure.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.