feat!: anchored bucketing layout for traffic ramping (contract v12)#41
Conversation
Beads: ai-driven-product-dev-0etc Adds the 59-vector cross-SDK bucketing fixture (19 v11 packed-regression + 40 v12 anchored) shared verbatim across all Convert SDKs. sha256 9993d0e6c5719cb17a4dcbcc73af9c0cb14ee0258e7fd29cc88f44b61083dd1b. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-t6in Adds the anchored parity suite (59-vector sweep) + AC1-AC9 gate/boundary tests and a deliberately-unimplemented AnchoredBucketing.selectBucket stub so the target compiles and the new assertions fail. Packed bucket()/selectBucket untouched (additive only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an anchored bucketing layout selected per experience by the served `experience.version` field. When `version` is greater than 11 the SDK buckets visitors under the anchored layout; experiences at version 11 or below (and those with a missing or non-numeric version) continue to use the existing packed layout, bit for bit. Under the anchored layout each variation occupies a band anchored to its cumulative position in the allocation space rather than being packed end to end. Raising an experiment's total traffic allocation therefore admits new visitors at each arm's own edge and never relocates a visitor who was already bucketed, and lowering allocation ejects out-of-range visitors without ever flipping a retained visitor to a different arm. Stopped arms and arms with an explicit zero allocation keep their anchor position but receive zero width. The hash, seed, scaling, stored-decision precedence, forced-variation path, and event payloads are unchanged. BREAKING CHANGE: bumps the cross-SDK bucketing contract to v12. Experiences served with `version` greater than 11 are bucketed with the anchored layout, which assigns different variations than the previous packed layout for the same visitor. Experiences at version 11 or below are unaffected.
…tation Beads: ai-driven-product-dev-t6in Removes stale Phase-1 RED/stub framing from the anchored test doc comments so they describe the current implemented behavior. Comment-only; no assertions or production code changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the anchored bucketing layout (contract v12+) via the new AnchoredBucketing module, gates its usage in BucketingManager based on the experience version, and updates ExperienceManager to route through this version gate. It also adds extensive test suites and JSON fixtures to ensure cross-SDK parity and boundary correctness. The review feedback suggests simplifying the allocation logic in AnchoredBucketing to improve consistency and readability, as well as removing redundant nil-coalescing operations in BucketingManager by directly utilizing the guaranteed non-nil selectedId.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
…id directly Beads: ai-driven-product-dev-t6in Addresses code-review feedback (behavior-preserving): - buildAllocations: drop the redundant isDefaulted term (allocation>0 already covers the NaN/absent->100 case) and use the if-let default form. - bucketVersionGated: use the resolved selectedId directly instead of selected.id ?? "" (the arm was matched on id, so it is guaranteed equal). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Approved ✅
Reviewed the anchored bucketing layout end-to-end and it holds up:
- Anchored algorithm is correct — recomputed the golden bands by hand (thirds 15%/25%, stopped-arm stability, NaN-default,
ta:0zero-width, total-weight-zero); all match, including thetotal ≠ 100"sliver" growth and the half-open[anchor, anchor+width)edges. - Cross-SDK parity confirmed —
cross-sdk-bucketing-vectors.jsonis byte-identical (sha2569993d0e6…83dd1b) to the JS reference in convertcom/javascript-sdk#414, the version gate matches (experience.version > 11), and the port mirrors the JS_buildVariationAllocations/getBucketRanges/selectBucketAnchoredline-for-line. - Determinism preserved — MurmurHash3, seed, and the stored-decision short-circuit are untouched; the anchored path re-derives the bucket value byte-for-byte identically to the packed path, and the event shape (one
.bucketing,segments: nil) is unchanged. - Gate coverage is complete — every production path (
runExperience(s), andrunFeature(s)viaFeatureManager) routes throughExperienceManager.selectVariation→bucketVersionGated; nothing bypasses it. - Anchored layout ships inert — it can't activate while the backend stamps
CURRENT_EXPERIENCE_VERSION = 11. - Ran
swift testlocally (Swift 6.2.3): 337 tests / 67 suites pass, 0 failures, including the 59-vector parity sweep and the AC gate/boundary suites.
Nice work on the sha-pinned shared fixture and the parameterized suites. ✅
Summary
Adds the anchored bucketing layout as an extra pass beside the existing packed walk, selected per experience by the served
experience.versionfield:version > 11→ anchored;version ≤ 11/ missing / non-numeric → packed (bit-for-bit as today).This is a breaking change to the cross-SDK bucketing contract (v12) but ships INERT — the backend currently stamps every experience
version 11, so no served experience triggers the anchored path until a separate backend bump to 12. Hash, seed (9999), scaling, stored-decision guard, forced-variation path, and event payloads are all unchanged.Under the anchored layout each variation occupies a band anchored to its cumulative position, so raising an experiment's total traffic admits new visitors at each arm's own edge instead of relocating existing assignments (fixes the packed-layout reallocation defect). The JS SDK is the cross-SDK parity oracle; equality is proven by a shared golden-vector fixture.
Changes
AnchoredBucketing.swift— new anchored selector + allocation builder (config-order allocations,isNaN/absent → 100.0, active/inactive with weight-preserving zero-width inactive arms, half-open[anchor, anchor+width)), a faithful port of the JS reference.BucketingManager.swift— newbucketVersionGatedversion-gated entry point; packedeligiblebuilder now defaults an omitted/NaNtraffic_allocationto 100.0 to match the JS packed builder (the only packed-path change; the accumulate-first-wins walk is untouched).ExperienceManager.swift—selectVariationnow routes bucketing throughbucketVersionGated(sticky short-circuit still precedes it, so stored-decision precedence is unchanged).cross-sdk-bucketing-vectors.json, shared verbatim across all Convert SDKs).Tests
swift test→ 337/337 passing.AnchoredBucketingParity→ 59/59 golden vectors (19 v11 packed-regression + 40 v12 anchored) — AC7.HashParity(74 cases) green unchanged; packed path bit-identical forversion ≤ 11(AC6).--strict: 0 violations.Notes
Coordination
The backend's next
CURRENT_EXPERIENCE_VERSIONbump to 12 MUST be the anchored-rollout bump (an unrelated bump to 12 would activate anchored prematurely). Safe deploy order is SDK-first.