Skip to content

feat(bucketing)!: anchored bucketing layout for ramping allocations (contract v12) - #51

Merged
JosephSamirL merged 5 commits into
feat/fullstack-v12from
feat/anchored-bucketing-layout
Jul 6, 2026
Merged

feat(bucketing)!: anchored bucketing layout for ramping allocations (contract v12)#51
JosephSamirL merged 5 commits into
feat/fullstack-v12from
feat/anchored-bucketing-layout

Conversation

@abbaseya

@abbaseya abbaseya commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the anchored bucketing layout as an additional selection pass beside the existing packed walk, selected per experience from the served config's version field: 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:core BucketingManager — new anchored selector (getBucketRanges / selectBucketAnchored / getBucketForVisitorAnchored): totalWeight over 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:core BucketingLayoutResolver (new) — the version > 11 gate (BigDecimal.compareTo, mirroring JS Number(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 single resolveVariationId seam.
  • :packages:sdk ConvertContextallocateAndRecord wired to the shared resolveVariationId seam (no divergent duplicate between runtime and tests). Sticky/stored-decision recall still short-circuits upstream — unchanged.

Tests (hard gate — all green)

  • Cross-SDK golden vectors 59/59 (v11 packed × 19 + v12 anchored × 40), consumed verbatim from the shared fixture (sha256 9993d0e6…83dd1b) via a table-driven parameterized runner.
  • AC1–AC9 acceptance 12/12 (gate branching, raise-is-superset incl. the exact 15%→25% table, lower-ejects-evenly-no-flip, stops/ta:0 zero-width, defaults/boundaries, packed regression lock, sticky precedence, no event/return-type drift).
  • Packed regression lock (AC6): existing HashParityTest 69/69 and BucketingManagerTest 16/16 green unchanged; packed path is byte-identical for version ≤ 11.
  • detekt clean; Kover coverage gates (core 85, sdk 70) pass.

Notes for reviewers

  • No OpenAPI / serving-schema change — this is an SDK-internal behavioral change gated on a field the served config already carries.
  • The golden-vector fixture is the immutable cross-SDK contract; if a vector ever fails, the bug is in the port, not the fixture.

Related sibling PRs: JS reference (javascript-sdk), Python (python-sdk#52), PHP, Ruby.

abbaseya and others added 4 commits July 4, 2026 04:45
…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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@abbaseya abbaseya self-assigned this Jul 4, 2026
@abbaseya
abbaseya requested a review from a team July 4, 2026 03:32
@abbaseya
abbaseya changed the base branch from main to feat/fullstack-v12 July 4, 2026 15:03

@JosephSamirL JosephSamirL left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 verbatimcross-sdk-bucketing-vectors.json is byte-for-byte identical to the JS packages/bucketing/tests/cross-sdk-bucketing-vectors.json source (sha256 9993d0e6…83dd1b), including the v11 ≡ v12 coincidence cases (single-arm, 100%-total).
  • getBucketRanges matches JS bucketing-manager.ts exactly: 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_TRAFFIC constant, matching JS (DEFAULT_MAX_TRAFFIC, not the configurable maxTraffic).
  • selectBucketAnchored is the identical half-open [anchor, anchor+width), first match in config order.
  • buildVariationAllocations active flag equals JS _buildVariationAllocations for every backend-emitted shape (absent → 100/active, explicit 0 → inactive, stopped → inactive-but-weight-preserved).
  • Gate wired to a real runtime fieldConfigExperience.version (@Contextual BigDecimal? = null) exists and defaults null → packed. (The golden test binds version to a test-only class, so the runtime field warranted separate confirmation.)
  • Single bucketing seam — the only getBucketForVisitor* caller in src/main is ConvertContext.allocateAndRecord → resolveVariationId; experience.version is read nowhere else. Nothing bypasses the gate.
  • Determinism preservedgetBucketForVisitorAnchored reuses getValueVisitorBased unchanged (seed 9999); the MurmurHash path is untouched (69-vector HashParityTest still green); the sticky / stored-decision recall (runExperience Step 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 both 11 and missing → packed, so no served experience activates the anchored layout until the separate backend bump.
  • Packed path frozen (AC6) — the new buildPackedBuckets is token-identical to the removed buildBuckets; 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.

@JosephSamirL
JosephSamirL merged commit 7e6e897 into feat/fullstack-v12 Jul 6, 2026
10 checks passed
@JosephSamirL
JosephSamirL deleted the feat/anchored-bucketing-layout branch July 6, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants