Assignment and Bucketing
Episode 1: who sees which variant is one hash of one token, computed in the browser and reproducible by anyone
Copy this guide and your current settings as a prompt for your AI agent.
Preview agent promptSee the guide, code and settings included when you copy.
Two visitors open the same page. One sees the control, one sees variant B. No server chose and no row was written: the whole decision is one number. This episode is exactly how that number is made.
On this page
01Where assignment runs
Not at the edge. The edge only serves cf.js with your published project
baked in: every seed, every allocation, every bin range. Assignment runs in
the browser, inside init → modify page → apply variants, in one function,
getVariantsAndElements in sdk/shared/assignment.ts. Its only caller is
sdk-js/src/init/modifyPage.ts. The edge never sees a visitor get assigned.
02The three inputs
variantPartition(seed, uid, scale) needs three things and nothing else: no
network, no clock, no state.
| Input | What it is | Where it comes from |
|---|---|---|
| Token | The visitor’s coframe.user.token cookie, a random UUID |
Minted in the browser, see Identity and Consent |
| Seed | A 32-bit integer | Shipped with the project in cf.js |
| Bins | Integer ranges per variant | Computed by the backend at publish time |
03Step one: the hash
MurmurHash3, x86, 32-bit, the same code as the murmurhash3js package
(sdk/shared/tools/murmurhash.ts). It walks the token four bytes at a time,
mixes each block with two constants, rotates, multiplies, then finalizes, and
returns an unsigned 32-bit integer. Same token, same seed, same answer, on any
machine, forever.
04Step two: hash to bin
Divide the hash by the largest unsigned value (2³² − 1) for a fraction between
0 and 1. Multiply by scale − 1 and take the floor. The scale is 100,000
(ALLOCATION_SCALE in backend/analytics/domain/experiment_allocation.py),
so bins run from 0 to 99,999: a hundred thousand bins. The example visitor in
the video lands in bin 10,229.
05How the bins are made
Bins are made on the backend when you publish, never in the browser
(compute_bins in backend/exp/logic/mab/utils.py). Every variant gets an
integer share of 100,000. A cumulative sum hands each variant one closed
range, start to end − 1:
| Shares | Ranges |
|---|---|
| 40 / 35 / 25 % | 0–39,999 · 40,000–74,999 · 75,000–99,999 |
| 5 % holdout | 0–4,999 |
| 0 % | An empty range, unreachable |
Those ranges are what cf.js carries.
06Step three: the lookup
chooseVariant walks every variant’s ranges and asks whether
start ≤ bin ≤ end. First hit wins. No hit means the default: the holdout
variant for holdout flips, or an error if a real variant is missing.
07Three coin flips
The full sequence for one visitor:
- Frames first. If no frame’s activation condition matches this URL and audience, the function returns. The visitor is not even placed in the global holdout. See Frames, Audiences and Lanes.
- Flip one, global holdout (5 % by default, with the project’s holdout seed). Bin 10,229 is outside the holdout band, so continue. Inside it, the function returns early: holdout recorded, no variants applied.
- Flip two, experiment holdout (the experiment’s own holdout seed). Bin 70,132 is past the 50/50 split, so this visitor is in the experiment, not the baseline.
- Flip three, the variant (the iteration seed). Bin 62,973 falls inside variant B’s range.
Three seeds, three independent positions, one token.
08Seeds
Seeds are where reassignment lives. Each is a random 32-bit integer from the
backend (generate_seed, RNG.integers(0, 2**32)). The project’s holdout
seed is fixed at creation. An experiment gets a fresh iteration seed and
holdout seed every time the bandit starts a new iteration
(_generate_seeds in process_mab_iteration.py), so between iterations the
same token can land in a different bin. Sticky bucketing keeps those seeds
fixed, so seats never move. The three seeds are always forced to differ, so no
visitor’s flips can line up by accident.
09In short
Token, seed, bins. Hash to a 32-bit integer, scale to one of 100,000 bins, look it up in ranges the backend published. Deterministic, stateless, reproducible offline by anyone with the payload, which is exactly how Coframe audits it.
10Where in the code
| File | What to look for |
|---|---|
sdk/shared/assignment.ts |
variantPartition, chooseVariant, inRange, getVariantsAndElements |
sdk/shared/tools/murmurhash.ts |
murmurHash3_x86_32(key, seed) |
sdk/sdk-js/src/init/modifyPage.ts |
The only caller of getVariantsAndElements |
backend/exp/logic/mab/utils.py |
compute_bins, compute_holdout_info_allocation, generate_seed |
backend/exp/logic/projects/create_project.py |
Global holdout bins at project creation |
backend/exp/logic/mab/process_mab_iteration.py |
_generate_seeds and sticky bucketing |
# Assignment and Bucketing Episode 1: who sees which variant is one hash of one token, computed in the browser and reproducible by anyone Two visitors open the same page. One sees the control, one sees variant B. No server chose and no row was written: the whole decision is one number. This episode is exactly how that number is made. [Video: Video: Runtime Walkthrough, episode 1: where assignment runs, the three inputs, the hash, the bins, the lookup, the three coin flips, and seeds (4m 14s, narrated)](assets/videos/sdk-internals-01-bucketing.mp4) Watch: 4m 14s — Runtime Walkthrough, episode 1: where assignment runs, the three inputs, the hash, the bins, the lookup, the three coin flips, and seeds ## Where assignment runs Not at the edge. The edge only serves `cf.js` with your published project baked in: every seed, every allocation, every bin range. Assignment runs in the browser, inside init → modify page → apply variants, in one function, `getVariantsAndElements` in `sdk/shared/assignment.ts`. Its only caller is `sdk-js/src/init/modifyPage.ts`. The edge never sees a visitor get assigned. ## The three inputs `variantPartition(seed, uid, scale)` needs three things and nothing else: no network, no clock, no state. Input | What it is | Where it comes from | Token | The visitor’s `coframe.user.token` cookie, a random UUID | Minted in the browser, see [Identity and Consent](../docs/identity-and-consent.html) | Seed | A 32-bit integer | Shipped with the project in `cf.js` | Bins | Integer ranges per variant | Computed by the backend at publish time | ## Step one: the hash MurmurHash3, x86, 32-bit, the same code as the `murmurhash3js` package (`sdk/shared/tools/murmurhash.ts`). It walks the token four bytes at a time, mixes each block with two constants, rotates, multiplies, then finalizes, and returns an unsigned 32-bit integer. Same token, same seed, same answer, on any machine, forever. ## Step two: hash to bin Divide the hash by the largest unsigned value (2³² − 1) for a fraction between 0 and 1. Multiply by scale − 1 and take the floor. The scale is 100,000 (`ALLOCATION_SCALE` in `backend/analytics/domain/experiment_allocation.py`), so bins run from 0 to 99,999: a hundred thousand bins. The example visitor in the video lands in bin 10,229. ## How the bins are made Bins are made on the backend when you publish, never in the browser (`compute_bins` in `backend/exp/logic/mab/utils.py`). Every variant gets an integer share of 100,000. A cumulative sum hands each variant one closed range, start to end − 1: Shares | Ranges | 40 / 35 / 25 % | 0–39,999 · 40,000–74,999 · 75,000–99,999 | 5 % holdout | 0–4,999 | 0 % | An empty range, unreachable | Those ranges are what `cf.js` carries. ## Step three: the lookup `chooseVariant` walks every variant’s ranges and asks whether start ≤ bin ≤ end. First hit wins. No hit means the default: the holdout variant for holdout flips, or an error if a real variant is missing. ## Three coin flips The full sequence for one visitor: - Frames first. If no frame’s activation condition matches this URL and audience, the function returns. The visitor is not even placed in the global holdout. See [Frames, Audiences and Lanes](../docs/frames-audiences-lanes.html). - Flip one, global holdout (5 % by default, with the project’s holdout seed). Bin 10,229 is outside the holdout band, so continue. Inside it, the function returns early: holdout recorded, no variants applied. - Flip two, experiment holdout (the experiment’s own holdout seed). Bin 70,132 is past the 50/50 split, so this visitor is in the experiment, not the baseline. - Flip three, the variant (the iteration seed). Bin 62,973 falls inside variant B’s range. Three seeds, three independent positions, one token. ## Seeds Seeds are where reassignment lives. Each is a random 32-bit integer from the backend (`generate_seed`, `RNG.integers(0, 2**32)`). The project’s holdout seed is fixed at creation. An experiment gets a fresh iteration seed and holdout seed every time the bandit starts a new iteration (`_generate_seeds` in `process_mab_iteration.py`), so between iterations the same token can land in a different bin. Sticky bucketing keeps those seeds fixed, so seats never move. The three seeds are always forced to differ, so no visitor’s flips can line up by accident. ## In short Token, seed, bins. Hash to a 32-bit integer, scale to one of 100,000 bins, look it up in ranges the backend published. Deterministic, stateless, reproducible offline by anyone with the payload, which is exactly how Coframe audits it. ## Where in the code File | What to look for | `sdk/shared/assignment.ts` | `variantPartition`, `chooseVariant`, `inRange`, `getVariantsAndElements` | `sdk/shared/tools/murmurhash.ts` | `murmurHash3_x86_32(key, seed)` | `sdk/sdk-js/src/init/modifyPage.ts` | The only caller of `getVariantsAndElements` | `backend/exp/logic/mab/utils.py` | `compute_bins`, `compute_holdout_info_allocation`, `generate_seed` | `backend/exp/logic/projects/create_project.py` | Global holdout bins at project creation | `backend/exp/logic/mab/process_mab_iteration.py` | `_generate_seeds` and sticky bucketing |