Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
Comment thread
abbaseya marked this conversation as resolved.

/**
* 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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,32 @@ package com.convert.sdk.core.bucketing
import com.convert.sdk.core.config.ConfigDefaults
import com.convert.sdk.core.config.ConvertConfig
import com.convert.sdk.core.model.BucketingAllocation
import com.convert.sdk.core.model.VariationAllocation
import com.convert.sdk.core.port.Logger
import com.goncalossilva.murmurhash.MurmurHash3

/**
* A precomputed anchored bucket range for a single variation — qs-01 /
* contract v12. `anchor` and `anchor + width` bound the half-open interval
* `[anchor, anchor + width)` (per-10000 traffic space) that maps to [id]
* in the anchored layout. Mirrors the JS SDK's `BucketAnchoredRange`
* (`packages/bucketing/src/interfaces/bucketing-manager.ts`).
*
* @property id the variation id this range resolves to.
* @property anchor the range's lower (inclusive) bound, in `0..10000`
* traffic-space units. Computed from the entry's position in the
* cumulative weight walk — NOT from its own allocation — which is what
* gives the layout its "raise is a superset" property (AC2).
* @property width the range's span. `entry.allocation * 100` when
* [com.convert.sdk.core.model.VariationAllocation.active] is `true`,
* else `0.0` (AC4).
*/
public data class BucketAnchoredRange(
public val id: String,
public val anchor: Double,
public val width: Double,
)

/**
* Deterministic bucketing engine that hashes visitor + experience identity
* into a 0..`maxTraffic` integer and selects the matching variation from a
Expand Down Expand Up @@ -228,6 +251,107 @@ public class BucketingManager(
)
}

/**
* Builds the anchored bucket layout for [allocations] — qs-01 / contract
* v12. Mirrors the JS SDK's `getBucketRanges`
* (`packages/bucketing/src/bucketing-manager.ts`): anchors are computed
* over the total weight of ALL entries (active and inactive) so that
* raising an experience's total allocation only ever grows arms (AC2)
* and never reshuffles an already-bucketed visitor (AC3). Inactive
* entries keep their weight for anchor stability but resolve to a
* zero-width range (AC4).
*
* `totalWeight <= 0.0` (AC5 — e.g. every entry allocation is zero) short
* circuits to an empty list: there is nothing to anchor against.
*
* @param allocations variation allocations in config order.
* @return the anchored ranges, in the same order as [allocations].
*/
public fun getBucketRanges(allocations: List<VariationAllocation>): List<BucketAnchoredRange> {
val totalWeight = allocations.sumOf { it.allocation }
if (totalWeight <= 0.0) {
logger.debug(
message = "BucketingManager.getBucketRanges() allocations=$allocations " +
"totalWeight=$totalWeight (not bucketable)",
tag = TAG,
)
return emptyList()
}

var cumWeight = 0.0
val ranges = allocations.map { entry ->
val anchor = (cumWeight / totalWeight) * ConfigDefaults.DEFAULT_BUCKETING_MAX_TRAFFIC
val width = if (entry.active) entry.allocation * PERCENTAGE_TO_BASIS_MULTIPLIER else 0.0
cumWeight += entry.allocation
BucketAnchoredRange(id = entry.id, anchor = anchor, width = width)
}

logger.debug(
message = "BucketingManager.getBucketRanges() allocations=$allocations " +
"totalWeight=$totalWeight ranges=$ranges",
tag = TAG,
)
return ranges
}

/**
* Selects the variation whose anchored range contains [value] — qs-01 /
* contract v12. Mirrors the JS SDK's `selectBucketAnchored`: the first
* range (in [ranges] order — mirrors [getBucketRanges]'s output order)
* whose half-open interval `[anchor, anchor + width)` contains [value]
* wins (AC5 boundary semantics).
*
* @param ranges anchored bucket ranges (see [getBucketRanges]).
* @param value the bucket value, typically produced by [getValueVisitorBased].
* @return the matching variation id, or `null` when not bucketed.
*/
public fun selectBucketAnchored(ranges: List<BucketAnchoredRange>, value: Int): String? {
val selected = ranges.firstOrNull { range ->
value >= range.anchor && value < range.anchor + range.width
}?.id

logger.debug(
message = "BucketingManager.selectBucketAnchored() ranges=$ranges value=$value selected=$selected",
tag = TAG,
)
return selected
}

/**
* Convenience combining [getValueVisitorBased] + [getBucketRanges] +
* [selectBucketAnchored] into a single call — qs-01 / contract v12
* counterpart to [getBucketForVisitor]. Reuses the existing
* visitor-based hash value UNCHANGED (AC6 — hash path is frozen);
* only the range-resolution step differs from the packed layout.
*
* @param allocations variation allocations in config order.
* @param visitorId the visitor's opaque stable identifier.
* @param seed optional seed override; `null` uses [hashSeed].
* @param experienceId the experience's stable identifier — empty when
* `excludeExperienceIdHash` is set.
* @return a [BucketingAllocation] on success, or `null` when no bucket matched.
*/
public fun getBucketForVisitorAnchored(
allocations: List<VariationAllocation>,
visitorId: String,
seed: Int? = null,
experienceId: String = "",
): BucketingAllocation? {
val value = getValueVisitorBased(
visitorId = visitorId,
experienceId = experienceId,
seed = seed ?: hashSeed,
)
val variationId = selectBucketAnchored(
ranges = getBucketRanges(allocations),
value = value,
) ?: return null
return BucketingAllocation(
variationId = variationId,
bucketingAllocation = value,
)
}

public companion object {
private const val TAG: String = "BucketingManager"

Expand Down
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,
)
Loading
Loading