Skip to main content
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 — 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

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.
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

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:
  • 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

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.
  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. 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

Trevo publishes conformance vectors alongside this spec:
  • hashVectors{ input, hash } pairs exercising the hash directly, including ASCII, accented Latin in both NFC and NFD, Cyrillic, CJK, emoji, and astral-plane input.
  • cases{ 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.
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.