-
Notifications
You must be signed in to change notification settings - Fork 0
feat(bucketing)!: anchored bucketing layout for ramping allocations (contract v12) #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
JosephSamirL
merged 5 commits into
feat/fullstack-v12
from
feat/anchored-bucketing-layout
Jul 6, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5d165f3
wf(wf-androidsdk-qs01): [AND-1] import cross-sdk golden-vector fixtur…
abbaseya 9d6bfaa
wf(wf-androidsdk-qs01): [AND-2] anchored bucketing tests + stubs (RED)
abbaseya 243561f
feat(bucketing)!: add anchored bucketing layout for ramping allocations
abbaseya 8ca9237
docs(bucketing): remove stale Phase 1 scaffolding docstrings
abbaseya 43b61d8
docs(bucketing): document anchored parity suite in PARITY.md
abbaseya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
152 changes: 152 additions & 0 deletions
152
packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| /* | ||
| * Convert Android SDK — core/bucketing | ||
| * Copyright (c) 2026 Convert Insights, Inc. | ||
| * License: Apache-2.0 | ||
| */ | ||
| package com.convert.sdk.core.bucketing | ||
|
|
||
| import com.convert.sdk.core.model.BucketingAllocation | ||
| import com.convert.sdk.core.model.VariationAllocation | ||
| import com.convert.sdk.core.model.generated.ExperienceVariationConfig | ||
| import com.convert.sdk.core.model.generated.VariationStatuses | ||
| import java.math.BigDecimal | ||
|
|
||
| /** | ||
| * Anchored-vs-packed layout resolution — qs-01 / contract v12. | ||
| * | ||
| * This file is the single testable seam through which BOTH the golden-vector | ||
| * parity suite AND [com.convert.sdk.android.ConvertContext] (Phase 2) decide | ||
| * which bucketing layout an experience uses and build that layout's inputs. | ||
| * Housing the gate + allocation builders in `:packages:core` (rather than | ||
| * only in the Android-only `:packages:sdk` module, as the original grounded | ||
| * design sketched) lets the golden-vector parity test — which lives beside | ||
| * the fixture in `:packages:core` — exercise the FULL version-gated decision | ||
| * without standing up a Robolectric `ConvertSDK` harness. See the qs-01 | ||
| * decision log for the full rationale. | ||
| * | ||
| * Mirrors the JS SDK's `DataManager` fresh-bucketing branch | ||
| * (`packages/data/src/data-manager.ts`): `_retrieveBucketing`'s | ||
| * `isAnchoredLayout` gate, `_buildVariationAllocations`, and | ||
| * `_buildPackedBuckets`. | ||
| */ | ||
|
|
||
| /** JS-parity default weight for a variation with no `traffic_allocation` set (`isNaN(ta) -> 100`). */ | ||
| private const val DEFAULT_VARIATION_PCT: Double = 100.0 | ||
|
|
||
| /** | ||
| * Anchored-layout gate threshold — qs-01 / contract v12 AC1. `version` must | ||
| * compare strictly greater than this (via [BigDecimal.compareTo], never | ||
| * `equals`) to activate the anchored layout. Mirrors the JS SDK's | ||
| * `Number(experience.version) > 11`. | ||
| */ | ||
| private val ANCHORED_LAYOUT_VERSION_THRESHOLD: BigDecimal = BigDecimal("11") | ||
|
|
||
| /** | ||
| * Anchored-vs-packed GATE — qs-01 / contract v12 AC1. `version > 11` runs | ||
| * the anchored layout; `version <= 11`, missing, or non-numeric keeps the | ||
| * packed cumulative walk. Uses [BigDecimal.compareTo] (via the `>` | ||
| * operator), never `equals` — `BigDecimal("11.0") != BigDecimal("11")` under | ||
| * `equals`, but both must compare `<= 11` here. | ||
| * | ||
| * @param version the experience's `version` field, already coerced from the | ||
| * wire's numeric-or-numeric-string form by [com.convert.sdk.core.internal.BigDecimalSerializer]. | ||
| * @return `true` iff the anchored layout (contract v12) should run. | ||
| */ | ||
| internal fun isAnchoredLayout(version: BigDecimal?): Boolean = | ||
| version != null && version > ANCHORED_LAYOUT_VERSION_THRESHOLD | ||
|
|
||
| /** | ||
| * Builds the ordered [VariationAllocation] list the anchored layout | ||
| * consumes — qs-01 / contract v12 AC5. Mirrors the JS SDK's | ||
| * `_buildVariationAllocations`: null-id entries are dropped (JS: | ||
| * `if (!variation?.id) return allocations;`), remaining entries keep config | ||
| * order, `allocation` defaults absent/non-numeric `traffic_allocation` to | ||
| * `100.0`, and `active` is `false` for a non-`RUNNING` status OR an explicit | ||
| * zero allocation (AC4 — never defaults a stopped/zero arm back to 100%). | ||
| * | ||
| * @param variations the experience's variations, in declaration order. | ||
| * @return the anchored allocation inputs, in the same order as [variations] | ||
| * (minus null-id entries). | ||
| */ | ||
| internal fun buildVariationAllocations( | ||
| variations: List<ExperienceVariationConfig>?, | ||
| ): List<VariationAllocation> = | ||
| variations | ||
| ?.mapNotNull { variation -> | ||
| val id = variation.id ?: return@mapNotNull null | ||
| val allocation = variation.trafficAllocation?.toDouble() ?: DEFAULT_VARIATION_PCT | ||
| val statusOk = variation.status == null || variation.status == VariationStatuses.RUNNING | ||
| VariationAllocation( | ||
| id = id, | ||
| allocation = allocation, | ||
| active = statusOk && allocation > 0.0, | ||
| ) | ||
| } | ||
| ?: emptyList() | ||
|
|
||
| /** | ||
| * Builds the `variationId -> percentage` map for the packed layout — the | ||
| * frozen `version <= 11` path (AC6). This is a straight, unmodified port of | ||
| * [com.convert.sdk.android.ConvertContext]'s existing `buildBuckets` filter | ||
| * chain (itself a mirror of the JS SDK's `_buildPackedBuckets`), relocated | ||
| * here so [resolveVariationId] has a single packed-vs-anchored branch point | ||
| * that both the parity test and (Phase 2) `ConvertContext` share. Not a | ||
| * Phase-1 stub: this is already-shipped, already-tested behaviour being | ||
| * moved, not new logic. | ||
| * | ||
| * @param variations the experience's variations, in declaration order. | ||
| * @return ordered map of eligible variation id to traffic percentage. | ||
| */ | ||
| internal fun buildPackedBuckets(variations: List<ExperienceVariationConfig>?): Map<String, Double> = | ||
| variations | ||
| ?.asSequence() | ||
| ?.filter { it.id != null } | ||
| ?.filter { it.status == null || it.status == VariationStatuses.RUNNING } | ||
| ?.map { it to (it.trafficAllocation?.toDouble() ?: DEFAULT_VARIATION_PCT) } | ||
| ?.filter { (_, allocation) -> allocation > 0.0 } | ||
| ?.associateByTo( | ||
| destination = linkedMapOf(), | ||
| keySelector = { (variation, _) -> variation.id!! }, | ||
| valueTransform = { (_, allocation) -> allocation }, | ||
| ) | ||
| ?: emptyMap() | ||
|
|
||
| /** | ||
| * The single version-gated bucketing decision — qs-01 / contract v12. Both | ||
| * the golden-vector parity test (`AnchoredBucketingParityTest`) and, from | ||
| * Phase 2 onward, [com.convert.sdk.android.ConvertContext.allocateAndRecord] | ||
| * call this one function so there is never a divergent duplicate of the | ||
| * gate + allocation-building logic. | ||
| * | ||
| * @param version the experience's `version` field (see [isAnchoredLayout]). | ||
| * @param variations the experience's variations, in declaration order. | ||
| * @param visitorId the visitor's opaque stable identifier. | ||
| * @param experienceId the experience's stable identifier — empty when | ||
| * `excludeExperienceIdHash` is set. | ||
| * @return a [BucketingAllocation] on success, or `null` when the visitor is | ||
| * not bucketed into any variation. | ||
| */ | ||
| public fun BucketingManager.resolveVariationId( | ||
| version: BigDecimal?, | ||
| variations: List<ExperienceVariationConfig>?, | ||
| visitorId: String, | ||
| experienceId: String = "", | ||
| ): BucketingAllocation? = | ||
| if (isAnchoredLayout(version)) { | ||
| getBucketForVisitorAnchored( | ||
| allocations = buildVariationAllocations(variations), | ||
| visitorId = visitorId, | ||
| experienceId = experienceId, | ||
| ) | ||
| } else { | ||
| val buckets = buildPackedBuckets(variations) | ||
| if (buckets.isEmpty()) { | ||
| null | ||
| } else { | ||
| getBucketForVisitor( | ||
| buckets = buckets, | ||
| visitorId = visitorId, | ||
| experienceId = experienceId, | ||
| ) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
40 changes: 40 additions & 0 deletions
40
packages/core/src/main/kotlin/com/convert/sdk/core/model/VariationAllocation.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| /* | ||
| * Convert Android SDK — core/model | ||
| * Copyright (c) 2026 Convert Insights, Inc. | ||
| * License: Apache-2.0 | ||
| */ | ||
| package com.convert.sdk.core.model | ||
|
|
||
| import kotlinx.serialization.Serializable | ||
|
|
||
| /** | ||
| * One variation's weight + activity state, as consumed by the anchored | ||
| * bucketing layout (qs-01 / contract v12). | ||
| * | ||
| * Mirrors the JS SDK's `VariationAllocation` | ||
| * (`@convertcom/js-sdk-types`, `packages/types/src/VariationAllocation.ts`): | ||
| * exactly the three fields the anchored algorithm needs. Callers build an | ||
| * ordered [List] (never a [Map] — inactive arms and declaration order both | ||
| * matter for anchor stability) via the layout resolver's allocation | ||
| * builder, then hand it to | ||
| * [com.convert.sdk.core.bucketing.BucketingManager.getBucketRanges] / | ||
| * [com.convert.sdk.core.bucketing.BucketingManager.getBucketForVisitorAnchored]. | ||
| * | ||
| * @property id the variation id, as it appears in the backing experience's | ||
| * variations list. | ||
| * @property allocation the resolved weight in `0..100` traffic-percentage | ||
| * units — already defaulted (`isNaN(ta) ? 100.0 : ta` in the JS | ||
| * reference) so this field is never `NaN`. Kept for **every** entry | ||
| * (active and inactive) because [active]`false` entries still contribute | ||
| * their weight to the anchor space (anchor stability under stops). | ||
| * @property active whether this entry claims a non-zero-width range in the | ||
| * anchored layout. `false` for a `stopped` variation or an explicit | ||
| * `traffic_allocation: 0` — the entry's weight still counts toward | ||
| * `totalWeight`, but its range width is forced to zero. | ||
| */ | ||
| @Serializable | ||
| public data class VariationAllocation( | ||
| public val id: String, | ||
| public val allocation: Double, | ||
| public val active: Boolean, | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.