feat(bucketing)!: anchored bucketing layout for ramping allocations (contract v12) - #51
Conversation
…e verbatim
Beads: ai-driven-product-dev-jiwi
Copied byte-for-byte from javascript-sdk origin/feat/anchored-bucketing-layout.
sha256 9993d0e6c5719cb17a4dcbcc73af9c0cb14ee0258e7fd29cc88f44b61083dd1b, 59 vectors {11:19,12:40}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-fo5c Phase 1 TDD RED. New: BucketingLayoutResolver (gate + allocation builders + resolveVariationId seam), VariationAllocation model, anchored selector stubs in BucketingManager, table-driven golden-vector parity test (59 vectors) + AC1-AC9 acceptance tests. 21 new tests fail against stubs (12 parity discriminating v12 + 9 acceptance); packed path + HashParityTest (69) green, zero regressions; module compiles; detekt clean. ConvertContext not yet wired (Phase 2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the anchored bucketing layout as an additional selection pass beside the existing packed walk. Each variation is anchored to its own position in the allocation space, so raising or lowering an experience's total traffic allocation keeps every already-bucketed visitor on the same variation and admits or ejects visitors evenly at each arm's own edge. The layout is selected per experience from the served config's version field. BREAKING CHANGE: The bucketing contract is now versioned. Experiences served with version greater than 11 use the anchored layout; version 11 or lower, missing, or non-numeric continue to use the packed layout unchanged. For an experience on the new layout, visitor-to-variation assignments differ from the packed layout for the same inputs.
Removes three leftover "Phase 1 (RED)" KDoc blocks that no longer describe the shipped behavior: the isAnchoredLayout gate KDoc's stub/TODO note (the gate is real), and the "expected to FAIL" notes atop the anchored parity and acceptance test classes (all vectors and assertions pass). No code, test assertion, constant, or fixture changed.
There was a problem hiding this comment.
Code Review
This pull request introduces the new anchored bucketing layout (contract v12) to the Android SDK, enabling version-gated layout resolution between the legacy packed cumulative walk and the new anchored layout. The core logic has been consolidated in the :packages:core module with the addition of BucketingLayoutResolver.kt, VariationAllocation.kt, and corresponding bucketing manager updates, which are fully validated by new acceptance and cross-SDK parity tests. Additionally, ConvertContext has been refactored to delegate to this shared resolution seam. The review feedback suggests a clean refactoring of buildPackedBuckets using mapNotNull to optimize sequence operations and eliminate an unsafe double-bang (!!) operator.
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.
Adds the second cross-SDK bucketing parity suite (anchored layout, contract v12) alongside the existing hash-vector suite: the cross-sdk-bucketing-vectors fixture (imported verbatim from the JS reference, not generated) and its AnchoredBucketingParityTest gate.
There was a problem hiding this comment.
✅ Approve — faithful, byte-verified parity port of the anchored bucketing layout
Reviewed the full diff plus the surrounding runtime code and compared it line-by-line against the JS feat/anchored-bucketing-layout reference. No correctness defects.
Parity verified (against the JS reference, not just self-consistency)
- Fixture is verbatim —
cross-sdk-bucketing-vectors.jsonis byte-for-byte identical to the JSpackages/bucketing/tests/cross-sdk-bucketing-vectors.jsonsource (sha2569993d0e6…83dd1b), including the v11 ≡ v12 coincidence cases (single-arm, 100%-total). getBucketRangesmatches JSbucketing-manager.tsexactly:totalWeight ≤ 0 → [],anchor = (cum/total)·10000,width = active ? alloc·100 : 0, cumulative weight over all entries (active + inactive) → the anchor-stability / raise-is-superset property.- Anchor scale uses the fixed
DEFAULT_BUCKETING_MAX_TRAFFICconstant, matching JS (DEFAULT_MAX_TRAFFIC, not the configurablemaxTraffic). selectBucketAnchoredis the identical half-open[anchor, anchor+width), first match in config order.buildVariationAllocationsactiveflag equals JS_buildVariationAllocationsfor every backend-emitted shape (absent → 100/active, explicit 0 → inactive, stopped → inactive-but-weight-preserved).- Gate wired to a real runtime field —
ConfigExperience.version(@Contextual BigDecimal? = null) exists and defaults null → packed. (The golden test bindsversionto a test-only class, so the runtime field warranted separate confirmation.) - Single bucketing seam — the only
getBucketForVisitor*caller insrc/mainisConvertContext.allocateAndRecord → resolveVariationId;experience.versionis read nowhere else. Nothing bypasses the gate. - Determinism preserved —
getBucketForVisitorAnchoredreusesgetValueVisitorBasedunchanged (seed 9999); the MurmurHash path is untouched (69-vectorHashParityTeststill green); the sticky / stored-decision recall (runExperienceStep 3,resolveSticky(...)?.let { return it }) short-circuits before the gate and is byte-untouched by this diff, so an already-bucketed visitor never re-enters the layout switch. - Ships inert — backend
ExperienceService::CURRENT_EXPERIENCE_VERSION = 11, and the gate maps both11and missing → packed, so no served experience activates the anchored layout until the separate backend bump. - Packed path frozen (AC6) — the new
buildPackedBucketsis token-identical to the removedbuildBuckets; clean removal, no dangling refs. Constants match JS (seed 9999, maxTraffic 10000).
One caveat on the version gate
The "non-numeric → packed" description isn't literally true on Android. BigDecimalSerializer coerces JSON numbers and numeric strings ("11"/"12" → parity with JS Number(x)), but a genuinely non-numeric version throws NumberFormatException → config decode failure, rather than JS's silent NaN → packed. This is unreachable in practice (the backend always stamps an integer, per the constant above) and is a pre-existing property of the shared @Contextual BigDecimal serializer used across every numeric config field — not introduced here. Flagging only so the parity guarantee reads precisely: numeric / numeric-string / missing → packed; alphabetic → decode failure, never the anchored path.
LGTM to merge into feat/fullstack-v12.
Summary
Adds the anchored bucketing layout as an additional selection pass beside the existing packed walk, selected per experience from the served config's
versionfield:experience.version > 11→ anchored;version ≤ 11, missing, or non-numeric → packed, bit-for-bit as today. This is the Android SDK pass of a cross-SDK breaking change (bucketing contract v12) — a faithful sibling of the JS reference and the already-shipped Python, PHP, and Ruby passes.Each variation is anchored to its own position in the allocation space, so raising or lowering an experience's total traffic allocation keeps every already-bucketed visitor on the same variation and admits/ejects visitors evenly at each arm's own edge (fixing the packed layout's "new band lands on the last variation" defect, Asana 1215457291725231).
Ships inert: the backend currently stamps every experience with
version = 11, so no served experience activates anchored until a separate backend bump (out of scope). Hash, seed (9999), stored-decision guard, forced-variation path, and event payloads are all untouched.What changed
:packages:coreBucketingManager— new anchored selector (getBucketRanges/selectBucketAnchored/getBucketForVisitorAnchored):totalWeightover all entries (active + inactive),totalWeight ≤ 0→ not bucketed,anchor = (cumWeight/totalWeight)·10000,width = active ? allocation·100 : 0, half-open[anchor, anchor+width). Reuses the frozen hash path unchanged.:packages:coreBucketingLayoutResolver(new) — theversion > 11gate (BigDecimal.compareTo, mirroring JSNumber(version) > 11), the allocation builder (keeps inactive arms at zero width; absent traffic → 100, explicit 0 → zero width never 100), the relocated frozen packed builder, and a singleresolveVariationIdseam.:packages:sdkConvertContext—allocateAndRecordwired to the sharedresolveVariationIdseam (no divergent duplicate between runtime and tests). Sticky/stored-decision recall still short-circuits upstream — unchanged.Tests (hard gate — all green)
9993d0e6…83dd1b) via a table-driven parameterized runner.ta:0zero-width, defaults/boundaries, packed regression lock, sticky precedence, no event/return-type drift).HashParityTest69/69 andBucketingManagerTest16/16 green unchanged; packed path is byte-identical forversion ≤ 11.Notes for reviewers
Related sibling PRs: JS reference (
javascript-sdk), Python (python-sdk#52), PHP, Ruby.