Cohorts and rollout math¶
Rollouts in SimpleOTA are deterministic. Same (seed, device_id)
always produces the same cohort bucket. This means:
- Ramping
percentagefrom5→25only adds devices to the exposed set; it never re-rolls and demotes anyone. - Re-creating a deployment with the same seed reproduces the same decisions, useful for surgical retries.
- A device's exposure depends on its database UUID (
device.id, assigned at registration) and the deployment's seed (auto-generated). Note this is not thedevice_idstring your firmware reports, so you cannot steer cohort assignment by choosing device names.
The function¶
The implementation lives in deployments/rollout.py:
BUCKET_RESOLUTION = 10_000 # 0.01% granularity
def stable_bucket(rollout_seed, device_id) -> int:
"""Return a stable integer in [0, BUCKET_RESOLUTION)."""
payload = f"{rollout_seed}:{device_id}".encode("utf-8")
digest = hashlib.sha256(payload).digest()
n = int.from_bytes(digest[:8], byteorder="big")
return n % BUCKET_RESOLUTION
def is_in_rollout(rollout_seed, device_id, rollout_percentage) -> bool:
if rollout_percentage <= 0:
return False
if rollout_percentage >= 100:
return True
threshold = rollout_percentage * (BUCKET_RESOLUTION // 100)
return stable_bucket(rollout_seed, device_id) < threshold
The 10,000 buckets give rollouts 0.01% granularity. A device is in the
cohort when its bucket is below percentage * 100. SHA-256 is used rather
than Python's hash() so the result is reproducible across processes and
versions.
Properties¶
- Uniform: over a large fleet,
stable_bucketdistributes evenly across[0, 10000). Sampled over 20,000 devices the mean bucket is ~4,991 against an ideal of 5,000, and a 5% rollout catches ~5.3%. - Stable: fixing the seed, the same device always lands in the same bucket forever.
- Independent across deployments: a different seed re-rolls the bucket, so a device's exposure to deployment A says nothing about its exposure to deployment B.
Ramping safely¶
t=0 seed=S, percentage=5 → buckets 0..499 (~5% of fleet)
t=1h seed=S, percentage=25 → adds buckets 500..2499 (cumulative 25%)
t=4h seed=S, percentage=100 → every device, short-circuited to True
Anyone exposed at t=0 is still exposed at t=1h; buckets 0..499 are
strictly inside buckets 0..2499. Never change the seed mid-rollout or
this property breaks.
Worked example¶
>>> seed = "3f2a9c10-5b7e-4a12-9f3d-2b8c1e4d7a55" # deployment.rollout_seed
>>> dev = "a1b2c3d4-0000-4000-8000-000000000001" # device.id
>>> stable_bucket(seed, dev)
5370
>>> is_in_rollout(seed, dev, 5) # threshold 500
False
>>> is_in_rollout(seed, dev, 25) # threshold 2500
False
>>> is_in_rollout(seed, dev, 100)
True
This device sits in bucket 5,370, a little past halfway. It stays out of the rollout until the percentage passes 53.7%, and once it is in, it stays in as long as the seed does not change.