Assignment and Bucketing

Episode 1: who sees which variant is one hash of one token, computed in the browser and reproducible by anyone

Guide~3 min read

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.

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
On this page
  1. 01Where assignment runs
  2. 02The three inputs
  3. 03Step one: the hash
  4. 04Step two: hash to bin
  5. 05How the bins are made
  6. 06Step three: the lookup
  7. 07Three coin flips
  8. 08Seeds
  9. 09In short
  10. 10Where in the code

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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

Related reading

Content and video lessons from Coframe documentation. Code examples are not executed on this page.

BACK TOHow Coframe Works

Documentation

Open Coframe App

Search documentation

Search all guides and references↑ ↓ Navigate · Esc Close