Assignment and bucketing
Grona assigns each shopper to a variant or a holdout with a deterministic hash. Android, iOS and Grona’s servers compute the same arm for the same shopper without storing or syncing anything. This page publishes the algorithm, the order of the draws and test vectors, for anyone who reproduces assignment outside the SDK, for example in analysis or on a server.
The algorithm
Section titled “The algorithm”input = UTF-8 bytes of "v1:" + salt + ":" + unit_iddigest = SHA-256(input)bucket = (first 8 bytes of digest, read as an unsigned big-endian integer) mod 10000- Buckets run from 0 to 9999, so one bucket is one basis point (0.01%) of traffic.
- Encode the input as UTF-8 explicitly. Never rely on a platform-default charset, and never use a language hash such as Java’s
String.hashCodeor Swift’shashValue. - Changing anything in the algorithm re-buckets every shopper, so it is fixed.
The unit
Section titled “The unit”unit_id is one of two values, depending on the campaign’s unit:
unit |
unit_id |
Notes |
|---|---|---|
install |
The SDK’s install id, a lowercase UUID such as 8b0f6c2e-3d41-4f9a-9c57-2e6b1a0d4f13 |
A new install id, for example after a reset, gives the install new buckets |
customer |
The opaque customer id your app passes to identify, such as cus_7Hq2Lm9x |
Shoppers who are not identified skip customer-unit campaigns |
Customer ids must match [A-Za-z0-9._:-]{1,128}. The SDK ignores an identify call with any other id and keeps its current customer. Every campaign with a money treatment uses the customer unit, so logged-out shoppers never see offers. See Shoppers and identity.
The draws, in order
Section titled “The draws, in order”Each campaign makes up to three draws for a shopper. Each draw has its own salt, and a shopper held out at one step takes no later draw.
| Order | Draw | Salt | Result |
|---|---|---|---|
| 1 | Global holdout | "global:" + workspace |
A bucket below settings.global_holdout_bps gives arm global_holdout |
| 2 | Campaign holdout | traffic.holdout_salt |
A bucket below traffic.holdout_bps gives arm holdout |
| 3 | Variant | traffic.variant_salt |
Walk variants in order, adding each weight_bps. The first variant whose running total exceeds the bucket wins |
Weights are integer basis points and sum to 10,000. A variant marked control gives arm control, and any other variant gives arm treatment. Every arm logs an eligible event, but only treatment renders. A control or holdout shopper leaves the placement to the next campaign, which makes its own draws. See Events and exposure for how the arms are measured.
The global holdout
Section titled “The global holdout”The global holdout means no Grona anywhere for that unit. Its salt is global: plus the workspace key, and it is drawn on each campaign’s own unit id. For the workspace acme-market and the customer cus_7Hq2Lm9x, the input is v1:global:acme-market:cus_7Hq2Lm9x.
A customer held out sees no customer-unit campaign, and an install held out sees no install-unit campaign. The two draws are independent, so one shopper can be held out of customer-unit campaigns and still see install-unit ones, or the reverse. Compare the global holdout within one unit when you analyse it.
Why the holdout and variant salts are separate
Section titled “Why the holdout and variant salts are separate”Suppose one salt served both draws. The holdout would take the lowest buckets, and with a 50/50 split those buckets all belong to the first variant. Every shopper held out would come from one variant, which unbalances the split. Raising the holdout later would move more shoppers out of that one variant only.
With separate salts, the holdout draw is independent of the variant draw. The holdout takes shoppers evenly from every variant, and changing holdout_bps never moves anyone from one variant to another. The worked example below shows this.
Worked example
Section titled “Worked example”Acme Market runs a weekend breakfast banner. The workspace holds out 5% of each unit (global_holdout_bps is 500), and the campaign uses this traffic block:
{ "unit": "customer", "holdout_salt": "acme-market:breakfast:holdout", "holdout_bps": 1000, "variant_salt": "acme-market:breakfast:variant", "variants": [ {"id": "A", "weight_bps": 5000, "control": true}, {"id": "B", "weight_bps": 5000} ]}Variant A covers buckets 0 to 4999 and variant B covers 5000 to 9999.
| Shopper | Global draw (below 500 holds out) | Campaign holdout draw (below 1000 holds out) | Variant draw | Arm |
|---|---|---|---|---|
cus_7Hq2Lm9x |
1729 | 8434 | 6871, so B | treatment |
cus_9Kd3Pz7v |
5192 | 4603 | 2939, so A | control |
cus_2Bn6Xc8j |
5382 | 291 | Not drawn | holdout |
cus_9Pa2Lf6h |
162 | Not drawn | Not drawn | global_holdout |
If Acme Market raises holdout_bps to 5000, cus_9Kd3Pz7v moves into the holdout, because its holdout bucket is 4603. cus_7Hq2Lm9x stays in variant B, because its variant bucket has not changed.
Assignments are recomputed, not stored
Section titled “Assignments are recomputed, not stored”The SDK recomputes each assignment when it evaluates a campaign instead of storing it. Editing weights while a test runs moves shoppers between variants, as with any deterministic bucketing. To change a split, start a new salt instead.
Test vectors
Section titled “Test vectors”Use these vectors in your unit tests. In vectors 1, 3 and 5 the first byte of the digest is 0x80 or above, so the top bit of the 64-bit value is set. These are the cases that catch signed arithmetic.
| # | Salt | Unit id | Top bit set | Bucket |
|---|---|---|---|---|
| 1 | global:acme-market |
cus_7Hq2Lm9x |
Yes | 1729 |
| 2 | acme-market:breakfast:holdout |
cus_7Hq2Lm9x |
No | 8434 |
| 3 | acme-market:breakfast:variant |
cus_7Hq2Lm9x |
Yes | 6871 |
| 4 | acme-market:breakfast:variant |
8b0f6c2e-3d41-4f9a-9c57-2e6b1a0d4f13 |
No | 2650 |
| 5 | acme-market:breakfast:holdout |
cus_2Bn6Xc8j |
Yes | 291 |
The same vectors as JSON, with the full SHA-256 digest and the unsigned 64-bit value read from its first 8 bytes. The 64-bit values are strings because they do not fit a JavaScript number.
[ {"salt": "global:acme-market", "unit_id": "cus_7Hq2Lm9x", "sha256": "d1ae8704da012c81223809c8da516a0b18a93732699205a1ecfd9cf11735a76a", "u64": "15109162254781721729", "bucket": 1729}, {"salt": "acme-market:breakfast:holdout", "unit_id": "cus_7Hq2Lm9x", "sha256": "11df08641a3dead2cde797d0fe7f2467fba2cbf2559637d3168b89a2fcff321d", "u64": "1287757244481268434", "bucket": 8434}, {"salt": "acme-market:breakfast:variant", "unit_id": "cus_7Hq2Lm9x", "sha256": "d997eac7e2e3415717d53562ccbd9c86bf3997f4039b627db94cd114a9c79391", "u64": "15679258771939606871", "bucket": 6871}, {"salt": "acme-market:breakfast:variant", "unit_id": "8b0f6c2e-3d41-4f9a-9c57-2e6b1a0d4f13", "sha256": "4ef48e953b1e76fa154bf54966c817e97304204c5a6fc76110e1d029a052a6fe", "u64": "5689329000868902650", "bucket": 2650}, {"salt": "acme-market:breakfast:holdout", "unit_id": "cus_2Bn6Xc8j", "sha256": "e6b6130f5e5e2613341cbb0eebd90f7ea1100640b3f0aee209089c5528d8028e", "u64": "16624496031213430291", "bucket": 291}]You can check a vector from a shell without any code. This prints the digest of vector 1:
printf '%s' 'v1:global:acme-market:cus_7Hq2Lm9x' | shasum -a 256Implementations
Section titled “Implementations”import { createHash } from 'node:crypto';
export function bucket(salt, unitId) { const digest = createHash('sha256').update(`v1:${salt}:${unitId}`, 'utf8').digest(); return Number(digest.readBigUInt64BE(0) % 10000n);}The value is read as a BigInt, because a JavaScript number cannot hold 64 bits exactly.
import hashlib
def bucket(salt: str, unit_id: str) -> int: digest = hashlib.sha256(f"v1:{salt}:{unit_id}".encode("utf-8")).digest() return int.from_bytes(digest[:8], "big") % 10000import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.security.NoSuchAlgorithmException;
final class Bucketing { static int bucket(String salt, String unitId) throws NoSuchAlgorithmException { byte[] digest = MessageDigest.getInstance("SHA-256") .digest(("v1:" + salt + ":" + unitId).getBytes(StandardCharsets.UTF_8)); long x = 0; for (int i = 0; i < 8; i++) x = (x << 8) | (digest[i] & 0xFF); // x is signed, so take the unsigned remainder: 10000 = 2 * 5000 return (int) (((x >>> 1) % 5000) * 2 + (x & 1)); }}JVM note for Java and Kotlin
Section titled “JVM note for Java and Kotlin”A Java or Kotlin long that holds the first 8 bytes is signed. Whenever the top bit is set, as in vectors 1, 3 and 5, x % 10000 returns a negative number. For vector 1 it returns -9887 instead of 1729.
Use ((x >>> 1) % 5000) * 2 + (x & 1), which equals the unsigned remainder. Shifting right by one makes the value non-negative, and because 10000 is 2 times 5000, doubling the remainder of the half and adding the low bit gives the remainder of the whole. In Kotlin the operators are ushr and and. Long.remainderUnsigned gives the same result, but on Android it needs API level 26, and the formula runs on every API level.