Skip to content
The mobile SDKs are available on request. Email support@grona.ai to get access.

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.

input = UTF-8 bytes of "v1:" + salt + ":" + unit_id
digest = 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.hashCode or Swift’s hashValue.
  • Changing anything in the algorithm re-buckets every shopper, so it is fixed.

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.

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

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.

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.

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:

Terminal window
printf '%s' 'v1:global:acme-market:cus_7Hq2Lm9x' | shasum -a 256
bucket.mjs
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.

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.