diff --git a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt new file mode 100644 index 000000000..f20c5659e --- /dev/null +++ b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingLayoutResolver.kt @@ -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?, +): List = + 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?): Map = + 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?, + 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, + ) + } + } diff --git a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt index a149603b1..bb0b6a65d 100644 --- a/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt +++ b/packages/core/src/main/kotlin/com/convert/sdk/core/bucketing/BucketingManager.kt @@ -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 @@ -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): List { + 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, 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, + 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" diff --git a/packages/core/src/main/kotlin/com/convert/sdk/core/model/VariationAllocation.kt b/packages/core/src/main/kotlin/com/convert/sdk/core/model/VariationAllocation.kt new file mode 100644 index 000000000..ac6b9dff0 --- /dev/null +++ b/packages/core/src/main/kotlin/com/convert/sdk/core/model/VariationAllocation.kt @@ -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, +) diff --git a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt new file mode 100644 index 000000000..a5a133ca4 --- /dev/null +++ b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingAcceptanceTest.kt @@ -0,0 +1,237 @@ +/* + * Convert Android SDK — core/bucketing tests + * Copyright (c) 2026 Convert Insights, Inc. + * License: Apache-2.0 + */ +package com.convert.sdk.core.bucketing + +import com.convert.sdk.core.config.ConvertConfig +import com.convert.sdk.core.model.VariationAllocation +import com.convert.sdk.core.model.generated.ExperienceVariationConfig +import com.convert.sdk.core.model.generated.VariationStatuses +import com.convert.sdk.core.port.Logger +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal +import java.util.stream.Stream + +/** + * Focused AC1-AC5 coverage for the anchored bucketing layout — qs-01 / + * contract v12. Complements [AnchoredBucketingParityTest] (which drives the + * shared cross-SDK golden vectors end-to-end): this file isolates each + * acceptance criterion against the individual `BucketingLayoutResolver.kt` + * / [BucketingManager] anchored functions so a failure points at the exact + * broken primitive rather than only a vector's final id. + * + * AC6 (packed regression lock) and AC7 (golden vectors) are covered by + * [AnchoredBucketingParityTest] plus the pre-existing, unmodified + * [HashParityTest] / [BucketingManagerTest] suites — no new assertions + * needed here. AC8 (stored-decision precedence) and AC9 (event/API + * stability) are [com.convert.sdk.android.ConvertContext] concerns with NO + * code changes in this qs-01 pass (`resolveSticky` short-circuits before + * the gate; event/return shapes are untouched) — already exercised by the + * existing `ConvertContextRunExperienceTest` suite, so no new test is + * added for them either. + */ +internal class AnchoredBucketingAcceptanceTest { + + // --- AC1: gate branching ----------------------------------------------- + + @ParameterizedTest(name = "{0}") + @MethodSource("gateVectors") + fun `AC1 gate branches on version`(description: String, version: BigDecimal?, expectedAnchored: Boolean) { + assertEquals(expectedAnchored, isAnchoredLayout(version), description) + } + + // --- AC2: raise is a superset (exact 15% -> 25% 3-arm table) ----------- + + @Test + fun `AC2 raising 15pct to 25pct keeps every already-bucketed visitor's arm`() { + val ranges15 = BucketingManager(TEST_CONFIG, TEST_LOGGER).getBucketRanges(threeEqualArms(FIFTEEN_PCT)) + val ranges25 = BucketingManager(TEST_CONFIG, TEST_LOGGER).getBucketRanges(threeEqualArms(TWENTY_FIVE_PCT)) + + // Spec table (qs-01 "Layouts at 15% -> 25%, 3 equal arms"): + // anchored 15%: O [0,500) V1 [3333.33,3833.33) V2 [6666.67,7167) + // anchored 25%: O [0,833.33) V1 [3333.33,4166.67) V2 [6666.67,7500) + assertRange(ranges15, id = "O", anchor = 0.0, width = 500.0) + assertRange(ranges15, id = "V1", anchor = THIRD_OF_10000, width = 500.0) + assertRange(ranges15, id = "V2", anchor = TWO_THIRDS_OF_10000, width = 500.0) + assertRange(ranges25, id = "O", anchor = 0.0, width = 833.3333333333334) + assertRange(ranges25, id = "V1", anchor = TWENTY_FIVE_PCT_THIRD_OF_10000, width = 833.3333333333334) + assertRange(ranges25, id = "V2", anchor = TWENTY_FIVE_PCT_TWO_THIRDS_OF_10000, width = 833.3333333333334) + + // A visitor at value 3500 sits inside V1's 15% band AND inside V1's + // (superset) 25% band -> same arm both times, never reassigned. + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + assertEquals("V1", manager.selectBucketAnchored(ranges15, value = SAMPLE_V1_VALUE)) + assertEquals("V1", manager.selectBucketAnchored(ranges25, value = SAMPLE_V1_VALUE)) + } + + // --- AC3: lower ejects evenly and never flips --------------------------- + + @Test + fun `AC3 lowering 25pct to 15pct ejects out-of-range visitors without reassigning them`() { + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + val ranges25 = manager.getBucketRanges(threeEqualArms(TWENTY_FIVE_PCT)) + val ranges15 = manager.getBucketRanges(threeEqualArms(FIFTEEN_PCT)) + + // value 4000 is inside V1's 25% band [3333.33,4166.67) but OUTSIDE + // V1's 15% band [3333.33,3833.33) -> ejected to not-bucketed, never + // reassigned to O or V2 (the packed-layout flip this qs-01 fixes). + assertEquals("V1", manager.selectBucketAnchored(ranges25, value = EJECTED_VALUE)) + assertNull(manager.selectBucketAnchored(ranges15, value = EJECTED_VALUE)) + } + + // --- AC4: stops / explicit ta:0 zero the arm's width, others unchanged - + + @Test + fun `AC4 stopping one arm zero-widths only that arm, other anchors byte-identical`() { + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + val allRunning = threeEqualArms(FIFTEEN_PCT) + val v1Stopped = listOf( + VariationAllocation(id = "O", allocation = FIFTEEN_PCT, active = true), + VariationAllocation(id = "V1", allocation = FIFTEEN_PCT, active = false), + VariationAllocation(id = "V2", allocation = FIFTEEN_PCT, active = true), + ) + + val rangesRunning = manager.getBucketRanges(allRunning) + val rangesStopped = manager.getBucketRanges(v1Stopped) + + // O and V2 keep IDENTICAL anchors + widths (weight preserved by the + // stopped V1 arm) — only V1's width drops to zero. + assertEquals(rangesRunning.first { it.id == "O" }, rangesStopped.first { it.id == "O" }) + assertEquals(rangesRunning.first { it.id == "V2" }, rangesStopped.first { it.id == "V2" }) + val stoppedV1 = rangesStopped.first { it.id == "V1" } + assertEquals(THIRD_OF_10000, stoppedV1.anchor) + assertEquals(0.0, stoppedV1.width) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("inactiveArmVectors") + fun `AC4 inactive arms keep their weight but lose active status`( + description: String, + variation: ExperienceVariationConfig, + expectedAllocation: Double, + ) { + val allocations = buildVariationAllocations(listOf(variation)) + val entry = allocations.first { it.id == variation.id } + + assertEquals(expectedAllocation, entry.allocation, "$description: allocation (weight) must be preserved") + assertEquals(false, entry.active, "$description: an inactive arm is never active regardless of weight") + } + + // --- AC5: defaults + boundaries ----------------------------------------- + + @Test + fun `AC5 absent traffic_allocation defaults to 100pct weight`() { + val variation = ExperienceVariationConfig(id = "SOLO", trafficAllocation = null, status = null) + + val allocations = buildVariationAllocations(listOf(variation)) + + assertEquals(1, allocations.size) + assertEquals(100.0, allocations.first().allocation) + assertEquals(true, allocations.first().active) + } + + @Test + fun `AC5 totalWeight of zero or less is not bucketed`() { + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + val allZero = listOf( + VariationAllocation(id = "O", allocation = 0.0, active = false), + VariationAllocation(id = "V1", allocation = 0.0, active = false), + ) + + val ranges = manager.getBucketRanges(allZero) + + assertEquals(emptyList(), ranges) + assertNull(manager.selectBucketAnchored(ranges, value = 0)) + } + + @Test + fun `AC5 boundary value equal to anchor is in, equal to anchor plus width is out`() { + val manager = BucketingManager(TEST_CONFIG, TEST_LOGGER) + val ranges = listOf(BucketAnchoredRange(id = "X", anchor = BOUNDARY_ANCHOR, width = BOUNDARY_WIDTH)) + + assertEquals("X", manager.selectBucketAnchored(ranges, value = BOUNDARY_ANCHOR.toInt())) + assertNull(manager.selectBucketAnchored(ranges, value = (BOUNDARY_ANCHOR + BOUNDARY_WIDTH).toInt())) + } + + // --- shared fixtures / helpers ------------------------------------------ + + private fun threeEqualArms(pctEach: Double): List = listOf( + VariationAllocation(id = "O", allocation = pctEach, active = true), + VariationAllocation(id = "V1", allocation = pctEach, active = true), + VariationAllocation(id = "V2", allocation = pctEach, active = true), + ) + + private fun assertRange(ranges: List, id: String, anchor: Double, width: Double) { + val range = ranges.first { it.id == id } + assertEquals(anchor, range.anchor, "$id anchor") + assertEquals(width, range.width, "$id width") + } + + companion object { + private val TEST_LOGGER = Logger.NoOp + private val TEST_CONFIG = ConvertConfig() + + private const val FIFTEEN_PCT: Double = 5.0 + private const val TWENTY_FIVE_PCT: Double = 8.333333333333334 + private const val THIRD_OF_10000: Double = 3333.333333333333 + private const val TWO_THIRDS_OF_10000: Double = 6666.666666666666 + + /** + * `ranges25`'s V1/V2 anchors are NOT bit-identical to [THIRD_OF_10000] / + * [TWO_THIRDS_OF_10000] — [TWENTY_FIVE_PCT] (8.333333333333334, the + * per-arm share of 25%) is itself an imprecise double, so + * `(cumWeight / totalWeight) * 10000` rounds to a different ULP than + * the [FIFTEEN_PCT]-derived case. Verified against the JS SDK oracle + * (`packages/bucketing/src/bucketing-manager.ts` `getBucketRanges`) + * executed directly in Node — `node -e` with the identical + * `(cumWeight/totalWeight)*10000` walk over three + * `8.333333333333334`-weighted arms prints + * `{"id":"V1","anchor":3333.3333333333335,...}` / + * `{"id":"V2","anchor":6666.666666666667,...}`, confirming this is a + * genuine floating-point-parity fact, not a Kotlin-side defect. + */ + private const val TWENTY_FIVE_PCT_THIRD_OF_10000: Double = 3333.3333333333335 + private const val TWENTY_FIVE_PCT_TWO_THIRDS_OF_10000: Double = 6666.666666666667 + private const val SAMPLE_V1_VALUE: Int = 3500 + private const val EJECTED_VALUE: Int = 4000 + private const val BOUNDARY_ANCHOR: Double = 1000.0 + private const val BOUNDARY_WIDTH: Double = 500.0 + + @JvmStatic + fun gateVectors(): Stream = Stream.of( + Arguments.of("version 11 (production stamp) -> packed", BigDecimal("11"), false), + Arguments.of("version 11.9 -> anchored", BigDecimal("11.9"), true), + Arguments.of("version 12 -> anchored", BigDecimal("12"), true), + Arguments.of("missing version (null) -> packed", null, false), + ) + + @JvmStatic + fun inactiveArmVectors(): Stream = Stream.of( + Arguments.of( + "explicit ta:0, status running -> zero weight, never defaults to 100", + ExperienceVariationConfig( + id = "ZERO_TA", + trafficAllocation = BigDecimal.ZERO, + status = VariationStatuses.RUNNING, + ), + 0.0, + ), + Arguments.of( + "stopped status, ta preserved at 5 -> weight kept for anchor stability", + ExperienceVariationConfig( + id = "STOPPED", + trafficAllocation = BigDecimal("5"), + status = VariationStatuses.STOPPED, + ), + 5.0, + ), + ) + } +} diff --git a/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt new file mode 100644 index 000000000..eb5ef864d --- /dev/null +++ b/packages/core/src/test/kotlin/com/convert/sdk/core/bucketing/AnchoredBucketingParityTest.kt @@ -0,0 +1,153 @@ +/* + * Convert Android SDK — core/bucketing tests + * Copyright (c) 2026 Convert Insights, Inc. + * License: Apache-2.0 + */ +package com.convert.sdk.core.bucketing + +import com.convert.sdk.core.config.ConvertConfig +import com.convert.sdk.core.internal.sharedSerializersModule +import com.convert.sdk.core.model.generated.ExperienceVariationConfig +import com.convert.sdk.core.port.Logger +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal +import java.util.stream.Stream + +/** + * # HARD CI GATE — DO NOT WEAKEN OR SKIP + * + * Cross-SDK anchored-bucketing-layout parity test — qs-01 / contract v12. + * Loads `cross-sdk-bucketing-vectors.json` (copied byte-for-byte from the + * JS SDK reference, see [com.convert.sdk.core.bucketing.HashParityTest] for + * the sibling hash-only fixture) and, for every vector, asserts that + * [BucketingManager.resolveVariationId] — the single version-gated + * decision seam shared with [com.convert.sdk.android.ConvertContext] + * (Phase 2) — selects the same variation id the JS SDK reference selected. + * + * A vector carries `version: 11` (packed, byte-for-byte unchanged, AC6) or + * `version: 12` (anchored, the new qs-01 layout, AC1-AC5/AC7). Both routes + * flow through [BucketingManager.resolveVariationId], so a single + * parameterised test covers the whole version-gated decision. + * + * ## Failure remediation + * + * If a vector fails: + * 1. **DO NOT** regenerate or hand-edit the vectors file to "fix" the + * discrepancy — the fixture IS the cross-SDK contract (qs-01 + * "Golden-vector fixture (consume — do NOT recompute)"). + * 2. **DO** read the failing vector's description — it names the exact + * layout, coverage percentages, and visitor under test — and diagnose + * why [BucketingManager.resolveVariationId] (or the + * `BucketingLayoutResolver.kt` functions it delegates to) diverges + * from the JS reference for that input class. + * 3. **FIX the Kotlin side** to restore parity, then confirm the test + * passes without changing the vectors. + * + * ## Vector file shape + * + * The JSON is a top-level array of objects: + * `{description, experienceId, visitorId, version, variations: [{id, + * traffic_allocation, status?}], expected}`. `variations` deserialises + * directly into the real generated [ExperienceVariationConfig] — the exact + * production type [BucketingLayoutResolver.kt] consumes — so this test + * exercises the real wire-shape coercion (e.g. `traffic_allocation` as a + * `@Contextual BigDecimal`), not a hand-rolled parallel model. + */ +internal class AnchoredBucketingParityTest { + + /** + * One golden vector as parsed from `cross-sdk-bucketing-vectors.json`. + * + * @property version the experience's `version` field. Always a plain + * numeric literal in this fixture (`11` or `12`); AC1's non-numeric / + * fractional / missing gate cases are covered separately by + * [AnchoredBucketingAcceptanceTest], not by this shared fixture. + * @property variations decodes straight into the real generated + * [ExperienceVariationConfig] list — config declaration order is + * preserved by [kotlinx.serialization]'s `List` decoding. + * @property expected the JS-SDK-computed variation id, or `null` when + * the JS reference did not bucket this visitor. + */ + @Serializable + internal data class GoldenBucketingVector( + val description: String, + val experienceId: String, + val visitorId: String, + @Contextual val version: BigDecimal, + val variations: List, + val expected: String? = null, + ) + + @ParameterizedTest(name = "{0}") + @MethodSource("goldenVectors") + fun `golden vector selects expected variation`( + description: String, + vector: GoldenBucketingVector, + ) { + val manager = BucketingManager(ConvertConfig(), Logger.NoOp) + + val actual = manager.resolveVariationId( + version = vector.version, + variations = vector.variations, + visitorId = vector.visitorId, + experienceId = vector.experienceId, + )?.variationId + + assertEquals( + vector.expected, + actual, + "Vector \"${vector.description}\": expected=${vector.expected}, got=$actual " + + "(version=${vector.version}, visitorId=${vector.visitorId}, " + + "experienceId=${vector.experienceId})", + ) + } + + companion object { + /** + * Resource path relative to the classpath root — the same fixture + * file [HashParityTest] documents the regeneration procedure for, + * imported verbatim from the JS SDK reference (qs-01 AND-1). + */ + private const val VECTORS_RESOURCE: String = "/cross-sdk-bucketing-vectors.json" + + /** Fixture is imported at exactly 59 vectors ({11: 19, 12: 40}) — qs-01 AND-1. */ + private const val EXPECTED_VECTOR_COUNT: Int = 59 + + /** + * [sharedSerializersModule] registers the `@Contextual BigDecimal` + * serializer the generated [ExperienceVariationConfig] and + * [GoldenBucketingVector.version] fields both rely on. + */ + private val json: Json = Json { + ignoreUnknownKeys = true + serializersModule = sharedSerializersModule + } + + private fun loadVectors(): List { + val stream = AnchoredBucketingParityTest::class.java.getResourceAsStream(VECTORS_RESOURCE) + ?: error( + "Missing test resource $VECTORS_RESOURCE — copy verbatim from the JS SDK " + + "reference branch `feat/anchored-bucketing-layout`, file " + + "`packages/bucketing/tests/cross-sdk-bucketing-vectors.json` (qs-01 AND-1).", + ) + val text = stream.bufferedReader(Charsets.UTF_8).use { it.readText() } + return json.decodeFromString(text) + } + + @JvmStatic + fun goldenVectors(): Stream { + val vectors = loadVectors() + check(vectors.size == EXPECTED_VECTOR_COUNT) { + "Expected exactly $EXPECTED_VECTOR_COUNT golden vectors (qs-01 AND-1), " + + "got ${vectors.size}. Check cross-sdk-bucketing-vectors.json import." + } + return vectors.stream().map { vector -> Arguments.of(vector.description, vector) } + } + } +} diff --git a/packages/core/src/test/resources/cross-sdk-bucketing-vectors.json b/packages/core/src/test/resources/cross-sdk-bucketing-vectors.json new file mode 100644 index 000000000..d1015509f --- /dev/null +++ b/packages/core/src/test/resources/cross-sdk-bucketing-vectors.json @@ -0,0 +1,701 @@ +[ + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 601) now lands in O's RELOCATED band [0,833.33) -> reassigned to O. Documents the Distilled.ie incident: raising total allocation FLIPPED this visitor from V1 to O under the packed cumulative walk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) lands in V2's band [1000,1500) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1213) now lands in V1's RELOCATED band [833.33,1666.67) -> reassigned to V1. Second flip from the same incident (V2 -> V1)", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 877 (visitor thirds-stable-V1-77) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 877) still lands in V1's band [833.33,1666.67) -> V1 unaffected. Contrast vector: not every visitor flips on a packed raise, only those whose value falls inside a relocated sub-range", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1547 (visitor thirds-null-to-V1-25pct-48) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][lower-ejection-contrast] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1547) is newly admitted into V1's band [833.33,1666.67) at 25%. Read in reverse (25% -> 15%), this is AC3's packed lower-skew contrast vector: lowering coverage EJECTS this visitor to null, it is never reassigned to a different arm", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1733 (visitor thirds-null-to-V2-25pct-majority-6) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][incident-skew] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1733) is newly admitted into V2's band [1666.67,2500) at 25%. Documents the incident's uneven skew: the newly opened packed band overwhelmingly favors the LAST arm (V2), not an even 3-way split", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 3134 (visitor thirds-idle-both-packed-3) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 3134) ALSO exceeds the 25% total allocation -> not bucketed at either coverage", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 293) stays in O's SUPERSET band [0,833.33) -> O. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 3617 (visitor thirds-anchored-V1-core-1) lands in V1's band [3333.33,3833.33) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3617) stays in V1's SUPERSET band [3333.33,4166.67) -> V1. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 6871 (visitor thirds-anchored-V2-core-24) lands in V2's band [6666.67,7166.67) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 6871) stays in V2's SUPERSET band [6666.67,7500) -> V2. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) is NOT bucketed (falls between O's band [0,500) and V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 601) is newly admitted into O's growth sliver [500,833.33) at 25%. Contrast with the packed vector for this same value (V1 -> O flip): anchored never reassigns an already-bucketed visitor, it only ever admits from null", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 3899 (visitor thirds-anchored-V1-sliver-15) is NOT bucketed (exceeds V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3899) is newly admitted into V1's growth sliver (3833.33,4166.67) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 7353 (visitor thirds-anchored-V2-sliver-14) is NOT bucketed (exceeds V2's band [6666.67,7166.67))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 7353) is newly admitted into V2's growth sliver (7166.67,7500) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) is NOT bucketed under anchored. Contrast with the packed vectors for this same value (V2 -> V1 flip): anchored has no arm assignment at all here at either coverage, so there is no reassignment risk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 25% (8.333.../each): SAME visitor as above (value 1213) is STILL NOT bucketed under anchored at the higher coverage either", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 5848 (visitor thirds-idle-both-anchored-mid-2) is idle (falls between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 5848) is STILL idle at the higher coverage (still between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 8455 (visitor thirds-idle-both-high-0) exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 8455) STILL exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 102) -> O, unaffected by V1's stop (AC4 anchor stability)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 9807) -> V2's anchor (9000) is byte-identical whether V1 runs or is stopped (AC4)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 4957) -> stopped V1 keeps its weight (anchor stable at 1000) but has zero width, so it is never selected -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": null + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 4957 (visitor anchor-gate-visitor-17) -> V1. Z's explicit zero allocation is never defaulted to 100 and never perturbs V1's anchor; Z is never selected", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 9807 (visitor anchor-gate-visitor-162) -> V2. Z's zero-width entry does not shift V2's anchor since it contributes zero weight", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[nan-default] v12, single arm DEFAULT with traffic_allocation OMITTED (isNaN(ta) -> 100.0 default, full traffic space): any visitor is bucketed into DEFAULT", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 12, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v11, SAME single arm DEFAULT with traffic_allocation OMITTED: packed path also defaults to 100.0 -> DEFAULT (v11 === v12 for the NaN-default single-arm case)", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 11, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 102 (visitor anchor-gate-visitor-106) falls in B's own band [0,500) -> B (isNaN default on A does not swallow values clearly inside B's own range; config order wins ties)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "B" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 9807 (visitor anchor-gate-visitor-162) falls well inside A's defaulted 100-weight band -> A", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "A" + }, + { + "description": "[single-arm-v11-eq-v12] v11, single arm ONLY at traffic_allocation=100: any visitor -> ONLY", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 11, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[single-arm-v11-eq-v12] v12, SAME single arm ONLY at traffic_allocation=100: anchored path -> ONLY (v11 === v12 for a single full-allocation arm)", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 12, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 102) -> O. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 4957) -> V1. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 9807) -> V2. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 999 (visitor boundary-999-25207) is just below V1's anchor (1000) -> O (upper edge of O's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-999-25207", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 1000 (visitor boundary-1000-1145) EQUALS V1's anchor exactly -> V1 (anchor is inclusive: anchor <= value)", + "experienceId": "900000001", + "visitorId": "boundary-1000-1145", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 8999 (visitor boundary-8999-359) is just below V2's anchor (9000) -> V1 (upper edge of V1's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-8999-359", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9000 (visitor boundary-9000-9598) EQUALS V2's anchor exactly -> V2 (anchor is inclusive)", + "experienceId": "900000001", + "visitorId": "boundary-9000-9598", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9999 (visitor boundary-9999-5699) is the maximum representable traffic value, still inside V2's range -> V2", + "experienceId": "900000001", + "visitorId": "boundary-9999-5699", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[total-weight-zero] v12, two arms both traffic_allocation=0 (one running, one stopped): totalWeight is 0 -> not bucketed regardless of visitor", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + }, + { + "description": "[total-weight-zero] v11, SAME two zero-allocation arms: packed path filters both out entirely (empty bucket set) -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + } +] diff --git a/packages/sdk/src/main/kotlin/com/convert/sdk/android/ConvertContext.kt b/packages/sdk/src/main/kotlin/com/convert/sdk/android/ConvertContext.kt index 5732d155b..2f6f74ca2 100644 --- a/packages/sdk/src/main/kotlin/com/convert/sdk/android/ConvertContext.kt +++ b/packages/sdk/src/main/kotlin/com/convert/sdk/android/ConvertContext.kt @@ -5,6 +5,7 @@ */ package com.convert.sdk.android +import com.convert.sdk.core.bucketing.resolveVariationId import com.convert.sdk.core.event.SystemEvents import com.convert.sdk.core.model.Feature import com.convert.sdk.core.model.GoalData @@ -14,7 +15,6 @@ import com.convert.sdk.core.model.generated.ConfigExperience import com.convert.sdk.core.model.generated.ConfigLocation import com.convert.sdk.core.model.generated.ConfigResponseData import com.convert.sdk.core.model.generated.ExperienceVariationConfig -import com.convert.sdk.core.model.generated.VariationStatuses import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -288,6 +288,18 @@ public class ConvertContext internal constructor( * the sticky lookup has missed. Returns the selected [Variation], or * `null` if the visitor is not bucketed into any variation. * + * ### qs-01 / contract v12 — shared version-gated seam + * + * Delegates to [com.convert.sdk.core.bucketing.resolveVariationId] — + * the SAME function [com.convert.sdk.core.bucketing.AnchoredBucketingParityTest] + * exercises against the cross-SDK golden vectors — so the runtime path + * and the parity test can never diverge. `experience.version` decides + * the layout: `null`/`<= 11` runs the frozen packed cumulative walk + * (AC6, byte-identical to the pre-qs-01 behaviour); `> 11` runs the + * new anchored layout (AC1-AC5). Both branches still resolve through + * [com.convert.sdk.core.bucketing.BucketingManager.getValueVisitorBased] + * unchanged. + * * Split from [runExperience] to keep it inside detekt's line limit. */ @Suppress("ReturnCount") @@ -297,17 +309,9 @@ public class ConvertContext internal constructor( experienceKey: String, enableTracking: Boolean, ): Variation? { - val buckets = buildBuckets(experience.variations) - if (buckets.isEmpty()) { - sdk.logger.debug( - message = "ConvertContext.runExperience: experience '$experienceKey' has no " + - "eligible variations", - tag = TAG, - ) - return null - } - val allocation = sdk.bucketingManager.getBucketForVisitor( - buckets = buckets, + val allocation = sdk.bucketingManager.resolveVariationId( + version = experience.version, + variations = experience.variations, visitorId = visitorId, experienceId = experience.id.orEmpty(), ) ?: run { @@ -357,42 +361,6 @@ public class ConvertContext internal constructor( ) } - /** - * Builds the `variationId -> percentage` map consumed by - * [com.convert.sdk.core.bucketing.BucketingManager.getBucketForVisitor]. - * - * Matches the JS SDK's `data-manager.ts:620-637` filter chain: - * - Drop variations whose `status` is set AND not `RUNNING` - * (unset status defaults to "eligible", same as JS). - * - Drop zero-traffic variations (stopped variations in disguise). - * - Use the variation's `trafficAllocation` as-is. Although the - * OpenAPI schema describes the field as `0..10000`, the actual - * CDN-emitted values are percentages `0..100` — the JS SDK's - * `* 100` multiplier inside `selectBucket` assumes percentages, - * and real config fixtures (tests/test-config.json across the - * JS SDK) carry `50.0` for a 50% variation. We mirror that - * interpretation. - * - * Insertion order is preserved (the generated - * [ExperienceVariationConfig] list is a plain [List], so iteration - * is declaration order — which the bucketing engine relies on). - */ - private fun buildBuckets( - variations: List?, - ): Map = - 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() - /** * Lifts a [ConfigExperience] + [ExperienceVariationConfig] pair into * the SDK's public [Variation] shape. Populates `experienceId` / @@ -1008,15 +976,6 @@ public class ConvertContext internal constructor( /** Log tag for [runExperience] DEBUG/WARN emissions. */ const val TAG: String = "ConvertContext" - /** - * Traffic percentage applied to a variation whose - * `trafficAllocation` field is absent. Matches JS SDK - * `data-manager.ts:635` (`variation?.traffic_allocation || 100.0`) — - * when no allocation is specified, the variation gets 100% - * (i.e. the whole wheel). - */ - const val DEFAULT_VARIATION_PCT: Double = 100.0 - /** * Key in the per-call `conversionSetting` map that toggles * force-multiple-transactions semantics. JS SDK parity with diff --git a/tools/PARITY.md b/tools/PARITY.md index c0b5adbf5..b1a3aa496 100644 --- a/tools/PARITY.md +++ b/tools/PARITY.md @@ -35,6 +35,26 @@ The JS side maintains its own equivalent parity suite. Other SDKs (PHP, Python, …) consume the SAME vector file conceptually but mirror it into their own test resources — coordinated via the backend channel. +### Second parity suite — anchored bucketing layout (qs-01 / contract v12) + +Alongside the generated `hash-parity-vectors.json` above, the repo carries a +**second, distinctly-provisioned** parity fixture: +`packages/core/src/test/resources/cross-sdk-bucketing-vectors.json` (a bare +JSON array of 59 vectors, versions `{11, 12}`), gated by +`AnchoredBucketingParityTest.kt`. It pins the version-gated bucketing decision +`BucketingManager.resolveVariationId`: `version: 11` vectors lock the frozen +packed cumulative walk (byte-for-byte unchanged), `version: 12` vectors lock +the anchored layout (`getBucketRanges` / `selectBucketAnchored`). + +**Provenance differs from the hash vectors above.** This fixture is **imported +verbatim** from the shared cross-SDK golden set (JS reference branch +`feat/anchored-bucketing-layout`, file +`packages/bucketing/tests/cross-sdk-bucketing-vectors.json`) — it is **NOT** +produced by `tools/generate-parity-vectors.mjs`, which regenerates only +`hash-parity-vectors.json`. The divergence workflow and anti-patterns below +apply identically: a failing vector is always a Kotlin-side bug to fix, never a +fixture to regenerate or hand-edit. + --- ## When a parity divergence is discovered