From 802ad6ac2b17afd2a026ac9f7d2f1f8eb02920be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 22:23:27 +0000 Subject: [PATCH 01/35] feat(switchback): deserialize switchback experiment fields in ContextData Adds randomizationType, switchbackClusterUnitType, switchbackStartAt, switchbackPeriodMs, carryover/burn-in/burn-out durations, design type and estimand to json.Experiment, including equals/hashCode/toString. Absent fields stay null; unknown fields are still ignored. --- .../com/absmartly/sdk/json/Experiment.java | 57 +++++++++- .../DefaultContextDataDeserializerTest.java | 80 +++++++++++++ .../json/ExperimentSwitchbackFieldsTest.java | 106 ++++++++++++++++++ .../test/resources/switchback_context.json | 86 ++++++++++++++ 4 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 core-api/src/test/java/com/absmartly/sdk/json/ExperimentSwitchbackFieldsTest.java create mode 100644 core-api/src/test/resources/switchback_context.json diff --git a/core-api/src/main/java/com/absmartly/sdk/json/Experiment.java b/core-api/src/main/java/com/absmartly/sdk/json/Experiment.java index f8173dd..db743d0 100644 --- a/core-api/src/main/java/com/absmartly/sdk/json/Experiment.java +++ b/core-api/src/main/java/com/absmartly/sdk/json/Experiment.java @@ -5,6 +5,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; +import com.absmartly.sdk.java.util.Objects; + @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public class Experiment { @@ -33,6 +35,19 @@ public class Experiment { // experiment is not covered by any holdout. Absent on holdout entries themselves. public int[] holdoutIds; + // Switchback configuration. randomizationType is "unit" or "switchback"; null means unit. + // Instants and durations are epoch/duration milliseconds. Carryover and burn windows are + // analysis-only and never read by assignment. + public String randomizationType; + public String switchbackClusterUnitType; + public Long switchbackStartAt; + public Long switchbackPeriodMs; + public Long switchbackCarryoverDurationMs; + public Long switchbackBurnInDurationMs; + public Long switchbackBurnOutDurationMs; + public String switchbackDesignType; + public String switchbackEstimand; + public Experiment() {} @Override @@ -78,7 +93,29 @@ public boolean equals(Object o) { return false; if (holdoutType != null ? !holdoutType.equals(that.holdoutType) : that.holdoutType != null) return false; - return Arrays.equals(holdoutIds, that.holdoutIds); + if (!Arrays.equals(holdoutIds, that.holdoutIds)) + return false; + if (!Objects.equals(randomizationType, that.randomizationType)) + return false; + if (!Objects.equals(switchbackClusterUnitType, that.switchbackClusterUnitType)) + return false; + if (!Objects.equals(switchbackStartAt, that.switchbackStartAt)) + return false; + if (!Objects.equals(switchbackPeriodMs, that.switchbackPeriodMs)) + return false; + if (!Objects.equals(switchbackCarryoverDurationMs, that.switchbackCarryoverDurationMs)) + return false; + if (!Objects.equals(switchbackBurnInDurationMs, that.switchbackBurnInDurationMs)) + return false; + if (!Objects.equals(switchbackBurnOutDurationMs, that.switchbackBurnOutDurationMs)) + return false; + if (!Objects.equals(switchbackDesignType, that.switchbackDesignType)) + return false; + return Objects.equals(switchbackEstimand, that.switchbackEstimand); + } + + private static int hash(Object o) { + return (o != null) ? o.hashCode() : 0; } @Override @@ -101,6 +138,15 @@ public int hashCode() { result = 31 * result + Arrays.hashCode(customFieldValues); result = 31 * result + (holdoutType != null ? holdoutType.hashCode() : 0); result = 31 * result + Arrays.hashCode(holdoutIds); + result = 31 * result + hash(randomizationType); + result = 31 * result + hash(switchbackClusterUnitType); + result = 31 * result + hash(switchbackStartAt); + result = 31 * result + hash(switchbackPeriodMs); + result = 31 * result + hash(switchbackCarryoverDurationMs); + result = 31 * result + hash(switchbackBurnInDurationMs); + result = 31 * result + hash(switchbackBurnOutDurationMs); + result = 31 * result + hash(switchbackDesignType); + result = 31 * result + hash(switchbackEstimand); return result; } @@ -125,6 +171,15 @@ public String toString() { ", customFieldValues=" + Arrays.toString(customFieldValues) + ", holdoutType='" + holdoutType + '\'' + ", holdoutIds=" + Arrays.toString(holdoutIds) + + ", randomizationType='" + randomizationType + '\'' + + ", switchbackClusterUnitType='" + switchbackClusterUnitType + '\'' + + ", switchbackStartAt=" + switchbackStartAt + + ", switchbackPeriodMs=" + switchbackPeriodMs + + ", switchbackCarryoverDurationMs=" + switchbackCarryoverDurationMs + + ", switchbackBurnInDurationMs=" + switchbackBurnInDurationMs + + ", switchbackBurnOutDurationMs=" + switchbackBurnOutDurationMs + + ", switchbackDesignType='" + switchbackDesignType + '\'' + + ", switchbackEstimand='" + switchbackEstimand + '\'' + '}'; } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java index fb3b2cf..20e46fe 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java @@ -196,6 +196,86 @@ void deserializeHoldouts() { assertEquals(expected, data); } + @Test + void deserializeSwitchback() { + final byte[] bytes = getResourceBytes("switchback_context.json"); + + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + final ContextData data = deser.deserialize(bytes, 0, bytes.length); + + final Experiment switchback = new Experiment(); + switchback.id = 1; + switchback.name = "exp_test_switchback"; + switchback.unitType = "store_id"; + switchback.iteration = 1; + switchback.seedHi = 3603515; + switchback.seedLo = 233373850; + switchback.split = new double[]{0.5, 0.5}; + switchback.trafficSeedHi = 449867249; + switchback.trafficSeedLo = 455443629; + switchback.trafficSplit = new double[]{0.0, 1.0}; + switchback.fullOnVariant = 0; + switchback.applications = new ExperimentApplication[]{new ExperimentApplication("website")}; + switchback.variants = new ExperimentVariant[]{ + new ExperimentVariant("A", null), + new ExperimentVariant("B", "{\"banner.border\":1}") + }; + switchback.randomizationType = "switchback"; + switchback.switchbackClusterUnitType = "store_id"; + switchback.switchbackStartAt = 1767225600000L; + switchback.switchbackPeriodMs = 3600000L; + switchback.switchbackCarryoverDurationMs = 900000L; + switchback.switchbackBurnInDurationMs = 600000L; + switchback.switchbackBurnOutDurationMs = 0L; + switchback.switchbackDesignType = "balanced_block"; + switchback.switchbackEstimand = "global_average_treatment_effect"; + + final Experiment unit = new Experiment(); + unit.id = 2; + unit.name = "exp_test_unit"; + unit.unitType = "session_id"; + unit.iteration = 1; + unit.seedHi = 55006150; + unit.seedLo = 47189152; + unit.split = new double[]{0.5, 0.5}; + unit.trafficSeedHi = 705671872; + unit.trafficSeedLo = 212903484; + unit.trafficSplit = new double[]{0.0, 1.0}; + unit.fullOnVariant = 0; + unit.applications = new ExperimentApplication[]{new ExperimentApplication("website")}; + unit.variants = new ExperimentVariant[]{ + new ExperimentVariant("A", null), + new ExperimentVariant("B", null) + }; + unit.randomizationType = "unit"; + + final ContextData expected = new ContextData(); + expected.experiments = new Experiment[]{switchback, unit}; + + assertNotNull(data); + assertEquals(expected, data); + assertEquals(expected.hashCode(), data.hashCode()); + } + + @Test + void deserializeWithoutSwitchbackFieldsLeavesThemNull() { + final byte[] bytes = getResourceBytes("context.json"); + + final ContextData data = new DefaultContextDataDeserializer().deserialize(bytes, 0, bytes.length); + + for (final Experiment experiment : data.experiments) { + assertNull(experiment.randomizationType); + assertNull(experiment.switchbackClusterUnitType); + assertNull(experiment.switchbackStartAt); + assertNull(experiment.switchbackPeriodMs); + assertNull(experiment.switchbackCarryoverDurationMs); + assertNull(experiment.switchbackBurnInDurationMs); + assertNull(experiment.switchbackBurnOutDurationMs); + assertNull(experiment.switchbackDesignType); + assertNull(experiment.switchbackEstimand); + } + } + @Test void testMalformedJsonResponse() { final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); diff --git a/core-api/src/test/java/com/absmartly/sdk/json/ExperimentSwitchbackFieldsTest.java b/core-api/src/test/java/com/absmartly/sdk/json/ExperimentSwitchbackFieldsTest.java new file mode 100644 index 0000000..a547dc6 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/json/ExperimentSwitchbackFieldsTest.java @@ -0,0 +1,106 @@ +package com.absmartly.sdk.json; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ExperimentSwitchbackFieldsTest { + private static Experiment switchback() { + final Experiment experiment = new Experiment(); + experiment.id = 1; + experiment.name = "exp"; + experiment.unitType = "store_id"; + experiment.randomizationType = "switchback"; + experiment.switchbackClusterUnitType = "store_id"; + experiment.switchbackStartAt = 1767225600000L; + experiment.switchbackPeriodMs = 3600000L; + experiment.switchbackCarryoverDurationMs = 900000L; + experiment.switchbackBurnInDurationMs = 600000L; + experiment.switchbackBurnOutDurationMs = 0L; + experiment.switchbackDesignType = "balanced_block"; + experiment.switchbackEstimand = "global_average_treatment_effect"; + return experiment; + } + + private interface Mutation { + void apply(Experiment e); + } + + @Test + void equalsAndHashCodeCoverEverySwitchbackField() { + assertEquals(switchback(), switchback()); + assertEquals(switchback().hashCode(), switchback().hashCode()); + + final Mutation[] mutations = new Mutation[]{ + new Mutation() { + public void apply(Experiment e) { + e.randomizationType = "unit"; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackClusterUnitType = "user_id"; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackStartAt = 1767229200000L; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackPeriodMs = 86400000L; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackCarryoverDurationMs = null; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackBurnInDurationMs = 1L; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackBurnOutDurationMs = 1L; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackDesignType = "other"; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackEstimand = "other"; + } + }, + }; + + for (final Mutation mutation : mutations) { + final Experiment changed = switchback(); + mutation.apply(changed); + assertNotEquals(switchback(), changed); + assertNotEquals(changed, switchback()); + assertNotEquals(switchback().hashCode(), changed.hashCode()); + } + } + + @Test + void toStringIncludesSwitchbackFields() { + final String s = switchback().toString(); + assertTrue(s.contains("randomizationType='switchback'")); + assertTrue(s.contains("switchbackClusterUnitType='store_id'")); + assertTrue(s.contains("switchbackStartAt=1767225600000")); + assertTrue(s.contains("switchbackPeriodMs=3600000")); + assertTrue(s.contains("switchbackCarryoverDurationMs=900000")); + assertTrue(s.contains("switchbackBurnInDurationMs=600000")); + assertTrue(s.contains("switchbackBurnOutDurationMs=0")); + assertTrue(s.contains("switchbackDesignType='balanced_block'")); + assertTrue(s.contains("switchbackEstimand='global_average_treatment_effect'")); + } +} diff --git a/core-api/src/test/resources/switchback_context.json b/core-api/src/test/resources/switchback_context.json new file mode 100644 index 0000000..04097e7 --- /dev/null +++ b/core-api/src/test/resources/switchback_context.json @@ -0,0 +1,86 @@ +{ + "experiments":[ + { + "id":1, + "name":"exp_test_switchback", + "iteration":1, + "unitType":"store_id", + "seedHi":3603515, + "seedLo":233373850, + "split":[ + 0.5, + 0.5 + ], + "trafficSeedHi":449867249, + "trafficSeedLo":455443629, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"banner.border\":1}" + } + ], + "audience":null, + "randomizationType":"switchback", + "switchbackClusterUnitType":"store_id", + "switchbackStartAt":1767225600000, + "switchbackPeriodMs":3600000, + "switchbackCarryoverDurationMs":900000, + "switchbackBurnInDurationMs":600000, + "switchbackBurnOutDurationMs":0, + "switchbackDesignType":"balanced_block", + "switchbackEstimand":"global_average_treatment_effect", + "switchbackFutureField":{"nested":[1,2,3]} + }, + { + "id":2, + "name":"exp_test_unit", + "iteration":1, + "unitType":"session_id", + "seedHi":55006150, + "seedLo":47189152, + "split":[ + 0.5, + 0.5 + ], + "trafficSeedHi":705671872, + "trafficSeedLo":212903484, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":null + } + ], + "audience":null, + "randomizationType":"unit", + "switchbackStartAt":null + } + ] +} From 29d735f3942595ddf4d90a7cf45911e20c837b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 22:24:41 +0000 Subject: [PATCH 02/35] feat(switchback): port balanced_block assignSwitchback with golden vectors Java 1.6 port of the platform's SwitchbackAssignment using the SDK's own Murmur3_32 and little-endian Buffers (le64 seed then le32 key hash). The platform golden-vector fixture is copied verbatim; every assignSwitchback (882) and fisherYatesPermutation (504) case is asserted, parsing 64-bit fields from decimal strings. --- .../sdk/internal/SwitchbackAssigner.java | 78 + .../SwitchbackAssignerGoldenVectorsTest.java | 131 ++ .../resources/switchback/golden-vectors.json | 1392 +++++++++++++++++ 3 files changed, 1601 insertions(+) create mode 100644 core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAssigner.java create mode 100644 core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAssignerGoldenVectorsTest.java create mode 100644 core-api/src/test/resources/switchback/golden-vectors.json diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAssigner.java b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAssigner.java new file mode 100644 index 0000000..db2157f --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAssigner.java @@ -0,0 +1,78 @@ +package com.absmartly.sdk.internal; + +import com.absmartly.sdk.internal.hashing.Murmur3_32; + +// Balanced-block switchback assignment. Port of the platform's +// com.absmartly.lib.analytics.switchback.SwitchbackAssignment (Guava murmur3_32_fixed, seed 0): +// key = utf8(clusterUnit + "\u001f" + cycleIndex) +// baseHash = murmur3_32(le64(seed) ++ le32(murmur3_32(key))) +// stepHash = murmur3_32(le32(baseHash) ++ le32(i)), i = armCount-1 .. 1 +// j = min(i, floor(u32(stepHash) / 0xffffffff * (i + 1))) +// variant = permutation[floorMod(periodIndex, armCount)], cycle = floorDiv(periodIndex, armCount) +// Any change here breaks parity with the Collector and the analysis-side expected schedule. +public abstract class SwitchbackAssigner { + private SwitchbackAssigner() {} + + private static final double NORMALIZER = 1.0 / 0xffffffffL; + private static final char SEPARATOR = '\u001f'; + + public static int assign(final CharSequence clusterUnit, final long periodIndex, final long seed, + final int armCount) { + requireValidArmCount(armCount); + final long cycleIndex = floorDiv(periodIndex, armCount); + // floorMod; the product may wrap near Long.MIN_VALUE but the difference is exact mod 2^64. + final int cycleSlot = (int) (periodIndex - cycleIndex * armCount); + return fisherYatesPermutation(armCount, baseHash(clusterUnit, cycleIndex, seed))[cycleSlot]; + } + + public static int baseHash(final CharSequence clusterUnit, final long cycleIndex, final long seed) { + final String key = new StringBuilder(clusterUnit.length() + 21).append(clusterUnit).append(SEPARATOR) + .append(cycleIndex).toString(); + final byte[] keyBytes = new byte[key.length() * 4]; + final int keyLen = Buffers.encodeUTF8(keyBytes, 0, key); + final int keyHash = Murmur3_32.digest(keyBytes, 0, keyLen, 0); + + final byte[] buffer = new byte[12]; + Buffers.putUInt32(buffer, 0, (int) seed); + Buffers.putUInt32(buffer, 4, (int) (seed >>> 32)); + Buffers.putUInt32(buffer, 8, keyHash); + return Murmur3_32.digest(buffer, 0); + } + + public static int[] fisherYatesPermutation(final int armCount, final int baseHash) { + requireValidArmCount(armCount); + + final int[] permutation = new int[armCount]; + for (int i = 0; i < armCount; ++i) { + permutation[i] = i; + } + + final byte[] buffer = new byte[8]; + Buffers.putUInt32(buffer, 0, baseHash); + for (int i = armCount - 1; i > 0; --i) { + Buffers.putUInt32(buffer, 4, i); + final int stepHash = Murmur3_32.digest(buffer, 0); + final double prob = (stepHash & 0xffffffffL) * NORMALIZER; + // prob == 1.0 would map to i + 1 + final int j = Math.min(i, (int) Math.floor(prob * (i + 1))); + + final int tmp = permutation[i]; + permutation[i] = permutation[j]; + permutation[j] = tmp; + } + + return permutation; + } + + // Math.floorDiv is Java 8+; divisor is always positive here. + public static long floorDiv(final long dividend, final long divisor) { + final long q = dividend / divisor; + return ((dividend % divisor) != 0 && ((dividend ^ divisor) < 0)) ? q - 1 : q; + } + + private static void requireValidArmCount(final int armCount) { + if (armCount < 2) { + throw new IllegalArgumentException("armCount must be >= 2, got " + armCount); + } + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAssignerGoldenVectorsTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAssignerGoldenVectorsTest.java new file mode 100644 index 0000000..10dad96 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAssignerGoldenVectorsTest.java @@ -0,0 +1,131 @@ +package com.absmartly.sdk.internal; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +// Fixture copied byte-for-byte from absmartly/abs +// lib/analytics/src/test/resources/switchback/golden-vectors.json (git blob b314abeb9e97). +class SwitchbackAssignerGoldenVectorsTest { + private static JsonNode loadVectors() throws IOException { + final InputStream in = SwitchbackAssignerGoldenVectorsTest.class.getClassLoader() + .getResourceAsStream("switchback/golden-vectors.json"); + try { + return new ObjectMapper().readTree(in); + } finally { + in.close(); + } + } + + // The platform hashes a numeric cluster unit as Long.toString(n); strings pass through. + private static String clusterUnit(JsonNode node) { + if (node.isIntegralNumber()) { + return Long.toString(node.longValue()); + } + assertTrue(node.isTextual(), "clusterUnit must be a string or integer: " + node); + return node.textValue(); + } + + // 64-bit fields are decimal strings; a numeric node would mean the fixture regressed to a + // lossy encoding. + private static long exactLong(JsonNode node) { + assertTrue(node.isTextual(), "64-bit field must be a decimal string: " + node); + return Long.parseLong(node.textValue()); + } + + @Test + void assignSwitchbackMatchesEveryGoldenVector() throws IOException { + final JsonNode cases = loadVectors().get("assignSwitchback"); + assertEquals(882, cases.size()); + + final Set armCounts = new HashSet(); + int boundaryCases = 0; + for (final JsonNode c : cases) { + final String unit = clusterUnit(c.get("clusterUnit")); + final long periodIndex = exactLong(c.get("periodIndex")); + final long seed = exactLong(c.get("seed")); + final int armCount = c.get("armCount").intValue(); + armCounts.add(armCount); + if (periodIndex == Long.MIN_VALUE || periodIndex == Long.MAX_VALUE || seed == Long.MIN_VALUE + || seed == Long.MAX_VALUE) { + ++boundaryCases; + } + + assertEquals(c.get("expectedVariant").intValue(), + SwitchbackAssigner.assign(unit, periodIndex, seed, armCount), + c.toString()); + } + + assertTrue(armCounts.contains(2) && armCounts.contains(3) && armCounts.contains(10), armCounts.toString()); + assertTrue(boundaryCases > 0); + } + + @Test + void fisherYatesPermutationMatchesEveryGoldenVector() throws IOException { + final JsonNode cases = loadVectors().get("fisherYatesPermutation"); + assertEquals(504, cases.size()); + + for (final JsonNode c : cases) { + final String unit = clusterUnit(c.get("clusterUnit")); + final long cycleIndex = exactLong(c.get("cycleIndex")); + final long seed = exactLong(c.get("seed")); + final int armCount = c.get("armCount").intValue(); + + final JsonNode expectedNode = c.get("expectedPermutation"); + final int[] expected = new int[expectedNode.size()]; + for (int i = 0; i < expected.length; ++i) { + expected[i] = expectedNode.get(i).intValue(); + } + + final int[] actual = SwitchbackAssigner.fisherYatesPermutation(armCount, + SwitchbackAssigner.baseHash(unit, cycleIndex, seed)); + assertArrayEquals(expected, actual, c.toString()); + } + } + + @Test + void everyCycleCoversEveryArmExactlyOnce() { + final int[] armCounts = {2, 3, 10}; + for (final int armCount : armCounts) { + for (long cycle = -3; cycle < 20; ++cycle) { + final boolean[] seen = new boolean[armCount]; + for (int slot = 0; slot < armCount; ++slot) { + final int variant = SwitchbackAssigner.assign("store_42", cycle * armCount + slot, + 0x1234_5678_9abcL, + armCount); + assertTrue(!seen[variant], "arm repeated within a cycle"); + seen[variant] = true; + } + } + } + } + + @Test + void rejectsFewerThanTwoArms() { + assertThrows(IllegalArgumentException.class, () -> SwitchbackAssigner.assign("u", 0, 0, 1)); + assertThrows(IllegalArgumentException.class, () -> SwitchbackAssigner.fisherYatesPermutation(0, 0)); + } + + @Test + void floorDiv() { + assertEquals(0, SwitchbackAssigner.floorDiv(0, 3)); + assertEquals(0, SwitchbackAssigner.floorDiv(2, 3)); + assertEquals(1, SwitchbackAssigner.floorDiv(3, 3)); + assertEquals(-1, SwitchbackAssigner.floorDiv(-1, 3)); + assertEquals(-1, SwitchbackAssigner.floorDiv(-3, 3)); + assertEquals(-2, SwitchbackAssigner.floorDiv(-4, 3)); + assertEquals(Math.floorDiv(Long.MIN_VALUE, 3L), SwitchbackAssigner.floorDiv(Long.MIN_VALUE, 3)); + assertEquals(Math.floorDiv(Long.MAX_VALUE, 10L), SwitchbackAssigner.floorDiv(Long.MAX_VALUE, 10)); + } +} diff --git a/core-api/src/test/resources/switchback/golden-vectors.json b/core-api/src/test/resources/switchback/golden-vectors.json new file mode 100644 index 0000000..b314abe --- /dev/null +++ b/core-api/src/test/resources/switchback/golden-vectors.json @@ -0,0 +1,1392 @@ +{ + "assignSwitchback": [ + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "123456789", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "123456789", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "123456789", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "123456789", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "123456789", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "0", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "0", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "0", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "0", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "0", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "0", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "0", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "0", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "0", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "0", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "0", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "0", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "0", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "0", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "1", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "1", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "1", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "1", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "1", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "1", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "1", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "1", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "1", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "42", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "42", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "42", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "42", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "42", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "42", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "42", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "42", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "42", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "42", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "42", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "42", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "42", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "42", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "-1", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "-1", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "-1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "-1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "-1", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "-1", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "-1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "-1", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "-1", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "-1", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "-1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "-1", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "-1", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "-1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "123456789", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "123456789", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "123456789", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "123456789", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "123456789", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "123456789", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "123456789", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "123456789", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "123456789", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "123456789", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "123456789", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "123456789", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "123456789", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "123456789", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_1", "periodIndex": "0", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "1", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "2", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "5", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "9", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_1", "periodIndex": "10", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "11", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_1", "periodIndex": "23", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_1", "periodIndex": "100", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_1", "periodIndex": "-1", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_1", "periodIndex": "-2", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_1", "periodIndex": "-11", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_1", "periodIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_1", "periodIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "123456789", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "123456789", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "0", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "0", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "0", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "0", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "0", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "0", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "0", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "0", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "0", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "0", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "0", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "0", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "0", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "0", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "1", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "1", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "1", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "1", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "1", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "1", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "1", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "1", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "42", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "42", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "42", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "42", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "42", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "42", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "42", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "42", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "42", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "42", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "42", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "42", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "42", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "42", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "-1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "-1", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "-1", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "-1", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "-1", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "-1", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "-1", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "-1", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "-1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "-1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "-1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "-1", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "-1", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "-1", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "123456789", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "123456789", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "123456789", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "123456789", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "123456789", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "123456789", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "123456789", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "123456789", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "123456789", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "123456789", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "123456789", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "123456789", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "123456789", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "123456789", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "0", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "1", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": "market_2", "periodIndex": "2", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": "market_2", "periodIndex": "5", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_2", "periodIndex": "9", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": "market_2", "periodIndex": "10", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": "market_2", "periodIndex": "11", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_2", "periodIndex": "23", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": "market_2", "periodIndex": "100", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "-1", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": "market_2", "periodIndex": "-2", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": "market_2", "periodIndex": "-11", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": "market_2", "periodIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": "market_2", "periodIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "0", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "0", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "42", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "42", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "-1", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "-1", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "123456789", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "123456789", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 2, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "0", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "0", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "0", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "42", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "42", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "42", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "-1", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "-1", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "-1", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "123456789", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "123456789", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "123456789", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "123456789", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 3, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "0", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "0", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "0", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "0", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "0", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "0", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "0", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "0", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "0", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "0", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "0", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "0", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "0", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "0", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "1", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "1", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "1", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "1", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "1", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "1", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "1", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "1", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "42", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "42", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "42", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "42", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "42", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "42", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "42", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "42", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "42", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "42", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "42", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "42", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "42", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "42", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "-1", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "-1", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "-1", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "-1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "-1", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "-1", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "-1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "-1", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "-1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "-1", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "-1", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "-1", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "-1", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "-1", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "123456789", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "123456789", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "123456789", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "123456789", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "123456789", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "123456789", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "123456789", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "123456789", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "123456789", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "123456789", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "123456789", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "123456789", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "123456789", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "123456789", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 7}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 3}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 1}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "0", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 5}, + {"clusterUnit": 123456789, "periodIndex": "1", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "2", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "5", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "9", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "10", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "11", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 8}, + {"clusterUnit": 123456789, "periodIndex": "23", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "100", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 2}, + {"clusterUnit": 123456789, "periodIndex": "-1", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 4}, + {"clusterUnit": 123456789, "periodIndex": "-2", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 0}, + {"clusterUnit": 123456789, "periodIndex": "-11", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 9}, + {"clusterUnit": 123456789, "periodIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 6}, + {"clusterUnit": 123456789, "periodIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 10, "expectedVariant": 2} + ], + "fisherYatesPermutation": [ + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "42", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "42", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "42", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "-1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "-1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "-1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "0", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "0", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "0", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "0", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "0", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "0", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "0", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "0", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "1", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "1", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "1", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "1", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "1", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "1", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "1", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "42", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "42", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "42", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "42", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "42", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "42", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "42", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "42", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "-1", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "-1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "-1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "-1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "-1", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "-1", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "-1", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "-1", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "0", "armCount": 10, "expectedPermutation": [0, 3, 7, 5, 8, 2, 6, 4, 1, 9]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "0", "armCount": 10, "expectedPermutation": [6, 0, 3, 8, 9, 7, 2, 4, 5, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "0", "armCount": 10, "expectedPermutation": [3, 0, 4, 8, 1, 6, 9, 2, 7, 5]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "0", "armCount": 10, "expectedPermutation": [0, 2, 7, 9, 1, 6, 4, 5, 3, 8]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "0", "armCount": 10, "expectedPermutation": [6, 9, 8, 3, 4, 7, 2, 5, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "0", "armCount": 10, "expectedPermutation": [1, 7, 8, 3, 9, 5, 0, 4, 6, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "0", "armCount": 10, "expectedPermutation": [8, 9, 3, 6, 4, 1, 5, 0, 2, 7]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "0", "armCount": 10, "expectedPermutation": [6, 4, 3, 1, 0, 7, 8, 9, 5, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "1", "armCount": 10, "expectedPermutation": [9, 4, 3, 5, 8, 0, 2, 6, 7, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "1", "armCount": 10, "expectedPermutation": [3, 8, 4, 0, 1, 7, 5, 6, 2, 9]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "1", "armCount": 10, "expectedPermutation": [4, 1, 5, 3, 6, 7, 0, 9, 2, 8]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "1", "armCount": 10, "expectedPermutation": [7, 0, 1, 5, 2, 9, 8, 6, 4, 3]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "1", "armCount": 10, "expectedPermutation": [9, 1, 7, 3, 4, 5, 2, 6, 8, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "1", "armCount": 10, "expectedPermutation": [0, 5, 7, 2, 6, 1, 9, 4, 8, 3]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "1", "armCount": 10, "expectedPermutation": [2, 4, 0, 7, 5, 1, 3, 8, 9, 6]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "1", "armCount": 10, "expectedPermutation": [6, 2, 5, 3, 4, 1, 7, 8, 9, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "42", "armCount": 10, "expectedPermutation": [3, 7, 2, 9, 5, 8, 0, 1, 4, 6]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "42", "armCount": 10, "expectedPermutation": [0, 8, 1, 6, 3, 4, 5, 9, 7, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "42", "armCount": 10, "expectedPermutation": [1, 3, 2, 0, 7, 6, 8, 4, 9, 5]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "42", "armCount": 10, "expectedPermutation": [5, 7, 0, 6, 1, 4, 3, 8, 2, 9]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "42", "armCount": 10, "expectedPermutation": [6, 5, 2, 0, 1, 3, 4, 9, 8, 7]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "42", "armCount": 10, "expectedPermutation": [0, 6, 9, 1, 4, 2, 3, 8, 7, 5]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "42", "armCount": 10, "expectedPermutation": [8, 1, 3, 6, 5, 0, 4, 9, 7, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "42", "armCount": 10, "expectedPermutation": [6, 8, 3, 5, 7, 4, 2, 0, 1, 9]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "-1", "armCount": 10, "expectedPermutation": [1, 5, 7, 0, 9, 4, 2, 3, 6, 8]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "-1", "armCount": 10, "expectedPermutation": [6, 4, 9, 8, 0, 3, 5, 1, 2, 7]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "-1", "armCount": 10, "expectedPermutation": [4, 0, 5, 8, 9, 3, 7, 6, 1, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "-1", "armCount": 10, "expectedPermutation": [6, 5, 9, 1, 0, 8, 2, 4, 7, 3]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "-1", "armCount": 10, "expectedPermutation": [6, 7, 2, 5, 8, 4, 1, 0, 3, 9]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "-1", "armCount": 10, "expectedPermutation": [0, 5, 6, 3, 1, 7, 8, 4, 2, 9]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "-1", "armCount": 10, "expectedPermutation": [2, 5, 4, 8, 0, 9, 7, 6, 3, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "-1", "armCount": 10, "expectedPermutation": [9, 8, 6, 3, 1, 7, 2, 0, 4, 5]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "123456789", "armCount": 10, "expectedPermutation": [3, 6, 5, 1, 2, 9, 8, 0, 4, 7]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "123456789", "armCount": 10, "expectedPermutation": [6, 2, 1, 7, 3, 8, 0, 4, 5, 9]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "123456789", "armCount": 10, "expectedPermutation": [8, 1, 2, 5, 7, 9, 0, 6, 4, 3]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "123456789", "armCount": 10, "expectedPermutation": [8, 3, 9, 0, 7, 1, 6, 5, 4, 2]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "123456789", "armCount": 10, "expectedPermutation": [1, 9, 3, 7, 4, 8, 0, 5, 2, 6]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "123456789", "armCount": 10, "expectedPermutation": [1, 4, 8, 9, 2, 3, 7, 5, 6, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "123456789", "armCount": 10, "expectedPermutation": [1, 0, 7, 3, 4, 6, 5, 2, 8, 9]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "123456789", "armCount": 10, "expectedPermutation": [6, 4, 3, 7, 1, 9, 8, 2, 0, 5]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [3, 9, 6, 8, 4, 7, 2, 5, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [3, 1, 7, 8, 9, 0, 2, 4, 5, 6]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [7, 6, 8, 3, 2, 5, 4, 1, 9, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [8, 3, 7, 6, 2, 9, 5, 4, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [4, 9, 2, 7, 1, 6, 0, 8, 3, 5]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [4, 9, 3, 5, 8, 6, 7, 2, 0, 1]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [0, 9, 2, 7, 1, 3, 6, 8, 4, 5]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [9, 5, 1, 0, 6, 7, 2, 4, 8, 3]}, + {"clusterUnit": "market_1", "cycleIndex": "0", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [8, 1, 6, 3, 5, 7, 0, 4, 2, 9]}, + {"clusterUnit": "market_1", "cycleIndex": "1", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [0, 5, 6, 3, 2, 7, 4, 9, 1, 8]}, + {"clusterUnit": "market_1", "cycleIndex": "2", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [7, 9, 5, 3, 4, 6, 8, 2, 1, 0]}, + {"clusterUnit": "market_1", "cycleIndex": "-1", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [0, 7, 5, 3, 6, 2, 4, 9, 1, 8]}, + {"clusterUnit": "market_1", "cycleIndex": "-2", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [2, 5, 3, 7, 9, 1, 4, 0, 8, 6]}, + {"clusterUnit": "market_1", "cycleIndex": "100", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [6, 1, 2, 9, 5, 0, 8, 3, 7, 4]}, + {"clusterUnit": "market_1", "cycleIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [7, 5, 6, 9, 4, 8, 2, 0, 1, 3]}, + {"clusterUnit": "market_1", "cycleIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [5, 7, 8, 6, 3, 2, 0, 4, 1, 9]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "42", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "42", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "42", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "-1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "-1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "-1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "-1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "0", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "0", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "0", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "0", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "0", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "0", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "0", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "0", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "1", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "1", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "1", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "1", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "1", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "42", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "42", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "42", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "42", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "42", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "42", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "42", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "42", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "-1", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "-1", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "-1", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "-1", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "-1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "-1", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "-1", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "-1", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "123456789", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "0", "armCount": 10, "expectedPermutation": [4, 6, 2, 7, 5, 8, 9, 0, 1, 3]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "0", "armCount": 10, "expectedPermutation": [1, 2, 5, 9, 8, 6, 0, 3, 4, 7]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "0", "armCount": 10, "expectedPermutation": [8, 4, 6, 3, 1, 2, 0, 5, 9, 7]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "0", "armCount": 10, "expectedPermutation": [5, 2, 3, 0, 4, 6, 7, 1, 9, 8]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "0", "armCount": 10, "expectedPermutation": [9, 7, 8, 2, 1, 6, 0, 3, 4, 5]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "0", "armCount": 10, "expectedPermutation": [8, 6, 9, 3, 4, 2, 0, 1, 7, 5]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "0", "armCount": 10, "expectedPermutation": [0, 9, 5, 4, 1, 7, 8, 6, 2, 3]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "0", "armCount": 10, "expectedPermutation": [5, 8, 0, 2, 9, 7, 6, 3, 4, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "1", "armCount": 10, "expectedPermutation": [4, 2, 5, 9, 0, 7, 8, 1, 3, 6]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "1", "armCount": 10, "expectedPermutation": [1, 7, 9, 2, 6, 0, 4, 5, 3, 8]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "1", "armCount": 10, "expectedPermutation": [2, 8, 7, 5, 6, 9, 3, 4, 0, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "1", "armCount": 10, "expectedPermutation": [2, 3, 4, 9, 6, 7, 8, 5, 1, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "1", "armCount": 10, "expectedPermutation": [2, 3, 8, 5, 9, 1, 7, 6, 4, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "1", "armCount": 10, "expectedPermutation": [4, 6, 2, 3, 0, 5, 9, 1, 8, 7]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "1", "armCount": 10, "expectedPermutation": [8, 6, 1, 2, 5, 9, 4, 0, 3, 7]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "1", "armCount": 10, "expectedPermutation": [7, 5, 0, 9, 1, 2, 6, 4, 3, 8]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "42", "armCount": 10, "expectedPermutation": [9, 2, 3, 8, 0, 6, 5, 1, 7, 4]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "42", "armCount": 10, "expectedPermutation": [0, 9, 7, 3, 5, 6, 2, 4, 1, 8]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "42", "armCount": 10, "expectedPermutation": [4, 0, 3, 8, 1, 6, 9, 2, 7, 5]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "42", "armCount": 10, "expectedPermutation": [5, 9, 3, 0, 4, 8, 6, 7, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "42", "armCount": 10, "expectedPermutation": [7, 8, 0, 9, 6, 2, 5, 4, 3, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "42", "armCount": 10, "expectedPermutation": [1, 3, 5, 8, 6, 4, 0, 9, 7, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "42", "armCount": 10, "expectedPermutation": [3, 1, 0, 8, 2, 7, 6, 4, 5, 9]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "42", "armCount": 10, "expectedPermutation": [0, 9, 6, 5, 2, 1, 7, 3, 8, 4]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "-1", "armCount": 10, "expectedPermutation": [0, 6, 9, 7, 2, 8, 3, 4, 5, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "-1", "armCount": 10, "expectedPermutation": [1, 5, 2, 9, 3, 8, 6, 7, 4, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "-1", "armCount": 10, "expectedPermutation": [8, 7, 3, 1, 2, 9, 6, 4, 5, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "-1", "armCount": 10, "expectedPermutation": [3, 0, 6, 1, 9, 2, 5, 8, 7, 4]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "-1", "armCount": 10, "expectedPermutation": [9, 5, 1, 4, 3, 7, 6, 0, 2, 8]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "-1", "armCount": 10, "expectedPermutation": [5, 6, 9, 7, 8, 1, 0, 4, 2, 3]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "-1", "armCount": 10, "expectedPermutation": [0, 8, 2, 4, 6, 3, 5, 1, 9, 7]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "-1", "armCount": 10, "expectedPermutation": [4, 3, 8, 6, 0, 2, 5, 9, 7, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "123456789", "armCount": 10, "expectedPermutation": [4, 1, 2, 7, 0, 6, 9, 8, 3, 5]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "123456789", "armCount": 10, "expectedPermutation": [4, 5, 3, 9, 1, 7, 6, 0, 8, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "123456789", "armCount": 10, "expectedPermutation": [7, 8, 6, 9, 0, 3, 5, 4, 2, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "123456789", "armCount": 10, "expectedPermutation": [6, 8, 2, 5, 7, 4, 9, 1, 3, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "123456789", "armCount": 10, "expectedPermutation": [6, 5, 7, 8, 0, 2, 3, 1, 9, 4]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "123456789", "armCount": 10, "expectedPermutation": [3, 6, 4, 5, 0, 7, 9, 8, 1, 2]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "123456789", "armCount": 10, "expectedPermutation": [8, 6, 3, 4, 7, 9, 5, 1, 2, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "123456789", "armCount": 10, "expectedPermutation": [0, 1, 8, 9, 2, 6, 4, 5, 3, 7]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [0, 5, 6, 4, 2, 1, 9, 3, 7, 8]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [4, 2, 3, 8, 1, 5, 0, 9, 7, 6]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [7, 3, 2, 1, 5, 9, 8, 0, 4, 6]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [4, 2, 5, 7, 9, 3, 8, 1, 0, 6]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [5, 4, 1, 8, 9, 2, 7, 0, 6, 3]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [2, 9, 6, 4, 8, 0, 1, 5, 3, 7]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [9, 5, 8, 2, 7, 3, 1, 6, 4, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [7, 9, 6, 0, 2, 5, 1, 8, 3, 4]}, + {"clusterUnit": "market_2", "cycleIndex": "0", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [2, 0, 5, 9, 1, 7, 8, 3, 4, 6]}, + {"clusterUnit": "market_2", "cycleIndex": "1", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [3, 7, 9, 8, 4, 2, 1, 6, 5, 0]}, + {"clusterUnit": "market_2", "cycleIndex": "2", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [7, 6, 0, 2, 5, 9, 1, 3, 4, 8]}, + {"clusterUnit": "market_2", "cycleIndex": "-1", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [9, 0, 6, 4, 3, 7, 5, 2, 8, 1]}, + {"clusterUnit": "market_2", "cycleIndex": "-2", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [6, 1, 9, 4, 8, 5, 3, 2, 0, 7]}, + {"clusterUnit": "market_2", "cycleIndex": "100", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [5, 9, 2, 1, 0, 4, 7, 6, 3, 8]}, + {"clusterUnit": "market_2", "cycleIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [0, 3, 7, 6, 4, 5, 2, 8, 1, 9]}, + {"clusterUnit": "market_2", "cycleIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [8, 0, 5, 9, 7, 4, 6, 3, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "0", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "0", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "42", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "42", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "42", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "-1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "-1", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "-1", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "123456789", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "123456789", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 2, "expectedPermutation": [0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "0", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "0", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "0", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "0", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "0", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "0", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "0", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "0", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "1", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "1", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "1", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "1", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "1", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "42", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "42", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "42", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "42", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "42", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "42", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "42", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "42", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "-1", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "-1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "-1", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "-1", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "-1", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "-1", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "-1", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "-1", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "123456789", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "123456789", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "123456789", "armCount": 3, "expectedPermutation": [1, 2, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 1, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [0, 2, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 0, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 3, "expectedPermutation": [2, 1, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "0", "armCount": 10, "expectedPermutation": [9, 0, 1, 7, 3, 4, 5, 8, 2, 6]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "0", "armCount": 10, "expectedPermutation": [4, 9, 7, 6, 1, 5, 8, 2, 0, 3]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "0", "armCount": 10, "expectedPermutation": [4, 1, 5, 8, 9, 6, 2, 3, 0, 7]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "0", "armCount": 10, "expectedPermutation": [5, 0, 1, 6, 7, 3, 9, 8, 2, 4]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "0", "armCount": 10, "expectedPermutation": [5, 2, 9, 4, 1, 0, 8, 6, 3, 7]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "0", "armCount": 10, "expectedPermutation": [7, 5, 8, 3, 0, 9, 2, 6, 1, 4]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "0", "armCount": 10, "expectedPermutation": [0, 5, 7, 9, 3, 2, 4, 1, 8, 6]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "0", "armCount": 10, "expectedPermutation": [9, 5, 6, 3, 1, 2, 8, 7, 4, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "1", "armCount": 10, "expectedPermutation": [9, 7, 4, 8, 1, 0, 5, 3, 6, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "1", "armCount": 10, "expectedPermutation": [9, 3, 7, 1, 8, 5, 0, 4, 6, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "1", "armCount": 10, "expectedPermutation": [9, 5, 1, 8, 0, 3, 4, 6, 7, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "1", "armCount": 10, "expectedPermutation": [4, 9, 3, 1, 6, 8, 7, 2, 5, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "1", "armCount": 10, "expectedPermutation": [1, 0, 6, 9, 8, 5, 2, 7, 3, 4]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "1", "armCount": 10, "expectedPermutation": [0, 7, 8, 3, 1, 5, 4, 9, 6, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "1", "armCount": 10, "expectedPermutation": [1, 9, 5, 4, 8, 2, 0, 7, 6, 3]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "1", "armCount": 10, "expectedPermutation": [3, 4, 2, 9, 0, 5, 1, 7, 6, 8]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "42", "armCount": 10, "expectedPermutation": [6, 1, 9, 7, 5, 8, 3, 4, 2, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "42", "armCount": 10, "expectedPermutation": [2, 8, 3, 0, 1, 7, 9, 6, 4, 5]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "42", "armCount": 10, "expectedPermutation": [5, 1, 3, 2, 0, 6, 9, 8, 7, 4]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "42", "armCount": 10, "expectedPermutation": [2, 7, 6, 9, 5, 0, 1, 8, 3, 4]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "42", "armCount": 10, "expectedPermutation": [8, 5, 4, 3, 2, 1, 0, 7, 9, 6]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "42", "armCount": 10, "expectedPermutation": [4, 6, 5, 9, 0, 2, 7, 3, 8, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "42", "armCount": 10, "expectedPermutation": [1, 9, 4, 0, 6, 8, 7, 3, 2, 5]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "42", "armCount": 10, "expectedPermutation": [8, 1, 4, 7, 0, 9, 3, 2, 6, 5]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "-1", "armCount": 10, "expectedPermutation": [8, 4, 5, 2, 3, 0, 9, 7, 6, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "-1", "armCount": 10, "expectedPermutation": [8, 0, 4, 2, 6, 7, 9, 1, 3, 5]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "-1", "armCount": 10, "expectedPermutation": [2, 8, 7, 6, 9, 0, 5, 4, 3, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "-1", "armCount": 10, "expectedPermutation": [7, 3, 4, 8, 5, 6, 9, 1, 0, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "-1", "armCount": 10, "expectedPermutation": [5, 6, 0, 1, 2, 8, 4, 3, 7, 9]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "-1", "armCount": 10, "expectedPermutation": [9, 6, 3, 4, 8, 2, 1, 7, 0, 5]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "-1", "armCount": 10, "expectedPermutation": [4, 2, 0, 5, 1, 3, 8, 9, 6, 7]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "-1", "armCount": 10, "expectedPermutation": [0, 4, 1, 6, 3, 5, 9, 2, 8, 7]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "123456789", "armCount": 10, "expectedPermutation": [9, 0, 2, 7, 5, 4, 3, 1, 8, 6]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "123456789", "armCount": 10, "expectedPermutation": [6, 1, 2, 7, 5, 3, 0, 9, 4, 8]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "123456789", "armCount": 10, "expectedPermutation": [7, 9, 2, 8, 5, 6, 0, 4, 3, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "123456789", "armCount": 10, "expectedPermutation": [5, 2, 8, 4, 0, 9, 1, 7, 6, 3]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "123456789", "armCount": 10, "expectedPermutation": [1, 0, 7, 3, 5, 6, 4, 8, 9, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "123456789", "armCount": 10, "expectedPermutation": [3, 0, 7, 4, 1, 6, 5, 2, 8, 9]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "123456789", "armCount": 10, "expectedPermutation": [9, 1, 3, 5, 6, 4, 7, 0, 8, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "123456789", "armCount": 10, "expectedPermutation": [6, 2, 0, 7, 8, 1, 3, 4, 5, 9]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [9, 0, 8, 2, 7, 5, 3, 6, 4, 1]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [0, 5, 1, 8, 2, 3, 9, 6, 7, 4]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [5, 0, 8, 2, 9, 4, 1, 6, 3, 7]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [5, 4, 8, 6, 1, 9, 7, 2, 0, 3]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [0, 1, 3, 7, 6, 4, 2, 9, 8, 5]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [0, 5, 2, 9, 6, 1, 4, 8, 3, 7]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [9, 8, 5, 1, 3, 0, 6, 4, 7, 2]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "-9223372036854775808", "armCount": 10, "expectedPermutation": [8, 3, 7, 2, 0, 1, 4, 5, 9, 6]}, + {"clusterUnit": 123456789, "cycleIndex": "0", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [5, 6, 8, 1, 7, 9, 4, 2, 3, 0]}, + {"clusterUnit": 123456789, "cycleIndex": "1", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [4, 8, 0, 6, 9, 7, 5, 2, 1, 3]}, + {"clusterUnit": 123456789, "cycleIndex": "2", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [2, 0, 8, 6, 3, 7, 9, 5, 1, 4]}, + {"clusterUnit": 123456789, "cycleIndex": "-1", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [6, 2, 3, 5, 7, 1, 9, 8, 0, 4]}, + {"clusterUnit": 123456789, "cycleIndex": "-2", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [5, 6, 7, 4, 2, 1, 8, 3, 0, 9]}, + {"clusterUnit": 123456789, "cycleIndex": "100", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [4, 9, 1, 7, 2, 5, 0, 6, 3, 8]}, + {"clusterUnit": 123456789, "cycleIndex": "-9223372036854775808", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [3, 4, 1, 2, 8, 0, 5, 9, 6, 7]}, + {"clusterUnit": 123456789, "cycleIndex": "9223372036854775807", "seed": "9223372036854775807", "armCount": 10, "expectedPermutation": [2, 5, 4, 6, 7, 9, 1, 0, 3, 8]} + ] +} From c3f128a953642cbdb87ced4bbdde7ad30bc01696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 22:35:14 +0000 Subject: [PATCH 03/35] feat(switchback): serve switchback experiments from Context For randomizationType=switchback the variant draw becomes SwitchbackAssigner.assign(hashUnit(uid), periodIndex, seed, arms) with periodIndex = floorDiv(now - ceilHour(switchbackStartAt), periodMs), read once from the injected Clock. Assignments are cached under name + U+001F + periodIndex, so each period gets a fresh exposure state: one exposure per experiment/cluster/period, rollover on the boundary. Precedence is unchanged (override, holdouts, audience, full-on, custom, traffic, variant); traffic eligibility keeps the per-unit traffic hash. Before the anchor, with a null start/period, or with a cluster unit type different from the unit type, the experiment is ineligible; a missing unit stays unassigned. There is no unit-assignment fallback. Grid fields (type, cluster unit type, start, period, design) join the cache-validity check so a refresh that changes them re-resolves; burn, carryover and estimand do not. Per-period entries are bounded to the two most recent periods per experiment. Exposure payloads are unchanged. --- .../main/java/com/absmartly/sdk/Context.java | 229 ++++++++++++++---- 1 file changed, 180 insertions(+), 49 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index 389719b..fc579d4 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -24,6 +24,7 @@ import com.absmartly.sdk.internal.Algorithm; import com.absmartly.sdk.internal.Concurrency; +import com.absmartly.sdk.internal.SwitchbackAssigner; import com.absmartly.sdk.internal.VariantAssigner; import com.absmartly.sdk.internal.hashing.Hashing; import com.absmartly.sdk.java.nio.charset.StandardCharsets; @@ -36,6 +37,11 @@ public class Context implements Closeable { private static final int ASSIGNMENT_EXPOSED = 1; private static final int ASSIGNMENT_RETIRED = 2; private static final int MAX_EXPOSURE_ATTEMPTS = 3; + private static final String RANDOMIZATION_SWITCHBACK = "switchback"; + private static final char SWITCHBACK_KEY_SEPARATOR = '\u001f'; + private static final long NO_SWITCHBACK_PERIOD = Long.MIN_VALUE; + private static final long HOUR_MS = 3600000L; + private static final int SWITCHBACK_RETAINED_PERIODS = 2; private static final Logger log = LoggerFactory.getLogger(Context.class); public static Context create(@Nonnull final Clock clock, @Nonnull final ContextConfig config, @@ -977,7 +983,96 @@ private boolean experimentMatches(final ContextExperiment experiment, final Assi experiment.data.iteration == assignment.iteration && experiment.data.fullOnVariant == assignment.fullOnVariant && Arrays.equals(experiment.data.trafficSplit, assignment.trafficSplit) && - holdoutSetMatches(experiment.holdouts, assignment.holdouts); + holdoutSetMatches(experiment.holdouts, assignment.holdouts) && + switchbackConfigMatches(experiment.data, assignment); + } + + // Grid identity: a change to any of these re-maps periods to variants. Carryover and burn + // windows are analysis-only and deliberately excluded, as are seed and split, which do not + // invalidate unit assignments either. + private static boolean switchbackConfigMatches(final Experiment experiment, final Assignment assignment) { + if (isSwitchback(experiment) != assignment.switchback) { + return false; + } + return !assignment.switchback + || (Objects.equals(experiment.switchbackClusterUnitType, assignment.switchbackClusterUnitType) + && Objects.equals(experiment.switchbackStartAt, assignment.switchbackStartAt) + && Objects.equals(experiment.switchbackPeriodMs, assignment.switchbackPeriodMs) + && Objects.equals(experiment.switchbackDesignType, assignment.switchbackDesignType)); + } + + private static void pinSwitchbackConfig(final Assignment assignment, final Experiment experiment) { + assignment.switchback = isSwitchback(experiment); + if (assignment.switchback) { + assignment.switchbackClusterUnitType = experiment.switchbackClusterUnitType; + assignment.switchbackStartAt = experiment.switchbackStartAt; + assignment.switchbackPeriodMs = experiment.switchbackPeriodMs; + assignment.switchbackDesignType = experiment.switchbackDesignType; + } + } + + private static boolean isSwitchback(final Experiment experiment) { + return RANDOMIZATION_SWITCHBACK.equals(experiment.randomizationType); + } + + private static long switchbackSeed(final Experiment experiment) { + return ((long) experiment.seedHi << 32) | (experiment.seedLo & 0xffffffffL); + } + + // The grid anchor is switchbackStartAt rounded up to the UTC hour; the server already stores + // it aligned, and rounding an aligned value is a no-op. Returns NO_SWITCHBACK_PERIOD before + // the anchor and for any grid the SDK cannot evaluate: missing start or period, a cluster unit + // type other than the experiment's unit type (Phase 1 invariant), or fewer than two arms. + private static long switchbackPeriodIndex(final Experiment experiment, final long nowMs) { + final Long startAt = experiment.switchbackStartAt; + final Long periodMs = experiment.switchbackPeriodMs; + if ((startAt == null) || (periodMs == null) || (periodMs <= 0)) { + return NO_SWITCHBACK_PERIOD; + } + if ((experiment.switchbackClusterUnitType != null) + && !experiment.switchbackClusterUnitType.equals(experiment.unitType)) { + return NO_SWITCHBACK_PERIOD; + } + if ((experiment.split == null) || (experiment.split.length < 2)) { + return NO_SWITCHBACK_PERIOD; + } + + final long anchor = -SwitchbackAssigner.floorDiv(-startAt, HOUR_MS) * HOUR_MS; + if (nowMs < anchor) { + return NO_SWITCHBACK_PERIOD; + } + return SwitchbackAssigner.floorDiv(nowMs - anchor, periodMs); + } + + // Unit experiments, and switchback experiments with no current period, keep the bare name. + private static String assignmentCacheKey(final String experimentName, final ContextExperiment experiment, + final long nowMs) { + if ((experiment != null) && isSwitchback(experiment.data)) { + final long periodIndex = switchbackPeriodIndex(experiment.data, nowMs); + if (periodIndex != NO_SWITCHBACK_PERIOD) { + return switchbackCacheKey(experimentName, periodIndex); + } + } + return experimentName; + } + + private static String switchbackCacheKey(final String experimentName, final long periodIndex) { + return experimentName + SWITCHBACK_KEY_SEPARATOR + periodIndex; + } + + // Bounds the per-period entries of one experiment to the two most recent periods. The previous + // period is kept so a caller that read the clock just before a boundary still finds its + // already-exposed assignment instead of re-creating it and exposing that period twice. + private void retainRecentSwitchbackPeriods(final String experimentName, final long periodIndex) { + TreeSet periods = switchbackPeriods_.get(experimentName); + if (periods == null) { + periods = new TreeSet(); + switchbackPeriods_.put(experimentName, periods); + } + periods.add(periodIndex); + while (periods.size() > SWITCHBACK_RETAINED_PERIODS) { + assignmentCache_.remove(switchbackCacheKey(experimentName, periods.pollFirst())); + } } // Applicable-holdout identity for cache-validity purposes is (id, iteration) per entry, in @@ -1063,6 +1158,13 @@ private static class Assignment { Map variables = Collections.emptyMap(); int attrsSeq; + // Switchback grid this assignment was resolved under; compared by switchbackConfigMatches. + boolean switchback; + String switchbackClusterUnitType; + Long switchbackStartAt; + Long switchbackPeriodMs; + String switchbackDesignType; + final AtomicInteger exposureState = new AtomicInteger(ASSIGNMENT_UNEXPOSED); } @@ -1126,42 +1228,40 @@ boolean matches(Experiment current) { } } + private boolean isAssignmentCurrent(final Assignment assignment, final Integer custom, final Integer override, + final ContextExperiment experiment) { + if (override != null) { + // An override still evaluates the experiment for holdout purposes (see the + // write path below), so the fast path must revalidate the applicable-holdout + // set exactly like the ordinary branch does via experimentMatches - otherwise + // a holdout that becomes applicable after a refresh never fires for an + // already-overridden experiment, and stays that way forever. + return assignment.overridden && assignment.variant == override + && holdoutSetMatches((experiment != null) ? experiment.holdouts : null, assignment.holdouts); + } else if (experiment == null) { + // previously not-running experiment + return !assignment.assigned; + } else if (Objects.equals(custom, assignment.customAssignment)) { + return experimentMatches(experiment, assignment) && audienceMatches(experiment.data, assignment); + } + return false; + } + private Assignment getAssignment(final String experimentName) { + // One clock read per resolution, so the cache key and the drawn period always agree. + final long now = clock_.millis(); + final ReentrantReadWriteLock.ReadLock readLock = contextLock_.readLock(); try { readLock.lock(); - final Assignment assignment = assignmentCache_.get(experimentName); - - if (assignment != null) { - final Integer custom = cassignments_.get(experimentName); - final Integer override = overrides_.get(experimentName); - final ContextExperiment experiment = Context.this.getExperiment(experimentName); - - if (override != null) { - // An override still evaluates the experiment for holdout purposes (see the - // write path below), so the fast path must revalidate the applicable-holdout - // set exactly like the ordinary branch does via experimentMatches - otherwise - // a holdout that becomes applicable after a refresh never fires for an - // already-overridden experiment, and stays that way forever. - if (assignment.overridden && assignment.variant == override - && holdoutSetMatches((experiment != null) ? experiment.holdouts : null, - assignment.holdouts)) { - // override and holdout coverage both up-to-date - return assignment; - } - } else if (experiment == null) { - if (!assignment.assigned) { - // previously not-running experiment - return assignment; - } - } else if (Objects.equals(custom, assignment.customAssignment)) { - if (experimentMatches(experiment, assignment) - && audienceMatches(experiment.data, assignment)) { - // assignment up-to-date - return assignment; - } - } + final ContextExperiment experiment = Context.this.getExperiment(experimentName); + final Assignment assignment = assignmentCache_ + .get(assignmentCacheKey(experimentName, experiment, now)); + + if ((assignment != null) && isAssignmentCurrent(assignment, cassignments_.get(experimentName), + overrides_.get(experimentName), experiment)) { + return assignment; } } finally { readLock.unlock(); @@ -1175,6 +1275,18 @@ && audienceMatches(experiment.data, assignment)) { final Integer custom = cassignments_.get(experimentName); final Integer override = overrides_.get(experimentName); final ContextExperiment experiment = Context.this.getExperiment(experimentName); + final String cacheKey = assignmentCacheKey(experimentName, experiment, now); + + // Recheck: a concurrent caller may have installed this key (e.g. the first call in a + // new switchback period) between the two locks; replacing it would reset its exposure. + final Assignment cached = assignmentCache_.get(cacheKey); + if ((cached != null) && isAssignmentCurrent(cached, custom, override, experiment)) { + return cached; + } + + final long periodIndex = ((experiment != null) && isSwitchback(experiment.data)) + ? switchbackPeriodIndex(experiment.data, now) + : NO_SWITCHBACK_PERIOD; final Assignment assignment = new Assignment(); assignment.name = experimentName; @@ -1198,6 +1310,7 @@ && audienceMatches(experiment.data, assignment)) { } else { if (experiment != null) { final String unitType = experiment.data.unitType; + pinSwitchbackConfig(assignment, experiment.data); // Share the experiment's applicable-holdouts array by reference rather than // copying: it is only read here (and via Arrays.equals in experimentMatches) and @@ -1256,25 +1369,37 @@ && audienceMatches(experiment.data, assignment)) { if (uid != null) { final byte[] unitHash = Context.this.getUnitHash(unitType, uid); - final VariantAssigner assigner = Context.this.getVariantAssigner(unitType, - unitHash); - final boolean eligible = assigner.assign(experiment.data.trafficSplit, - experiment.data.trafficSeedHi, - experiment.data.trafficSeedLo) == 1; - if (eligible) { - if (custom != null) { - assignment.variant = custom; - assignment.custom = true; - } else { - assignment.variant = assigner.assign(experiment.data.split, - experiment.data.seedHi, - experiment.data.seedLo); - } - } else { + if (assignment.switchback && (periodIndex == NO_SWITCHBACK_PERIOD)) { + // Before the grid anchor, or a grid this SDK cannot evaluate: + // ineligible like a traffic miss, never a unit-assignment draw. assignment.eligible = false; assignment.variant = 0; + assignment.assigned = true; + } else { + final VariantAssigner assigner = Context.this.getVariantAssigner(unitType, + unitHash); + final boolean eligible = assigner.assign(experiment.data.trafficSplit, + experiment.data.trafficSeedHi, + experiment.data.trafficSeedLo) == 1; + if (eligible) { + if (custom != null) { + assignment.variant = custom; + assignment.custom = true; + } else if (assignment.switchback) { + assignment.variant = SwitchbackAssigner.assign( + new String(unitHash, StandardCharsets.US_ASCII), periodIndex, + switchbackSeed(experiment.data), experiment.data.split.length); + } else { + assignment.variant = assigner.assign(experiment.data.split, + experiment.data.seedHi, + experiment.data.seedLo); + } + } else { + assignment.eligible = false; + assignment.variant = 0; + } + assignment.assigned = true; } - assignment.assigned = true; } } else { assignment.assigned = true; @@ -1297,7 +1422,10 @@ && audienceMatches(experiment.data, assignment)) { assignment.variables = experiment.variables.get(assignment.variant); } - assignmentCache_.put(experimentName, assignment); + assignmentCache_.put(cacheKey, assignment); + if (periodIndex != NO_SWITCHBACK_PERIOD) { + retainRecentSwitchbackPeriods(experimentName, periodIndex); + } return assignment; } finally { @@ -1779,7 +1907,10 @@ private Map buildAttributesMap() { private final Map hashedUnits_; private final Map assigners_; + // Keyed by experiment name, or by name + U+001F + period index for a switchback experiment + // inside its grid (see assignmentCacheKey), so each period gets its own exposure state. private final Map assignmentCache_ = new HashMap(); + private final Map> switchbackPeriods_ = new HashMap>(); // A holdout's identity, for both this cache's keying and for detecting whether a covered // experiment's own set of applicable holdouts is still current (holdoutSetMatches), is its // (id, iteration) pair - never a full Experiment comparison. Rationale: the governing From 363a7c1a4d968be93914eb76602973d0305a41fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 22:35:14 +0000 Subject: [PATCH 04/35] test(switchback): Context switchback behaviour Hashed cluster identity, 2/3/10-arm schedules against the platform's Python reference, 1h/24h edges, pre-anchor and null-start ineligibility, same-period cache and rollover exposures, variables, missing unit, full-on, override/custom, sticky traffic, refresh invalidation, holdouts, bounded cache, and the byte-exact publish payload. --- .../absmartly/sdk/ContextSwitchbackTest.java | 612 ++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java new file mode 100644 index 0000000..cbdb6ae --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -0,0 +1,612 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import com.absmartly.sdk.internal.SwitchbackAssigner; +import com.absmartly.sdk.internal.hashing.Hashing; +import com.absmartly.sdk.java.nio.charset.StandardCharsets; +import com.absmartly.sdk.java.time.Clock; +import com.absmartly.sdk.json.ContextData; +import com.absmartly.sdk.json.Experiment; +import com.absmartly.sdk.json.ExperimentApplication; +import com.absmartly.sdk.json.ExperimentVariant; +import com.absmartly.sdk.json.Exposure; +import com.absmartly.sdk.json.PublishEvent; + +class ContextSwitchbackTest { + static final String UNIT_TYPE = "store_id"; + static final String UID = "e791e240fcd3df7d238cfc285f475e8152fcc0ec"; + // Hashing.hashUnit(UID): the MD5/base64url string the Collector's UnitHasher also produces. + static final String UID_HASH = "pAE3a1i5Drs5mKRNq56adA"; + + static final long HOUR = 3_600_000L; + static final long DAY = 24 * HOUR; + // 2026-01-01T00:00:00Z, hour aligned. + static final long ANCHOR = 1_767_225_600_000L; + + static final int SEED_HI = -5; + static final int SEED_LO = -7; + + // Periods 0..11 for UID_HASH under seed (SEED_HI, SEED_LO), computed with the platform's + // independent Python reference (abs tests/scripts/switchback_ref.py, assign_switchback). + static final int[] EXPECTED_2_ARMS = {1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0}; + static final int[] EXPECTED_3_ARMS = {1, 2, 0, 2, 1, 0, 2, 0, 1, 1, 2, 0}; + static final int[] EXPECTED_10_ARMS = {1, 2, 5, 3, 7, 4, 8, 0, 6, 9, 4, 6}; + // Same reference fed the raw UID instead of its hash: what a wrong identity would serve. + static final int[] RAW_UID_3_ARMS = {2, 0, 1, 0, 1, 2, 1, 2, 0, 0, 1, 2}; + + static final class MutableClock extends Clock { + long now; + + MutableClock(long now) { + this.now = now; + } + + @Override + public long millis() { + return now; + } + } + + MutableClock clock; + ContextDataProvider dataProvider; + ContextEventHandler eventHandler; + ScheduledExecutorService scheduler; + + @BeforeEach + void setUp() { + clock = new MutableClock(ANCHOR); + dataProvider = mock(ContextDataProvider.class); + eventHandler = mock(ContextEventHandler.class); + scheduler = mock(ScheduledExecutorService.class); + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture. completedFuture(null)); + } + + static Experiment switchback(int id, String name, int armCount, long periodMs) { + final Experiment experiment = new Experiment(); + experiment.id = id; + experiment.name = name; + experiment.unitType = UNIT_TYPE; + experiment.iteration = 1; + experiment.seedHi = SEED_HI; + experiment.seedLo = SEED_LO; + experiment.split = new double[armCount]; + experiment.variants = new ExperimentVariant[armCount]; + for (int i = 0; i < armCount; ++i) { + experiment.split[i] = 1.0 / armCount; + experiment.variants[i] = new ExperimentVariant(String.valueOf((char) ('A' + i)), + "{\"sb.arm\":" + i + "}"); + } + experiment.trafficSeedHi = 1; + experiment.trafficSeedLo = 2; + experiment.trafficSplit = new double[]{0.0, 1.0}; + experiment.applications = new ExperimentApplication[]{new ExperimentApplication("website")}; + experiment.randomizationType = "switchback"; + experiment.switchbackClusterUnitType = UNIT_TYPE; + experiment.switchbackStartAt = ANCHOR; + experiment.switchbackPeriodMs = periodMs; + experiment.switchbackCarryoverDurationMs = 0L; + experiment.switchbackBurnInDurationMs = 0L; + experiment.switchbackBurnOutDurationMs = 0L; + experiment.switchbackDesignType = "balanced_block"; + experiment.switchbackEstimand = "global_average_treatment_effect"; + return experiment; + } + + static Experiment switchback(int armCount, long periodMs) { + return switchback(1, "exp_sb", armCount, periodMs); + } + + static ContextData dataOf(Experiment... experiments) { + final ContextData data = new ContextData(); + data.experiments = experiments; + return data; + } + + Context createContext(Map units, ContextData data) { + final ContextConfig config = ContextConfig.create(); + if (units != null) { + config.setUnits(units); + } + return Context.create(clock, config, scheduler, CompletableFuture.completedFuture(data), dataProvider, + eventHandler, null, new DefaultVariableParser(), + new AudienceMatcher(new DefaultAudienceDeserializer())); + } + + Context createContext(ContextData data) { + return createContext(TestUtils.mapOf(UNIT_TYPE, UID), data); + } + + List publishAndCapture(Context context, int expectedPublishes) { + context.publish(); + final ArgumentCaptor captor = ArgumentCaptor.forClass(PublishEvent.class); + verify(eventHandler, Mockito.timeout(5000).times(expectedPublishes)).publish(Mockito.eq(context), + captor.capture()); + return captor.getAllValues(); + } + + Exposure[] publishExposures(Context context) { + final List events = publishAndCapture(context, 1); + final Exposure[] exposures = events.get(0).exposures; + return (exposures != null) ? exposures : new Exposure[0]; + } + + static Exposure exposure(int id, String name, int variant, long exposedAt, boolean assigned, boolean eligible) { + return new Exposure(id, name, UNIT_TYPE, variant, exposedAt, assigned, eligible, false, false, false, false); + } + + int[] peekSchedule(Context context, String name, long periodMs, int periods) { + final int[] result = new int[periods]; + for (int i = 0; i < periods; ++i) { + clock.now = ANCHOR + i * periodMs; + result[i] = context.peekTreatment(name); + } + return result; + } + + @Test + void clusterIdentityIsTheSdkUnitHash() { + assertEquals(UID_HASH, new String(Hashing.hashUnit(UID), StandardCharsets.US_ASCII)); + + final Context context = createContext(dataOf(switchback(3, HOUR))); + final int[] served = peekSchedule(context, "exp_sb", HOUR, 12); + + assertArrayEquals(EXPECTED_3_ARMS, served); + assertFalse(java.util.Arrays.equals(RAW_UID_3_ARMS, served)); + for (int i = 0; i < 12; ++i) { + assertEquals(SwitchbackAssigner.assign(UID_HASH, i, ((long) SEED_HI << 32) | (SEED_LO & 0xffffffffL), 3), + served[i]); + } + } + + @Test + void servesTwoThreeAndTenArmPermutations() { + final Context context = createContext(dataOf(switchback(1, "exp_2", 2, HOUR), + switchback(2, "exp_3", 3, HOUR), switchback(3, "exp_10", 10, HOUR))); + + assertArrayEquals(EXPECTED_2_ARMS, peekSchedule(context, "exp_2", HOUR, 12)); + assertArrayEquals(EXPECTED_3_ARMS, peekSchedule(context, "exp_3", HOUR, 12)); + assertArrayEquals(EXPECTED_10_ARMS, peekSchedule(context, "exp_10", HOUR, 12)); + } + + @Test + void oneHourBoundaryAtExactEdgeMilliseconds() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + + clock.now = ANCHOR - 1; + assertEquals(0, context.peekTreatment("exp_sb")); + clock.now = ANCHOR; + assertEquals(EXPECTED_2_ARMS[0], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + HOUR - 1; + assertEquals(EXPECTED_2_ARMS[0], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + HOUR; + assertEquals(EXPECTED_2_ARMS[1], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + 3 * HOUR - 1; + assertEquals(EXPECTED_2_ARMS[2], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + 3 * HOUR; + assertEquals(EXPECTED_2_ARMS[3], context.peekTreatment("exp_sb")); + } + + @Test + void twentyFourHourBoundaryAtExactEdgeMilliseconds() { + final Context context = createContext(dataOf(switchback(2, DAY))); + + clock.now = ANCHOR - 1; + assertEquals(0, context.peekTreatment("exp_sb")); + clock.now = ANCHOR; + assertEquals(EXPECTED_2_ARMS[0], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + HOUR; + assertEquals(EXPECTED_2_ARMS[0], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + DAY - 1; + assertEquals(EXPECTED_2_ARMS[0], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + DAY; + assertEquals(EXPECTED_2_ARMS[1], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + 4 * DAY - 1; + assertEquals(EXPECTED_2_ARMS[3], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + 4 * DAY; + assertEquals(EXPECTED_2_ARMS[4], context.peekTreatment("exp_sb")); + } + + // The anchor is the start instant rounded up to the hour; start..anchor is pre-period. + @Test + void unalignedStartIsRoundedUpAndPreAnchorIsIneligible() { + final Experiment experiment = switchback(2, HOUR); + final long start = ANCHOR - 1_234_567L; + experiment.switchbackStartAt = start; + final Context context = createContext(dataOf(experiment)); + + clock.now = start; + assertEquals(0, context.getTreatment("exp_sb")); + clock.now = ANCHOR - 1; + assertEquals(0, context.getTreatment("exp_sb")); + clock.now = ANCHOR; + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + clock.now = ANCHOR + HOUR; + assertEquals(EXPECTED_2_ARMS[1], context.getTreatment("exp_sb")); + + final Exposure[] exposures = publishExposures(context); + assertArrayEquals(new Exposure[]{ + exposure(1, "exp_sb", 0, start, true, false), + exposure(1, "exp_sb", EXPECTED_2_ARMS[0], ANCHOR, true, true), + exposure(1, "exp_sb", EXPECTED_2_ARMS[1], ANCHOR + HOUR, true, true), + }, exposures); + } + + @Test + void nullStartAtIsIneligibleAndNeverFallsBackToUnitAssignment() { + final Experiment experiment = switchback(2, HOUR); + experiment.switchbackStartAt = null; + final Context context = createContext(dataOf(experiment)); + + for (int i = 0; i < 4; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(0, context.getTreatment("exp_sb")); + } + + assertArrayEquals(new Exposure[]{exposure(1, "exp_sb", 0, ANCHOR, true, false)}, publishExposures(context)); + } + + @Test + void samePeriodIsCachedAndNextPeriodRollsOverWithOneExposureEach() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + + clock.now = ANCHOR + 10; + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + clock.now = ANCHOR + HOUR - 1; + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(EXPECTED_2_ARMS[0], context.peekTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + + clock.now = ANCHOR + HOUR; + assertEquals(EXPECTED_2_ARMS[1], context.peekTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + assertEquals(EXPECTED_2_ARMS[1], context.getTreatment("exp_sb")); + assertEquals(2, context.getPendingCount()); + clock.now = ANCHOR + HOUR + 30 * 60_000L; + assertEquals(EXPECTED_2_ARMS[1], context.getTreatment("exp_sb")); + assertEquals(2, context.getPendingCount()); + + final Exposure[] exposures = publishExposures(context); + assertArrayEquals(new Exposure[]{ + exposure(1, "exp_sb", EXPECTED_2_ARMS[0], ANCHOR + 10, true, true), + exposure(1, "exp_sb", EXPECTED_2_ARMS[1], ANCHOR + HOUR, true, true), + }, exposures); + } + + @Test + void variableLookupsFollowThePeriodAndExposeOncePerPeriod() { + final Context context = createContext(dataOf(switchback(3, HOUR))); + + assertEquals(TestUtils.mapOf("sb.arm", TestUtils.listOf("exp_sb")), context.getVariableKeys()); + assertArrayEquals(new String[]{"exp_sb"}, context.getExperiments()); + + for (int i = 0; i < 3; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(EXPECTED_3_ARMS[i], context.peekVariableValue("sb.arm", -1)); + assertEquals(EXPECTED_3_ARMS[i], context.getVariableValue("sb.arm", -1)); + assertEquals(EXPECTED_3_ARMS[i], context.getVariableValue("sb.arm", -1)); + assertEquals(EXPECTED_3_ARMS[i], context.getTreatment("exp_sb")); + } + assertEquals(3, context.getPendingCount()); + } + + @Test + void missingClusterUnitIsUnassignedWithoutFallback() { + final Context context = createContext(TestUtils.mapOf("user_id", UID), dataOf(switchback(2, HOUR))); + + for (int i = 0; i < 3; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(0, context.peekTreatment("exp_sb")); + assertEquals(0, context.getTreatment("exp_sb")); + assertEquals(-1, context.getVariableValue("sb.arm", -1)); + } + + for (final Exposure exposure : publishExposures(context)) { + assertEquals(0, exposure.variant); + assertFalse(exposure.assigned); + } + } + + @Test + void fullOnServesTheFullOnVariantInEveryPeriod() { + final Experiment experiment = switchback(3, HOUR); + experiment.fullOnVariant = 2; + final Context context = createContext(dataOf(experiment)); + + for (int i = 0; i < 4; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(2, context.getTreatment("exp_sb")); + } + for (final Exposure exposure : publishExposures(context)) { + assertEquals(2, exposure.variant); + assertTrue(exposure.fullOn); + } + } + + @Test + void overrideAndCustomAssignmentWinInEveryPeriod() { + final Context context = createContext(dataOf(switchback(1, "exp_override", 3, HOUR), + switchback(2, "exp_custom", 3, HOUR))); + context.setOverride("exp_override", 7); + context.setCustomAssignment("exp_custom", 2); + + for (int i = 0; i < 3; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(7, context.getTreatment("exp_override")); + assertEquals(2, context.getTreatment("exp_custom")); + } + for (final Exposure exposure : publishExposures(context)) { + if (exposure.name.equals("exp_override")) { + assertTrue(exposure.overridden); + assertEquals(7, exposure.variant); + } else { + assertTrue(exposure.custom); + assertEquals(2, exposure.variant); + } + } + } + + // Traffic eligibility uses the existing per-unit traffic hash, so it matches a unit-randomized + // twin with the same traffic seed and stays fixed across periods. + @Test + void trafficEligibilityIsClusterSticky() { + int eligibleCount = 0; + int ineligibleCount = 0; + for (int u = 0; u < 24; ++u) { + reset(eventHandler); + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture. completedFuture(null)); + + final Experiment sb = switchback(1, "exp_sb", 2, HOUR); + sb.trafficSplit = new double[]{0.5, 0.5}; + final Experiment twin = switchback(2, "exp_unit", 2, HOUR); + twin.trafficSplit = new double[]{0.5, 0.5}; + twin.randomizationType = null; + twin.switchbackStartAt = null; + + final Context context = createContext(TestUtils.mapOf(UNIT_TYPE, "store_" + u), dataOf(sb, twin)); + clock.now = ANCHOR; + context.getTreatment("exp_unit"); + for (int i = 0; i < 4; ++i) { + clock.now = ANCHOR + i * HOUR; + context.getTreatment("exp_sb"); + } + + final Exposure[] exposures = publishExposures(context); + assertEquals(5, exposures.length); + final boolean twinEligible = exposures[0].eligible; + for (int i = 1; i < exposures.length; ++i) { + assertEquals("exp_sb", exposures[i].name); + assertEquals(twinEligible, exposures[i].eligible); + if (!twinEligible) { + assertEquals(0, exposures[i].variant); + } + } + if (twinEligible) { + ++eligibleCount; + } else { + ++ineligibleCount; + } + } + assertTrue(eligibleCount > 0); + assertTrue(ineligibleCount > 0); + } + + @Test + void unitExperimentsAreUnaffected() { + final Experiment unit = switchback(2, HOUR); + unit.randomizationType = "unit"; + final Experiment absent = switchback(2, "exp_absent", 2, HOUR); + absent.randomizationType = null; + + final Context context = createContext(dataOf(unit, absent)); + final int variant = context.getTreatment("exp_sb"); + final int absentVariant = context.getTreatment("exp_absent"); + for (int i = 1; i < 6; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(variant, context.getTreatment("exp_sb")); + assertEquals(absentVariant, context.getTreatment("exp_absent")); + } + assertEquals(2, context.getPendingCount()); + } + + @Test + void refreshInvalidatesWhenGridFieldsChangeButNotForAnalysisOnlyFields() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + clock.now = ANCHOR + 10; + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + + final Experiment burnsChanged = switchback(2, HOUR); + burnsChanged.switchbackBurnInDurationMs = 600_000L; + burnsChanged.switchbackBurnOutDurationMs = 300_000L; + burnsChanged.switchbackCarryoverDurationMs = 900_000L; + burnsChanged.switchbackEstimand = "other"; + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(burnsChanged))); + context.refresh(); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + + // Same period index 0 under both grids, so only the config comparison can invalidate. + final Experiment periodChanged = switchback(2, 2 * HOUR); + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(periodChanged))); + context.refresh(); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(2, context.getPendingCount()); + + final Experiment designChanged = switchback(2, 2 * HOUR); + designChanged.switchbackDesignType = "other_design"; + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(designChanged))); + context.refresh(); + context.getTreatment("exp_sb"); + assertEquals(3, context.getPendingCount()); + + final Experiment toUnit = switchback(2, 2 * HOUR); + toUnit.randomizationType = "unit"; + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(toUnit))); + context.refresh(); + context.getTreatment("exp_sb"); + assertEquals(4, context.getPendingCount()); + + final Experiment backToSwitchback = switchback(2, 2 * HOUR); + when(dataProvider.getContextData()) + .thenReturn(CompletableFuture.completedFuture(dataOf(backToSwitchback))); + context.refresh(); + context.getTreatment("exp_sb"); + assertEquals(5, context.getPendingCount()); + } + + @Test + void refreshSettingStartAtStartsTheGrid() { + final Experiment pending = switchback(2, HOUR); + pending.switchbackStartAt = null; + final Context context = createContext(dataOf(pending)); + clock.now = ANCHOR + 10; + assertEquals(0, context.getTreatment("exp_sb")); + + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(switchback(2, HOUR)))); + context.refresh(); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(2, context.getPendingCount()); + } + + @Test + void holdoutSuppressionAndExposureSpanPeriods() { + final Experiment holdout = new Experiment(); + holdout.id = 11; + holdout.name = "holdout_a"; + holdout.unitType = UNIT_TYPE; + holdout.iteration = 1; + // split[0.1,0.9], seedHi=13, seedLo=111 -> variant 0 (held out) for UID. + holdout.seedHi = 13; + holdout.seedLo = 111; + holdout.split = new double[]{0.1, 0.9}; + holdout.trafficSplit = new double[]{0.0, 1.0}; + + final Experiment covered = switchback(2, HOUR); + covered.holdoutIds = new int[]{11}; + final ContextData data = dataOf(covered); + data.holdouts = new Experiment[]{holdout}; + + final Context context = createContext(data); + for (int i = 0; i < 3; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(0, context.getTreatment("exp_sb")); + } + + assertArrayEquals(new Exposure[]{ + new Exposure(11, "holdout_a", UNIT_TYPE, 0, ANCHOR, true, true, false, false, false, false) + }, publishExposures(context)); + } + + @Test + void customFieldValuesAndUnitLookupsWork() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + assertEquals(UID, context.getUnit(UNIT_TYPE)); + assertEquals(0, context.getCustomFieldKeys().length); + assertEquals(null, context.getCustomFieldValue("exp_sb", "missing")); + } + + // Each experiment keeps entries for at most its two most recent periods. + @Test + void perPeriodCacheIsBounded() throws Exception { + final Context context = createContext(dataOf(switchback(2, HOUR))); + for (int i = 0; i < 50; ++i) { + clock.now = ANCHOR + i * HOUR; + context.getTreatment("exp_sb"); + } + final Field field = Context.class.getDeclaredField("assignmentCache_"); + field.setAccessible(true); + assertEquals(2, ((Map) field.get(context)).size()); + assertEquals(50, context.getPendingCount()); + } + + @Test + void clusterUnitTypeDifferentFromUnitTypeIsIneligible() { + final Experiment experiment = switchback(2, HOUR); + experiment.switchbackClusterUnitType = "user_id"; + final Context context = createContext( + TestUtils.mapOf(UNIT_TYPE, UID, "user_id", "u1"), dataOf(experiment)); + for (int i = 0; i < 3; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(0, context.getTreatment("exp_sb")); + } + final Exposure[] exposures = publishExposures(context); + assertEquals(1, exposures.length); + assertFalse(exposures[0].eligible); + } + + // The publish payload carries no switchback-specific exposure fields. + @Test + void publishPayloadHasNoNewFields() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + clock.now = ANCHOR + 5; + context.getTreatment("exp_sb"); + clock.now = ANCHOR + HOUR + 7; + context.getTreatment("exp_sb"); + clock.now = ANCHOR + HOUR + 9; + + final PublishEvent event = publishAndCapture(context, 1).get(0); + final String json = new String(new DefaultContextEventSerializer().serialize(event), + StandardCharsets.UTF_8); + + assertEquals("{\"hashed\":true,\"units\":[{\"type\":\"store_id\",\"uid\":\"" + UID_HASH + "\"}]," + + "\"publishedAt\":" + (ANCHOR + HOUR + 9) + "," + + "\"exposures\":[" + + "{\"id\":1,\"name\":\"exp_sb\",\"unit\":\"store_id\",\"variant\":" + EXPECTED_2_ARMS[0] + + ",\"exposedAt\":" + (ANCHOR + 5) + + ",\"assigned\":true,\"eligible\":true,\"overridden\":false,\"fullOn\":false,\"custom\":false," + + "\"audienceMismatch\":false}," + + "{\"id\":1,\"name\":\"exp_sb\",\"unit\":\"store_id\",\"variant\":" + EXPECTED_2_ARMS[1] + + ",\"exposedAt\":" + (ANCHOR + HOUR + 7) + + ",\"assigned\":true,\"eligible\":true,\"overridden\":false,\"fullOn\":false,\"custom\":false," + + "\"audienceMismatch\":false}" + + "]}", json); + assertNotEquals(EXPECTED_2_ARMS[0], EXPECTED_2_ARMS[1]); + } + + @Test + void periodsPublishedAcrossFlushesStayOnePerPeriod() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + final List variants = new ArrayList(); + clock.now = ANCHOR; + context.getTreatment("exp_sb"); + context.publish(); + clock.now = ANCHOR + 1; + context.getTreatment("exp_sb"); + assertEquals(0, context.getPendingCount()); + clock.now = ANCHOR + HOUR; + context.getTreatment("exp_sb"); + context.publish(); + + for (final PublishEvent event : publishAndCapture(context, 2)) { + for (final Exposure exposure : event.exposures) { + variants.add(exposure.variant); + } + } + assertEquals(TestUtils.listOf(EXPECTED_2_ARMS[0], EXPECTED_2_ARMS[1]), variants); + verify(eventHandler, times(2)).publish(any(), any()); + } +} From 8cf734b60c2953efff36f49e141477d413034e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 22:35:48 +0000 Subject: [PATCH 05/35] docs(switchback): document switchback serving and old-client behaviour --- README.md | 4 ++++ SWITCHBACK.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 SWITCHBACK.md diff --git a/README.md b/README.md index 4696568..6b96a96 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,10 @@ The `close()` and `closeAsync()` methods will ensure all events have been publis context.close(); ``` +### Switchback Experiments + +Switchback experiments need no code changes: `getTreatment`, `peekTreatment` and the variable methods return the variant for the current time period, and the SDK emits one exposure per period. See [SWITCHBACK.md](SWITCHBACK.md) for the assignment rules, the minimum SDK version and how older SDKs behave. + ### Custom Event Logger The A/B Smartly SDK can be instantiated with an event logger used for all contexts. diff --git a/SWITCHBACK.md b/SWITCHBACK.md new file mode 100644 index 0000000..9d30bfc --- /dev/null +++ b/SWITCHBACK.md @@ -0,0 +1,57 @@ +# Switchback experiments + +A switchback experiment randomizes a *cluster* (in Phase 1, always the experiment's own unit type) +into a variant that changes over time on a fixed UTC grid. Every unit of that cluster sees the same +variant within a period. + +## Minimum SDK version + +Switchback serving is supported from the first `core-api` release after **1.6.3**. Every release up +to and including 1.6.3 lacks it. This change does not bump `VERSION`; the release that ships it sets +the number. + +## What the SDK does + +The Collector adds these optional fields to each experiment in the context data: + +| Field | Meaning | +| --- | --- | +| `randomizationType` | `"unit"` or `"switchback"`. Absent means `"unit"`. | +| `switchbackClusterUnitType` | Cluster unit type name. Must equal `unitType` in Phase 1. | +| `switchbackStartAt` | Grid start, epoch milliseconds. Null before the experiment starts. | +| `switchbackPeriodMs` | Period length in milliseconds (whole hours). | +| `switchbackCarryoverDurationMs`, `switchbackBurnInDurationMs`, `switchbackBurnOutDurationMs` | Analysis-only windows. The SDK carries them but does not use them. | +| `switchbackDesignType`, `switchbackEstimand` | Design metadata. | + +For a switchback experiment: + +- `anchor = switchbackStartAt` rounded up to the UTC hour, and + `periodIndex = floorDiv(now - anchor, switchbackPeriodMs)`, where `now` comes from the + context's `Clock`. +- The variant is the balanced-block draw + `assignSwitchback(hashUnit(uid), periodIndex, seed, split.length)`, with + `seed = (seedHi << 32) | (seedLo & 0xffffffff)`. `hashUnit(uid)` is the same MD5/base64url string + the SDK publishes as the hashed unit, and the Collector hashes that same string. The algorithm is + pinned by the platform's golden vectors (`core-api/src/test/resources/switchback/golden-vectors.json`). +- Precedence does not change: override, holdouts, audience, full-on, custom assignment, traffic, + then variant. Traffic eligibility still uses the per-unit traffic hash, so it is sticky per cluster + across periods. +- The experiment is ineligible (variant 0) before the anchor, when `switchbackStartAt` or + `switchbackPeriodMs` is null, or when the cluster unit type differs from the unit type. If the + cluster unit is not set on the context, the experiment is unassigned. The SDK never falls back to + unit assignment. +- Each period gets its own assignment, so the SDK emits **at most one exposure per experiment, + cluster and period**. After a period boundary, the next `getTreatment`/`getVariableValue` + returns the new period's variant and emits a new exposure. Exposures continue during burn windows. +- The exposure and publish payloads are unchanged. Analysis derives the period from `exposedAt`. +- If a refresh changes `randomizationType`, `switchbackClusterUnitType`, `switchbackStartAt`, + `switchbackPeriodMs` or `switchbackDesignType`, cached assignments are re-resolved. Changes to + burn, carryover and estimand values do not invalidate them. + +## Older SDKs + +No capability filter exists, so older SDKs receive switchback experiments too. They ignore the +unknown fields and serve a sticky unit assignment from `split`/`seedHi`/`seedLo`, which does not +change over time. Their exposures are well-formed but do not match the expected switchback +schedule. The analysis side reconciles each exposure against that schedule and flags these +mismatches. Upgrade every SDK that serves a switchback experiment. From 09a79908c84229004af4e5f3d47a258607a6bfde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 23:10:09 +0000 Subject: [PATCH 06/35] fix(switchback): stamp switchback exposures with the resolution instant A switchback assignment's period is selected from one clock reading, but enqueueExposure read the clock again, so a call crossing a period boundary published the previous period's variant with an exposedAt in the next period. The decision instant now flows from getTreatment/getVariableValue through getAssignment and settleExposure to the exposure. Each exposing call uses its own instant, so an exposure after a peek is not pinned to the peek. Unit experiments and holdout exposures keep the enqueue-time reading. --- .../main/java/com/absmartly/sdk/Context.java | 66 +++++++++------- .../com/absmartly/sdk/ContextHoldoutTest.java | 28 ++++--- .../absmartly/sdk/ContextSwitchbackTest.java | 79 +++++++++++++++++++ 3 files changed, 132 insertions(+), 41 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index fc579d4..1f92229 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -459,18 +459,16 @@ public int getTreatment(@Nonnull final String experimentName) { return 0; } - return getExposedTreatmentVariant(getAssignment(experimentName)); - } - - private int getExposedTreatmentVariant(final Assignment assignment) { - return exposeTreatmentAssignment(assignment).variant; + final long now = clock_.millis(); + return exposeTreatmentAssignment(experimentName, getAssignment(experimentName, now), now).variant; } - private Assignment exposeTreatmentAssignment(final Assignment assignment) { - return settleExposure(assignment, new Function() { + private Assignment exposeTreatmentAssignment(final String experimentName, final Assignment assignment, + final long resolvedAt) { + return settleExposure(assignment, resolvedAt, new Function() { @Override - public Assignment apply(final Assignment retired) { - return getAssignment(retired.name); + public Assignment apply(final Long now) { + return getAssignment(experimentName, now); } }); } @@ -484,12 +482,16 @@ public Assignment apply(final Assignment retired) { // time we get here, so a skipped holdout trigger would never be retried for this context's // life. Failures are collected and re-thrown once every holdout has had a chance to fire, // rather than swallowed or allowed to abort the loop early. - private int triggerExposure(final Assignment assignment) { + // + // A switchback exposure is stamped with resolvedAt, the clock reading that selected its period: + // analysis derives the period from exposedAt, so a later reading could attribute this variant + // to the next period. Unit experiments read the clock at enqueue time. + private int triggerExposure(final Assignment assignment, final long resolvedAt) { if (assignment.exposureState.compareAndSet(ASSIGNMENT_UNEXPOSED, ASSIGNMENT_EXPOSED)) { RuntimeException failure = null; try { if (!assignment.suppressed) { - enqueueExposure(assignment); + enqueueExposure(assignment, assignment.switchback ? resolvedAt : clock_.millis()); } } catch (final RuntimeException e) { failure = e; @@ -547,7 +549,7 @@ private void triggerHoldoutExposure(final Experiment holdoutExperiment, final St private void triggerHoldoutExposure(final Assignment holdoutAssignment) { if ((holdoutAssignment != null) && holdoutAssignment.exposureState.compareAndSet(ASSIGNMENT_UNEXPOSED, ASSIGNMENT_EXPOSED)) { - enqueueExposure(holdoutAssignment); + enqueueExposure(holdoutAssignment, clock_.millis()); } } @@ -557,13 +559,13 @@ private void triggerHoldoutExposure(final Assignment holdoutAssignment) { // without ever enqueueing its own (e.g. an already-exposed winner, or a losing variable-key // candidate that only fires holdouts), and setTimeout() is idempotent, so centralizing the // call is strictly safer than tracking every call site individually. - private void enqueueExposure(final Assignment assignment) { + private void enqueueExposure(final Assignment assignment, final long exposedAt) { final Exposure exposure = new Exposure(); exposure.id = assignment.id; exposure.name = assignment.name; exposure.unit = assignment.unitType; exposure.variant = assignment.variant; - exposure.exposedAt = clock_.millis(); + exposure.exposedAt = exposedAt; exposure.assigned = assignment.assigned; exposure.eligible = assignment.eligible; exposure.overridden = assignment.overridden; @@ -594,7 +596,7 @@ public int peekTreatment(@Nonnull final String experimentName) { return 0; } - return getAssignment(experimentName).variant; + return getAssignment(experimentName, clock_.millis()).variant; } public Map> getVariableKeys() { @@ -627,7 +629,8 @@ public Object getVariableValue(@Nonnull final String key, final Object defaultVa return defaultValue; } - final Assignment assignment = exposeVariableAssignment(key, getVariableAssignment(key, false)); + final long now = clock_.millis(); + final Assignment assignment = exposeVariableAssignment(key, getVariableAssignment(key, false, now), now); if (assignment != null) { if (assignment.variables != null) { if (assignment.variables.containsKey(key)) { @@ -638,26 +641,30 @@ public Object getVariableValue(@Nonnull final String key, final Object defaultVa return defaultValue; } - private Assignment exposeVariableAssignment(final String key, final Assignment assignment) { - return settleExposure(assignment, new Function() { + private Assignment exposeVariableAssignment(final String key, final Assignment assignment, final long resolvedAt) { + return settleExposure(assignment, resolvedAt, new Function() { @Override - public Assignment apply(final Assignment retired) { - return getVariableAssignment(key, false); + public Assignment apply(final Long now) { + return getVariableAssignment(key, false, now); } }); } - private Assignment settleExposure(final Assignment assignment, final Function resolver) { + // Each retry after a concurrent retirement resolves at a fresh clock reading and exposes at it. + private Assignment settleExposure(final Assignment assignment, final long resolvedAt, + final Function resolver) { Assignment current = assignment; + long now = resolvedAt; for (int attempt = 0; current != null; ++attempt) { - if (triggerExposure(current) != ASSIGNMENT_RETIRED) { + if (triggerExposure(current, now) != ASSIGNMENT_RETIRED) { return current; } if (attempt + 1 >= MAX_EXPOSURE_ATTEMPTS) { // Exhaustion returns the attempted assignment that lost to a concurrent retirement. return current; } - current = resolver.apply(current); + now = clock_.millis(); + current = resolver.apply(now); } return current; } @@ -667,7 +674,7 @@ public Object peekVariableValue(@Nonnull final String key, final Object defaultV return defaultValue; } - final Assignment assignment = getVariableAssignment(key, true); + final Assignment assignment = getVariableAssignment(key, true, clock_.millis()); if (assignment != null) { if (assignment.variables != null) { if (assignment.variables.containsKey(key)) { @@ -1247,10 +1254,9 @@ private boolean isAssignmentCurrent(final Assignment assignment, final Integer c return false; } - private Assignment getAssignment(final String experimentName) { - // One clock read per resolution, so the cache key and the drawn period always agree. - final long now = clock_.millis(); - + // now is the caller's single clock reading for this resolution, so the cache key, the drawn + // period and a switchback exposure's exposedAt all agree. + private Assignment getAssignment(final String experimentName, final long now) { final ReentrantReadWriteLock.ReadLock readLock = contextLock_.readLock(); try { readLock.lock(); @@ -1467,14 +1473,14 @@ private static boolean isHeldOutBy(final int holdoutVariant, final int holdoutAr // ends up the one returned, so on the non-peek path each candidate's applicable holdouts fire // as it is visited - the ordinary experiment exposure is still emitted only for the winner, // by the caller. peek must stay side-effect free, so it skips triggering entirely. - private Assignment getVariableAssignment(final String key, final boolean peek) { + private Assignment getVariableAssignment(final String key, final boolean peek, final long now) { final List keyExperimentVariables = getVariableExperiments(key); if (keyExperimentVariables != null) { Assignment suppressedFallback = null; RuntimeException failure = null; for (final ContextExperiment experimentVariables : keyExperimentVariables) { - final Assignment assignment = getAssignment(experimentVariables.data.name); + final Assignment assignment = getAssignment(experimentVariables.data.name, now); if (!peek) { final RuntimeException holdoutFailure = triggerApplicableHoldoutExposures(assignment); if (failure == null) { diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextHoldoutTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextHoldoutTest.java index 77b05f3..72e533a 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextHoldoutTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextHoldoutTest.java @@ -221,28 +221,33 @@ Exposure holdoutExposure(String unitType, int id, String name, int variant) { } Object getAssignment(Context context, String experimentName) throws Exception { - final Method method = Context.class.getDeclaredMethod("getAssignment", String.class); + final Method method = Context.class.getDeclaredMethod("getAssignment", String.class, long.class); method.setAccessible(true); - return method.invoke(context, experimentName); + return method.invoke(context, experimentName, clock.millis()); } Object exposeTreatmentAssignment(Context context, Object assignment) throws Exception { - final Method method = Context.class.getDeclaredMethod("exposeTreatmentAssignment", assignment.getClass()); + final Method method = Context.class.getDeclaredMethod("exposeTreatmentAssignment", String.class, + assignment.getClass(), long.class); method.setAccessible(true); - return method.invoke(context, assignment); + return method.invoke(context, assignmentName(assignment), assignment, clock.millis()); } int getExposedTreatmentVariant(Context context, Object assignment) throws Exception { - final Method method = Context.class.getDeclaredMethod("getExposedTreatmentVariant", assignment.getClass()); - method.setAccessible(true); - return (Integer) method.invoke(context, assignment); + return assignmentVariant(exposeTreatmentAssignment(context, assignment)); } Object exposeVariableAssignment(Context context, String key, Object assignment) throws Exception { final Method method = Context.class.getDeclaredMethod("exposeVariableAssignment", String.class, - assignment.getClass()); + assignment.getClass(), long.class); method.setAccessible(true); - return method.invoke(context, key, assignment); + return method.invoke(context, key, assignment, clock.millis()); + } + + String assignmentName(Object assignment) throws Exception { + final Field field = assignment.getClass().getDeclaredField("name"); + field.setAccessible(true); + return (String) field.get(assignment); } void retireAssignment(Object assignment) throws Exception { @@ -258,9 +263,10 @@ int assignmentVariant(Object assignment) throws Exception { } Object settleExposure(Context context, Object assignment, Function resolver) throws Exception { - final Method method = Context.class.getDeclaredMethod("settleExposure", assignment.getClass(), Function.class); + final Method method = Context.class.getDeclaredMethod("settleExposure", assignment.getClass(), long.class, + Function.class); method.setAccessible(true); - return method.invoke(context, assignment, resolver); + return method.invoke(context, assignment, clock.millis(), resolver); } // A held-out unit gets control values and emits only the holdout exposure. diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index cbdb6ae..e187bfa 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -59,6 +59,8 @@ class ContextSwitchbackTest { static final class MutableClock extends Clock { long now; + // When set, the next read returns this value once, then reads fall back to `now`. + Long nextRead; MutableClock(long now) { this.now = now; @@ -66,6 +68,11 @@ static final class MutableClock extends Clock { @Override public long millis() { + if (nextRead != null) { + final long value = nextRead; + nextRead = null; + return value; + } return now; } } @@ -609,4 +616,76 @@ void periodsPublishedAcrossFlushesStayOnePerPeriod() { assertEquals(TestUtils.listOf(EXPECTED_2_ARMS[0], EXPECTED_2_ARMS[1]), variants); verify(eventHandler, times(2)).publish(any(), any()); } + + // The clock crosses into period 1 between resolution (first read) and any later read; the + // exposure must still carry the instant that selected period 0. + @Test + void treatmentExposureIsStampedWithTheResolutionInstant() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + clock.now = ANCHOR + HOUR; + clock.nextRead = ANCHOR + HOUR - 1; + + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertArrayEquals(new Exposure[]{exposure(1, "exp_sb", EXPECTED_2_ARMS[0], ANCHOR + HOUR - 1, true, true)}, + publishExposures(context)); + } + + @Test + void variableExposureIsStampedWithTheResolutionInstantAcrossHoldoutTriggering() { + final Experiment holdout = new Experiment(); + holdout.id = 11; + holdout.name = "holdout_a"; + holdout.unitType = UNIT_TYPE; + holdout.iteration = 1; + holdout.seedHi = 13; + holdout.seedLo = 111; + // Two arms, all traffic in arm 1: never holds the covered experiment out. + holdout.split = new double[]{0.0, 1.0}; + holdout.trafficSplit = new double[]{0.0, 1.0}; + + final Experiment covered = switchback(2, HOUR); + covered.holdoutIds = new int[]{11}; + final ContextData data = dataOf(covered); + data.holdouts = new Experiment[]{holdout}; + + final Context context = createContext(data); + clock.now = ANCHOR + HOUR; + clock.nextRead = ANCHOR + HOUR - 1; + + assertEquals(EXPECTED_2_ARMS[0], context.getVariableValue("sb.arm", -1)); + final Exposure[] exposures = publishExposures(context); + assertEquals(2, exposures.length); + // Holdout membership is not period-scoped and keeps its own enqueue-time reading. + assertEquals(new Exposure(11, "holdout_a", UNIT_TYPE, 1, ANCHOR + HOUR, true, true, false, false, false, + false), exposures[0]); + assertEquals(exposure(1, "exp_sb", EXPECTED_2_ARMS[0], ANCHOR + HOUR - 1, true, true), exposures[1]); + } + + // A peek caches the period's assignment without exposing; the later getTreatment exposes at + // its own resolution instant, not the peek's. + @Test + void exposureAfterPeekUsesTheExposingCallsInstant() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + clock.now = ANCHOR + 10; + assertEquals(EXPECTED_2_ARMS[0], context.peekTreatment("exp_sb")); + clock.now = ANCHOR + 20 * 60_000L; + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertArrayEquals( + new Exposure[]{exposure(1, "exp_sb", EXPECTED_2_ARMS[0], ANCHOR + 20 * 60_000L, true, true)}, + publishExposures(context)); + } + + // Unit experiments keep stamping at enqueue time. + @Test + void unitExposureTimingIsUnchanged() { + final Experiment unit = switchback(2, HOUR); + unit.randomizationType = "unit"; + final Context context = createContext(dataOf(unit)); + clock.now = ANCHOR + HOUR; + clock.nextRead = ANCHOR + HOUR - 1; + + final int variant = context.getTreatment("exp_sb"); + assertArrayEquals(new Exposure[]{exposure(1, "exp_sb", variant, ANCHOR + HOUR, true, true)}, + publishExposures(context)); + } } From e870c24ebe0459007047aecf52a1818aadc15781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 23:12:35 +0000 Subject: [PATCH 07/35] fix(switchback): scope period retention to the switchback identity Retention compared period indices across grids and always evicted the lowest, so a refresh that lowered the current index evicted the entry just installed and every call re-exposed. Retention is now held per experiment under its switchback identity (id, iteration, cluster unit type, start, period length, design). An identity change drops that experiment's previous period slots together, and eviction removes the lowest index other than the one just resolved. A rewound clock re-exposes an evicted period once, not once per call. --- .../main/java/com/absmartly/sdk/Context.java | 73 +++++++++++++++---- .../absmartly/sdk/ContextSwitchbackTest.java | 71 ++++++++++++++++++ 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index 1f92229..b380379 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1067,18 +1067,63 @@ private static String switchbackCacheKey(final String experimentName, final long return experimentName + SWITCHBACK_KEY_SEPARATOR + periodIndex; } - // Bounds the per-period entries of one experiment to the two most recent periods. The previous - // period is kept so a caller that read the clock just before a boundary still finds its - // already-exposed assignment instead of re-creating it and exposing that period twice. - private void retainRecentSwitchbackPeriods(final String experimentName, final long periodIndex) { - TreeSet periods = switchbackPeriods_.get(experimentName); - if (periods == null) { - periods = new TreeSet(); - switchbackPeriods_.put(experimentName, periods); - } - periods.add(periodIndex); - while (periods.size() > SWITCHBACK_RETAINED_PERIODS) { - assignmentCache_.remove(switchbackCacheKey(experimentName, periods.pollFirst())); + // Period slots of one experiment under one switchback identity. Indices from different + // identities are never compared: a changed start or period length re-maps every index. + private static final class SwitchbackEpoch { + final int id; + final int iteration; + final String clusterUnitType; + final Long startAt; + final Long periodMs; + final String designType; + final TreeSet periods = new TreeSet(); + + SwitchbackEpoch(final Experiment experiment) { + id = experiment.id; + iteration = experiment.iteration; + clusterUnitType = experiment.switchbackClusterUnitType; + startAt = experiment.switchbackStartAt; + periodMs = experiment.switchbackPeriodMs; + designType = experiment.switchbackDesignType; + } + + boolean matches(final Experiment experiment) { + return isSwitchback(experiment) && (id == experiment.id) && (iteration == experiment.iteration) + && Objects.equals(clusterUnitType, experiment.switchbackClusterUnitType) + && Objects.equals(startAt, experiment.switchbackStartAt) + && Objects.equals(periodMs, experiment.switchbackPeriodMs) + && Objects.equals(designType, experiment.switchbackDesignType); + } + } + + // Records periodIndex as resolved under the experiment's current identity, first dropping + // every period slot of a previous identity. At most SWITCHBACK_RETAINED_PERIODS indices are + // kept; the lowest index other than periodIndex is evicted, so the entry just installed always + // survives. Exposure dedup therefore holds only within the retained window: re-entering an + // evicted period (clock rewind, or a caller delayed across two boundaries) creates one new + // assignment for it, and that assignment then stays cached like any other. + private void retainSwitchbackPeriod(final String experimentName, final Experiment experiment, + final long periodIndex) { + SwitchbackEpoch epoch = switchbackEpochs_.get(experimentName); + if ((epoch == null) || !epoch.matches(experiment)) { + if (epoch != null) { + for (final Long period : epoch.periods) { + if (period != periodIndex) { + assignmentCache_.remove(switchbackCacheKey(experimentName, period)); + } + } + } + epoch = new SwitchbackEpoch(experiment); + switchbackEpochs_.put(experimentName, epoch); + } + epoch.periods.add(periodIndex); + while (epoch.periods.size() > SWITCHBACK_RETAINED_PERIODS) { + Long evicted = epoch.periods.first(); + if (evicted == periodIndex) { + evicted = epoch.periods.higher(periodIndex); + } + epoch.periods.remove(evicted); + assignmentCache_.remove(switchbackCacheKey(experimentName, evicted)); } } @@ -1430,7 +1475,7 @@ private Assignment getAssignment(final String experimentName, final long now) { assignmentCache_.put(cacheKey, assignment); if (periodIndex != NO_SWITCHBACK_PERIOD) { - retainRecentSwitchbackPeriods(experimentName, periodIndex); + retainSwitchbackPeriod(experimentName, experiment.data, periodIndex); } return assignment; @@ -1916,7 +1961,7 @@ private Map buildAttributesMap() { // Keyed by experiment name, or by name + U+001F + period index for a switchback experiment // inside its grid (see assignmentCacheKey), so each period gets its own exposure state. private final Map assignmentCache_ = new HashMap(); - private final Map> switchbackPeriods_ = new HashMap>(); + private final Map switchbackEpochs_ = new HashMap(); // A holdout's identity, for both this cache's keying and for detecting whether a covered // experiment's own set of applicable holdouts is still current (holdoutSetMatches), is its // (id, iteration) pair - never a full Experiment comparison. Rationale: the governing diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index e187bfa..51ff112 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -688,4 +688,75 @@ void unitExposureTimingIsUnchanged() { assertArrayEquals(new Exposure[]{exposure(1, "exp_sb", variant, ANCHOR + HOUR, true, true)}, publishExposures(context)); } + + void refreshTo(Context context, Experiment... experiments) { + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(experiments))); + context.refresh(); + } + + // A grid change that lowers the current index below the retained ones must not evict the + // entry it just installed. + @Test + void gridRefreshToALowerIndexExposesOnce() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + clock.now = ANCHOR + 99 * HOUR; + context.getTreatment("exp_sb"); + clock.now = ANCHOR + 100 * HOUR; + context.getTreatment("exp_sb"); + assertEquals(2, context.getPendingCount()); + + final Experiment restarted = switchback(2, HOUR); + restarted.switchbackStartAt = ANCHOR + 100 * HOUR; + refreshTo(context, restarted); + for (int i = 0; i < 3; ++i) { + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + } + assertEquals(3, context.getPendingCount()); + } + + @Test + void periodLengthRefreshToALowerIndexExposesOnce() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + clock.now = ANCHOR + 100 * HOUR; + context.getTreatment("exp_sb"); + clock.now = ANCHOR + 101 * HOUR; + context.getTreatment("exp_sb"); + + refreshTo(context, switchback(2, 200 * HOUR)); + context.getTreatment("exp_sb"); + context.getTreatment("exp_sb"); + assertEquals(3, context.getPendingCount()); + } + + // Rewinding the clock across retained periods re-exposes each re-entered evicted period at most + // once, never once per call. + @Test + void clockRewindReexposesAnEvictedPeriodAtMostOnce() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + for (int i = 0; i < 3; ++i) { + clock.now = ANCHOR + i * HOUR; + context.getTreatment("exp_sb"); + } + assertEquals(3, context.getPendingCount()); + + clock.now = ANCHOR; + for (int i = 0; i < 3; ++i) { + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + } + assertEquals(4, context.getPendingCount()); + + // Retained {0, 2}: period 2 is still deduplicated, period 1 was evicted. + clock.now = ANCHOR + 2 * HOUR; + context.getTreatment("exp_sb"); + assertEquals(4, context.getPendingCount()); + clock.now = ANCHOR + HOUR; + for (int i = 0; i < 3; ++i) { + assertEquals(EXPECTED_2_ARMS[1], context.getTreatment("exp_sb")); + } + assertEquals(5, context.getPendingCount()); + + final Exposure[] exposures = publishExposures(context); + assertEquals(exposure(1, "exp_sb", EXPECTED_2_ARMS[0], ANCHOR, true, true), exposures[3]); + assertEquals(exposure(1, "exp_sb", EXPECTED_2_ARMS[1], ANCHOR + HOUR, true, true), exposures[4]); + } } From ddb3a1eca0c05fd4c7e790513f578dd2da3fe763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 23:14:37 +0000 Subject: [PATCH 08/35] fix(switchback): revalidate the switchback identity of override assignments The override fast path checked only the forced variant and holdout set and the override branch never pinned the grid, so an overridden switchback slot survived a grid change and kept the previous definition's variable values. Override assignments now pin the switchback identity and revalidate it like ordinary ones. Override precedence and holdout firing are unchanged. --- .../main/java/com/absmartly/sdk/Context.java | 8 +++- .../absmartly/sdk/ContextSwitchbackTest.java | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index b380379..5f90f61 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1287,9 +1287,12 @@ private boolean isAssignmentCurrent(final Assignment assignment, final Integer c // write path below), so the fast path must revalidate the applicable-holdout // set exactly like the ordinary branch does via experimentMatches - otherwise // a holdout that becomes applicable after a refresh never fires for an - // already-overridden experiment, and stays that way forever. + // already-overridden experiment, and stays that way forever. The switchback identity is + // revalidated too, so a grid change re-resolves an overridden slot like any other. return assignment.overridden && assignment.variant == override - && holdoutSetMatches((experiment != null) ? experiment.holdouts : null, assignment.holdouts); + && holdoutSetMatches((experiment != null) ? experiment.holdouts : null, assignment.holdouts) + && ((experiment != null) ? switchbackConfigMatches(experiment.data, assignment) + : !assignment.switchback); } else if (experiment == null) { // previously not-running experiment return !assignment.assigned; @@ -1354,6 +1357,7 @@ private Assignment getAssignment(final String experimentName, final long now) { // assignment. Override precedence itself is untouched: the returned variant // stays the overridden one. assignment.holdouts = experiment.holdouts; + pinSwitchbackConfig(assignment, experiment.data); } assignment.overridden = true; diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index 51ff112..0d6c0d7 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -759,4 +759,52 @@ void clockRewindReexposesAnEvictedPeriodAtMostOnce() { assertEquals(exposure(1, "exp_sb", EXPECTED_2_ARMS[0], ANCHOR, true, true), exposures[3]); assertEquals(exposure(1, "exp_sb", EXPECTED_2_ARMS[1], ANCHOR + HOUR, true, true), exposures[4]); } + + // An overridden switchback slot is re-resolved on a grid change like a normal one; the + // override still wins and the holdout still fires exactly once. + @Test + void overrideIsReresolvedWhenTheGridChanges() { + final Experiment holdout = new Experiment(); + holdout.id = 11; + holdout.name = "holdout_a"; + holdout.unitType = UNIT_TYPE; + holdout.iteration = 1; + holdout.seedHi = 13; + holdout.seedLo = 111; + holdout.split = new double[]{0.0, 1.0}; + holdout.trafficSplit = new double[]{0.0, 1.0}; + + final Experiment original = switchback(3, HOUR); + original.holdoutIds = new int[]{11}; + final ContextData data = dataOf(original); + data.holdouts = new Experiment[]{holdout}; + final Context context = createContext(data); + context.setOverride("exp_sb", 2); + + clock.now = ANCHOR + 10; + assertEquals(2, context.getTreatment("exp_sb")); + assertEquals(2, context.getVariableValue("sb.arm", -1)); + assertEquals(2, context.getPendingCount()); + + // Same index 0 under the new period length; variant 2 now carries a different value. + final Experiment regridded = switchback(3, 2 * HOUR); + regridded.holdoutIds = new int[]{11}; + regridded.variants[2] = new ExperimentVariant("C", "{\"sb.arm\":22}"); + final ContextData refreshed = dataOf(regridded); + refreshed.holdouts = new Experiment[]{holdout}; + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(refreshed)); + context.refresh(); + + assertEquals(2, context.getTreatment("exp_sb")); + assertEquals(22, context.getVariableValue("sb.arm", -1)); + assertEquals(3, context.getPendingCount()); + + final Exposure[] exposures = publishExposures(context); + assertEquals(new Exposure(11, "holdout_a", UNIT_TYPE, 1, ANCHOR + 10, true, true, false, false, false, + false), exposures[1]); + for (final int i : new int[]{0, 2}) { + assertEquals(new Exposure(1, "exp_sb", UNIT_TYPE, 2, ANCHOR + 10, false, true, true, false, false, + false), exposures[i]); + } + } } From 83219913e775ce9b81f725317264cd6981b92c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 23:17:06 +0000 Subject: [PATCH 09/35] fix(switchback): drop all switchback slots on an identity transition Bare-name and period slots were managed independently, so after switchback -> unit -> identical switchback (or removal and restore) the old period slot matched the restored definition again and was returned with its exposed state. All of an experiment's switchback slots now share one epoch keyed by its switchback identity. setData drops every slot of an epoch the installed definition no longer matches, under the context write lock, so an identity transition is atomic for resolvers and a return to an identical definition re-resolves. --- .../main/java/com/absmartly/sdk/Context.java | 57 +++++++++++++------ .../absmartly/sdk/ContextSwitchbackTest.java | 55 ++++++++++++++++++ 2 files changed, 94 insertions(+), 18 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index 5f90f61..ab1ba20 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1067,8 +1067,9 @@ private static String switchbackCacheKey(final String experimentName, final long return experimentName + SWITCHBACK_KEY_SEPARATOR + periodIndex; } - // Period slots of one experiment under one switchback identity. Indices from different - // identities are never compared: a changed start or period length re-maps every index. + // Lifecycle of one switchback experiment's cache slots: the bare-name slot (pre-anchor or an + // unevaluable grid) and its period slots, all under one switchback identity. Indices from + // different identities are never compared: a changed start or period length re-maps them. private static final class SwitchbackEpoch { final int id; final int iteration; @@ -1088,7 +1089,8 @@ private static final class SwitchbackEpoch { } boolean matches(final Experiment experiment) { - return isSwitchback(experiment) && (id == experiment.id) && (iteration == experiment.iteration) + return (experiment != null) && isSwitchback(experiment) && (id == experiment.id) + && (iteration == experiment.iteration) && Objects.equals(clusterUnitType, experiment.switchbackClusterUnitType) && Objects.equals(startAt, experiment.switchbackStartAt) && Objects.equals(periodMs, experiment.switchbackPeriodMs) @@ -1096,26 +1098,36 @@ boolean matches(final Experiment experiment) { } } - // Records periodIndex as resolved under the experiment's current identity, first dropping - // every period slot of a previous identity. At most SWITCHBACK_RETAINED_PERIODS indices are - // kept; the lowest index other than periodIndex is evicted, so the entry just installed always - // survives. Exposure dedup therefore holds only within the retained window: re-entering an - // evicted period (clock rewind, or a caller delayed across two boundaries) creates one new - // assignment for it, and that assignment then stays cached like any other. + // Drops every slot of the experiment's switchback epoch, bare-name slot included, when the + // installed definition no longer matches it: a type change, removal, restart or grid edit + // starts a new epoch, so returning to an identical definition re-resolves. Caller holds the + // contextLock_ write lock. Experiments never served as switchback have no epoch. + private void reconcileSwitchbackEpoch(final String experimentName, final Experiment experiment) { + final SwitchbackEpoch epoch = switchbackEpochs_.get(experimentName); + if ((epoch != null) && !epoch.matches(experiment)) { + for (final Long period : epoch.periods) { + assignmentCache_.remove(switchbackCacheKey(experimentName, period)); + } + assignmentCache_.remove(experimentName); + switchbackEpochs_.remove(experimentName); + } + } + + // Records a switchback resolution under the current epoch. At most SWITCHBACK_RETAINED_PERIODS + // period indices are kept; the lowest index other than periodIndex is evicted, so the entry + // just installed always survives. Exposure dedup therefore holds only within the retained + // window: re-entering an evicted period (clock rewind, or a caller delayed across two + // boundaries) creates one new assignment for it, which then stays cached like any other. private void retainSwitchbackPeriod(final String experimentName, final Experiment experiment, final long periodIndex) { SwitchbackEpoch epoch = switchbackEpochs_.get(experimentName); - if ((epoch == null) || !epoch.matches(experiment)) { - if (epoch != null) { - for (final Long period : epoch.periods) { - if (period != periodIndex) { - assignmentCache_.remove(switchbackCacheKey(experimentName, period)); - } - } - } + if (epoch == null) { epoch = new SwitchbackEpoch(experiment); switchbackEpochs_.put(experimentName, epoch); } + if (periodIndex == NO_SWITCHBACK_PERIOD) { + return; + } epoch.periods.add(periodIndex); while (epoch.periods.size() > SWITCHBACK_RETAINED_PERIODS) { Long evicted = epoch.periods.first(); @@ -1478,7 +1490,7 @@ private Assignment getAssignment(final String experimentName, final long now) { } assignmentCache_.put(cacheKey, assignment); - if (periodIndex != NO_SWITCHBACK_PERIOD) { + if ((experiment != null) && isSwitchback(experiment.data)) { retainSwitchbackPeriod(experimentName, experiment.data, periodIndex); } @@ -1884,6 +1896,9 @@ public int compare(ContextExperiment a, ContextExperiment b) { } try { + // contextLock_ is taken first (the order getAssignment uses) so installing the data + // and dropping superseded switchback epochs is one atomic step for resolvers. + contextLock_.writeLock().lock(); dataLock_.writeLock().lock(); index_ = index; @@ -1891,9 +1906,15 @@ public int compare(ContextExperiment a, ContextExperiment b) { holdoutsById_ = holdoutsById; data_ = data; + for (final String name : new ArrayList(switchbackEpochs_.keySet())) { + final ContextExperiment experiment = index.get(name); + reconcileSwitchbackEpoch(name, (experiment != null) ? experiment.data : null); + } + setRefreshTimer(); } finally { dataLock_.writeLock().unlock(); + contextLock_.writeLock().unlock(); } } diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index 0d6c0d7..b158411 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -807,4 +807,59 @@ void overrideIsReresolvedWhenTheGridChanges() { false), exposures[i]); } } + + // Each identity transition is a new epoch: returning to an identical grid re-resolves instead of + // resurrecting the slot from before the transition. + @Test + void switchbackToUnitToIdenticalSwitchbackReresolves() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + clock.now = ANCHOR + 10; + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + + final Experiment toUnit = switchback(2, HOUR); + toUnit.randomizationType = "unit"; + refreshTo(context, toUnit); + context.getTreatment("exp_sb"); + assertEquals(2, context.getPendingCount()); + + refreshTo(context, switchback(2, HOUR)); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(3, context.getPendingCount()); + } + + @Test + void removedAndRestoredSwitchbackReresolves() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + clock.now = ANCHOR + 10; + context.getTreatment("exp_sb"); + + refreshTo(context); + assertEquals(0, context.getTreatment("exp_sb")); + assertEquals(2, context.getPendingCount()); + + refreshTo(context, switchback(2, HOUR)); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(3, context.getPendingCount()); + } + + // The pre-anchor bare-name slot shares the lifecycle of the period slots. + @Test + void preAnchorSlotIsDroppedWhenTheGridChanges() { + final Experiment gridA = switchback(2, HOUR); + gridA.switchbackStartAt = ANCHOR + 10 * HOUR; + final Context context = createContext(dataOf(gridA)); + clock.now = ANCHOR + 10; + assertEquals(0, context.getTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + + refreshTo(context, switchback(2, HOUR)); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(2, context.getPendingCount()); + + refreshTo(context, gridA); + assertEquals(0, context.getTreatment("exp_sb")); + assertEquals(3, context.getPendingCount()); + } } From 413cafd34607c1f2ac470ee4f75666566341652f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 23:18:20 +0000 Subject: [PATCH 10/35] docs(switchback): state the dedup bound, epochs and integration status Document that exposure dedup is guaranteed only within the two retained periods, what a re-entered period costs, which identity changes start a new epoch, and that switchback exposures are stamped with the resolution instant. Correct the precedence order (traffic gates custom assignment). Stop claiming the Collector already serves switchback fields or that the reconciliation query itself flags schedule mismatches. --- README.md | 2 +- SWITCHBACK.md | 76 ++++++++++++++----- .../sdk/internal/SwitchbackAssigner.java | 2 +- 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6b96a96..4dfc8dc 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ context.close(); ### Switchback Experiments -Switchback experiments need no code changes: `getTreatment`, `peekTreatment` and the variable methods return the variant for the current time period, and the SDK emits one exposure per period. See [SWITCHBACK.md](SWITCHBACK.md) for the assignment rules, the minimum SDK version and how older SDKs behave. +Switchback experiments need no code changes: `getTreatment`, `peekTreatment` and the variable methods return the variant for the current time period, and the SDK emits one exposure per period while time moves forward. See [SWITCHBACK.md](SWITCHBACK.md) for the assignment rules, the exposure deduplication bound, the minimum SDK version and how older SDKs behave. ### Custom Event Logger diff --git a/SWITCHBACK.md b/SWITCHBACK.md index 9d30bfc..cd26aa5 100644 --- a/SWITCHBACK.md +++ b/SWITCHBACK.md @@ -12,7 +12,9 @@ the number. ## What the SDK does -The Collector adds these optional fields to each experiment in the context data: +The SDK reads these optional fields from each experiment in the context data. Emitting them is the +Collector's job and is a separate integration dependency: a Collector that does not emit them makes +every experiment a unit experiment for this SDK. | Field | Meaning | | --- | --- | @@ -26,32 +28,70 @@ The Collector adds these optional fields to each experiment in the context data: For a switchback experiment: - `anchor = switchbackStartAt` rounded up to the UTC hour, and - `periodIndex = floorDiv(now - anchor, switchbackPeriodMs)`, where `now` comes from the - context's `Clock`. + `periodIndex = floorDiv(now - anchor, switchbackPeriodMs)`, where `now` is one reading of the + context's `Clock` taken by the `getTreatment`, `peekTreatment`, `getVariableValue` or + `peekVariableValue` call. - The variant is the balanced-block draw `assignSwitchback(hashUnit(uid), periodIndex, seed, split.length)`, with `seed = (seedHi << 32) | (seedLo & 0xffffffff)`. `hashUnit(uid)` is the same MD5/base64url string - the SDK publishes as the hashed unit, and the Collector hashes that same string. The algorithm is - pinned by the platform's golden vectors (`core-api/src/test/resources/switchback/golden-vectors.json`). -- Precedence does not change: override, holdouts, audience, full-on, custom assignment, traffic, - then variant. Traffic eligibility still uses the per-unit traffic hash, so it is sticky per cluster - across periods. + the SDK publishes as the hashed unit. The algorithm is pinned by the platform's golden vectors + (`core-api/src/test/resources/switchback/golden-vectors.json`). A Collector that serves switchback + must hash the cluster unit the same way. +- Precedence is the same as for unit experiments: override, holdouts, audience, full-on, traffic + eligibility, then custom assignment or the switchback draw. Custom assignments therefore apply only + to traffic-eligible units. Traffic eligibility uses the per-unit traffic hash, so it is sticky per + cluster across periods. - The experiment is ineligible (variant 0) before the anchor, when `switchbackStartAt` or `switchbackPeriodMs` is null, or when the cluster unit type differs from the unit type. If the cluster unit is not set on the context, the experiment is unassigned. The SDK never falls back to unit assignment. -- Each period gets its own assignment, so the SDK emits **at most one exposure per experiment, - cluster and period**. After a period boundary, the next `getTreatment`/`getVariableValue` - returns the new period's variant and emits a new exposure. Exposures continue during burn windows. -- The exposure and publish payloads are unchanged. Analysis derives the period from `exposedAt`. -- If a refresh changes `randomizationType`, `switchbackClusterUnitType`, `switchbackStartAt`, - `switchbackPeriodMs` or `switchbackDesignType`, cached assignments are re-resolved. Changes to - burn, carryover and estimand values do not invalidate them. +- The exposure and publish payloads are unchanged. Analysis derives the period from `exposedAt`, so + a switchback exposure is stamped with the clock reading that selected its period, not a later + one. A call that resolves just before a boundary and exposes just after it still publishes a + timestamp inside the period whose variant it returned. Each exposing call uses its own reading, so + an exposure that follows an earlier `peekTreatment` carries the exposing call's time. Unit + experiments and holdout exposures are stamped when the exposure is enqueued, as before. + Exposures continue during burn windows. + +### Exposure deduplication + +Each period gets its own cached assignment and exposure state. The context keeps at most the +**two most recently resolved period indices** per experiment. When a third is resolved, the lowest +other index is evicted; the period just resolved is never evicted. + +- In normal forward time, the SDK emits one exposure per experiment, cluster and period. After a + boundary, the next `getTreatment`/`getVariableValue` returns the new period's variant and emits a + new exposure. +- Dedup holds only within the retained window. Re-entering an evicted period emits one more + exposure for that period. This happens when the clock moves back by two or more periods, or a + caller reads the clock and is then delayed until two later periods have been resolved. The + new assignment is cached again, so re-entry costs one extra exposure per period re-entered, + never one per call. The duplicate has the same variant and lies in the same period, so analysis + sees it as a repeat, not a mismatch. +- Pre-anchor and unevaluable-grid resolutions share one slot per experiment, so they expose once. + +### Refresh and epochs + +An experiment's switchback identity is `randomizationType`, `switchbackClusterUnitType`, +`switchbackStartAt`, `switchbackPeriodMs`, `switchbackDesignType`, its id and its iteration. The +backend rejects changes to the grid fields after an experiment starts, so within a live experiment +a change means a pre-start edit, a restart (new iteration), or a type change. + +When a refresh installs a definition whose identity differs from the one the cached switchback +assignments were resolved under, every one of that experiment's switchback slots is dropped +together. That includes the pre-anchor slot and override assignments. The change starts a new +epoch, and the next call re-resolves and exposes again even if the period number is unchanged. +This also applies when an experiment switches to unit randomization, or is removed, and later +returns with an identical definition. Changes to burn, carryover and estimand values do not +start a new epoch. Seed and split edits do not invalidate an already cached assignment; the +next new period uses them. ## Older SDKs No capability filter exists, so older SDKs receive switchback experiments too. They ignore the unknown fields and serve a sticky unit assignment from `split`/`seedHi`/`seedLo`, which does not -change over time. Their exposures are well-formed but do not match the expected switchback -schedule. The analysis side reconciles each exposure against that schedule and flags these -mismatches. Upgrade every SDK that serves a switchback experiment. +change over time. Their exposures are well-formed but generally do not match the expected +switchback schedule. Analysis is designed to reconcile each exposure block against the expected +schedule and report mismatching blocks. The platform's reconciliation query returns the observed +per-block variants, and the expected-schedule comparison is an engine-side step. Upgrade every SDK +that serves a switchback experiment rather than relying on that reconciliation. diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAssigner.java b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAssigner.java index db2157f..eb7be47 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAssigner.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAssigner.java @@ -9,7 +9,7 @@ // stepHash = murmur3_32(le32(baseHash) ++ le32(i)), i = armCount-1 .. 1 // j = min(i, floor(u32(stepHash) / 0xffffffff * (i + 1))) // variant = permutation[floorMod(periodIndex, armCount)], cycle = floorDiv(periodIndex, armCount) -// Any change here breaks parity with the Collector and the analysis-side expected schedule. +// Any change here breaks parity with the platform golden vectors and the analysis-side schedule. public abstract class SwitchbackAssigner { private SwitchbackAssigner() {} From 5c46b1145dd98936b1773f46fc40b74f3a1b5bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 23:43:51 +0000 Subject: [PATCH 11/35] fix(switchback): invalidate epochs by generation; setData takes dataLock_ only Installing data under contextLock_ write deadlocked a refresh started from audience evaluation, which already runs under contextLock_: a completed provider future ran setData inline and attempted a read-to-write upgrade, and an async provider's completion thread waited on the lock held by the matcher that waited on it. This affected unit-only contexts too. setData is back to dataLock_ write only. While installing the index it compares each experiment's switchback identity (randomization type, id, iteration, cluster unit type, startAt, periodMs, design) with the previous index and either carries the switchback generation over or assigns a fresh one. A SwitchbackEpoch records the generation it was created under; resolution drops the whole epoch (period and bare-name slots, overrides included) when the installed definition is absent, not switchback, or of a different generation. Every transition, including an unobserved unit or removed state, yields a new generation, so a stale slot is never resurrected. --- .../main/java/com/absmartly/sdk/Context.java | 106 +++++++----- .../absmartly/sdk/ContextSwitchbackTest.java | 151 +++++++++++++++++- 2 files changed, 211 insertions(+), 46 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index ab1ba20..42a73af 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1067,49 +1067,55 @@ private static String switchbackCacheKey(final String experimentName, final long return experimentName + SWITCHBACK_KEY_SEPARATOR + periodIndex; } + // Switchback identity: id, iteration, cluster unit type, startAt, periodMs and design. Seed, + // split and analysis-only fields are excluded. Absent and unit experiments have no identity. + private static boolean sameSwitchbackIdentity(final ContextExperiment a, final ContextExperiment b) { + final boolean aSwitchback = (a != null) && isSwitchback(a.data); + final boolean bSwitchback = (b != null) && isSwitchback(b.data); + if (!aSwitchback || !bSwitchback) { + return aSwitchback == bSwitchback; + } + return (a.data.id == b.data.id) && (a.data.iteration == b.data.iteration) + && Objects.equals(a.data.switchbackClusterUnitType, b.data.switchbackClusterUnitType) + && Objects.equals(a.data.switchbackStartAt, b.data.switchbackStartAt) + && Objects.equals(a.data.switchbackPeriodMs, b.data.switchbackPeriodMs) + && Objects.equals(a.data.switchbackDesignType, b.data.switchbackDesignType); + } + // Lifecycle of one switchback experiment's cache slots: the bare-name slot (pre-anchor or an - // unevaluable grid) and its period slots, all under one switchback identity. Indices from - // different identities are never compared: a changed start or period length re-maps them. + // unevaluable grid) and its period slots, all created under one switchback generation (see + // ContextExperiment.switchbackGeneration). Indices from different generations are never + // compared: a changed start or period length re-maps them. private static final class SwitchbackEpoch { - final int id; - final int iteration; - final String clusterUnitType; - final Long startAt; - final Long periodMs; - final String designType; + final long generation; final TreeSet periods = new TreeSet(); - SwitchbackEpoch(final Experiment experiment) { - id = experiment.id; - iteration = experiment.iteration; - clusterUnitType = experiment.switchbackClusterUnitType; - startAt = experiment.switchbackStartAt; - periodMs = experiment.switchbackPeriodMs; - designType = experiment.switchbackDesignType; + SwitchbackEpoch(final long generation) { + this.generation = generation; } + } - boolean matches(final Experiment experiment) { - return (experiment != null) && isSwitchback(experiment) && (id == experiment.id) - && (iteration == experiment.iteration) - && Objects.equals(clusterUnitType, experiment.switchbackClusterUnitType) - && Objects.equals(startAt, experiment.switchbackStartAt) - && Objects.equals(periodMs, experiment.switchbackPeriodMs) - && Objects.equals(designType, experiment.switchbackDesignType); + // An epoch is stale once the installed definition is absent, not switchback, or carries a + // different generation. Every identity transition, including an intermediate unit or removed + // state that was never looked up, assigns a fresh generation, so a stale epoch never matches a + // later identical definition. Caller holds contextLock_ (read or write). + private boolean isSwitchbackEpochStale(final String experimentName, final ContextExperiment experiment) { + if (switchbackEpochs_.isEmpty()) { + return false; } + final SwitchbackEpoch epoch = switchbackEpochs_.get(experimentName); + return (epoch != null) && ((experiment == null) || !isSwitchback(experiment.data) + || (epoch.generation != experiment.switchbackGeneration)); } - // Drops every slot of the experiment's switchback epoch, bare-name slot included, when the - // installed definition no longer matches it: a type change, removal, restart or grid edit - // starts a new epoch, so returning to an identical definition re-resolves. Caller holds the - // contextLock_ write lock. Experiments never served as switchback have no epoch. - private void reconcileSwitchbackEpoch(final String experimentName, final Experiment experiment) { - final SwitchbackEpoch epoch = switchbackEpochs_.get(experimentName); - if ((epoch != null) && !epoch.matches(experiment)) { + // Drops every slot of the epoch, bare-name slot included. Caller holds the contextLock_ write lock. + private void dropSwitchbackEpoch(final String experimentName) { + final SwitchbackEpoch epoch = switchbackEpochs_.remove(experimentName); + if (epoch != null) { for (final Long period : epoch.periods) { assignmentCache_.remove(switchbackCacheKey(experimentName, period)); } assignmentCache_.remove(experimentName); - switchbackEpochs_.remove(experimentName); } } @@ -1118,11 +1124,11 @@ private void reconcileSwitchbackEpoch(final String experimentName, final Experim // just installed always survives. Exposure dedup therefore holds only within the retained // window: re-entering an evicted period (clock rewind, or a caller delayed across two // boundaries) creates one new assignment for it, which then stays cached like any other. - private void retainSwitchbackPeriod(final String experimentName, final Experiment experiment, + private void retainSwitchbackPeriod(final String experimentName, final ContextExperiment experiment, final long periodIndex) { SwitchbackEpoch epoch = switchbackEpochs_.get(experimentName); if (epoch == null) { - epoch = new SwitchbackEpoch(experiment); + epoch = new SwitchbackEpoch(experiment.switchbackGeneration); switchbackEpochs_.put(experimentName, epoch); } if (periodIndex == NO_SWITCHBACK_PERIOD) { @@ -1325,8 +1331,10 @@ private Assignment getAssignment(final String experimentName, final long now) { final Assignment assignment = assignmentCache_ .get(assignmentCacheKey(experimentName, experiment, now)); - if ((assignment != null) && isAssignmentCurrent(assignment, cassignments_.get(experimentName), - overrides_.get(experimentName), experiment)) { + // A stale epoch's slots are dropped by the write path below. + if ((assignment != null) && !isSwitchbackEpochStale(experimentName, experiment) + && isAssignmentCurrent(assignment, cassignments_.get(experimentName), + overrides_.get(experimentName), experiment)) { return assignment; } } finally { @@ -1343,6 +1351,10 @@ private Assignment getAssignment(final String experimentName, final long now) { final ContextExperiment experiment = Context.this.getExperiment(experimentName); final String cacheKey = assignmentCacheKey(experimentName, experiment, now); + if (isSwitchbackEpochStale(experimentName, experiment)) { + dropSwitchbackEpoch(experimentName); + } + // Recheck: a concurrent caller may have installed this key (e.g. the first call in a // new switchback period) between the two locks; replacing it would reset its exposure. final Assignment cached = assignmentCache_.get(cacheKey); @@ -1491,7 +1503,7 @@ private Assignment getAssignment(final String experimentName, final long now) { assignmentCache_.put(cacheKey, assignment); if ((experiment != null) && isSwitchback(experiment.data)) { - retainSwitchbackPeriod(experimentName, experiment.data, periodIndex); + retainSwitchbackPeriod(experimentName, experiment, periodIndex); } return assignment; @@ -1756,6 +1768,9 @@ private static class ContextExperiment { Experiment[] holdouts; List> variables; Map customFieldValues; + // Assigned in setData under dataLock_ write: carried over from the previous index while the + // switchback identity is unchanged, otherwise a fresh value. Unused for unit experiments. + long switchbackGeneration; } private static class ContextCustomFieldValue { @@ -1896,25 +1911,26 @@ public int compare(ContextExperiment a, ContextExperiment b) { } try { - // contextLock_ is taken first (the order getAssignment uses) so installing the data - // and dropping superseded switchback epochs is one atomic step for resolvers. - contextLock_.writeLock().lock(); dataLock_.writeLock().lock(); + for (final Map.Entry entry : index.entrySet()) { + final ContextExperiment installed = entry.getValue(); + if (isSwitchback(installed.data)) { + final ContextExperiment previous = (index_ != null) ? index_.get(entry.getKey()) : null; + installed.switchbackGeneration = sameSwitchbackIdentity(previous, installed) + ? previous.switchbackGeneration + : ++switchbackGenerationCounter_; + } + } + index_ = index; indexVariables_ = indexVariables; holdoutsById_ = holdoutsById; data_ = data; - for (final String name : new ArrayList(switchbackEpochs_.keySet())) { - final ContextExperiment experiment = index.get(name); - reconcileSwitchbackEpoch(name, (experiment != null) ? experiment.data : null); - } - setRefreshTimer(); } finally { dataLock_.writeLock().unlock(); - contextLock_.writeLock().unlock(); } } @@ -1979,6 +1995,8 @@ private Map buildAttributesMap() { // Id-keyed view of the holdouts installed by the most recent setData, used to resolve a // holdout's live definition independent of any stale Experiment reference a caller holds. private Map holdoutsById_; + // Source of ContextExperiment.switchbackGeneration; guarded by dataLock_ write. + private long switchbackGenerationCounter_; private final ReentrantReadWriteLock contextLock_ = new ReentrantReadWriteLock(); private final Map hashedUnits_; diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index b158411..51da4b0 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -133,13 +133,16 @@ static ContextData dataOf(Experiment... experiments) { } Context createContext(Map units, ContextData data) { + return createContext(units, data, new AudienceMatcher(new DefaultAudienceDeserializer())); + } + + Context createContext(Map units, ContextData data, AudienceMatcher audienceMatcher) { final ContextConfig config = ContextConfig.create(); if (units != null) { config.setUnits(units); } return Context.create(clock, config, scheduler, CompletableFuture.completedFuture(data), dataProvider, - eventHandler, null, new DefaultVariableParser(), - new AudienceMatcher(new DefaultAudienceDeserializer())); + eventHandler, null, new DefaultVariableParser(), audienceMatcher); } Context createContext(ContextData data) { @@ -862,4 +865,148 @@ void preAnchorSlotIsDroppedWhenTheGridChanges() { assertEquals(0, context.getTreatment("exp_sb")); assertEquals(3, context.getPendingCount()); } + + // Epochs are reconciled lazily; an intermediate state that is never looked up must still end the + // epoch. + @Test + void transitionsWithoutAnInterveningLookupStillReresolve() { + final Context context = createContext(dataOf(switchback(2, HOUR))); + clock.now = ANCHOR + 10; + context.getTreatment("exp_sb"); + assertEquals(1, context.getPendingCount()); + + final Experiment toUnit = switchback(2, HOUR); + toUnit.randomizationType = "unit"; + refreshTo(context, toUnit); + refreshTo(context, switchback(2, HOUR)); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(2, context.getPendingCount()); + + refreshTo(context); + refreshTo(context, switchback(2, HOUR)); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(3, context.getPendingCount()); + + final Experiment regridded = switchback(2, HOUR); + regridded.switchbackDesignType = "other"; + refreshTo(context, regridded); + refreshTo(context, switchback(2, HOUR)); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); + assertEquals(4, context.getPendingCount()); + + // An unchanged identity keeps the epoch. + refreshTo(context, switchback(2, HOUR)); + context.getTreatment("exp_sb"); + assertEquals(4, context.getPendingCount()); + } + + // Audience evaluation runs under contextLock_; this matcher refreshes the context from inside it. + static final class RefreshingAudienceMatcher extends AudienceMatcher { + volatile Context context; + volatile boolean refreshOnNextEvaluation; + + RefreshingAudienceMatcher() { + super(new DefaultAudienceDeserializer()); + } + + @Override + public Result evaluate(String audience, Map attributes) { + if (refreshOnNextEvaluation) { + refreshOnNextEvaluation = false; + context.refresh(); + } + return new Result(true); + } + } + + void stubRefreshData(boolean completeOnAnotherThread, ContextData data) { + if (!completeOnAnotherThread) { + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(data)); + return; + } + when(dataProvider.getContextData()).thenAnswer(invocation -> { + final CompletableFuture future = new CompletableFuture(); + // Completes only once refreshAsync has attached its continuation, so setData runs on + // this thread rather than inline on the caller. + final Thread completer = new Thread(() -> { + while (future.getNumberOfDependents() == 0) { + Thread.yield(); + } + future.complete(data); + }); + completer.setDaemon(true); + completer.start(); + return future; + }); + } + + // Runs getTreatment on a daemon worker and fails, rather than hanging the build, if it does not + // return within the bound. + int getTreatmentWithin(Context context, String name, long timeoutMs) throws InterruptedException { + final int[] result = new int[1]; + final Throwable[] failure = new Throwable[1]; + final Thread worker = new Thread(() -> { + try { + result[0] = context.getTreatment(name); + } catch (Throwable t) { + failure[0] = t; + } + }); + worker.setDaemon(true); + worker.start(); + worker.join(timeoutMs); + assertFalse(worker.isAlive(), "getTreatment did not return: refresh from audience evaluation deadlocked"); + if (failure[0] != null) { + throw new AssertionError(failure[0]); + } + return result[0]; + } + + void refreshFromAudienceRevalidationCompletes(boolean completeOnAnotherThread) throws InterruptedException { + final Experiment unit = switchback(2, HOUR); + unit.randomizationType = "unit"; + unit.audience = "{\"filter\":[{\"value\":true}]}"; + final RefreshingAudienceMatcher matcher = new RefreshingAudienceMatcher(); + final Context context = createContext(TestUtils.mapOf(UNIT_TYPE, UID), dataOf(unit), matcher); + matcher.context = context; + stubRefreshData(completeOnAnotherThread, dataOf(unit)); + + final int primed = context.getTreatment("exp_sb"); + context.setAttribute("country", "PT"); + matcher.refreshOnNextEvaluation = true; + + assertEquals(primed, getTreatmentWithin(context, "exp_sb", 10_000)); + verify(dataProvider, times(1)).getContextData(); + assertEquals(1, context.getPendingCount()); + } + + @Test + void refreshFromAudienceRevalidationWithACompletedProviderFutureDoesNotDeadlock() + throws InterruptedException { + refreshFromAudienceRevalidationCompletes(false); + } + + @Test + void refreshFromAudienceRevalidationWithAnAsyncProviderDoesNotDeadlock() throws InterruptedException { + refreshFromAudienceRevalidationCompletes(true); + } + + // First resolution evaluates the audience under the contextLock_ write lock. + @Test + void refreshFromAudienceEvaluationOnFirstResolutionWithAnAsyncProviderDoesNotDeadlock() + throws InterruptedException { + final Experiment unit = switchback(2, HOUR); + unit.randomizationType = "unit"; + unit.audience = "{\"filter\":[{\"value\":true}]}"; + final RefreshingAudienceMatcher matcher = new RefreshingAudienceMatcher(); + final Context context = createContext(TestUtils.mapOf(UNIT_TYPE, UID), dataOf(unit), matcher); + matcher.context = context; + stubRefreshData(true, dataOf(unit)); + matcher.refreshOnNextEvaluation = true; + + getTreatmentWithin(context, "exp_sb", 10_000); + verify(dataProvider, times(1)).getContextData(); + assertEquals(1, context.getPendingCount()); + } } From 17a6fde5cbac6f7eb2457beb80373d0981945dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 23:46:18 +0000 Subject: [PATCH 12/35] test(switchback): concurrent resolution of one period exposes once Two readers revalidating the same cached period slot are held at a barrier inside audience evaluation, so both reject the slot under contextLock_ read before either installs a replacement. Exactly one exposure must result; with the write-lock recheck disabled the test produces two. --- .../absmartly/sdk/ContextSwitchbackTest.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index 51da4b0..b41e23e 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -16,7 +16,10 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java8.util.concurrent.CompletableFuture; import org.junit.jupiter.api.BeforeEach; @@ -1009,4 +1012,72 @@ void refreshFromAudienceEvaluationOnFirstResolutionWithAnAsyncProviderDoesNotDea verify(dataProvider, times(1)).getContextData(); assertEquals(1, context.getPendingCount()); } + + // Holds both revalidating readers at a barrier, so both have rejected the cached slot under + // contextLock_ read before either can take the write lock. + static final class BarrierAudienceMatcher extends AudienceMatcher { + final AtomicInteger evaluations = new AtomicInteger(); + final CyclicBarrier readers = new CyclicBarrier(2); + + BarrierAudienceMatcher() { + super(new DefaultAudienceDeserializer()); + } + + @Override + public Result evaluate(String audience, Map attributes) { + final int call = evaluations.incrementAndGet(); + if (call == 1) { + return new Result(true); + } + if (call <= 3) { + try { + readers.await(10, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + return new Result(false); + } + } + + @Test + void concurrentResolutionOfTheSamePeriodExposesOnce() throws InterruptedException { + final Experiment experiment = switchback(2, HOUR); + experiment.audience = "{\"filter\":[{\"value\":true}]}"; + final BarrierAudienceMatcher matcher = new BarrierAudienceMatcher(); + final Context context = createContext(TestUtils.mapOf(UNIT_TYPE, UID), dataOf(experiment), matcher); + clock.now = ANCHOR + HOUR + 10; + + // Caches period 1 without exposing it; the attribute change forces both callers to revalidate. + assertEquals(EXPECTED_2_ARMS[1], context.peekTreatment("exp_sb")); + context.setAttribute("country", "PT"); + + final int[] variants = new int[2]; + final Throwable[] failures = new Throwable[2]; + final Thread[] workers = new Thread[2]; + for (int i = 0; i < 2; ++i) { + final int slot = i; + workers[i] = new Thread(() -> { + try { + variants[slot] = context.getTreatment("exp_sb"); + } catch (Throwable t) { + failures[slot] = t; + } + }); + workers[i].setDaemon(true); + workers[i].start(); + } + for (final Thread worker : workers) { + worker.join(10_000); + assertFalse(worker.isAlive()); + } + assertEquals(null, failures[0]); + assertEquals(null, failures[1]); + + assertEquals(EXPECTED_2_ARMS[1], variants[0]); + assertEquals(EXPECTED_2_ARMS[1], variants[1]); + assertEquals(1, context.getPendingCount()); + assertArrayEquals(new Exposure[]{new Exposure(1, "exp_sb", UNIT_TYPE, EXPECTED_2_ARMS[1], ANCHOR + HOUR + 10, + true, true, false, false, false, true)}, publishExposures(context)); + } } From cca8adb992eb7594b30383cb69171395124fcea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 23:46:38 +0000 Subject: [PATCH 13/35] docs(switchback): qualify re-entry dedup and describe index-based retention A re-created evicted period has the same variant only while seed, split and custom assignment are unchanged; re-entry dedup is bounded-cache best effort. Retention keeps at most two period indices and evicts the lowest other than the one just installed. Epoch invalidation is described as generation-based. --- SWITCHBACK.md | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/SWITCHBACK.md b/SWITCHBACK.md index cd26aa5..3142b90 100644 --- a/SWITCHBACK.md +++ b/SWITCHBACK.md @@ -55,9 +55,10 @@ For a switchback experiment: ### Exposure deduplication -Each period gets its own cached assignment and exposure state. The context keeps at most the -**two most recently resolved period indices** per experiment. When a third is resolved, the lowest -other index is evicted; the period just resolved is never evicted. +Each period gets its own cached assignment and exposure state. The context retains **at most two +period indices** per experiment. On installing a third, it evicts the lowest index other than the +one just installed. Retention is by index, not recency: a cache hit does not protect a period from +eviction. - In normal forward time, the SDK emits one exposure per experiment, cluster and period. After a boundary, the next `getTreatment`/`getVariableValue` returns the new period's variant and emits a @@ -66,8 +67,11 @@ other index is evicted; the period just resolved is never evicted. exposure for that period. This happens when the clock moves back by two or more periods, or a caller reads the clock and is then delayed until two later periods have been resolved. The new assignment is cached again, so re-entry costs one extra exposure per period re-entered, - never one per call. The duplicate has the same variant and lies in the same period, so analysis - sees it as a repeat, not a mismatch. + never one per call. Provided seed, split and custom assignment are unchanged, the duplicate has + the same variant and lies in the same period, so analysis sees it as a repeat. If any of them + changed in between, the recreated assignment uses the current values and can carry a different + variant. Deduplication on re-entry is a bounded-cache best effort, not a guarantee against + mixed-variant records for a period. - Pre-anchor and unevaluable-grid resolutions share one slot per experiment, so they expose once. ### Refresh and epochs @@ -77,12 +81,15 @@ An experiment's switchback identity is `randomizationType`, `switchbackClusterUn backend rejects changes to the grid fields after an experiment starts, so within a live experiment a change means a pre-start edit, a restart (new iteration), or a type change. -When a refresh installs a definition whose identity differs from the one the cached switchback -assignments were resolved under, every one of that experiment's switchback slots is dropped -together. That includes the pre-anchor slot and override assignments. The change starts a new -epoch, and the next call re-resolves and exposes again even if the period number is unchanged. -This also applies when an experiment switches to unit randomization, or is removed, and later -returns with an identical definition. Changes to burn, carryover and estimand values do not +Each refresh compares every experiment's identity with the previously installed definition. Any +difference, including the experiment being absent or a unit experiment on either side, gives the +experiment a new generation. Cached switchback assignments record the generation they were resolved +under, and the next resolution after a generation change drops every one of that experiment's +switchback slots together. That includes the pre-anchor slot and override assignments. The change +starts a new epoch, and the next call re-resolves and exposes again even if the period number is +unchanged. This also applies when an experiment switches to unit randomization, or is removed, and +later returns with an identical definition, whether or not the context was queried in between. +Changes to burn, carryover and estimand values do not start a new epoch. Seed and split edits do not invalidate an already cached assignment; the next new period uses them. From 0e7a89b354209e8be578f70b9fc40127b829cc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Sun, 11 Oct 2026 23:59:08 +0000 Subject: [PATCH 14/35] docs(switchback): describe generation assignment exactly and the lazy-invalidation retention bound --- SWITCHBACK.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/SWITCHBACK.md b/SWITCHBACK.md index 3142b90..6ddb811 100644 --- a/SWITCHBACK.md +++ b/SWITCHBACK.md @@ -81,14 +81,20 @@ An experiment's switchback identity is `randomizationType`, `switchbackClusterUn backend rejects changes to the grid fields after an experiment starts, so within a live experiment a change means a pre-start edit, a restart (new iteration), or a type change. -Each refresh compares every experiment's identity with the previously installed definition. Any -difference, including the experiment being absent or a unit experiment on either side, gives the -experiment a new generation. Cached switchback assignments record the generation they were resolved -under, and the next resolution after a generation change drops every one of that experiment's -switchback slots together. That includes the pre-anchor slot and override assignments. The change -starts a new epoch, and the next call re-resolves and exposes again even if the period number is -unchanged. This also applies when an experiment switches to unit randomization, or is removed, and -later returns with an identical definition, whether or not the context was queried in between. +On each refresh, an installed switchback keeps its previous generation only when its previous +definition was a switchback with the same identity; otherwise it receives a fresh generation. Unit +experiments never receive one, so their caches are unaffected. Because removal or a unit definition +breaks the chain, a switchback that returns later with an identical definition gets a new +generation, whether or not the context was queried in between. Cached switchback assignments record +the generation they were resolved under, and the next resolution after a generation change drops +every one of that experiment's switchback slots together, including the pre-anchor slot and +override assignments. The change starts a new epoch, and the next call re-resolves and exposes again +even if the period number is unchanged. + +Invalidation is lazy: slots are dropped on the experiment's next resolution, not during refresh, +which takes only the data lock. An experiment that is removed and never queried again keeps its +retained slots (at most two periods plus one pre-anchor slot) for the life of the context, just as +an exposed unit-experiment assignment does. Changes to burn, carryover and estimand values do not start a new epoch. Seed and split edits do not invalidate an already cached assignment; the next new period uses them. From 24af47f747d762333458ee3e9b5548d27a458f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Mon, 12 Oct 2026 00:03:12 +0000 Subject: [PATCH 15/35] refactor(switchback): simplify Phase 1B sdk --- .../main/java/com/absmartly/sdk/Context.java | 31 ++++---- .../absmartly/sdk/ContextSwitchbackTest.java | 78 ++++++++----------- 2 files changed, 47 insertions(+), 62 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index 42a73af..10fc519 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1051,16 +1051,20 @@ private static long switchbackPeriodIndex(final Experiment experiment, final lon return SwitchbackAssigner.floorDiv(nowMs - anchor, periodMs); } + // Also NO_SWITCHBACK_PERIOD for absent and unit experiments. + private static long currentSwitchbackPeriod(final ContextExperiment experiment, final long nowMs) { + if ((experiment == null) || !isSwitchback(experiment.data)) { + return NO_SWITCHBACK_PERIOD; + } + return switchbackPeriodIndex(experiment.data, nowMs); + } + // Unit experiments, and switchback experiments with no current period, keep the bare name. - private static String assignmentCacheKey(final String experimentName, final ContextExperiment experiment, - final long nowMs) { - if ((experiment != null) && isSwitchback(experiment.data)) { - final long periodIndex = switchbackPeriodIndex(experiment.data, nowMs); - if (periodIndex != NO_SWITCHBACK_PERIOD) { - return switchbackCacheKey(experimentName, periodIndex); - } + private static String assignmentCacheKey(final String experimentName, final long periodIndex) { + if (periodIndex == NO_SWITCHBACK_PERIOD) { + return experimentName; } - return experimentName; + return switchbackCacheKey(experimentName, periodIndex); } private static String switchbackCacheKey(final String experimentName, final long periodIndex) { @@ -1302,7 +1306,7 @@ private boolean isAssignmentCurrent(final Assignment assignment, final Integer c final ContextExperiment experiment) { if (override != null) { // An override still evaluates the experiment for holdout purposes (see the - // write path below), so the fast path must revalidate the applicable-holdout + // write path in getAssignment), so the fast path must revalidate the applicable-holdout // set exactly like the ordinary branch does via experimentMatches - otherwise // a holdout that becomes applicable after a refresh never fires for an // already-overridden experiment, and stays that way forever. The switchback identity is @@ -1329,7 +1333,7 @@ private Assignment getAssignment(final String experimentName, final long now) { final ContextExperiment experiment = Context.this.getExperiment(experimentName); final Assignment assignment = assignmentCache_ - .get(assignmentCacheKey(experimentName, experiment, now)); + .get(assignmentCacheKey(experimentName, currentSwitchbackPeriod(experiment, now))); // A stale epoch's slots are dropped by the write path below. if ((assignment != null) && !isSwitchbackEpochStale(experimentName, experiment) @@ -1349,7 +1353,8 @@ && isAssignmentCurrent(assignment, cassignments_.get(experimentName), final Integer custom = cassignments_.get(experimentName); final Integer override = overrides_.get(experimentName); final ContextExperiment experiment = Context.this.getExperiment(experimentName); - final String cacheKey = assignmentCacheKey(experimentName, experiment, now); + final long periodIndex = currentSwitchbackPeriod(experiment, now); + final String cacheKey = assignmentCacheKey(experimentName, periodIndex); if (isSwitchbackEpochStale(experimentName, experiment)) { dropSwitchbackEpoch(experimentName); @@ -1362,10 +1367,6 @@ && isAssignmentCurrent(assignment, cassignments_.get(experimentName), return cached; } - final long periodIndex = ((experiment != null) && isSwitchback(experiment.data)) - ? switchbackPeriodIndex(experiment.data, now) - : NO_SWITCHBACK_PERIOD; - final Assignment assignment = new Assignment(); assignment.name = experimentName; assignment.eligible = true; diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index b41e23e..979ed86 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -14,6 +14,7 @@ import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.CyclicBarrier; @@ -179,6 +180,25 @@ int[] peekSchedule(Context context, String name, long periodMs, int periods) { return result; } + void refreshTo(Context context, Experiment... experiments) { + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(experiments))); + context.refresh(); + } + + // Holdout 11 for UID under seed (13, 111); split decides whether UID is held out. + static Experiment holdout(double[] split) { + final Experiment holdout = new Experiment(); + holdout.id = 11; + holdout.name = "holdout_a"; + holdout.unitType = UNIT_TYPE; + holdout.iteration = 1; + holdout.seedHi = 13; + holdout.seedLo = 111; + holdout.split = split; + holdout.trafficSplit = new double[]{0.0, 1.0}; + return holdout; + } + @Test void clusterIdentityIsTheSdkUnitHash() { assertEquals(UID_HASH, new String(Hashing.hashUnit(UID), StandardCharsets.US_ASCII)); @@ -187,7 +207,7 @@ void clusterIdentityIsTheSdkUnitHash() { final int[] served = peekSchedule(context, "exp_sb", HOUR, 12); assertArrayEquals(EXPECTED_3_ARMS, served); - assertFalse(java.util.Arrays.equals(RAW_UID_3_ARMS, served)); + assertFalse(Arrays.equals(RAW_UID_3_ARMS, served)); for (int i = 0; i < 12; ++i) { assertEquals(SwitchbackAssigner.assign(UID_HASH, i, ((long) SEED_HI << 32) | (SEED_LO & 0xffffffffL), 3), served[i]); @@ -457,36 +477,30 @@ void refreshInvalidatesWhenGridFieldsChangeButNotForAnalysisOnlyFields() { burnsChanged.switchbackBurnOutDurationMs = 300_000L; burnsChanged.switchbackCarryoverDurationMs = 900_000L; burnsChanged.switchbackEstimand = "other"; - when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(burnsChanged))); - context.refresh(); + refreshTo(context, burnsChanged); assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); assertEquals(1, context.getPendingCount()); // Same period index 0 under both grids, so only the config comparison can invalidate. final Experiment periodChanged = switchback(2, 2 * HOUR); - when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(periodChanged))); - context.refresh(); + refreshTo(context, periodChanged); assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); assertEquals(2, context.getPendingCount()); final Experiment designChanged = switchback(2, 2 * HOUR); designChanged.switchbackDesignType = "other_design"; - when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(designChanged))); - context.refresh(); + refreshTo(context, designChanged); context.getTreatment("exp_sb"); assertEquals(3, context.getPendingCount()); final Experiment toUnit = switchback(2, 2 * HOUR); toUnit.randomizationType = "unit"; - when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(toUnit))); - context.refresh(); + refreshTo(context, toUnit); context.getTreatment("exp_sb"); assertEquals(4, context.getPendingCount()); final Experiment backToSwitchback = switchback(2, 2 * HOUR); - when(dataProvider.getContextData()) - .thenReturn(CompletableFuture.completedFuture(dataOf(backToSwitchback))); - context.refresh(); + refreshTo(context, backToSwitchback); context.getTreatment("exp_sb"); assertEquals(5, context.getPendingCount()); } @@ -499,24 +513,15 @@ void refreshSettingStartAtStartsTheGrid() { clock.now = ANCHOR + 10; assertEquals(0, context.getTreatment("exp_sb")); - when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(switchback(2, HOUR)))); - context.refresh(); + refreshTo(context, switchback(2, HOUR)); assertEquals(EXPECTED_2_ARMS[0], context.getTreatment("exp_sb")); assertEquals(2, context.getPendingCount()); } @Test void holdoutSuppressionAndExposureSpanPeriods() { - final Experiment holdout = new Experiment(); - holdout.id = 11; - holdout.name = "holdout_a"; - holdout.unitType = UNIT_TYPE; - holdout.iteration = 1; - // split[0.1,0.9], seedHi=13, seedLo=111 -> variant 0 (held out) for UID. - holdout.seedHi = 13; - holdout.seedLo = 111; - holdout.split = new double[]{0.1, 0.9}; - holdout.trafficSplit = new double[]{0.0, 1.0}; + // split[0.1,0.9] -> variant 0 (held out) for UID. + final Experiment holdout = holdout(new double[]{0.1, 0.9}); final Experiment covered = switchback(2, HOUR); covered.holdoutIds = new int[]{11}; @@ -638,16 +643,8 @@ void treatmentExposureIsStampedWithTheResolutionInstant() { @Test void variableExposureIsStampedWithTheResolutionInstantAcrossHoldoutTriggering() { - final Experiment holdout = new Experiment(); - holdout.id = 11; - holdout.name = "holdout_a"; - holdout.unitType = UNIT_TYPE; - holdout.iteration = 1; - holdout.seedHi = 13; - holdout.seedLo = 111; // Two arms, all traffic in arm 1: never holds the covered experiment out. - holdout.split = new double[]{0.0, 1.0}; - holdout.trafficSplit = new double[]{0.0, 1.0}; + final Experiment holdout = holdout(new double[]{0.0, 1.0}); final Experiment covered = switchback(2, HOUR); covered.holdoutIds = new int[]{11}; @@ -695,11 +692,6 @@ void unitExposureTimingIsUnchanged() { publishExposures(context)); } - void refreshTo(Context context, Experiment... experiments) { - when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(experiments))); - context.refresh(); - } - // A grid change that lowers the current index below the retained ones must not evict the // entry it just installed. @Test @@ -770,15 +762,7 @@ void clockRewindReexposesAnEvictedPeriodAtMostOnce() { // override still wins and the holdout still fires exactly once. @Test void overrideIsReresolvedWhenTheGridChanges() { - final Experiment holdout = new Experiment(); - holdout.id = 11; - holdout.name = "holdout_a"; - holdout.unitType = UNIT_TYPE; - holdout.iteration = 1; - holdout.seedHi = 13; - holdout.seedLo = 111; - holdout.split = new double[]{0.0, 1.0}; - holdout.trafficSplit = new double[]{0.0, 1.0}; + final Experiment holdout = holdout(new double[]{0.0, 1.0}); final Experiment original = switchback(3, HOUR); original.holdoutIds = new int[]{11}; From 6060d8fba14e89dc39804dab13045bcb19c636ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 04:17:10 +0000 Subject: [PATCH 16/35] feat(switchback): hashed cluster bucket function and attribute path resolution Port of lib:utils SwitchbackBuckets (MD5 bucket, attribute normalisation) and the Collector's SwitchbackAttributePath, Java 1.6 compatible. Copies the platform's bucket-golden-vectors.json and attribute-path-cases.json byte-identical and reproduces all 702 bucket vectors, the unit-key and attribute-key vectors and every path case. --- .../sdk/internal/SwitchbackAttributePath.java | 122 + .../sdk/internal/SwitchbackBuckets.java | 65 + .../internal/SwitchbackAttributePathTest.java | 100 + .../sdk/internal/SwitchbackBucketsTest.java | 132 + .../switchback/attribute-path-cases.json | 16 + .../switchback/bucket-golden-vectors.json | 4352 +++++++++++++++++ 6 files changed, 4787 insertions(+) create mode 100644 core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java create mode 100644 core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackBuckets.java create mode 100644 core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java create mode 100644 core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java create mode 100644 core-api/src/test/resources/switchback/attribute-path-cases.json create mode 100644 core-api/src/test/resources/switchback/bucket-golden-vectors.json diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java new file mode 100644 index 0000000..b99105a --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java @@ -0,0 +1,122 @@ +package com.absmartly.sdk.internal; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +// A switchback cluster attribute path, resolved exactly like the Collector's +// SwitchbackAttributePath: "/" separates segments, a segment wrapped in ' or " is a literal key, +// and an unquoted segment that Guava Ints.tryParse accepts (ASCII digits, optional leading '-') +// indexes a List and nothing else. Every other segment reads a Map key. +public final class SwitchbackAttributePath { + private final List keys; + // Parallel to keys; null where the segment is a map key. + private final List indices; + + private SwitchbackAttributePath(final List keys, final List indices) { + this.keys = keys; + this.indices = indices; + } + + // Returns null for an unsupported path: empty, or an array index that is negative, first, + // last, or directly after another index. Analysis cannot read those. + public static SwitchbackAttributePath parseSupported(final String path) { + if (path == null) { + return null; + } + final SwitchbackAttributePath parsed = parse(path); + final int n = parsed.keys.size(); + if (n == 0) { + return null; + } + for (int i = 0; i < n; ++i) { + final Integer index = parsed.indices.get(i); + if ((index != null) && ((index < 0) || (i == 0) || (i == n - 1) || (parsed.indices.get(i - 1) != null))) { + return null; + } + } + return parsed; + } + + public Object resolve(final Map attributes) { + Object current = attributes; + for (int i = 0; i < keys.size(); ++i) { + current = step(current, keys.get(i), indices.get(i)); + if (current == null) { + return null; + } + } + return current; + } + + private static SwitchbackAttributePath parse(final String path) { + final List keys = new ArrayList(); + final List indices = new ArrayList(); + int i = 0; + while (i < path.length()) { + if (path.charAt(i) == '/') { + ++i; + continue; + } + + final char first = path.charAt(i); + if ((first == '\'') || (first == '"')) { + final int end = path.indexOf(first, i + 1); + if (end < 0) { + // The platform's JsonPathParser drops an unterminated quoted tail. + break; + } + keys.add(path.substring(i + 1, end)); + indices.add(null); + i = end + 1; + } else { + final int end = path.indexOf('/', i); + final int stop = (end < 0) ? path.length() : end; + final String key = path.substring(i, stop); + keys.add(key); + indices.add(tryParseInt(key)); + i = stop; + } + } + return new SwitchbackAttributePath(Collections.unmodifiableList(keys), Collections.unmodifiableList(indices)); + } + + private static Object step(final Object current, final String key, final Integer index) { + if (index != null) { + if (current instanceof List) { + final List list = (List) current; + return ((index >= 0) && (index < list.size())) ? list.get(index) : null; + } + return null; + } + if (current instanceof Map) { + return ((Map) current).get(key); + } + return null; + } + + // Guava Ints.tryParse(String): optional '-', then one or more ASCII digits, within int range; + // otherwise null. "+1" and non-ASCII digits are not numbers; "-0" is 0. + static Integer tryParseInt(final String s) { + final int length = s.length(); + final boolean negative = (length > 0) && (s.charAt(0) == '-'); + int i = negative ? 1 : 0; + if (i == length) { + return null; + } + long magnitude = 0; + for (; i < length; ++i) { + final char c = s.charAt(i); + if ((c < '0') || (c > '9')) { + return null; + } + magnitude = magnitude * 10 + (c - '0'); + if (magnitude > (1L << 31)) { + return null; + } + } + final long value = negative ? -magnitude : magnitude; + return (value > Integer.MAX_VALUE) ? null : Integer.valueOf((int) value); + } +} diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackBuckets.java b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackBuckets.java new file mode 100644 index 0000000..d361b5b --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackBuckets.java @@ -0,0 +1,65 @@ +package com.absmartly.sdk.internal; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import com.absmartly.sdk.java.nio.charset.StandardCharsets; + +// Hashed switchback cluster buckets. Port of the platform's +// com.absmartly.lib.utils.switchback.SwitchbackBuckets, which the Collector and the warehouse SQL +// also implement: +// h = MD5(utf8("sbcluster" + U+001F + seedHi + ":" + seedLo + U+001F + key))[0..3], big-endian, unsigned +// bucket = h mod count +// The bucket, written in base 10, is the cluster passed to SwitchbackAssigner.assign. Any change +// here breaks parity with switchback/bucket-golden-vectors.json and with analysis. +public abstract class SwitchbackBuckets { + private SwitchbackBuckets() {} + + private static final char SEPARATOR = '\u001f'; + + private static final ThreadLocal MD5 = new ThreadLocal() { + @Override + protected MessageDigest initialValue() { + try { + return MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException e) { + // Every Java platform is required to provide MD5. + throw new IllegalStateException(e); + } + } + }; + + public static boolean isAllowedCount(final Integer count) { + return (count != null) && ((count == 10) || (count == 50) || (count == 100)); + } + + public static int bucketOf(final int seedHi, final int seedLo, final String key, final int count) { + if (!isAllowedCount(count)) { + throw new IllegalArgumentException("Switchback cluster count must be one of [10, 50, 100], got " + count); + } + final String input = new StringBuilder(key.length() + 34).append("sbcluster").append(SEPARATOR) + .append(seedHi).append(':').append(seedLo).append(SEPARATOR).append(key).toString(); + // String.getBytes, not Buffers.encodeUTF8: the platform replaces an unpaired surrogate with + // '?' and the hashed bytes must match it. + final byte[] digest = MD5.get().digest(input.getBytes(StandardCharsets.UTF_8)); + final long hash = (((digest[0] & 0xffL) << 24) | ((digest[1] & 0xffL) << 16) | ((digest[2] & 0xffL) << 8) + | (digest[3] & 0xffL)); + return (int) (hash % count); + } + + // Bucket key of an attribute value, or null when the unit is ineligible: a non-empty String + // as-is, an integral Byte/Short/Integer/Long/BigInteger in base 10, anything else null. + public static String normalizeAttribute(final Object value) { + if (value instanceof String) { + final String s = (String) value; + return s.isEmpty() ? null : s; + } else if ((value instanceof Long) || (value instanceof Integer) || (value instanceof Short) + || (value instanceof Byte)) { + return Long.toString(((Number) value).longValue()); + } else if (value instanceof BigInteger) { + return value.toString(); + } + return null; + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java new file mode 100644 index 0000000..c53b910 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java @@ -0,0 +1,100 @@ +package com.absmartly.sdk.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.absmartly.sdk.TestUtils; + +// Fixture copied byte-for-byte from absmartly/abs +// lib/utils/src/test/resources/switchback/attribute-path-cases.json (git blob c2da44286878). +class SwitchbackAttributePathTest { + static JsonNode loadCases() throws IOException { + final InputStream in = SwitchbackAttributePathTest.class.getClassLoader() + .getResourceAsStream("switchback/attribute-path-cases.json"); + try { + return new ObjectMapper().readTree(in); + } finally { + in.close(); + } + } + + @Test + void matchesEveryPathCase() throws IOException { + final ObjectMapper mapper = new ObjectMapper(); + final JsonNode fixture = loadCases(); + final Map attributes = mapper.convertValue(fixture.get("attributes"), + new TypeReference>() {}); + final JsonNode cases = fixture.get("cases"); + assertEquals(7, cases.size()); + + for (final JsonNode c : cases) { + final SwitchbackAttributePath path = SwitchbackAttributePath.parseSupported(c.get("path").textValue()); + if (!c.get("supported").booleanValue()) { + assertNull(path, c.toString()); + continue; + } + assertNotNull(path, c.toString()); + assertEquals(mapper.convertValue(c.get("value"), Object.class), path.resolve(attributes), c.toString()); + } + } + + @Test + void quotedSegmentsAreLiteralKeys() { + final Map attributes = TestUtils.mapOf("geo", TestUtils.mapOf("0", "zero", "a/b", "slash")); + assertEquals("zero", SwitchbackAttributePath.parseSupported("geo/'0'").resolve(attributes)); + assertEquals("slash", SwitchbackAttributePath.parseSupported("geo/\"a/b\"").resolve(attributes)); + // An unterminated quoted tail is dropped, so the path resolves to the parent object. + assertEquals(attributes.get("geo"), SwitchbackAttributePath.parseSupported("geo/'0").resolve(attributes)); + } + + @Test + void separatorsAreCollapsed() { + final Map attributes = TestUtils.mapOf("geo", TestUtils.mapOf("city", "Porto")); + assertEquals("Porto", SwitchbackAttributePath.parseSupported("/geo//city/").resolve(attributes)); + assertEquals(attributes.get("geo"), SwitchbackAttributePath.parseSupported("geo").resolve(attributes)); + } + + @Test + void rejectsUnsupportedPaths() { + for (final String path : new String[]{null, "", "/", "//", "0/city", "items/0", "items/0/1/city", + "items/-1/city", "'"}) { + assertNull(SwitchbackAttributePath.parseSupported(path), String.valueOf(path)); + } + } + + @Test + void indexSegmentsReadOnlyListsAndKeySegmentsReadOnlyMaps() { + final Map attributes = TestUtils.mapOf("items", + TestUtils.listOf(TestUtils.mapOf("city", "Lisbon")), "geo", TestUtils.mapOf("0", "zero"), + "name", "scalar"); + assertNull(SwitchbackAttributePath.parseSupported("geo/0/city").resolve(attributes)); + assertNull(SwitchbackAttributePath.parseSupported("items/1/city").resolve(attributes)); + assertNull(SwitchbackAttributePath.parseSupported("name/x").resolve(attributes)); + assertNull(SwitchbackAttributePath.parseSupported("missing/x").resolve(attributes)); + assertEquals("Lisbon", SwitchbackAttributePath.parseSupported("items/-0/city").resolve(attributes)); + } + + @Test + void tryParseIntFollowsGuava() { + assertEquals(Integer.valueOf(0), SwitchbackAttributePath.tryParseInt("0")); + assertEquals(Integer.valueOf(0), SwitchbackAttributePath.tryParseInt("-0")); + assertEquals(Integer.valueOf(7), SwitchbackAttributePath.tryParseInt("007")); + assertEquals(Integer.valueOf(Integer.MAX_VALUE), SwitchbackAttributePath.tryParseInt("2147483647")); + assertEquals(Integer.valueOf(Integer.MIN_VALUE), SwitchbackAttributePath.tryParseInt("-2147483648")); + for (final String s : new String[]{"", "-", "+1", "1a", " 1", "2147483648", "-2147483649", + "99999999999999999999", "\u0660", "1.0"}) { + assertNull(SwitchbackAttributePath.tryParseInt(s), s); + } + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java new file mode 100644 index 0000000..9c68820 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java @@ -0,0 +1,132 @@ +package com.absmartly.sdk.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.absmartly.sdk.internal.hashing.Hashing; +import com.absmartly.sdk.java.nio.charset.StandardCharsets; + +// Fixture copied byte-for-byte from absmartly/abs +// lib/utils/src/test/resources/switchback/bucket-golden-vectors.json (git blob 7b1d3fed5985). +class SwitchbackBucketsTest { + static JsonNode loadVectors() throws IOException { + final InputStream in = SwitchbackBucketsTest.class.getClassLoader() + .getResourceAsStream("switchback/bucket-golden-vectors.json"); + try { + return new ObjectMapper().readTree(in); + } finally { + in.close(); + } + } + + static int bucketOf(String key, long seed, int count) { + return SwitchbackBuckets.bucketOf((int) (seed >> 32), (int) seed, key, count); + } + + @Test + void bucketOfMatchesEveryGoldenVector() throws IOException { + final JsonNode cases = loadVectors().get("buckets"); + assertEquals(702, cases.size()); + + final Set counts = new HashSet(); + final Set seeds = new HashSet(); + for (final JsonNode c : cases) { + assertTrue(c.get("seed").isTextual(), "64-bit seed must be a decimal string: " + c); + final long seed = Long.parseLong(c.get("seed").textValue()); + final int count = c.get("count").intValue(); + counts.add(count); + seeds.add(seed); + + assertEquals(c.get("expectedBucket").intValue(), bucketOf(c.get("key").textValue(), seed, count), + c.toString()); + } + + assertEquals(new HashSet(Arrays.asList(10, 50, 100)), counts); + assertTrue(seeds.contains(Long.MIN_VALUE) && seeds.contains(Long.MAX_VALUE) && seeds.contains(-1L)); + } + + // Computed with Python hashlib, independently of the generator; the first h is above 2^31. + @Test + void matchesVectorsComputedIndependentlyFromMd5() { + assertEquals(6, bucketOf("1tdwU5K8evYzMovqjExpBA", 0L, 10)); + assertEquals(19, bucketOf("\u6771\u4eac", -123456789012345L, 50)); + assertEquals(43, bucketOf("a\u001fb", Long.MIN_VALUE, 100)); + } + + @Test + void unitKeyIsTheSdkUnitHash() throws IOException { + final JsonNode cases = loadVectors().get("unitKeys"); + assertEquals(12, cases.size()); + for (final JsonNode c : cases) { + final JsonNode uid = c.get("unitUid"); + final String normalized = uid.isIntegralNumber() ? Long.toString(uid.longValue()) : uid.textValue(); + assertEquals(c.get("key").textValue(), + new String(Hashing.hashUnit(normalized), StandardCharsets.US_ASCII), c.toString()); + } + } + + @Test + void normalizeAttributeMatchesEveryGoldenVector() throws IOException { + final ObjectMapper mapper = new ObjectMapper(); + final JsonNode cases = loadVectors().get("attributeKeys"); + assertEquals(21, cases.size()); + int ineligible = 0; + for (final JsonNode c : cases) { + // Decoded the way the SDK's attribute values arrive from JSON: Integer, Long, + // BigInteger, Double, Boolean, String or null. + final Object value = mapper.convertValue(c.get("value"), Object.class); + final JsonNode key = c.get("key"); + if (key.isNull()) { + ++ineligible; + assertNull(SwitchbackBuckets.normalizeAttribute(value), c.toString()); + } else { + assertEquals(key.textValue(), SwitchbackBuckets.normalizeAttribute(value), c.toString()); + } + } + assertEquals(6, ineligible); + } + + @Test + void normalizeAttributeAcceptsEveryIntegerTypeAndRejectsOthers() { + assertEquals("-3", SwitchbackBuckets.normalizeAttribute((byte) -3)); + assertEquals("300", SwitchbackBuckets.normalizeAttribute((short) 300)); + assertEquals("7", SwitchbackBuckets.normalizeAttribute(7)); + assertEquals("-9223372036854775808", SwitchbackBuckets.normalizeAttribute(Long.MIN_VALUE)); + assertEquals("123456789012345678901234567890", + SwitchbackBuckets.normalizeAttribute(new BigInteger("123456789012345678901234567890"))); + assertNull(SwitchbackBuckets.normalizeAttribute(2.0f)); + assertNull(SwitchbackBuckets.normalizeAttribute(new BigDecimal("2"))); + assertNull(SwitchbackBuckets.normalizeAttribute(Collections.emptyMap())); + assertNull(SwitchbackBuckets.normalizeAttribute(Collections.emptyList())); + assertNull(SwitchbackBuckets.normalizeAttribute('c')); + } + + @Test + void onlyTenFiftyAndHundredAreAllowed() { + for (final int count : new int[]{10, 50, 100}) { + assertTrue(SwitchbackBuckets.isAllowedCount(count)); + } + for (final Integer count : new Integer[]{null, 0, 1, 2, 20, 99, 101, 1000, -10}) { + assertFalse(SwitchbackBuckets.isAllowedCount(count)); + } + assertThrows(IllegalArgumentException.class, () -> SwitchbackBuckets.bucketOf(0, 0, "k", 1000)); + assertThrows(IllegalArgumentException.class, () -> SwitchbackBuckets.bucketOf(0, 0, "k", 0)); + } +} diff --git a/core-api/src/test/resources/switchback/attribute-path-cases.json b/core-api/src/test/resources/switchback/attribute-path-cases.json new file mode 100644 index 0000000..7b1d3fe --- /dev/null +++ b/core-api/src/test/resources/switchback/attribute-path-cases.json @@ -0,0 +1,16 @@ +{ + "description": "Cluster attribute paths read by both the Collector (SwitchbackAttributePath) and analysis (ClickHouseSwitchbackExpr). An unquoted segment is an array index exactly when Guava Ints.tryParse parses it (no '+', ASCII digits only). supported: accepted by isSupported and by requireSupportedAttributePath; a negative index is unsupported because ClickHouse throws ZERO_ARRAY_OR_TUPLE_INDEX on the [0] subscript pathToArgs renders for it. value: what the path resolves to in attributes; null means missing (no cluster key).", + "attributes": { + "items": [{ "city": "Lisbon" }, { "city": "Faro" }], + "geo": { "city": "Porto", "-1": "minus-one-key", "+1": "plus-one-key" } + }, + "cases": [ + { "path": "items/0/city", "supported": true, "value": "Lisbon" }, + { "path": "items/+0/city", "supported": true, "value": null }, + { "path": "items/-1/city", "supported": false, "value": null }, + { "path": "geo/-1", "supported": false, "value": null }, + { "path": "geo/+1", "supported": true, "value": "plus-one-key" }, + { "path": "items/\u0660/city", "supported": true, "value": null }, + { "path": "geo/city", "supported": true, "value": "Porto" } + ] +} diff --git a/core-api/src/test/resources/switchback/bucket-golden-vectors.json b/core-api/src/test/resources/switchback/bucket-golden-vectors.json new file mode 100644 index 0000000..c2da442 --- /dev/null +++ b/core-api/src/test/resources/switchback/bucket-golden-vectors.json @@ -0,0 +1,4352 @@ +{ + "unitKeys" : [ + { + "unitUid" : "user-1", + "key" : "1tdwU5K8evYzMovqjExpBA" + }, + { + "unitUid" : "123", + "key" : "ICy5YqxZB1uWSwcVLSNLcA" + }, + { + "unitUid" : 123, + "key" : "ICy5YqxZB1uWSwcVLSNLcA" + }, + { + "unitUid" : 0, + "key" : "z80ghJXVZe9m59_5-Ydk2g" + }, + { + "unitUid" : -42, + "key" : "jfy4n9hiDj5_tqA6U_MH3A" + }, + { + "unitUid" : 9007199254740991, + "key" : "i_qa0JD7v4flGPHOJKk-7g" + }, + { + "unitUid" : "", + "key" : "1B2M2Y8AsgTpgAmY7PhCfg" + }, + { + "unitUid" : "São Paulo", + "key" : "6nbArp3YF-tEj9Gz22JTuw" + }, + { + "unitUid" : "東京", + "key" : "cHuhfH742e8Is57zFK30Mg" + }, + { + "unitUid" : "😀", + "key" : "KgLqw51xanDs83V5GFkntg" + }, + { + "unitUid" : "a\u001Fb", + "key" : "3eFBzFDWEYpd2q4aAbdPmQ" + }, + { + "unitUid" : "x'y\\z", + "key" : "WOAvK4WUCN3v173kQ-NR-g" + } + ], + "attributeKeys" : [ + { + "value" : "Lisboa", + "key" : "Lisboa" + }, + { + "value" : "São Paulo", + "key" : "São Paulo" + }, + { + "value" : "東京", + "key" : "東京" + }, + { + "value" : "😀", + "key" : "😀" + }, + { + "value" : "42", + "key" : "42" + }, + { + "value" : 42, + "key" : "42" + }, + { + "value" : -7, + "key" : "-7" + }, + { + "value" : 0, + "key" : "0" + }, + { + "value" : 9007199254740991, + "key" : "9007199254740991" + }, + { + "value" : -9007199254740991, + "key" : "-9007199254740991" + }, + { + "value" : 18446744073709551615, + "key" : "18446744073709551615" + }, + { + "value" : "a b", + "key" : "a b" + }, + { + "value" : " lead", + "key" : " lead" + }, + { + "value" : "x'y\\z", + "key" : "x'y\\z" + }, + { + "value" : "a\u001Fb", + "key" : "a\u001Fb" + }, + { + "value" : "", + "key" : null + }, + { + "value" : null, + "key" : null + }, + { + "value" : true, + "key" : null + }, + { + "value" : false, + "key" : null + }, + { + "value" : 1.5, + "key" : null + }, + { + "value" : 2.0, + "key" : null + } + ], + "buckets" : [ + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "0", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "0", + "count" : 50, + "expectedBucket" : 6 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "0", + "count" : 100, + "expectedBucket" : 6 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "1", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "1", + "count" : 50, + "expectedBucket" : 5 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "1", + "count" : 100, + "expectedBucket" : 5 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 40 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 40 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "42", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "42", + "count" : 50, + "expectedBucket" : 49 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "42", + "count" : 100, + "expectedBucket" : 99 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 6 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 6 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 34 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 84 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 36 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 36 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 27 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 27 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 6 + }, + { + "key" : "1tdwU5K8evYzMovqjExpBA", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 56 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "0", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "0", + "count" : 50, + "expectedBucket" : 1 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "0", + "count" : 100, + "expectedBucket" : 1 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "1", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "1", + "count" : 50, + "expectedBucket" : 39 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "1", + "count" : 100, + "expectedBucket" : 39 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 18 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 68 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "42", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "42", + "count" : 50, + "expectedBucket" : 5 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "42", + "count" : 100, + "expectedBucket" : 55 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 10 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 60 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 5 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 55 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 21 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 21 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 27 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 27 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 0 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 50 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "0", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "0", + "count" : 50, + "expectedBucket" : 1 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "0", + "count" : 100, + "expectedBucket" : 1 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "1", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "1", + "count" : 50, + "expectedBucket" : 39 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "1", + "count" : 100, + "expectedBucket" : 39 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 18 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 68 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "42", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "42", + "count" : 50, + "expectedBucket" : 5 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "42", + "count" : 100, + "expectedBucket" : 55 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 10 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 60 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 5 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 55 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 21 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 21 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 27 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 27 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 0 + }, + { + "key" : "ICy5YqxZB1uWSwcVLSNLcA", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 50 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "0", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "0", + "count" : 50, + "expectedBucket" : 40 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "0", + "count" : 100, + "expectedBucket" : 90 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "1", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "1", + "count" : 50, + "expectedBucket" : 28 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "1", + "count" : 100, + "expectedBucket" : 78 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 28 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 78 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "42", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "42", + "count" : 50, + "expectedBucket" : 36 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "42", + "count" : 100, + "expectedBucket" : 36 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 14 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 14 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 13 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 13 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 40 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 90 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 4 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 54 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 23 + }, + { + "key" : "z80ghJXVZe9m59_5-Ydk2g", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 23 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "0", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "0", + "count" : 50, + "expectedBucket" : 8 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "0", + "count" : 100, + "expectedBucket" : 58 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "1", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "1", + "count" : 50, + "expectedBucket" : 26 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "1", + "count" : 100, + "expectedBucket" : 26 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 47 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 47 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "42", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "42", + "count" : 50, + "expectedBucket" : 19 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "42", + "count" : 100, + "expectedBucket" : 69 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 20 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 20 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 32 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 32 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 39 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 39 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 46 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 96 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 7 + }, + { + "key" : "jfy4n9hiDj5_tqA6U_MH3A", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 57 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "0", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "0", + "count" : 50, + "expectedBucket" : 36 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "0", + "count" : 100, + "expectedBucket" : 86 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "1", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "1", + "count" : 50, + "expectedBucket" : 20 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "1", + "count" : 100, + "expectedBucket" : 70 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 43 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 43 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "42", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "42", + "count" : 50, + "expectedBucket" : 23 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "42", + "count" : 100, + "expectedBucket" : 23 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 4 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 54 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 34 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 34 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 21 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 71 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 47 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 97 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : "i_qa0JD7v4flGPHOJKk-7g", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 87 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "0", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "0", + "count" : 50, + "expectedBucket" : 9 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "0", + "count" : 100, + "expectedBucket" : 9 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "1", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "1", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "1", + "count" : 100, + "expectedBucket" : 37 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 38 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 88 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "42", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "42", + "count" : 50, + "expectedBucket" : 30 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "42", + "count" : 100, + "expectedBucket" : 30 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 20 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 70 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 49 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 49 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 22 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 22 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 37 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 9 + }, + { + "key" : "1B2M2Y8AsgTpgAmY7PhCfg", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 9 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "0", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "0", + "count" : 50, + "expectedBucket" : 2 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "0", + "count" : 100, + "expectedBucket" : 52 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "1", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "1", + "count" : 50, + "expectedBucket" : 43 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "1", + "count" : 100, + "expectedBucket" : 93 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 34 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 34 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "42", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "42", + "count" : 50, + "expectedBucket" : 39 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "42", + "count" : 100, + "expectedBucket" : 39 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 44 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 94 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 11 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 11 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 47 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 47 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 16 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 66 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 27 + }, + { + "key" : "6nbArp3YF-tEj9Gz22JTuw", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 77 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "0", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "0", + "count" : 50, + "expectedBucket" : 32 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "0", + "count" : 100, + "expectedBucket" : 82 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "1", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "1", + "count" : 50, + "expectedBucket" : 36 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "1", + "count" : 100, + "expectedBucket" : 36 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 40 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 90 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "42", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "42", + "count" : 50, + "expectedBucket" : 16 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "42", + "count" : 100, + "expectedBucket" : 16 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 29 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 29 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 32 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 32 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 25 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 75 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 35 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 35 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 42 + }, + { + "key" : "cHuhfH742e8Is57zFK30Mg", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 92 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "0", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "0", + "count" : 50, + "expectedBucket" : 28 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "0", + "count" : 100, + "expectedBucket" : 28 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "1", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "1", + "count" : 50, + "expectedBucket" : 49 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "1", + "count" : 100, + "expectedBucket" : 49 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 42 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 42 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "42", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "42", + "count" : 50, + "expectedBucket" : 16 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "42", + "count" : 100, + "expectedBucket" : 66 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 87 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 11 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 61 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 32 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 32 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 23 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 23 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : "KgLqw51xanDs83V5GFkntg", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 37 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "0", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "0", + "count" : 50, + "expectedBucket" : 35 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "0", + "count" : 100, + "expectedBucket" : 35 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "1", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "1", + "count" : 50, + "expectedBucket" : 2 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "1", + "count" : 100, + "expectedBucket" : 2 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 22 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 72 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "42", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "42", + "count" : 50, + "expectedBucket" : 40 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "42", + "count" : 100, + "expectedBucket" : 40 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 17 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 17 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 3 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 3 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 18 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 18 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 40 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 40 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 17 + }, + { + "key" : "3eFBzFDWEYpd2q4aAbdPmQ", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 67 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "0", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "0", + "count" : 50, + "expectedBucket" : 8 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "0", + "count" : 100, + "expectedBucket" : 8 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "1", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "1", + "count" : 50, + "expectedBucket" : 24 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "1", + "count" : 100, + "expectedBucket" : 24 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 12 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 62 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "42", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "42", + "count" : 50, + "expectedBucket" : 18 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "42", + "count" : 100, + "expectedBucket" : 18 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 8 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 58 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 20 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 70 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 36 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 86 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 31 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 31 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 28 + }, + { + "key" : "WOAvK4WUCN3v173kQ-NR-g", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 78 + }, + { + "key" : "Lisboa", + "seed" : "0", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "Lisboa", + "seed" : "0", + "count" : 50, + "expectedBucket" : 26 + }, + { + "key" : "Lisboa", + "seed" : "0", + "count" : 100, + "expectedBucket" : 76 + }, + { + "key" : "Lisboa", + "seed" : "1", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "Lisboa", + "seed" : "1", + "count" : 50, + "expectedBucket" : 8 + }, + { + "key" : "Lisboa", + "seed" : "1", + "count" : 100, + "expectedBucket" : 8 + }, + { + "key" : "Lisboa", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "Lisboa", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 0 + }, + { + "key" : "Lisboa", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 0 + }, + { + "key" : "Lisboa", + "seed" : "42", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "Lisboa", + "seed" : "42", + "count" : 50, + "expectedBucket" : 12 + }, + { + "key" : "Lisboa", + "seed" : "42", + "count" : 100, + "expectedBucket" : 62 + }, + { + "key" : "Lisboa", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "Lisboa", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 12 + }, + { + "key" : "Lisboa", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 62 + }, + { + "key" : "Lisboa", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "Lisboa", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 17 + }, + { + "key" : "Lisboa", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 67 + }, + { + "key" : "Lisboa", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "Lisboa", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 13 + }, + { + "key" : "Lisboa", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 63 + }, + { + "key" : "Lisboa", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "Lisboa", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 45 + }, + { + "key" : "Lisboa", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 45 + }, + { + "key" : "Lisboa", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "Lisboa", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 23 + }, + { + "key" : "Lisboa", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 23 + }, + { + "key" : "São Paulo", + "seed" : "0", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "São Paulo", + "seed" : "0", + "count" : 50, + "expectedBucket" : 0 + }, + { + "key" : "São Paulo", + "seed" : "0", + "count" : 100, + "expectedBucket" : 0 + }, + { + "key" : "São Paulo", + "seed" : "1", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "São Paulo", + "seed" : "1", + "count" : 50, + "expectedBucket" : 39 + }, + { + "key" : "São Paulo", + "seed" : "1", + "count" : 100, + "expectedBucket" : 39 + }, + { + "key" : "São Paulo", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "São Paulo", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 17 + }, + { + "key" : "São Paulo", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 67 + }, + { + "key" : "São Paulo", + "seed" : "42", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "São Paulo", + "seed" : "42", + "count" : 50, + "expectedBucket" : 26 + }, + { + "key" : "São Paulo", + "seed" : "42", + "count" : 100, + "expectedBucket" : 26 + }, + { + "key" : "São Paulo", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "São Paulo", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 10 + }, + { + "key" : "São Paulo", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 60 + }, + { + "key" : "São Paulo", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "São Paulo", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : "São Paulo", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 87 + }, + { + "key" : "São Paulo", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "São Paulo", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 46 + }, + { + "key" : "São Paulo", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 46 + }, + { + "key" : "São Paulo", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "São Paulo", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 30 + }, + { + "key" : "São Paulo", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 80 + }, + { + "key" : "São Paulo", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "São Paulo", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 38 + }, + { + "key" : "São Paulo", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 88 + }, + { + "key" : "東京", + "seed" : "0", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "東京", + "seed" : "0", + "count" : 50, + "expectedBucket" : 38 + }, + { + "key" : "東京", + "seed" : "0", + "count" : 100, + "expectedBucket" : 88 + }, + { + "key" : "東京", + "seed" : "1", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "東京", + "seed" : "1", + "count" : 50, + "expectedBucket" : 29 + }, + { + "key" : "東京", + "seed" : "1", + "count" : 100, + "expectedBucket" : 29 + }, + { + "key" : "東京", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "東京", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 20 + }, + { + "key" : "東京", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 20 + }, + { + "key" : "東京", + "seed" : "42", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "東京", + "seed" : "42", + "count" : 50, + "expectedBucket" : 24 + }, + { + "key" : "東京", + "seed" : "42", + "count" : 100, + "expectedBucket" : 24 + }, + { + "key" : "東京", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "東京", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 19 + }, + { + "key" : "東京", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 19 + }, + { + "key" : "東京", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "東京", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 42 + }, + { + "key" : "東京", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 92 + }, + { + "key" : "東京", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "東京", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 17 + }, + { + "key" : "東京", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 67 + }, + { + "key" : "東京", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "東京", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 22 + }, + { + "key" : "東京", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 72 + }, + { + "key" : "東京", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "東京", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 0 + }, + { + "key" : "東京", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 50 + }, + { + "key" : "😀", + "seed" : "0", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "😀", + "seed" : "0", + "count" : 50, + "expectedBucket" : 25 + }, + { + "key" : "😀", + "seed" : "0", + "count" : 100, + "expectedBucket" : 25 + }, + { + "key" : "😀", + "seed" : "1", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "😀", + "seed" : "1", + "count" : 50, + "expectedBucket" : 22 + }, + { + "key" : "😀", + "seed" : "1", + "count" : 100, + "expectedBucket" : 72 + }, + { + "key" : "😀", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "😀", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 39 + }, + { + "key" : "😀", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 39 + }, + { + "key" : "😀", + "seed" : "42", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "😀", + "seed" : "42", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : "😀", + "seed" : "42", + "count" : 100, + "expectedBucket" : 37 + }, + { + "key" : "😀", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "😀", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 11 + }, + { + "key" : "😀", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 11 + }, + { + "key" : "😀", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "😀", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 12 + }, + { + "key" : "😀", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 62 + }, + { + "key" : "😀", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "😀", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 42 + }, + { + "key" : "😀", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 92 + }, + { + "key" : "😀", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "😀", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 1 + }, + { + "key" : "😀", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 1 + }, + { + "key" : "😀", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "😀", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 47 + }, + { + "key" : "😀", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 47 + }, + { + "key" : "42", + "seed" : "0", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "42", + "seed" : "0", + "count" : 50, + "expectedBucket" : 32 + }, + { + "key" : "42", + "seed" : "0", + "count" : 100, + "expectedBucket" : 82 + }, + { + "key" : "42", + "seed" : "1", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "42", + "seed" : "1", + "count" : 50, + "expectedBucket" : 30 + }, + { + "key" : "42", + "seed" : "1", + "count" : 100, + "expectedBucket" : 30 + }, + { + "key" : "42", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "42", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 39 + }, + { + "key" : "42", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 39 + }, + { + "key" : "42", + "seed" : "42", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "42", + "seed" : "42", + "count" : 50, + "expectedBucket" : 22 + }, + { + "key" : "42", + "seed" : "42", + "count" : 100, + "expectedBucket" : 72 + }, + { + "key" : "42", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "42", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 5 + }, + { + "key" : "42", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 5 + }, + { + "key" : "42", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "42", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 41 + }, + { + "key" : "42", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 91 + }, + { + "key" : "42", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "42", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 10 + }, + { + "key" : "42", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 60 + }, + { + "key" : "42", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "42", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 26 + }, + { + "key" : "42", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 26 + }, + { + "key" : "42", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "42", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 45 + }, + { + "key" : "42", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 45 + }, + { + "key" : "-7", + "seed" : "0", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "-7", + "seed" : "0", + "count" : 50, + "expectedBucket" : 14 + }, + { + "key" : "-7", + "seed" : "0", + "count" : 100, + "expectedBucket" : 14 + }, + { + "key" : "-7", + "seed" : "1", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "-7", + "seed" : "1", + "count" : 50, + "expectedBucket" : 27 + }, + { + "key" : "-7", + "seed" : "1", + "count" : 100, + "expectedBucket" : 77 + }, + { + "key" : "-7", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "-7", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 31 + }, + { + "key" : "-7", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 31 + }, + { + "key" : "-7", + "seed" : "42", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "-7", + "seed" : "42", + "count" : 50, + "expectedBucket" : 39 + }, + { + "key" : "-7", + "seed" : "42", + "count" : 100, + "expectedBucket" : 89 + }, + { + "key" : "-7", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "-7", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 28 + }, + { + "key" : "-7", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 78 + }, + { + "key" : "-7", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "-7", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 22 + }, + { + "key" : "-7", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 22 + }, + { + "key" : "-7", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "-7", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 7 + }, + { + "key" : "-7", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 7 + }, + { + "key" : "-7", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "-7", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 21 + }, + { + "key" : "-7", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 71 + }, + { + "key" : "-7", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "-7", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 23 + }, + { + "key" : "-7", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 73 + }, + { + "key" : "0", + "seed" : "0", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "0", + "seed" : "0", + "count" : 50, + "expectedBucket" : 44 + }, + { + "key" : "0", + "seed" : "0", + "count" : 100, + "expectedBucket" : 94 + }, + { + "key" : "0", + "seed" : "1", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "0", + "seed" : "1", + "count" : 50, + "expectedBucket" : 22 + }, + { + "key" : "0", + "seed" : "1", + "count" : 100, + "expectedBucket" : 72 + }, + { + "key" : "0", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "0", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 6 + }, + { + "key" : "0", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 56 + }, + { + "key" : "0", + "seed" : "42", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "0", + "seed" : "42", + "count" : 50, + "expectedBucket" : 27 + }, + { + "key" : "0", + "seed" : "42", + "count" : 100, + "expectedBucket" : 77 + }, + { + "key" : "0", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "0", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 7 + }, + { + "key" : "0", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 7 + }, + { + "key" : "0", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "0", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 46 + }, + { + "key" : "0", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 46 + }, + { + "key" : "0", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "0", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 24 + }, + { + "key" : "0", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 24 + }, + { + "key" : "0", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "0", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 18 + }, + { + "key" : "0", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 18 + }, + { + "key" : "0", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "0", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 18 + }, + { + "key" : "0", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 68 + }, + { + "key" : "9007199254740991", + "seed" : "0", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "9007199254740991", + "seed" : "0", + "count" : 50, + "expectedBucket" : 0 + }, + { + "key" : "9007199254740991", + "seed" : "0", + "count" : 100, + "expectedBucket" : 50 + }, + { + "key" : "9007199254740991", + "seed" : "1", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "9007199254740991", + "seed" : "1", + "count" : 50, + "expectedBucket" : 12 + }, + { + "key" : "9007199254740991", + "seed" : "1", + "count" : 100, + "expectedBucket" : 12 + }, + { + "key" : "9007199254740991", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "9007199254740991", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 5 + }, + { + "key" : "9007199254740991", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 5 + }, + { + "key" : "9007199254740991", + "seed" : "42", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "9007199254740991", + "seed" : "42", + "count" : 50, + "expectedBucket" : 34 + }, + { + "key" : "9007199254740991", + "seed" : "42", + "count" : 100, + "expectedBucket" : 34 + }, + { + "key" : "9007199254740991", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "9007199254740991", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 34 + }, + { + "key" : "9007199254740991", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 34 + }, + { + "key" : "9007199254740991", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "9007199254740991", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 35 + }, + { + "key" : "9007199254740991", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 85 + }, + { + "key" : "9007199254740991", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "9007199254740991", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 47 + }, + { + "key" : "9007199254740991", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 47 + }, + { + "key" : "9007199254740991", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "9007199254740991", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 45 + }, + { + "key" : "9007199254740991", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 95 + }, + { + "key" : "9007199254740991", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "9007199254740991", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 30 + }, + { + "key" : "9007199254740991", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 80 + }, + { + "key" : "-9007199254740991", + "seed" : "0", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "-9007199254740991", + "seed" : "0", + "count" : 50, + "expectedBucket" : 33 + }, + { + "key" : "-9007199254740991", + "seed" : "0", + "count" : 100, + "expectedBucket" : 83 + }, + { + "key" : "-9007199254740991", + "seed" : "1", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "-9007199254740991", + "seed" : "1", + "count" : 50, + "expectedBucket" : 35 + }, + { + "key" : "-9007199254740991", + "seed" : "1", + "count" : 100, + "expectedBucket" : 35 + }, + { + "key" : "-9007199254740991", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "-9007199254740991", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 25 + }, + { + "key" : "-9007199254740991", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 25 + }, + { + "key" : "-9007199254740991", + "seed" : "42", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "-9007199254740991", + "seed" : "42", + "count" : 50, + "expectedBucket" : 27 + }, + { + "key" : "-9007199254740991", + "seed" : "42", + "count" : 100, + "expectedBucket" : 77 + }, + { + "key" : "-9007199254740991", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "-9007199254740991", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 36 + }, + { + "key" : "-9007199254740991", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 86 + }, + { + "key" : "-9007199254740991", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "-9007199254740991", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 48 + }, + { + "key" : "-9007199254740991", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 48 + }, + { + "key" : "-9007199254740991", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "-9007199254740991", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 39 + }, + { + "key" : "-9007199254740991", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 89 + }, + { + "key" : "-9007199254740991", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "-9007199254740991", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 46 + }, + { + "key" : "-9007199254740991", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 96 + }, + { + "key" : "-9007199254740991", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "-9007199254740991", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 42 + }, + { + "key" : "-9007199254740991", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 92 + }, + { + "key" : "18446744073709551615", + "seed" : "0", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "18446744073709551615", + "seed" : "0", + "count" : 50, + "expectedBucket" : 40 + }, + { + "key" : "18446744073709551615", + "seed" : "0", + "count" : 100, + "expectedBucket" : 90 + }, + { + "key" : "18446744073709551615", + "seed" : "1", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "18446744073709551615", + "seed" : "1", + "count" : 50, + "expectedBucket" : 40 + }, + { + "key" : "18446744073709551615", + "seed" : "1", + "count" : 100, + "expectedBucket" : 90 + }, + { + "key" : "18446744073709551615", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "18446744073709551615", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 42 + }, + { + "key" : "18446744073709551615", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 42 + }, + { + "key" : "18446744073709551615", + "seed" : "42", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "18446744073709551615", + "seed" : "42", + "count" : 50, + "expectedBucket" : 8 + }, + { + "key" : "18446744073709551615", + "seed" : "42", + "count" : 100, + "expectedBucket" : 58 + }, + { + "key" : "18446744073709551615", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "18446744073709551615", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 43 + }, + { + "key" : "18446744073709551615", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 93 + }, + { + "key" : "18446744073709551615", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "18446744073709551615", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : "18446744073709551615", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 37 + }, + { + "key" : "18446744073709551615", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "18446744073709551615", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 31 + }, + { + "key" : "18446744073709551615", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 31 + }, + { + "key" : "18446744073709551615", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "18446744073709551615", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 1 + }, + { + "key" : "18446744073709551615", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 1 + }, + { + "key" : "18446744073709551615", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "18446744073709551615", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 42 + }, + { + "key" : "18446744073709551615", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 42 + }, + { + "key" : "a b", + "seed" : "0", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "a b", + "seed" : "0", + "count" : 50, + "expectedBucket" : 25 + }, + { + "key" : "a b", + "seed" : "0", + "count" : 100, + "expectedBucket" : 75 + }, + { + "key" : "a b", + "seed" : "1", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "a b", + "seed" : "1", + "count" : 50, + "expectedBucket" : 46 + }, + { + "key" : "a b", + "seed" : "1", + "count" : 100, + "expectedBucket" : 46 + }, + { + "key" : "a b", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "a b", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 21 + }, + { + "key" : "a b", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 71 + }, + { + "key" : "a b", + "seed" : "42", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "a b", + "seed" : "42", + "count" : 50, + "expectedBucket" : 30 + }, + { + "key" : "a b", + "seed" : "42", + "count" : 100, + "expectedBucket" : 30 + }, + { + "key" : "a b", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "a b", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 27 + }, + { + "key" : "a b", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 27 + }, + { + "key" : "a b", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "a b", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 4 + }, + { + "key" : "a b", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 4 + }, + { + "key" : "a b", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "a b", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 7 + }, + { + "key" : "a b", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 7 + }, + { + "key" : "a b", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "a b", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 9 + }, + { + "key" : "a b", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 59 + }, + { + "key" : "a b", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "a b", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 10 + }, + { + "key" : "a b", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 60 + }, + { + "key" : " lead", + "seed" : "0", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : " lead", + "seed" : "0", + "count" : 50, + "expectedBucket" : 36 + }, + { + "key" : " lead", + "seed" : "0", + "count" : 100, + "expectedBucket" : 36 + }, + { + "key" : " lead", + "seed" : "1", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : " lead", + "seed" : "1", + "count" : 50, + "expectedBucket" : 30 + }, + { + "key" : " lead", + "seed" : "1", + "count" : 100, + "expectedBucket" : 30 + }, + { + "key" : " lead", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : " lead", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 35 + }, + { + "key" : " lead", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 85 + }, + { + "key" : " lead", + "seed" : "42", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : " lead", + "seed" : "42", + "count" : 50, + "expectedBucket" : 7 + }, + { + "key" : " lead", + "seed" : "42", + "count" : 100, + "expectedBucket" : 57 + }, + { + "key" : " lead", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : " lead", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 29 + }, + { + "key" : " lead", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 29 + }, + { + "key" : " lead", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : " lead", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 10 + }, + { + "key" : " lead", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 10 + }, + { + "key" : " lead", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : " lead", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : " lead", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 87 + }, + { + "key" : " lead", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : " lead", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 7 + }, + { + "key" : " lead", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 57 + }, + { + "key" : " lead", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : " lead", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 6 + }, + { + "key" : " lead", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 6 + }, + { + "key" : "x'y\\z", + "seed" : "0", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "x'y\\z", + "seed" : "0", + "count" : 50, + "expectedBucket" : 29 + }, + { + "key" : "x'y\\z", + "seed" : "0", + "count" : 100, + "expectedBucket" : 79 + }, + { + "key" : "x'y\\z", + "seed" : "1", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "x'y\\z", + "seed" : "1", + "count" : 50, + "expectedBucket" : 48 + }, + { + "key" : "x'y\\z", + "seed" : "1", + "count" : 100, + "expectedBucket" : 98 + }, + { + "key" : "x'y\\z", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 8 + }, + { + "key" : "x'y\\z", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 8 + }, + { + "key" : "x'y\\z", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 58 + }, + { + "key" : "x'y\\z", + "seed" : "42", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "x'y\\z", + "seed" : "42", + "count" : 50, + "expectedBucket" : 45 + }, + { + "key" : "x'y\\z", + "seed" : "42", + "count" : 100, + "expectedBucket" : 95 + }, + { + "key" : "x'y\\z", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 5 + }, + { + "key" : "x'y\\z", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 5 + }, + { + "key" : "x'y\\z", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 55 + }, + { + "key" : "x'y\\z", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 0 + }, + { + "key" : "x'y\\z", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 40 + }, + { + "key" : "x'y\\z", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 40 + }, + { + "key" : "x'y\\z", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "x'y\\z", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 36 + }, + { + "key" : "x'y\\z", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 36 + }, + { + "key" : "x'y\\z", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "x'y\\z", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 37 + }, + { + "key" : "x'y\\z", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 87 + }, + { + "key" : "x'y\\z", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 7 + }, + { + "key" : "x'y\\z", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 7 + }, + { + "key" : "x'y\\z", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 57 + }, + { + "key" : "a\u001Fb", + "seed" : "0", + "count" : 10, + "expectedBucket" : 2 + }, + { + "key" : "a\u001Fb", + "seed" : "0", + "count" : 50, + "expectedBucket" : 12 + }, + { + "key" : "a\u001Fb", + "seed" : "0", + "count" : 100, + "expectedBucket" : 12 + }, + { + "key" : "a\u001Fb", + "seed" : "1", + "count" : 10, + "expectedBucket" : 1 + }, + { + "key" : "a\u001Fb", + "seed" : "1", + "count" : 50, + "expectedBucket" : 21 + }, + { + "key" : "a\u001Fb", + "seed" : "1", + "count" : 100, + "expectedBucket" : 71 + }, + { + "key" : "a\u001Fb", + "seed" : "-1", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "a\u001Fb", + "seed" : "-1", + "count" : 50, + "expectedBucket" : 14 + }, + { + "key" : "a\u001Fb", + "seed" : "-1", + "count" : 100, + "expectedBucket" : 64 + }, + { + "key" : "a\u001Fb", + "seed" : "42", + "count" : 10, + "expectedBucket" : 6 + }, + { + "key" : "a\u001Fb", + "seed" : "42", + "count" : 50, + "expectedBucket" : 46 + }, + { + "key" : "a\u001Fb", + "seed" : "42", + "count" : 100, + "expectedBucket" : 96 + }, + { + "key" : "a\u001Fb", + "seed" : "-123456789012345", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "a\u001Fb", + "seed" : "-123456789012345", + "count" : 50, + "expectedBucket" : 34 + }, + { + "key" : "a\u001Fb", + "seed" : "-123456789012345", + "count" : 100, + "expectedBucket" : 34 + }, + { + "key" : "a\u001Fb", + "seed" : "8589934591", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "a\u001Fb", + "seed" : "8589934591", + "count" : 50, + "expectedBucket" : 29 + }, + { + "key" : "a\u001Fb", + "seed" : "8589934591", + "count" : 100, + "expectedBucket" : 29 + }, + { + "key" : "a\u001Fb", + "seed" : "-4294967295", + "count" : 10, + "expectedBucket" : 4 + }, + { + "key" : "a\u001Fb", + "seed" : "-4294967295", + "count" : 50, + "expectedBucket" : 24 + }, + { + "key" : "a\u001Fb", + "seed" : "-4294967295", + "count" : 100, + "expectedBucket" : 74 + }, + { + "key" : "a\u001Fb", + "seed" : "-9223372036854775808", + "count" : 10, + "expectedBucket" : 3 + }, + { + "key" : "a\u001Fb", + "seed" : "-9223372036854775808", + "count" : 50, + "expectedBucket" : 43 + }, + { + "key" : "a\u001Fb", + "seed" : "-9223372036854775808", + "count" : 100, + "expectedBucket" : 43 + }, + { + "key" : "a\u001Fb", + "seed" : "9223372036854775807", + "count" : 10, + "expectedBucket" : 9 + }, + { + "key" : "a\u001Fb", + "seed" : "9223372036854775807", + "count" : 50, + "expectedBucket" : 9 + }, + { + "key" : "a\u001Fb", + "seed" : "9223372036854775807", + "count" : 100, + "expectedBucket" : 59 + } + ] +} From 27a1f055b0eba265ae96fe5a9ade431445c1f616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 04:18:02 +0000 Subject: [PATCH 17/35] feat(switchback): read switchbackClusterCount and switchbackClusterAttribute Both are optional; an absent field deserialises to null, so unit experiments and older payloads are unchanged. --- .../com/absmartly/sdk/json/Experiment.java | 13 +++++++++- .../DefaultContextDataDeserializerTest.java | 4 ++++ .../json/ExperimentSwitchbackFieldsTest.java | 24 +++++++++++++++++++ .../test/resources/switchback_context.json | 2 ++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/json/Experiment.java b/core-api/src/main/java/com/absmartly/sdk/json/Experiment.java index db743d0..29a3f48 100644 --- a/core-api/src/main/java/com/absmartly/sdk/json/Experiment.java +++ b/core-api/src/main/java/com/absmartly/sdk/json/Experiment.java @@ -47,6 +47,9 @@ public class Experiment { public Long switchbackBurnOutDurationMs; public String switchbackDesignType; public String switchbackEstimand; + // Hashed cluster buckets: count is 10, 50 or 100; a null attribute hashes the experiment unit. + public Integer switchbackClusterCount; + public String switchbackClusterAttribute; public Experiment() {} @@ -111,7 +114,11 @@ public boolean equals(Object o) { return false; if (!Objects.equals(switchbackDesignType, that.switchbackDesignType)) return false; - return Objects.equals(switchbackEstimand, that.switchbackEstimand); + if (!Objects.equals(switchbackEstimand, that.switchbackEstimand)) + return false; + if (!Objects.equals(switchbackClusterCount, that.switchbackClusterCount)) + return false; + return Objects.equals(switchbackClusterAttribute, that.switchbackClusterAttribute); } private static int hash(Object o) { @@ -147,6 +154,8 @@ public int hashCode() { result = 31 * result + hash(switchbackBurnOutDurationMs); result = 31 * result + hash(switchbackDesignType); result = 31 * result + hash(switchbackEstimand); + result = 31 * result + hash(switchbackClusterCount); + result = 31 * result + hash(switchbackClusterAttribute); return result; } @@ -180,6 +189,8 @@ public String toString() { ", switchbackBurnOutDurationMs=" + switchbackBurnOutDurationMs + ", switchbackDesignType='" + switchbackDesignType + '\'' + ", switchbackEstimand='" + switchbackEstimand + '\'' + + ", switchbackClusterCount=" + switchbackClusterCount + + ", switchbackClusterAttribute='" + switchbackClusterAttribute + '\'' + '}'; } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java index 20e46fe..d8afc2e 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java @@ -229,6 +229,8 @@ void deserializeSwitchback() { switchback.switchbackBurnOutDurationMs = 0L; switchback.switchbackDesignType = "balanced_block"; switchback.switchbackEstimand = "global_average_treatment_effect"; + switchback.switchbackClusterCount = 50; + switchback.switchbackClusterAttribute = "geo/city"; final Experiment unit = new Experiment(); unit.id = 2; @@ -273,6 +275,8 @@ void deserializeWithoutSwitchbackFieldsLeavesThemNull() { assertNull(experiment.switchbackBurnOutDurationMs); assertNull(experiment.switchbackDesignType); assertNull(experiment.switchbackEstimand); + assertNull(experiment.switchbackClusterCount); + assertNull(experiment.switchbackClusterAttribute); } } diff --git a/core-api/src/test/java/com/absmartly/sdk/json/ExperimentSwitchbackFieldsTest.java b/core-api/src/test/java/com/absmartly/sdk/json/ExperimentSwitchbackFieldsTest.java index a547dc6..64dcc2c 100644 --- a/core-api/src/test/java/com/absmartly/sdk/json/ExperimentSwitchbackFieldsTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/json/ExperimentSwitchbackFieldsTest.java @@ -21,6 +21,8 @@ private static Experiment switchback() { experiment.switchbackBurnOutDurationMs = 0L; experiment.switchbackDesignType = "balanced_block"; experiment.switchbackEstimand = "global_average_treatment_effect"; + experiment.switchbackClusterCount = 50; + experiment.switchbackClusterAttribute = "geo/city"; return experiment; } @@ -79,6 +81,26 @@ public void apply(Experiment e) { e.switchbackEstimand = "other"; } }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackClusterCount = 100; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackClusterCount = null; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackClusterAttribute = null; + } + }, + new Mutation() { + public void apply(Experiment e) { + e.switchbackClusterAttribute = "geo/country"; + } + }, }; for (final Mutation mutation : mutations) { @@ -102,5 +124,7 @@ void toStringIncludesSwitchbackFields() { assertTrue(s.contains("switchbackBurnOutDurationMs=0")); assertTrue(s.contains("switchbackDesignType='balanced_block'")); assertTrue(s.contains("switchbackEstimand='global_average_treatment_effect'")); + assertTrue(s.contains("switchbackClusterCount=50")); + assertTrue(s.contains("switchbackClusterAttribute='geo/city'")); } } diff --git a/core-api/src/test/resources/switchback_context.json b/core-api/src/test/resources/switchback_context.json index 04097e7..53dca93 100644 --- a/core-api/src/test/resources/switchback_context.json +++ b/core-api/src/test/resources/switchback_context.json @@ -43,6 +43,8 @@ "switchbackBurnOutDurationMs":0, "switchbackDesignType":"balanced_block", "switchbackEstimand":"global_average_treatment_effect", + "switchbackClusterCount":50, + "switchbackClusterAttribute":"geo/city", "switchbackFutureField":{"nested":[1,2,3]} }, { From 7a0a3f09b519dd852fffde85b476d1f34d3add42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 04:40:03 +0000 Subject: [PATCH 18/35] feat(switchback): assign switchbacks by hashed cluster bucket The schedule is drawn for bucketOf(seed, key, switchbackClusterCount) instead of the per-value cluster. The key is the published unit hash, or the context attribute at switchbackClusterAttribute resolved by the Collector's path rules and normalised like the Collector (latest setAt at or before the call wins). A disallowed or missing count or an unsupported path makes the grid unevaluable (variant 0, not eligible), as the Collector's unservable rules do; a unit without a key is ineligible. The key is read once per period slot, so a setAttribute mid-period never moves a unit to another bucket (R32-3). Count and attribute join the switchback identity, so changing them starts a new epoch. --- SWITCHBACK.md | 42 +- .../main/java/com/absmartly/sdk/Context.java | 105 ++++- .../sdk/internal/SwitchbackAttributePath.java | 42 +- .../sdk/ContextSwitchbackClusterTest.java | 376 ++++++++++++++++++ .../absmartly/sdk/ContextSwitchbackTest.java | 26 +- 5 files changed, 554 insertions(+), 37 deletions(-) create mode 100644 core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java diff --git a/SWITCHBACK.md b/SWITCHBACK.md index 6ddb811..1d722e9 100644 --- a/SWITCHBACK.md +++ b/SWITCHBACK.md @@ -1,8 +1,8 @@ # Switchback experiments -A switchback experiment randomizes a *cluster* (in Phase 1, always the experiment's own unit type) -into a variant that changes over time on a fixed UTC grid. Every unit of that cluster sees the same -variant within a period. +A switchback experiment hashes each unit, or one of its context attributes, into one of 10, 50 or +100 *buckets*. The bucket is the cluster: it is randomized into a variant that changes over time on +a fixed UTC grid, and every unit in the bucket sees the same variant within a period. ## Minimum SDK version @@ -24,6 +24,8 @@ every experiment a unit experiment for this SDK. | `switchbackPeriodMs` | Period length in milliseconds (whole hours). | | `switchbackCarryoverDurationMs`, `switchbackBurnInDurationMs`, `switchbackBurnOutDurationMs` | Analysis-only windows. The SDK carries them but does not use them. | | `switchbackDesignType`, `switchbackEstimand` | Design metadata. | +| `switchbackClusterCount` | Number of buckets: 10, 50 or 100. | +| `switchbackClusterAttribute` | Attribute path hashed into buckets. Null hashes the unit. | For a switchback experiment: @@ -31,20 +33,35 @@ For a switchback experiment: `periodIndex = floorDiv(now - anchor, switchbackPeriodMs)`, where `now` is one reading of the context's `Clock` taken by the `getTreatment`, `peekTreatment`, `getVariableValue` or `peekVariableValue` call. +- The cluster key is `hashUnit(uid)`, the MD5/base64url string the SDK publishes as the hashed + unit, when `switchbackClusterAttribute` is null. Otherwise it is the context attribute at that + path (see below): a non-empty string as-is, or an integer in base 10. +- `bucket = u32be(MD5(utf8("sbcluster" + U+001F + seedHi + ":" + seedLo + U+001F + key))[0..3]) mod + switchbackClusterCount`, pinned by `core-api/src/test/resources/switchback/bucket-golden-vectors.json`. - The variant is the balanced-block draw - `assignSwitchback(hashUnit(uid), periodIndex, seed, split.length)`, with - `seed = (seedHi << 32) | (seedLo & 0xffffffff)`. `hashUnit(uid)` is the same MD5/base64url string - the SDK publishes as the hashed unit. The algorithm is pinned by the platform's golden vectors - (`core-api/src/test/resources/switchback/golden-vectors.json`). A Collector that serves switchback - must hash the cluster unit the same way. + `assignSwitchback(String.valueOf(bucket), periodIndex, seed, split.length)`, with + `seed = (seedHi << 32) | (seedLo & 0xffffffff)`, pinned by + `core-api/src/test/resources/switchback/golden-vectors.json`. Both functions are the Collector's. - Precedence is the same as for unit experiments: override, holdouts, audience, full-on, traffic eligibility, then custom assignment or the switchback draw. Custom assignments therefore apply only to traffic-eligible units. Traffic eligibility uses the per-unit traffic hash, so it is sticky per cluster across periods. - The experiment is ineligible (variant 0) before the anchor, when `switchbackStartAt` or - `switchbackPeriodMs` is null, or when the cluster unit type differs from the unit type. If the - cluster unit is not set on the context, the experiment is unassigned. The SDK never falls back to - unit assignment. + `switchbackPeriodMs` is null, when the cluster unit type differs from the unit type, when + `switchbackClusterCount` is not 10, 50 or 100, when `switchbackClusterAttribute` is not a + supported path, or when the unit has no cluster key: the attribute is absent, empty, a boolean, + a floating-point number, an object or an array. Custom assignments need a cluster key too. If the + experiment unit is not set on the context, the experiment is unassigned. The SDK never falls back + to unit assignment. +- Attribute paths follow the Collector: `/` separates segments, a segment quoted with `'` or `"` is + a literal key, and an unquoted integer segment (`items/0/city`) indexes a list. An index must be + non-negative and sit between two key segments. The first segment names an attribute set with + `setAttribute`; deeper segments read its value as the Collector reads its JSON encoding. Among + attributes with that name set at or before the call's clock reading, the one with the latest + `setAt` wins, and on a tie the one set last. +- The cluster key is read once per period slot. A `setAttribute` later in the same period does not + move the unit to another bucket, even when an audience change re-resolves the slot; the next + period reads the attribute again. - The exposure and publish payloads are unchanged. Analysis derives the period from `exposedAt`, so a switchback exposure is stamped with the clock reading that selected its period, not a later one. A call that resolves just before a boundary and exposes just after it still publishes a @@ -77,7 +94,8 @@ eviction. ### Refresh and epochs An experiment's switchback identity is `randomizationType`, `switchbackClusterUnitType`, -`switchbackStartAt`, `switchbackPeriodMs`, `switchbackDesignType`, its id and its iteration. The +`switchbackStartAt`, `switchbackPeriodMs`, `switchbackDesignType`, `switchbackClusterCount`, +`switchbackClusterAttribute`, its id and its iteration. The backend rejects changes to the grid fields after an experiment starts, so within a live experiment a change means a pre-start edit, a restart (new iteration), or a type change. diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index 10fc519..422343d 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -25,6 +25,8 @@ import com.absmartly.sdk.internal.Algorithm; import com.absmartly.sdk.internal.Concurrency; import com.absmartly.sdk.internal.SwitchbackAssigner; +import com.absmartly.sdk.internal.SwitchbackAttributePath; +import com.absmartly.sdk.internal.SwitchbackBuckets; import com.absmartly.sdk.internal.VariantAssigner; import com.absmartly.sdk.internal.hashing.Hashing; import com.absmartly.sdk.java.nio.charset.StandardCharsets; @@ -994,8 +996,8 @@ private boolean experimentMatches(final ContextExperiment experiment, final Assi switchbackConfigMatches(experiment.data, assignment); } - // Grid identity: a change to any of these re-maps periods to variants. Carryover and burn - // windows are analysis-only and deliberately excluded, as are seed and split, which do not + // Grid identity: a change to any of these re-maps periods or clusters to variants. Carryover and + // burn windows are analysis-only and deliberately excluded, as are seed and split, which do not // invalidate unit assignments either. private static boolean switchbackConfigMatches(final Experiment experiment, final Assignment assignment) { if (isSwitchback(experiment) != assignment.switchback) { @@ -1005,7 +1007,10 @@ private static boolean switchbackConfigMatches(final Experiment experiment, fina || (Objects.equals(experiment.switchbackClusterUnitType, assignment.switchbackClusterUnitType) && Objects.equals(experiment.switchbackStartAt, assignment.switchbackStartAt) && Objects.equals(experiment.switchbackPeriodMs, assignment.switchbackPeriodMs) - && Objects.equals(experiment.switchbackDesignType, assignment.switchbackDesignType)); + && Objects.equals(experiment.switchbackDesignType, assignment.switchbackDesignType) + && Objects.equals(experiment.switchbackClusterCount, assignment.switchbackClusterCount) + && Objects.equals(experiment.switchbackClusterAttribute, + assignment.switchbackClusterAttribute)); } private static void pinSwitchbackConfig(final Assignment assignment, final Experiment experiment) { @@ -1015,6 +1020,8 @@ private static void pinSwitchbackConfig(final Assignment assignment, final Exper assignment.switchbackStartAt = experiment.switchbackStartAt; assignment.switchbackPeriodMs = experiment.switchbackPeriodMs; assignment.switchbackDesignType = experiment.switchbackDesignType; + assignment.switchbackClusterCount = experiment.switchbackClusterCount; + assignment.switchbackClusterAttribute = experiment.switchbackClusterAttribute; } } @@ -1029,8 +1036,11 @@ private static long switchbackSeed(final Experiment experiment) { // The grid anchor is switchbackStartAt rounded up to the UTC hour; the server already stores // it aligned, and rounding an aligned value is a no-op. Returns NO_SWITCHBACK_PERIOD before // the anchor and for any grid the SDK cannot evaluate: missing start or period, a cluster unit - // type other than the experiment's unit type (Phase 1 invariant), or fewer than two arms. - private static long switchbackPeriodIndex(final Experiment experiment, final long nowMs) { + // type other than the experiment's unit type (Phase 1 invariant), fewer than two arms, a + // cluster count outside {10, 50, 100}, or an unsupported cluster attribute path. The last two + // are the Collector's unservable rules. + private static long switchbackPeriodIndex(final ContextExperiment contextExperiment, final long nowMs) { + final Experiment experiment = contextExperiment.data; final Long startAt = experiment.switchbackStartAt; final Long periodMs = experiment.switchbackPeriodMs; if ((startAt == null) || (periodMs == null) || (periodMs <= 0)) { @@ -1043,6 +1053,12 @@ private static long switchbackPeriodIndex(final Experiment experiment, final lon if ((experiment.split == null) || (experiment.split.length < 2)) { return NO_SWITCHBACK_PERIOD; } + if (!SwitchbackBuckets.isAllowedCount(experiment.switchbackClusterCount)) { + return NO_SWITCHBACK_PERIOD; + } + if ((experiment.switchbackClusterAttribute != null) && (contextExperiment.switchbackClusterPath == null)) { + return NO_SWITCHBACK_PERIOD; + } final long anchor = -SwitchbackAssigner.floorDiv(-startAt, HOUR_MS) * HOUR_MS; if (nowMs < anchor) { @@ -1056,7 +1072,35 @@ private static long currentSwitchbackPeriod(final ContextExperiment experiment, if ((experiment == null) || !isSwitchback(experiment.data)) { return NO_SWITCHBACK_PERIOD; } - return switchbackPeriodIndex(experiment.data, nowMs); + return switchbackPeriodIndex(experiment, nowMs); + } + + // The string hashed into the cluster bucket, or null when this unit has none (Collector + // ContextAPI.switchbackVariant): the unit source uses the published unit hash; the attribute + // source uses the attribute at the cluster path, normalised by SwitchbackBuckets. Caller holds + // the contextLock_ write lock. + private String switchbackClusterKey(final ContextExperiment experiment, final byte[] unitHash, final long now) { + if (experiment.data.switchbackClusterAttribute == null) { + return new String(unitHash, StandardCharsets.US_ASCII); + } + final SwitchbackAttributePath path = experiment.switchbackClusterPath; + final Attribute attribute = latestAttribute(path.rootKey(), now); + return (attribute != null) ? SwitchbackBuckets.normalizeAttribute(path.resolveAttribute(attribute.value)) + : null; + } + + // The attribute the Collector records with an exposure at `now` (ContextAPI.extractAttributes): + // among attributes set at or before now whose trimmed name matches, the greatest setAt wins and + // a tie goes to the one set last. Caller holds contextLock_. + private Attribute latestAttribute(final String name, final long now) { + Attribute selected = null; + for (final Attribute attribute : attributes_) { + if ((attribute.setAt <= now) && (attribute.name != null) && name.equals(attribute.name.trim()) + && ((selected == null) || (attribute.setAt >= selected.setAt))) { + selected = attribute; + } + } + return selected; } // Unit experiments, and switchback experiments with no current period, keep the bare name. @@ -1071,8 +1115,9 @@ private static String switchbackCacheKey(final String experimentName, final long return experimentName + SWITCHBACK_KEY_SEPARATOR + periodIndex; } - // Switchback identity: id, iteration, cluster unit type, startAt, periodMs and design. Seed, - // split and analysis-only fields are excluded. Absent and unit experiments have no identity. + // Switchback identity: id, iteration, cluster unit type, startAt, periodMs, design, cluster count + // and cluster attribute. Seed, split and analysis-only fields are excluded. Absent and unit + // experiments have no identity. private static boolean sameSwitchbackIdentity(final ContextExperiment a, final ContextExperiment b) { final boolean aSwitchback = (a != null) && isSwitchback(a.data); final boolean bSwitchback = (b != null) && isSwitchback(b.data); @@ -1083,7 +1128,9 @@ private static boolean sameSwitchbackIdentity(final ContextExperiment a, final C && Objects.equals(a.data.switchbackClusterUnitType, b.data.switchbackClusterUnitType) && Objects.equals(a.data.switchbackStartAt, b.data.switchbackStartAt) && Objects.equals(a.data.switchbackPeriodMs, b.data.switchbackPeriodMs) - && Objects.equals(a.data.switchbackDesignType, b.data.switchbackDesignType); + && Objects.equals(a.data.switchbackDesignType, b.data.switchbackDesignType) + && Objects.equals(a.data.switchbackClusterCount, b.data.switchbackClusterCount) + && Objects.equals(a.data.switchbackClusterAttribute, b.data.switchbackClusterAttribute); } // Lifecycle of one switchback experiment's cache slots: the bare-name slot (pre-anchor or an @@ -1238,6 +1285,12 @@ private static class Assignment { Long switchbackStartAt; Long switchbackPeriodMs; String switchbackDesignType; + Integer switchbackClusterCount; + String switchbackClusterAttribute; + // Set once the cluster key was read for this slot; switchbackClusterKey is then the key, or + // null for a unit without one. + boolean switchbackClusterKeyResolved; + String switchbackClusterKey; final AtomicInteger exposureState = new AtomicInteger(ASSIGNMENT_UNEXPOSED); } @@ -1371,6 +1424,12 @@ && isAssignmentCurrent(assignment, cassignments_.get(experimentName), assignment.name = experimentName; assignment.eligible = true; assignment.customAssignment = custom; + // Within one period slot the cluster key is read once (spec R32-3): a slot replaced in + // the same epoch, e.g. after setAttribute changed the audience, keeps its bucket. + if ((cached != null) && cached.switchbackClusterKeyResolved) { + assignment.switchbackClusterKeyResolved = true; + assignment.switchbackClusterKey = cached.switchbackClusterKey; + } if (override != null) { if (experiment != null) { @@ -1458,17 +1517,31 @@ && isAssignmentCurrent(assignment, cassignments_.get(experimentName), } else { final VariantAssigner assigner = Context.this.getVariantAssigner(unitType, unitHash); - final boolean eligible = assigner.assign(experiment.data.trafficSplit, + boolean eligible = assigner.assign(experiment.data.trafficSplit, experiment.data.trafficSeedHi, experiment.data.trafficSeedLo) == 1; + String clusterKey = null; + if (eligible && assignment.switchback) { + if (!assignment.switchbackClusterKeyResolved) { + assignment.switchbackClusterKeyResolved = true; + assignment.switchbackClusterKey = switchbackClusterKey(experiment, + unitHash, now); + } + clusterKey = assignment.switchbackClusterKey; + // No key: ineligible, as the Collector serves it. + eligible = clusterKey != null; + } if (eligible) { if (custom != null) { assignment.variant = custom; assignment.custom = true; } else if (assignment.switchback) { - assignment.variant = SwitchbackAssigner.assign( - new String(unitHash, StandardCharsets.US_ASCII), periodIndex, - switchbackSeed(experiment.data), experiment.data.split.length); + final int bucket = SwitchbackBuckets.bucketOf(experiment.data.seedHi, + experiment.data.seedLo, clusterKey, + experiment.data.switchbackClusterCount); + assignment.variant = SwitchbackAssigner.assign(Integer.toString(bucket), + periodIndex, switchbackSeed(experiment.data), + experiment.data.split.length); } else { assignment.variant = assigner.assign(experiment.data.split, experiment.data.seedHi, @@ -1772,6 +1845,8 @@ private static class ContextExperiment { // Assigned in setData under dataLock_ write: carried over from the previous index while the // switchback identity is unchanged, otherwise a fresh value. Unused for unit experiments. long switchbackGeneration; + // Parsed switchbackClusterAttribute; null for the unit source or an unsupported path. + SwitchbackAttributePath switchbackClusterPath; } private static class ContextCustomFieldValue { @@ -1830,6 +1905,10 @@ private void setData(final ContextData data) { final ContextExperiment contextExperiment = new ContextExperiment(); contextExperiment.data = experiment; contextExperiment.holdouts = resolveApplicableHoldouts(experiment, holdoutsById); + if (isSwitchback(experiment)) { + contextExperiment.switchbackClusterPath = SwitchbackAttributePath + .parseSupported(experiment.switchbackClusterAttribute); + } contextExperiment.variables = new ArrayList>( experiment.variants != null ? experiment.variants.length : 0); diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java index b99105a..66c5ad1 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java @@ -5,11 +5,15 @@ import java.util.List; import java.util.Map; +import com.fasterxml.jackson.databind.ObjectMapper; + // A switchback cluster attribute path, resolved exactly like the Collector's // SwitchbackAttributePath: "/" separates segments, a segment wrapped in ' or " is a literal key, // and an unquoted segment that Guava Ints.tryParse accepts (ASCII digits, optional leading '-') // indexes a List and nothing else. Every other segment reads a Map key. public final class SwitchbackAttributePath { + private static final ObjectMapper JSON = new ObjectMapper(); + private final List keys; // Parallel to keys; null where the segment is a map key. private final List indices; @@ -39,9 +43,43 @@ public static SwitchbackAttributePath parseSupported(final String path) { return parsed; } + // The attribute name the path starts at; a supported path always starts at a key segment. + public String rootKey() { + return keys.get(0); + } + public Object resolve(final Map attributes) { - Object current = attributes; - for (int i = 0; i < keys.size(); ++i) { + return resolveFrom(attributes, 0); + } + + // Resolves the path below rootKey() in an SDK attribute value, read as the Collector reads it: + // the value's JSON encoding decoded by Jackson into Map, List, String, Boolean, Integer, Long, + // BigInteger or Double. Returns null when the value has no JSON encoding. + public Object resolveAttribute(final Object rootValue) { + if (rootValue == null) { + return null; + } + final Object decoded; + if ((rootValue instanceof String) || (rootValue instanceof Boolean) || (rootValue instanceof Integer) + || (rootValue instanceof Long)) { + // Each of these decodes back to an equal value of the same type. + decoded = rootValue; + } else { + try { + decoded = JSON.readValue(JSON.writeValueAsBytes(rootValue), Object.class); + } catch (Exception e) { + return null; + } catch (StackOverflowError e) { + // A self-referencing Map or List. + return null; + } + } + return resolveFrom(decoded, 1); + } + + private Object resolveFrom(final Object root, final int first) { + Object current = root; + for (int i = first; i < keys.size(); ++i) { current = step(current, keys.get(i), indices.get(i)); if (current == null) { return null; diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java new file mode 100644 index 0000000..6c25a6b --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java @@ -0,0 +1,376 @@ +package com.absmartly.sdk; + +import static com.absmartly.sdk.ContextSwitchbackTest.ANCHOR; +import static com.absmartly.sdk.ContextSwitchbackTest.HOUR; +import static com.absmartly.sdk.ContextSwitchbackTest.SEED_HI; +import static com.absmartly.sdk.ContextSwitchbackTest.SEED_LO; +import static com.absmartly.sdk.ContextSwitchbackTest.UID; +import static com.absmartly.sdk.ContextSwitchbackTest.UID_HASH; +import static com.absmartly.sdk.ContextSwitchbackTest.UNIT_TYPE; +import static com.absmartly.sdk.ContextSwitchbackTest.dataOf; +import static com.absmartly.sdk.ContextSwitchbackTest.switchback; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import com.absmartly.sdk.ContextSwitchbackTest.MutableClock; +import com.absmartly.sdk.internal.SwitchbackAssigner; +import com.absmartly.sdk.internal.SwitchbackBuckets; +import com.absmartly.sdk.json.ContextData; +import com.absmartly.sdk.json.Experiment; +import com.absmartly.sdk.json.Exposure; +import com.absmartly.sdk.json.PublishEvent; + +// Cluster bucket selection inside Context. Variants are derived from SwitchbackBuckets and +// SwitchbackAssigner, both pinned by the platform golden vectors. +class ContextSwitchbackClusterTest { + static final long SEED = ((long) SEED_HI << 32) | (SEED_LO & 0xffffffffL); + // 3 arms over 12 periods: two keys in different buckets are told apart by their schedules. + static final int ARMS = 3; + + MutableClock clock; + ContextDataProvider dataProvider; + ContextEventHandler eventHandler; + + @BeforeEach + void setUp() { + clock = new MutableClock(ANCHOR); + dataProvider = mock(ContextDataProvider.class); + eventHandler = mock(ContextEventHandler.class); + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture. completedFuture(null)); + } + + Context createContext(ContextData data) { + final ContextConfig config = ContextConfig.create(); + config.setUnits(TestUtils.mapOf(UNIT_TYPE, UID)); + return Context.create(clock, config, mock(ScheduledExecutorService.class), + CompletableFuture.completedFuture(data), dataProvider, eventHandler, null, + new DefaultVariableParser(), new AudienceMatcher(new DefaultAudienceDeserializer())); + } + + void refreshTo(Context context, Experiment experiment) { + when(dataProvider.getContextData()).thenReturn(CompletableFuture.completedFuture(dataOf(experiment))); + context.refresh(); + } + + Exposure[] publishExposures(Context context) { + context.publish(); + final ArgumentCaptor captor = ArgumentCaptor.forClass(PublishEvent.class); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(Mockito.eq(context), captor.capture()); + return captor.getValue().exposures; + } + + static Experiment attributeSwitchback(String path, int count) { + final Experiment experiment = switchback(ARMS, HOUR); + experiment.switchbackClusterAttribute = path; + experiment.switchbackClusterCount = count; + return experiment; + } + + static int expected(String key, int count, long periodIndex) { + final int bucket = SwitchbackBuckets.bucketOf(SEED_HI, SEED_LO, key, count); + return SwitchbackAssigner.assign(String.valueOf(bucket), periodIndex, SEED, ARMS); + } + + static int[] expectedSchedule(String key, int count) { + final int[] result = new int[12]; + for (int i = 0; i < result.length; ++i) { + result[i] = expected(key, count, i); + } + return result; + } + + int[] peekSchedule(Context context) { + final int[] result = new int[12]; + for (int i = 0; i < result.length; ++i) { + clock.now = ANCHOR + i * HOUR; + result[i] = context.peekTreatment("exp_sb"); + } + return result; + } + + static Exposure exposure(int variant, long exposedAt, boolean eligible, boolean audienceMismatch) { + return new Exposure(1, "exp_sb", UNIT_TYPE, variant, exposedAt, true, eligible, false, false, false, + audienceMismatch); + } + + @Test + void unitSourceHashesThePublishedUnitHashForEveryCount() { + for (final int count : new int[]{10, 50, 100}) { + final Experiment experiment = switchback(ARMS, HOUR); + experiment.switchbackClusterCount = count; + assertArrayEquals(expectedSchedule(UID_HASH, count), peekSchedule(createContext(dataOf(experiment)))); + } + } + + @Test + void attributeSourceHashesTheResolvedAttributeForEveryCount() { + for (final int count : new int[]{10, 50, 100}) { + clock.now = ANCHOR; + final Context context = createContext(dataOf(attributeSwitchback("city", count))); + context.setAttribute("city", "Lisboa"); + assertArrayEquals(expectedSchedule("Lisboa", count), peekSchedule(context)); + } + } + + @Test + void unitsSharingAnAttributeValueShareTheSchedule() { + final Context lisboa = createContext(dataOf(attributeSwitchback("city", 100))); + lisboa.setAttribute("city", "Lisboa"); + final Context porto = createContext(dataOf(attributeSwitchback("city", 100))); + porto.setAttribute("city", "Porto"); + final ContextConfig other = ContextConfig.create(); + other.setUnits(TestUtils.mapOf(UNIT_TYPE, "another-store")); + final Context otherLisboa = Context.create(clock, other, mock(ScheduledExecutorService.class), + CompletableFuture.completedFuture(dataOf(attributeSwitchback("city", 100))), dataProvider, + eventHandler, null, new DefaultVariableParser(), + new AudienceMatcher(new DefaultAudienceDeserializer())); + otherLisboa.setAttribute("city", "Lisboa"); + + assertArrayEquals(expectedSchedule("Lisboa", 100), peekSchedule(lisboa)); + assertArrayEquals(expectedSchedule("Lisboa", 100), peekSchedule(otherLisboa)); + assertArrayEquals(expectedSchedule("Porto", 100), peekSchedule(porto)); + assertNotEquals(SwitchbackBuckets.bucketOf(SEED_HI, SEED_LO, "Lisboa", 100), + SwitchbackBuckets.bucketOf(SEED_HI, SEED_LO, "Porto", 100)); + } + + @Test + void nestedAndArrayPathsAndIntegerValuesResolve() { + clock.now = ANCHOR; + final Context nested = createContext(dataOf(attributeSwitchback("geo/city", 50))); + nested.setAttribute("geo", TestUtils.mapOf("city", "Porto")); + assertArrayEquals(expectedSchedule("Porto", 50), peekSchedule(nested)); + + clock.now = ANCHOR; + final Context array = createContext(dataOf(attributeSwitchback("items/1/city", 50))); + array.setAttribute("items", TestUtils.listOf(TestUtils.mapOf("city", "Lisbon"), + TestUtils.mapOf("city", "Faro"))); + assertArrayEquals(expectedSchedule("Faro", 50), peekSchedule(array)); + + clock.now = ANCHOR; + final Context integer = createContext(dataOf(attributeSwitchback("region", 10))); + integer.setAttribute("region", 42L); + assertArrayEquals(expectedSchedule("42", 10), peekSchedule(integer)); + } + + // Attribute values the Collector cannot bucket serve variant 0, not eligible, and never throw. + @Test + void unbucketableAttributeValuesAreIneligible() { + final Object[] values = {null, "", true, 1.5, 2.0, TestUtils.mapOf("a", "b"), TestUtils.listOf("x")}; + for (final Object value : values) { + final Context context = createContext(dataOf(attributeSwitchback("city", 10))); + context.setAttribute("city", value); + clock.now = ANCHOR + 10; + assertEquals(0, context.getTreatment("exp_sb"), String.valueOf(value)); + // Variant 0's own config, like any ineligible assignment. + assertEquals(0, context.getVariableValue("sb.arm", -1), String.valueOf(value)); + } + + final Context missing = createContext(dataOf(attributeSwitchback("geo/city", 10))); + missing.setAttribute("geo", TestUtils.mapOf("country", "PT")); + clock.now = ANCHOR + 10; + assertEquals(0, missing.getTreatment("exp_sb")); + assertArrayEquals(new Exposure[]{exposure(0, ANCHOR + 10, false, false)}, publishExposures(missing)); + } + + // A value with no JSON encoding has no cluster key. + @Test + void unencodableAttributeValuesAreIneligible() { + final Map cyclic = new HashMap(); + cyclic.put("self", cyclic); + final Object[] values = {cyclic, new Object()}; + for (final Object value : values) { + final Context context = createContext(dataOf(attributeSwitchback("geo/city", 10))); + context.setAttribute("geo", value); + assertEquals(0, context.getTreatment("exp_sb")); + } + } + + @Test + void unsupportedPathsAndDisallowedCountsAreIneligible() { + final Experiment[] experiments = {attributeSwitchback("items/0", 10), attributeSwitchback("items/-1/city", 10), + attributeSwitchback("", 10), attributeSwitchback("city", 1000), attributeSwitchback("city", 20), + attributeSwitchback("city", 0), switchback(ARMS, HOUR)}; + experiments[experiments.length - 1].switchbackClusterCount = null; + for (final Experiment experiment : experiments) { + final Context context = createContext(dataOf(experiment)); + context.setAttribute("city", "Lisboa"); + context.setAttribute("items", TestUtils.listOf(TestUtils.mapOf("city", "Lisbon"))); + for (int i = 0; i < 3; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(0, context.getTreatment("exp_sb"), experiment.toString()); + } + assertEquals(1, context.getPendingCount(), experiment.toString()); + } + } + + // The Collector reads the attribute set at or before the exposure instant, latest setAt first. + @Test + void latestAttributeSetAtOrBeforeTheResolutionInstantIsUsed() { + final Context context = createContext(dataOf(attributeSwitchback("city", 100))); + clock.now = ANCHOR + 2 * HOUR; + context.setAttribute("city", "Porto"); + clock.now = ANCHOR + 4 * HOUR; + context.setAttribute("city", "Lisboa"); + + clock.now = ANCHOR + HOUR; + assertEquals(0, context.peekTreatment("exp_sb")); + clock.now = ANCHOR + 3 * HOUR; + assertEquals(expected("Porto", 100, 3), context.peekTreatment("exp_sb")); + clock.now = ANCHOR + 4 * HOUR; + assertEquals(expected("Lisboa", 100, 4), context.peekTreatment("exp_sb")); + } + + // Spec R32-3: within a period the bucket is fixed at first resolution; the next period reads + // the attribute again. + @Test + void setAttributeMidPeriodDoesNotReassignWithinThePeriod() { + final Context context = createContext(dataOf(attributeSwitchback("city", 100))); + clock.now = ANCHOR + 10; + context.setAttribute("city", "Lisboa"); + assertEquals(expected("Lisboa", 100, 0), context.getTreatment("exp_sb")); + + clock.now = ANCHOR + 20; + context.setAttribute("city", "Porto"); + assertEquals(expected("Lisboa", 100, 0), context.getTreatment("exp_sb")); + assertEquals(expected("Lisboa", 100, 0), context.peekTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + + final int periods = 12; + int differing = 0; + for (int i = 1; i < periods; ++i) { + clock.now = ANCHOR + i * HOUR; + assertEquals(expected("Porto", 100, i), context.getTreatment("exp_sb")); + if (expected("Porto", 100, i) != expected("Lisboa", 100, i)) { + ++differing; + } + } + assertNotEquals(0, differing); + assertEquals(periods, context.getPendingCount()); + } + + // A unit first resolved without a key stays ineligible for the rest of that period. + @Test + void attributeSetAfterAnIneligibleResolutionWaitsForTheNextPeriod() { + final Context context = createContext(dataOf(attributeSwitchback("city", 100))); + clock.now = ANCHOR + 10; + assertEquals(0, context.getTreatment("exp_sb")); + context.setAttribute("city", "Lisboa"); + clock.now = ANCHOR + 20; + assertEquals(0, context.getTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + + clock.now = ANCHOR + HOUR; + assertEquals(expected("Lisboa", 100, 1), context.getTreatment("exp_sb")); + assertArrayEquals(new Exposure[]{exposure(0, ANCHOR + 10, false, false), + exposure(expected("Lisboa", 100, 1), ANCHOR + HOUR, true, false)}, publishExposures(context)); + } + + // A slot replaced through an ineligible resolution still keeps the period's first key. + @Test + void trafficChangesWithinThePeriodKeepThePeriodsBucket() { + final Context context = createContext(dataOf(attributeSwitchback("city", 100))); + clock.now = ANCHOR + 10; + context.setAttribute("city", "Lisboa"); + assertEquals(expected("Lisboa", 100, 0), context.getTreatment("exp_sb")); + + clock.now = ANCHOR + 20; + context.setAttribute("city", "Porto"); + final Experiment noTraffic = attributeSwitchback("city", 100); + noTraffic.trafficSplit = new double[]{1.0, 0.0}; + refreshTo(context, noTraffic); + assertEquals(0, context.getTreatment("exp_sb")); + + refreshTo(context, attributeSwitchback("city", 100)); + assertEquals(expected("Lisboa", 100, 0), context.getTreatment("exp_sb")); + assertEquals(3, context.getPendingCount()); + + clock.now = ANCHOR + HOUR; + assertEquals(expected("Porto", 100, 1), context.getTreatment("exp_sb")); + } + + // An audience change re-resolves the period slot (new exposure state) but keeps its bucket. + @Test + void audienceRevalidationAfterSetAttributeKeepsThePeriodsBucket() { + final Experiment experiment = attributeSwitchback("city", 100); + experiment.audience = "{\"filter\":[{\"eq\":[{\"var\":\"tier\"},{\"value\":\"gold\"}]}]}"; + final Context context = createContext(dataOf(experiment)); + clock.now = ANCHOR + 10; + context.setAttribute("city", "Lisboa"); + context.setAttribute("tier", "gold"); + assertEquals(expected("Lisboa", 100, 0), context.getTreatment("exp_sb")); + + clock.now = ANCHOR + 20; + context.setAttribute("city", "Porto"); + context.setAttribute("tier", "silver"); + assertEquals(expected("Lisboa", 100, 0), context.getTreatment("exp_sb")); + + assertArrayEquals(new Exposure[]{exposure(expected("Lisboa", 100, 0), ANCHOR + 10, true, false), + exposure(expected("Lisboa", 100, 0), ANCHOR + 20, true, true)}, publishExposures(context)); + } + + @Test + void clusterCountOrAttributeChangesInvalidateThePeriod() { + final Context context = createContext(dataOf(attributeSwitchback("city", 100))); + clock.now = ANCHOR + 10; + context.setAttribute("city", "Lisboa"); + context.setAttribute("geo", TestUtils.mapOf("city", "Porto")); + assertEquals(expected("Lisboa", 100, 0), context.getTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + + refreshTo(context, attributeSwitchback("city", 100)); + assertEquals(expected("Lisboa", 100, 0), context.getTreatment("exp_sb")); + assertEquals(1, context.getPendingCount()); + + refreshTo(context, attributeSwitchback("city", 10)); + assertEquals(expected("Lisboa", 10, 0), context.getTreatment("exp_sb")); + assertEquals(2, context.getPendingCount()); + + refreshTo(context, attributeSwitchback("geo/city", 10)); + assertEquals(expected("Porto", 10, 0), context.getTreatment("exp_sb")); + assertEquals(3, context.getPendingCount()); + + final Experiment unitSource = switchback(ARMS, HOUR); + unitSource.switchbackClusterCount = 10; + refreshTo(context, unitSource); + assertEquals(expected(UID_HASH, 10, 0), context.getTreatment("exp_sb")); + assertEquals(4, context.getPendingCount()); + } + + @Test + void customAssignmentAppliesOnlyToUnitsWithAClusterKey() { + final Context keyed = createContext(dataOf(attributeSwitchback("city", 10))); + keyed.setAttribute("city", "Lisboa"); + keyed.setCustomAssignment("exp_sb", 2); + assertEquals(2, keyed.getTreatment("exp_sb")); + + final Context unkeyed = createContext(dataOf(attributeSwitchback("city", 10))); + unkeyed.setCustomAssignment("exp_sb", 2); + assertEquals(0, unkeyed.getTreatment("exp_sb")); + } + + @Test + void unitExperimentsIgnoreClusterFields() { + final Experiment unit = attributeSwitchback("city", 1000); + unit.randomizationType = null; + final Experiment reference = switchback(ARMS, HOUR); + reference.randomizationType = null; + final Context withFields = createContext(dataOf(unit)); + final Context without = createContext(dataOf(reference)); + assertEquals(without.getTreatment("exp_sb"), withFields.getTreatment("exp_sb")); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index 979ed86..468d2af 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -29,6 +29,7 @@ import org.mockito.Mockito; import com.absmartly.sdk.internal.SwitchbackAssigner; +import com.absmartly.sdk.internal.SwitchbackBuckets; import com.absmartly.sdk.internal.hashing.Hashing; import com.absmartly.sdk.java.nio.charset.StandardCharsets; import com.absmartly.sdk.java.time.Clock; @@ -53,13 +54,16 @@ class ContextSwitchbackTest { static final int SEED_HI = -5; static final int SEED_LO = -7; - // Periods 0..11 for UID_HASH under seed (SEED_HI, SEED_LO), computed with the platform's - // independent Python reference (abs tests/scripts/switchback_ref.py, assign_switchback). - static final int[] EXPECTED_2_ARMS = {1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0}; - static final int[] EXPECTED_3_ARMS = {1, 2, 0, 2, 1, 0, 2, 0, 1, 1, 2, 0}; - static final int[] EXPECTED_10_ARMS = {1, 2, 5, 3, 7, 4, 8, 0, 6, 9, 4, 6}; - // Same reference fed the raw UID instead of its hash: what a wrong identity would serve. - static final int[] RAW_UID_3_ARMS = {2, 0, 1, 0, 1, 2, 1, 2, 0, 0, 1, 2}; + // UID_HASH falls in bucket 35 of 100 under seed (SEED_HI, SEED_LO). Periods 0..11 of bucket + // "35", computed with an independent Python reference (hashlib MD5 and murmur3 over the + // platform formulas) that reproduces every bucket and schedule golden vector. + static final int CLUSTER_COUNT = 100; + static final int UID_BUCKET = 35; + static final int[] EXPECTED_2_ARMS = {0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0}; + static final int[] EXPECTED_3_ARMS = {0, 2, 1, 2, 0, 1, 0, 1, 2, 2, 1, 0}; + static final int[] EXPECTED_10_ARMS = {5, 3, 8, 9, 0, 7, 4, 6, 1, 2, 2, 4}; + // Same reference fed UID_HASH itself rather than its bucket: the removed per-value clustering. + static final int[] PER_VALUE_3_ARMS = {1, 2, 0, 2, 1, 0, 2, 0, 1, 1, 2, 0}; static final class MutableClock extends Clock { long now; @@ -123,6 +127,7 @@ static Experiment switchback(int id, String name, int armCount, long periodMs) { experiment.switchbackBurnOutDurationMs = 0L; experiment.switchbackDesignType = "balanced_block"; experiment.switchbackEstimand = "global_average_treatment_effect"; + experiment.switchbackClusterCount = CLUSTER_COUNT; return experiment; } @@ -207,10 +212,11 @@ void clusterIdentityIsTheSdkUnitHash() { final int[] served = peekSchedule(context, "exp_sb", HOUR, 12); assertArrayEquals(EXPECTED_3_ARMS, served); - assertFalse(Arrays.equals(RAW_UID_3_ARMS, served)); + assertFalse(Arrays.equals(PER_VALUE_3_ARMS, served)); + assertEquals(UID_BUCKET, SwitchbackBuckets.bucketOf(SEED_HI, SEED_LO, UID_HASH, CLUSTER_COUNT)); for (int i = 0; i < 12; ++i) { - assertEquals(SwitchbackAssigner.assign(UID_HASH, i, ((long) SEED_HI << 32) | (SEED_LO & 0xffffffffL), 3), - served[i]); + assertEquals(SwitchbackAssigner.assign(String.valueOf(UID_BUCKET), i, + ((long) SEED_HI << 32) | (SEED_LO & 0xffffffffL), 3), served[i]); } } From 480cd05db1c72c5968ecf9a1a1fc949b99c01ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 04:42:59 +0000 Subject: [PATCH 19/35] test(switchback): server parity for hashed cluster buckets Serves contexts through Context and compares every exposure with the Collector's ContextAPI.switchbackVariant, taking the expected bucket from bucket-golden-vectors.json and path outcomes from attribute-path-cases.json: unit and attribute sources, top-level, nested and array paths, ineligible values, disallowed counts and all three allowed counts. --- .../sdk/SwitchbackServerParityTest.java | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java diff --git a/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java b/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java new file mode 100644 index 0000000..3b012dd --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java @@ -0,0 +1,327 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.absmartly.sdk.internal.SwitchbackAssigner; +import com.absmartly.sdk.java.time.Clock; +import com.absmartly.sdk.json.ContextData; +import com.absmartly.sdk.json.Experiment; +import com.absmartly.sdk.json.ExperimentApplication; +import com.absmartly.sdk.json.ExperimentVariant; +import com.absmartly.sdk.json.Exposure; + +// Serves contexts through the public Context API and compares every exposure with the Collector's +// ContextAPI.switchbackVariant: -1 (variant 0, not eligible) for an unservable experiment or a +// missing bucket key, otherwise assignSwitchback(bucketOf(key), periodIndex, seed, armCount). The +// expected bucket is read from bucket-golden-vectors.json, never computed by SDK code, and the +// expected path outcome from attribute-path-cases.json. +class SwitchbackServerParityTest { + static final String UNIT_TYPE = "store_id"; + static final long HOUR = 3_600_000L; + static final long ANCHOR = 1_767_225_600_000L; + static final int[] COUNTS = {10, 50, 100}; + static final int[] ARM_COUNTS = {2, 3, 10}; + static final int PERIODS = 4; + + static final ObjectMapper MAPPER = new ObjectMapper(); + static JsonNode bucketVectors; + static JsonNode pathCases; + // "key\u0000seed\u0000count" -> expectedBucket. + static final Map BUCKETS = new HashMap(); + static final List SEEDS = new ArrayList(); + + @BeforeAll + static void loadFixtures() throws IOException { + bucketVectors = read("switchback/bucket-golden-vectors.json"); + pathCases = read("switchback/attribute-path-cases.json"); + for (final JsonNode c : bucketVectors.get("buckets")) { + final long seed = Long.parseLong(c.get("seed").textValue()); + BUCKETS.put(bucketKey(c.get("key").textValue(), seed, c.get("count").intValue()), + c.get("expectedBucket").intValue()); + if (!SEEDS.contains(seed)) { + SEEDS.add(seed); + } + } + assertEquals(9, SEEDS.size()); + } + + static JsonNode read(String resource) throws IOException { + final InputStream in = SwitchbackServerParityTest.class.getClassLoader().getResourceAsStream(resource); + try { + return MAPPER.readTree(in); + } finally { + in.close(); + } + } + + static String bucketKey(String key, long seed, int count) { + return key + '\u0000' + seed + '\u0000' + count; + } + + // ContextAPI.switchbackVariant after the unservable and missing-key checks: the schedule of the + // key's fixture bucket. SwitchbackAssigner is pinned by switchback/golden-vectors.json. + static int collectorVariant(String key, long seed, int count, long periodIndex, int armCount) { + final Integer bucket = BUCKETS.get(bucketKey(key, seed, count)); + assertNotNull(bucket, "no fixture bucket for " + key + " seed " + seed + " count " + count); + return SwitchbackAssigner.assign(String.valueOf(bucket), periodIndex, seed, armCount); + } + + static final class FixedClock extends Clock { + long now = ANCHOR; + + @Override + public long millis() { + return now; + } + } + + static final class ExposureCapture implements ContextEventLogger { + final List exposures = new ArrayList(); + + @Override + public void handleEvent(Context context, EventType type, Object data) { + if (type == EventType.Exposure) { + exposures.add((Exposure) data); + } + } + } + + static Experiment experiment(long seed, int armCount, Integer count, String attribute) { + final Experiment experiment = new Experiment(); + experiment.id = 1; + experiment.name = "exp_sb"; + experiment.unitType = UNIT_TYPE; + experiment.iteration = 1; + experiment.seedHi = (int) (seed >> 32); + experiment.seedLo = (int) seed; + experiment.split = new double[armCount]; + experiment.variants = new ExperimentVariant[armCount]; + for (int i = 0; i < armCount; ++i) { + experiment.split[i] = 1.0 / armCount; + experiment.variants[i] = new ExperimentVariant(String.valueOf(i), null); + } + experiment.trafficSplit = new double[]{0.0, 1.0}; + experiment.applications = new ExperimentApplication[]{new ExperimentApplication("website")}; + experiment.randomizationType = "switchback"; + experiment.switchbackClusterUnitType = UNIT_TYPE; + experiment.switchbackStartAt = ANCHOR; + experiment.switchbackPeriodMs = HOUR; + experiment.switchbackDesignType = "balanced_block"; + experiment.switchbackClusterCount = count; + experiment.switchbackClusterAttribute = attribute; + return experiment; + } + + // Serves PERIODS periods with getTreatment and returns one variant per period, plus the + // exposures: one per period, or a single one for an unservable grid, whose periods share one + // slot. + static Served serve(Experiment experiment, String uid, Map attributes) { + final FixedClock clock = new FixedClock(); + final ExposureCapture capture = new ExposureCapture(); + final ContextConfig config = ContextConfig.create(); + config.setUnits(TestUtils.mapOf(UNIT_TYPE, uid)); + config.setEventLogger(capture); + final ContextData data = new ContextData(); + data.experiments = new Experiment[]{experiment}; + final ContextEventHandler handler = mock(ContextEventHandler.class); + when(handler.publish(any(), any())).thenReturn(CompletableFuture. completedFuture(null)); + final Context context = Context.create(clock, config, mock(ScheduledExecutorService.class), + CompletableFuture.completedFuture(data), mock(ContextDataProvider.class), handler, null, + new DefaultVariableParser(), new AudienceMatcher(new DefaultAudienceDeserializer())); + if (attributes != null) { + context.setAttributes(attributes); + } + final List variants = new ArrayList(); + for (int p = 0; p < PERIODS; ++p) { + clock.now = ANCHOR + p * HOUR + 1; + variants.add(context.getTreatment("exp_sb")); + } + return new Served(variants, capture.exposures); + } + + static final class Served { + final List variants; + final List exposures; + + Served(List variants, List exposures) { + this.variants = variants; + this.exposures = exposures; + } + } + + static void assertServed(Served served, String key, long seed, int count, int armCount, String message) { + assertTrue(!served.exposures.isEmpty(), message); + for (int p = 0; p < PERIODS; ++p) { + final int expected = (key == null) ? 0 : collectorVariant(key, seed, count, p, armCount); + assertEquals(expected, served.variants.get(p).intValue(), message); + } + for (int i = 0; i < served.exposures.size(); ++i) { + final Exposure exposure = served.exposures.get(i); + if (key == null) { + assertEquals(0, exposure.variant, message); + assertEquals(false, exposure.eligible, message); + } else { + assertEquals(PERIODS, served.exposures.size(), message); + assertEquals(collectorVariant(key, seed, count, i, armCount), exposure.variant, message); + assertEquals(true, exposure.eligible, message); + } + assertEquals(true, exposure.assigned, message); + } + } + + // Unit source: the bucket key is UnitHasher.hash(uid). The SDK keeps unit uids as strings and + // rejects a blank uid in setUnit, so numeric uids are served in base 10 and "" is skipped. + @Test + void unitSourceMatchesTheCollectorForEveryUnitKeySeedAndCount() { + int served = 0; + for (final JsonNode c : bucketVectors.get("unitKeys")) { + final JsonNode uidNode = c.get("unitUid"); + final String uid = uidNode.isIntegralNumber() ? Long.toString(uidNode.longValue()) : uidNode.textValue(); + if (uid.trim().isEmpty()) { + continue; + } + for (final long seed : SEEDS) { + for (final int count : COUNTS) { + final int armCount = ARM_COUNTS[served % ARM_COUNTS.length]; + assertServed(serve(experiment(seed, armCount, count, null), uid, null), + c.get("key").textValue(), seed, count, armCount, c + " seed " + seed + " count " + count); + ++served; + } + } + } + assertEquals(11 * 9 * 3, served); + } + + // Attribute source at a top-level, a nested and an array path: the key is + // normalizeAttribute(value), or none for "", null, booleans and floats. + @Test + void attributeSourceMatchesTheCollectorForEveryAttributeValueSeedAndCount() { + int served = 0; + int ineligible = 0; + for (final JsonNode c : bucketVectors.get("attributeKeys")) { + final Object value = MAPPER.convertValue(c.get("value"), Object.class); + final String key = c.get("key").isNull() ? null : c.get("key").textValue(); + final Map flat = new HashMap(); + flat.put("cluster", value); + final Map nested = new HashMap(); + nested.put("geo", singleton("city", value)); + final Map array = new HashMap(); + array.put("items", TestUtils.listOf(singleton("city", "other"), singleton("city", value))); + + final String[] paths = {"cluster", "geo/city", "items/1/city"}; + final List> attributes = TestUtils.listOf(flat, nested, array); + for (final long seed : SEEDS) { + for (final int count : COUNTS) { + final int armCount = ARM_COUNTS[served % ARM_COUNTS.length]; + final int shape = served % paths.length; + assertServed(serve(experiment(seed, armCount, count, paths[shape]), "u1", attributes.get(shape)), + key, seed, count, armCount, c + " path " + paths[shape] + " seed " + seed); + ++served; + if (key == null) { + ++ineligible; + } + } + } + } + assertEquals(21 * 9 * 3, served); + assertEquals(6 * 9 * 3, ineligible); + } + + // Each path case is served over the fixture's attributes. An unsupported path is unservable and + // a path resolving to null is ineligible; any other serves exactly what a top-level attribute + // holding the case's value serves. The case values are not bucket-fixture keys, so the + // top-level attribute bucket is the one the previous test pins. + @Test + void attributePathCasesMatchTheCollector() { + final Map attributes = MAPPER.convertValue(pathCases.get("attributes"), + new TypeReference>() {}); + int eligibleCases = 0; + for (final JsonNode c : pathCases.get("cases")) { + final String path = c.get("path").textValue(); + final Object value = MAPPER.convertValue(c.get("value"), Object.class); + for (final int count : COUNTS) { + final long seed = SEEDS.get(count % SEEDS.size()); + final Served served = serve(experiment(seed, 3, count, path), "u1", attributes); + if (!c.get("supported").booleanValue() || (value == null)) { + assertServed(served, null, seed, count, 3, c.toString()); + continue; + } + final Served reference = serve(experiment(seed, 3, count, "v"), "u1", singleton("v", value)); + assertEquals(PERIODS, served.exposures.size(), c.toString()); + for (int p = 0; p < PERIODS; ++p) { + assertEquals(true, served.exposures.get(p).eligible, c.toString()); + assertEquals(reference.variants.get(p), served.variants.get(p), c.toString()); + } + ++eligibleCases; + } + } + assertEquals(3 * COUNTS.length, eligibleCases); + } + + // The Collector's CLUSTER_COUNT unservable rule: any count outside {10, 50, 100}, or none. + @Test + void disallowedCountsAreUnservable() { + final String key = bucketVectors.get("unitKeys").get(0).get("key").textValue(); + final String uid = bucketVectors.get("unitKeys").get(0).get("unitUid").textValue(); + for (final Integer count : new Integer[]{null, 0, 1, 20, 99, 1000}) { + assertServed(serve(experiment(0L, 2, count, null), uid, null), null, 0L, 10, 2, String.valueOf(count)); + } + assertServed(serve(experiment(0L, 2, 10, null), uid, null), key, 0L, 10, 2, "control"); + } + + // The Collector resolves attributes by trimmed name, latest setAt winning. + @Test + void repeatedAttributeNamesResolveToTheLatest() { + final JsonNode keys = bucketVectors.get("attributeKeys"); + final String first = keys.get(0).get("key").textValue(); + final String second = keys.get(1).get("key").textValue(); + final Map attributes = new HashMap(); + attributes.put(" cluster ", first); + assertServed(serve(experiment(0L, 3, 50, "cluster"), "u1", attributes), first, 0L, 50, 3, "trimmed name"); + + final FixedClock clock = new FixedClock(); + final ExposureCapture capture = new ExposureCapture(); + final ContextConfig config = ContextConfig.create(); + config.setUnits(TestUtils.mapOf(UNIT_TYPE, "u1")); + config.setEventLogger(capture); + final ContextData data = new ContextData(); + data.experiments = new Experiment[]{experiment(0L, 3, 50, "cluster")}; + final Context context = Context.create(clock, config, mock(ScheduledExecutorService.class), + CompletableFuture.completedFuture(data), mock(ContextDataProvider.class), + mock(ContextEventHandler.class), null, new DefaultVariableParser(), + new AudienceMatcher(new DefaultAudienceDeserializer())); + context.setAttribute("cluster", first); + context.setAttribute("cluster", second); + clock.now = ANCHOR + 1; + context.getTreatment("exp_sb"); + assertEquals(collectorVariant(second, 0L, 50, 0, 3), capture.exposures.get(0).variant); + assertTrue(capture.exposures.get(0).eligible); + } + + static Map singleton(String key, Object value) { + final Map map = new HashMap(); + map.put(key, value); + return map; + } +} From ab2540b7844fe13646c87ec048814635995f8fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:12:07 +0000 Subject: [PATCH 20/35] fix(switchback): a unitType change starts a new switchback epoch With a null switchbackClusterUnitType the experiment's unitType alone chooses the hashed unit, but it was not part of the switchback identity, so a replacement slot inherited the previous unit type's cluster key. --- .../main/java/com/absmartly/sdk/Context.java | 11 +++++--- .../sdk/ContextSwitchbackClusterTest.java | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index 422343d..071c8d5 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1004,7 +1004,8 @@ private static boolean switchbackConfigMatches(final Experiment experiment, fina return false; } return !assignment.switchback - || (Objects.equals(experiment.switchbackClusterUnitType, assignment.switchbackClusterUnitType) + || (Objects.equals(experiment.unitType, assignment.unitType) + && Objects.equals(experiment.switchbackClusterUnitType, assignment.switchbackClusterUnitType) && Objects.equals(experiment.switchbackStartAt, assignment.switchbackStartAt) && Objects.equals(experiment.switchbackPeriodMs, assignment.switchbackPeriodMs) && Objects.equals(experiment.switchbackDesignType, assignment.switchbackDesignType) @@ -1115,9 +1116,10 @@ private static String switchbackCacheKey(final String experimentName, final long return experimentName + SWITCHBACK_KEY_SEPARATOR + periodIndex; } - // Switchback identity: id, iteration, cluster unit type, startAt, periodMs, design, cluster count - // and cluster attribute. Seed, split and analysis-only fields are excluded. Absent and unit - // experiments have no identity. + // Switchback identity: id, iteration, unit type, cluster unit type, startAt, periodMs, design, + // cluster count and cluster attribute, i.e. every input to the period grid and the cluster key. + // Seed, split and analysis-only fields are excluded. Absent and unit experiments have no + // identity. private static boolean sameSwitchbackIdentity(final ContextExperiment a, final ContextExperiment b) { final boolean aSwitchback = (a != null) && isSwitchback(a.data); final boolean bSwitchback = (b != null) && isSwitchback(b.data); @@ -1125,6 +1127,7 @@ private static boolean sameSwitchbackIdentity(final ContextExperiment a, final C return aSwitchback == bSwitchback; } return (a.data.id == b.data.id) && (a.data.iteration == b.data.iteration) + && Objects.equals(a.data.unitType, b.data.unitType) && Objects.equals(a.data.switchbackClusterUnitType, b.data.switchbackClusterUnitType) && Objects.equals(a.data.switchbackStartAt, b.data.switchbackStartAt) && Objects.equals(a.data.switchbackPeriodMs, b.data.switchbackPeriodMs) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java index 6c25a6b..932d73c 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java @@ -30,6 +30,8 @@ import com.absmartly.sdk.ContextSwitchbackTest.MutableClock; import com.absmartly.sdk.internal.SwitchbackAssigner; import com.absmartly.sdk.internal.SwitchbackBuckets; +import com.absmartly.sdk.internal.hashing.Hashing; +import com.absmartly.sdk.java.nio.charset.StandardCharsets; import com.absmartly.sdk.json.ContextData; import com.absmartly.sdk.json.Experiment; import com.absmartly.sdk.json.Exposure; @@ -351,6 +353,31 @@ void clusterCountOrAttributeChangesInvalidateThePeriod() { assertEquals(4, context.getPendingCount()); } + // With no cluster unit type, the experiment's unitType alone decides the hashed unit, so a + // unitType change re-keys the period. + @Test + void unitTypeChangeWithoutClusterUnitTypeRekeysThePeriod() { + final String userUid = "user-0002"; + final String userHash = new String(Hashing.hashUnit(userUid), StandardCharsets.US_ASCII); + final ContextConfig config = ContextConfig.create(); + config.setUnits(TestUtils.mapOf(UNIT_TYPE, UID, "user_id", userUid)); + final Experiment byStore = switchback(ARMS, HOUR); + byStore.switchbackClusterUnitType = null; + final Context context = Context.create(clock, config, mock(ScheduledExecutorService.class), + CompletableFuture.completedFuture(dataOf(byStore)), dataProvider, eventHandler, null, + new DefaultVariableParser(), new AudienceMatcher(new DefaultAudienceDeserializer())); + assertNotEquals(expected(UID_HASH, 100, 0), expected(userHash, 100, 0)); + + clock.now = ANCHOR + 1; + assertEquals(expected(UID_HASH, 100, 0), context.getTreatment("exp_sb")); + + final Experiment byUser = switchback(ARMS, HOUR); + byUser.switchbackClusterUnitType = null; + byUser.unitType = "user_id"; + refreshTo(context, byUser); + assertEquals(expected(userHash, 100, 0), context.getTreatment("exp_sb")); + } + @Test void customAssignmentAppliesOnlyToUnitsWithAClusterKey() { final Context keyed = createContext(dataOf(attributeSwitchback("city", 10))); From 072b37b27047f607198a6d89317d025a0b417ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:13:07 +0000 Subject: [PATCH 21/35] test(switchback): index segment must not read a digit map key --- .../com/absmartly/sdk/internal/SwitchbackAttributePathTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java index c53b910..e0ea8f9 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java @@ -76,7 +76,7 @@ void rejectsUnsupportedPaths() { @Test void indexSegmentsReadOnlyListsAndKeySegmentsReadOnlyMaps() { final Map attributes = TestUtils.mapOf("items", - TestUtils.listOf(TestUtils.mapOf("city", "Lisbon")), "geo", TestUtils.mapOf("0", "zero"), + TestUtils.listOf(TestUtils.mapOf("city", "Lisbon")), "geo", TestUtils.mapOf("0", TestUtils.mapOf("city", "zero")), "name", "scalar"); assertNull(SwitchbackAttributePath.parseSupported("geo/0/city").resolve(attributes)); assertNull(SwitchbackAttributePath.parseSupported("items/1/city").resolve(attributes)); From 6c0b0281d9b4fa80e3c352ad9397b195898a7f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:14:12 +0000 Subject: [PATCH 22/35] test(switchback): a missing cluster key survives slot replacement within the period --- .../sdk/ContextSwitchbackClusterTest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java index 932d73c..929a52f 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java @@ -325,6 +325,32 @@ void audienceRevalidationAfterSetAttributeKeepsThePeriodsBucket() { exposure(expected("Lisboa", 100, 0), ANCHOR + 20, true, true)}, publishExposures(context)); } + // A slot first resolved without a key carries that missing key into its replacement. + @Test + void audienceRevalidationAfterAnIneligibleResolutionKeepsTheMissingKey() { + final Experiment experiment = attributeSwitchback("city", 100); + experiment.audience = "{\"filter\":[{\"eq\":[{\"var\":\"tier\"},{\"value\":\"gold\"}]}]}"; + final Context context = createContext(dataOf(experiment)); + clock.now = ANCHOR + 10; + context.setAttribute("tier", "gold"); + assertEquals(0, context.getTreatment("exp_sb")); + + clock.now = ANCHOR + 20; + context.setAttribute("city", "Porto"); + context.setAttribute("tier", "silver"); + assertEquals(1, expected("Porto", 100, 0)); + assertEquals(0, context.getTreatment("exp_sb")); + assertArrayEquals(new Exposure[]{exposure(0, ANCHOR + 10, false, false), + exposure(0, ANCHOR + 20, false, true)}, publishExposures(context)); + + clock.now = ANCHOR + HOUR; + assertEquals(expected("Porto", 100, 1), context.peekTreatment("exp_sb")); + // Period 1 of Porto is variant 0; period 2 is not, so the key is visibly read again. + clock.now = ANCHOR + 2 * HOUR; + assertEquals(2, expected("Porto", 100, 2)); + assertEquals(expected("Porto", 100, 2), context.peekTreatment("exp_sb")); + } + @Test void clusterCountOrAttributeChangesInvalidateThePeriod() { final Context context = createContext(dataOf(attributeSwitchback("city", 100))); From 2df26d44956085aee6c27e5b3b41a71a9d0daf6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:15:03 +0000 Subject: [PATCH 23/35] test(switchback): pin the JSON round trip of attribute values --- .../sdk/ContextSwitchbackClusterTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java index 929a52f..1540bfd 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java @@ -17,6 +17,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ScheduledExecutorService; @@ -167,6 +168,24 @@ void nestedAndArrayPathsAndIntegerValuesResolve() { final Context integer = createContext(dataOf(attributeSwitchback("region", 10))); integer.setAttribute("region", 42L); assertArrayEquals(expectedSchedule("42", 10), peekSchedule(integer)); + + // Values are read through their JSON encoding, as the Collector decodes them: an integral + // BigDecimal becomes an integer, a Character a string, and a Float a non-integral double. + clock.now = ANCHOR; + final Context decimal = createContext(dataOf(attributeSwitchback("geo/city", 50))); + decimal.setAttribute("geo", TestUtils.mapOf("city", new BigDecimal("42"))); + assertArrayEquals(expectedSchedule("42", 50), peekSchedule(decimal)); + + clock.now = ANCHOR; + final Context character = createContext(dataOf(attributeSwitchback("city", 100))); + character.setAttribute("city", 'c'); + assertArrayEquals(expectedSchedule("c", 100), peekSchedule(character)); + + clock.now = ANCHOR + 10; + final Context floating = createContext(dataOf(attributeSwitchback("city", 100))); + floating.setAttribute("city", 2.0f); + assertEquals(0, floating.getTreatment("exp_sb")); + assertArrayEquals(new Exposure[]{exposure(0, ANCHOR + 10, false, false)}, publishExposures(floating)); } // Attribute values the Collector cannot bucket serve variant 0, not eligible, and never throw. From 7493aa15173fa5e2fd36bc0388e17264eae55f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:16:22 +0000 Subject: [PATCH 24/35] test(switchback): bucketOf is thread safe --- .../sdk/internal/SwitchbackBucketsTest.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java index 9c68820..36fdac3 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java @@ -14,6 +14,8 @@ import java.util.Collections; import java.util.HashSet; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; @@ -118,6 +120,45 @@ void normalizeAttributeAcceptsEveryIntegerTypeAndRejectsOthers() { assertNull(SwitchbackBuckets.normalizeAttribute('c')); } + // Interleaves the three hashlib vectors across threads; a digest shared between threads mixes + // their inputs and yields wrong buckets. + @Test + void bucketOfIsThreadSafe() throws InterruptedException { + final String[] keys = {"1tdwU5K8evYzMovqjExpBA", "\u6771\u4eac", "a\u001fb"}; + final long[] seeds = {0L, -123456789012345L, Long.MIN_VALUE}; + final int[] counts = {10, 50, 100}; + final int[] buckets = {6, 19, 43}; + final int threads = 8; + final int iterations = 200000; + final CountDownLatch start = new CountDownLatch(1); + final AtomicInteger wrong = new AtomicInteger(); + final Thread[] workers = new Thread[threads]; + for (int t = 0; t < threads; ++t) { + final int offset = t; + workers[t] = new Thread(() -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int i = 0; i < iterations; ++i) { + final int k = (i + offset) % keys.length; + if (bucketOf(keys[k], seeds[k], counts[k]) != buckets[k]) { + wrong.incrementAndGet(); + } + } + }); + workers[t].start(); + } + start.countDown(); + for (final Thread worker : workers) { + worker.join(60000); + assertFalse(worker.isAlive()); + } + assertEquals(0, wrong.get()); + } + @Test void onlyTenFiftyAndHundredAreAllowed() { for (final int count : new int[]{10, 50, 100}) { From 754829d9daf9ce9497e4b3dffa6e05cf60e95d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:17:00 +0000 Subject: [PATCH 25/35] test(switchback): traffic restored within the period reads the cluster key --- .../sdk/ContextSwitchbackClusterTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java index 1540bfd..906b992 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java @@ -324,6 +324,24 @@ void trafficChangesWithinThePeriodKeepThePeriodsBucket() { assertEquals(expected("Porto", 100, 1), context.getTreatment("exp_sb")); } + // A traffic miss never reads the key, so traffic restored later in the period reads it then. + @Test + void trafficRestoredWithinThePeriodReadsTheKey() { + final Experiment noTraffic = attributeSwitchback("city", 100); + noTraffic.trafficSplit = new double[]{1.0, 0.0}; + final Context context = createContext(dataOf(noTraffic)); + clock.now = ANCHOR + 10; + context.setAttribute("city", "Porto"); + assertEquals(0, context.getTreatment("exp_sb")); + + clock.now = ANCHOR + 20; + refreshTo(context, attributeSwitchback("city", 100)); + assertEquals(1, expected("Porto", 100, 0)); + assertEquals(expected("Porto", 100, 0), context.getTreatment("exp_sb")); + assertArrayEquals(new Exposure[]{exposure(0, ANCHOR + 10, false, false), + exposure(expected("Porto", 100, 0), ANCHOR + 20, true, false)}, publishExposures(context)); + } + // An audience change re-resolves the period slot (new exposure state) but keeps its bucket. @Test void audienceRevalidationAfterSetAttributeKeepsThePeriodsBucket() { From 04790b1eb9da1c62c2dd720dd24b0f4a19ec66e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:21:12 +0000 Subject: [PATCH 26/35] style(switchback): spotless formatting of the index-segment test --- .../absmartly/sdk/internal/SwitchbackAttributePathTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java index e0ea8f9..0af12af 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java @@ -76,7 +76,8 @@ void rejectsUnsupportedPaths() { @Test void indexSegmentsReadOnlyListsAndKeySegmentsReadOnlyMaps() { final Map attributes = TestUtils.mapOf("items", - TestUtils.listOf(TestUtils.mapOf("city", "Lisbon")), "geo", TestUtils.mapOf("0", TestUtils.mapOf("city", "zero")), + TestUtils.listOf(TestUtils.mapOf("city", "Lisbon")), "geo", + TestUtils.mapOf("0", TestUtils.mapOf("city", "zero")), "name", "scalar"); assertNull(SwitchbackAttributePath.parseSupported("geo/0/city").resolve(attributes)); assertNull(SwitchbackAttributePath.parseSupported("items/1/city").resolve(attributes)); From 5d694d51ce8e8811c7f882e4540d346afa616add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:21:12 +0000 Subject: [PATCH 27/35] test(switchback): concurrent setAttribute on an attribute-sourced switchback exposes once --- .../absmartly/sdk/ContextSwitchbackTest.java | 60 +++++++++++++++---- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index 468d2af..c6aad8d 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -1034,17 +1034,37 @@ public Result evaluate(String audience, Map attributes) { void concurrentResolutionOfTheSamePeriodExposesOnce() throws InterruptedException { final Experiment experiment = switchback(2, HOUR); experiment.audience = "{\"filter\":[{\"value\":true}]}"; + resolvePeriodOneConcurrently(experiment, null, EXPECTED_2_ARMS[1]); + } + + // Attribute-sourced: the cluster attribute changes before both readers re-resolve the slot, and + // a third worker keeps writing it meanwhile. The slot keeps the key read at first resolution + // (Lisboa, variant 1 in period 1); Porto is variant 0 there, so a re-read key is visible. + @Test + void concurrentResolutionWithConcurrentSetAttributeExposesOnce() throws InterruptedException { + final Experiment experiment = switchback(2, HOUR); + experiment.audience = "{\"filter\":[{\"value\":true}]}"; + experiment.switchbackClusterAttribute = "city"; + resolvePeriodOneConcurrently(experiment, "Porto", 1); + } + + void resolvePeriodOneConcurrently(Experiment experiment, String concurrentCity, int expectedVariant) + throws InterruptedException { final BarrierAudienceMatcher matcher = new BarrierAudienceMatcher(); final Context context = createContext(TestUtils.mapOf(UNIT_TYPE, UID), dataOf(experiment), matcher); clock.now = ANCHOR + HOUR + 10; + context.setAttribute("city", "Lisboa"); // Caches period 1 without exposing it; the attribute change forces both callers to revalidate. - assertEquals(EXPECTED_2_ARMS[1], context.peekTreatment("exp_sb")); + assertEquals(expectedVariant, context.peekTreatment("exp_sb")); context.setAttribute("country", "PT"); + if (concurrentCity != null) { + context.setAttribute("city", concurrentCity); + } final int[] variants = new int[2]; - final Throwable[] failures = new Throwable[2]; - final Thread[] workers = new Thread[2]; + final Throwable[] failures = new Throwable[3]; + final Thread[] workers = new Thread[(concurrentCity != null) ? 3 : 2]; for (int i = 0; i < 2; ++i) { final int slot = i; workers[i] = new Thread(() -> { @@ -1054,20 +1074,40 @@ void concurrentResolutionOfTheSamePeriodExposesOnce() throws InterruptedExceptio failures[slot] = t; } }); - workers[i].setDaemon(true); - workers[i].start(); + } + if (concurrentCity != null) { + final AtomicInteger writes = new AtomicInteger(); + workers[2] = new Thread(() -> { + try { + // Starts once both readers are in the audience barrier, so no reader blocks + // on a queued writer before the barrier trips. + while ((matcher.evaluations.get() < 3) && (workers[0].isAlive() || workers[1].isAlive())) { + Thread.yield(); + } + while ((workers[0].isAlive() || workers[1].isAlive() || writes.get() < 1000) + && writes.get() < 100_000) { + context.setAttribute("city", concurrentCity); + writes.incrementAndGet(); + } + } catch (Throwable t) { + failures[2] = t; + } + }); + } + for (final Thread worker : workers) { + worker.setDaemon(true); + worker.start(); } for (final Thread worker : workers) { worker.join(10_000); assertFalse(worker.isAlive()); } - assertEquals(null, failures[0]); - assertEquals(null, failures[1]); + assertArrayEquals(new Throwable[3], failures); - assertEquals(EXPECTED_2_ARMS[1], variants[0]); - assertEquals(EXPECTED_2_ARMS[1], variants[1]); + assertEquals(expectedVariant, variants[0]); + assertEquals(expectedVariant, variants[1]); assertEquals(1, context.getPendingCount()); - assertArrayEquals(new Exposure[]{new Exposure(1, "exp_sb", UNIT_TYPE, EXPECTED_2_ARMS[1], ANCHOR + HOUR + 10, + assertArrayEquals(new Exposure[]{new Exposure(1, "exp_sb", UNIT_TYPE, expectedVariant, ANCHOR + HOUR + 10, true, true, false, false, false, true)}, publishExposures(context)); } } From 215e5526e8c9e958211019d3dcb0855e3728d830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:22:36 +0000 Subject: [PATCH 28/35] test(switchback): independent expected buckets for the attribute path cases --- .../sdk/SwitchbackServerParityTest.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java b/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java index 3b012dd..1870213 100644 --- a/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java @@ -249,9 +249,13 @@ void attributeSourceMatchesTheCollectorForEveryAttributeValueSeedAndCount() { } // Each path case is served over the fixture's attributes. An unsupported path is unservable and - // a path resolving to null is ineligible; any other serves exactly what a top-level attribute - // holding the case's value serves. The case values are not bucket-fixture keys, so the - // top-level attribute bucket is the one the previous test pins. + // a path resolving to null is ineligible. Any other path serves the schedule of its value's + // bucket. The case values are not bucket-fixture keys, so their buckets for counts 10, 50 and + // 100 under the seeds used below were computed with Python hashlib MD5 over the platform + // formula, which reproduces all 702 bucket-fixture vectors. + static final Map PATH_CASE_BUCKETS = TestUtils.mapOf("items/0/city", new int[]{8, 10, 58}, + "geo/+1", new int[]{7, 12, 77}, "geo/city", new int[]{2, 5, 82}); + @Test void attributePathCasesMatchTheCollector() { final Map attributes = MAPPER.convertValue(pathCases.get("attributes"), @@ -260,18 +264,20 @@ void attributePathCasesMatchTheCollector() { for (final JsonNode c : pathCases.get("cases")) { final String path = c.get("path").textValue(); final Object value = MAPPER.convertValue(c.get("value"), Object.class); - for (final int count : COUNTS) { + for (int n = 0; n < COUNTS.length; ++n) { + final int count = COUNTS[n]; final long seed = SEEDS.get(count % SEEDS.size()); final Served served = serve(experiment(seed, 3, count, path), "u1", attributes); if (!c.get("supported").booleanValue() || (value == null)) { assertServed(served, null, seed, count, 3, c.toString()); continue; } - final Served reference = serve(experiment(seed, 3, count, "v"), "u1", singleton("v", value)); + final int bucket = PATH_CASE_BUCKETS.get(path)[n]; assertEquals(PERIODS, served.exposures.size(), c.toString()); for (int p = 0; p < PERIODS; ++p) { assertEquals(true, served.exposures.get(p).eligible, c.toString()); - assertEquals(reference.variants.get(p), served.variants.get(p), c.toString()); + assertEquals(SwitchbackAssigner.assign(String.valueOf(bucket), p, seed, 3), + served.variants.get(p).intValue(), c + " count " + count + " period " + p); } ++eligibleCases; } From a3b30fef7ffdc6d81a448d5f6020c2676676ae86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:25:29 +0000 Subject: [PATCH 29/35] fix(switchback): bounded cycle check before encoding a cluster attribute, warn on failure Jackson recursed into a cyclic Map or List until StackOverflowError while the contextLock_ write lock was held. An IdentityHashMap walk bounded at depth 64 rejects those values first, and every unencodable value now logs a warning naming the attribute and the exception class. The outcome is unchanged: no cluster key. --- .../sdk/internal/SwitchbackAttributePath.java | 49 +++++++++++++++++-- .../internal/SwitchbackAttributePathTest.java | 32 ++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java index 66c5ad1..7ca0c95 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackAttributePath.java @@ -1,10 +1,16 @@ package com.absmartly.sdk.internal; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.databind.ObjectMapper; // A switchback cluster attribute path, resolved exactly like the Collector's @@ -12,7 +18,9 @@ // and an unquoted segment that Guava Ints.tryParse accepts (ASCII digits, optional leading '-') // indexes a List and nothing else. Every other segment reads a Map key. public final class SwitchbackAttributePath { + private static final Logger log = LoggerFactory.getLogger(SwitchbackAttributePath.class); private static final ObjectMapper JSON = new ObjectMapper(); + private static final int MAX_DEPTH = 64; private final List keys; // Parallel to keys; null where the segment is a map key. @@ -54,7 +62,8 @@ public Object resolve(final Map attributes) { // Resolves the path below rootKey() in an SDK attribute value, read as the Collector reads it: // the value's JSON encoding decoded by Jackson into Map, List, String, Boolean, Integer, Long, - // BigInteger or Double. Returns null when the value has no JSON encoding. + // BigInteger or Double. Returns null when the value has no JSON encoding, contains a Map, + // Collection or array cycle, or nests those deeper than MAX_DEPTH. public Object resolveAttribute(final Object rootValue) { if (rootValue == null) { return null; @@ -66,17 +75,49 @@ public Object resolveAttribute(final Object rootValue) { decoded = rootValue; } else { try { + // Jackson recurses into Maps, Collections and arrays without a cycle check, so a + // cycle through them would end in a StackOverflowError. + if (!isAcyclicWithinDepth(rootValue, new IdentityHashMap(), 0)) { + log.warn("Switchback cluster attribute '{}' is cyclic or nested deeper than {}", rootKey(), + MAX_DEPTH); + return null; + } decoded = JSON.readValue(JSON.writeValueAsBytes(rootValue), Object.class); } catch (Exception e) { - return null; - } catch (StackOverflowError e) { - // A self-referencing Map or List. + log.warn("Switchback cluster attribute '{}' cannot be encoded as JSON: {}", rootKey(), + e.getClass().getName()); return null; } } return resolveFrom(decoded, 1); } + // `path` holds the containers on the current branch only, so a value shared by two siblings is + // not a cycle. + private static boolean isAcyclicWithinDepth(final Object value, final IdentityHashMap path, + final int depth) { + final Iterable children; + if (value instanceof Map) { + children = ((Map) value).values(); + } else if (value instanceof Collection) { + children = (Collection) value; + } else if (value instanceof Object[]) { + children = Arrays.asList((Object[]) value); + } else { + return true; + } + if ((depth >= MAX_DEPTH) || (path.put(value, Boolean.TRUE) != null)) { + return false; + } + for (final Object child : children) { + if (!isAcyclicWithinDepth(child, path, depth + 1)) { + return false; + } + } + path.remove(value); + return true; + } + private Object resolveFrom(final Object root, final int first) { Object current = root; for (int i = first; i < keys.size(); ++i) { diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java index 0af12af..c0923ff 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java @@ -6,6 +6,9 @@ import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -86,6 +89,35 @@ void indexSegmentsReadOnlyListsAndKeySegmentsReadOnlyMaps() { assertEquals("Lisbon", SwitchbackAttributePath.parseSupported("items/-0/city").resolve(attributes)); } + @Test + void cyclicOrTooDeepValuesHaveNoKey() { + final SwitchbackAttributePath path = SwitchbackAttributePath.parseSupported("geo/city"); + + final Map self = new HashMap(); + self.put("city", "Porto"); + self.put("self", self); + assertNull(path.resolveAttribute(self)); + + final Map outer = new HashMap(); + final List list = new ArrayList(); + list.add(new Object[]{outer}); + outer.put("city", "Porto"); + outer.put("items", list); + assertNull(path.resolveAttribute(outer)); + + // 65 nested maps: one past the depth bound. + Map deep = TestUtils.mapOf("leaf", "x"); + for (int i = 0; i < 64; ++i) { + deep = TestUtils.mapOf("city", "Porto", "next", deep); + } + assertNull(path.resolveAttribute(deep)); + assertEquals("Porto", path.resolveAttribute(deep.get("next"))); + + // A value reached twice through siblings is not a cycle. + final Map shared = TestUtils.mapOf("a", "b"); + assertEquals("Porto", path.resolveAttribute(TestUtils.mapOf("city", "Porto", "x", shared, "y", shared))); + } + @Test void tryParseIntFollowsGuava() { assertEquals(Integer.valueOf(0), SwitchbackAttributePath.tryParseInt("0")); From b93e3f1d67355c0e552206678b5143ad9e9b835b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:25:37 +0000 Subject: [PATCH 30/35] docs(switchback): traffic eligibility is sticky per unit, not per bucket --- SWITCHBACK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SWITCHBACK.md b/SWITCHBACK.md index 1d722e9..c6a2ead 100644 --- a/SWITCHBACK.md +++ b/SWITCHBACK.md @@ -45,7 +45,7 @@ For a switchback experiment: - Precedence is the same as for unit experiments: override, holdouts, audience, full-on, traffic eligibility, then custom assignment or the switchback draw. Custom assignments therefore apply only to traffic-eligible units. Traffic eligibility uses the per-unit traffic hash, so it is sticky per - cluster across periods. + unit across periods; units in the same bucket can differ in traffic eligibility. - The experiment is ineligible (variant 0) before the anchor, when `switchbackStartAt` or `switchbackPeriodMs` is null, when the cluster unit type differs from the unit type, when `switchbackClusterCount` is not 10, 50 or 100, when `switchbackClusterAttribute` is not a From 247034aac45558e51dd830a4eb0e076e5e76833b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:26:25 +0000 Subject: [PATCH 31/35] refactor(switchback): hold the allowed cluster counts once; drop spec ticket references --- .../src/main/java/com/absmartly/sdk/Context.java | 2 +- .../sdk/internal/SwitchbackBuckets.java | 16 ++++++++++++++-- .../sdk/ContextSwitchbackClusterTest.java | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index 071c8d5..b90493f 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -1427,7 +1427,7 @@ && isAssignmentCurrent(assignment, cassignments_.get(experimentName), assignment.name = experimentName; assignment.eligible = true; assignment.customAssignment = custom; - // Within one period slot the cluster key is read once (spec R32-3): a slot replaced in + // Within one period slot the cluster key is read once: a slot replaced in // the same epoch, e.g. after setAttribute changed the audience, keeps its bucket. if ((cached != null) && cached.switchbackClusterKeyResolved) { assignment.switchbackClusterKeyResolved = true; diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackBuckets.java b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackBuckets.java index d361b5b..0c5d710 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackBuckets.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/SwitchbackBuckets.java @@ -3,6 +3,7 @@ import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; import com.absmartly.sdk.java.nio.charset.StandardCharsets; @@ -30,13 +31,24 @@ protected MessageDigest initialValue() { } }; + private static final int[] ALLOWED_COUNTS = {10, 50, 100}; + public static boolean isAllowedCount(final Integer count) { - return (count != null) && ((count == 10) || (count == 50) || (count == 100)); + if (count == null) { + return false; + } + for (final int allowed : ALLOWED_COUNTS) { + if (count == allowed) { + return true; + } + } + return false; } public static int bucketOf(final int seedHi, final int seedLo, final String key, final int count) { if (!isAllowedCount(count)) { - throw new IllegalArgumentException("Switchback cluster count must be one of [10, 50, 100], got " + count); + throw new IllegalArgumentException( + "Switchback cluster count must be one of " + Arrays.toString(ALLOWED_COUNTS) + ", got " + count); } final String input = new StringBuilder(key.length() + 34).append("sbcluster").append(SEPARATOR) .append(seedHi).append(':').append(seedLo).append(SEPARATOR).append(key).toString(); diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java index 906b992..777758a 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java @@ -256,7 +256,7 @@ void latestAttributeSetAtOrBeforeTheResolutionInstantIsUsed() { assertEquals(expected("Lisboa", 100, 4), context.peekTreatment("exp_sb")); } - // Spec R32-3: within a period the bucket is fixed at first resolution; the next period reads + // Within a period the bucket is fixed at first resolution; the next period reads // the attribute again. @Test void setAttributeMidPeriodDoesNotReassignWithinThePeriod() { From e19fd59713cefd90ee5adcb4e22bc36c92060ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Fri, 23 Oct 2026 05:32:16 +0000 Subject: [PATCH 32/35] refactor(switchback): simplify Phase 1E SDK buckets --- .../sdk/ContextSwitchbackClusterTest.java | 20 ++++++++----------- .../absmartly/sdk/ContextSwitchbackTest.java | 2 +- .../sdk/SwitchbackServerParityTest.java | 19 +++++------------- 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java index 777758a..b99a853 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java @@ -58,8 +58,12 @@ void setUp() { } Context createContext(ContextData data) { + return createContext(TestUtils.mapOf(UNIT_TYPE, UID), data); + } + + Context createContext(Map units, ContextData data) { final ContextConfig config = ContextConfig.create(); - config.setUnits(TestUtils.mapOf(UNIT_TYPE, UID)); + config.setUnits(units); return Context.create(clock, config, mock(ScheduledExecutorService.class), CompletableFuture.completedFuture(data), dataProvider, eventHandler, null, new DefaultVariableParser(), new AudienceMatcher(new DefaultAudienceDeserializer())); @@ -136,12 +140,8 @@ void unitsSharingAnAttributeValueShareTheSchedule() { lisboa.setAttribute("city", "Lisboa"); final Context porto = createContext(dataOf(attributeSwitchback("city", 100))); porto.setAttribute("city", "Porto"); - final ContextConfig other = ContextConfig.create(); - other.setUnits(TestUtils.mapOf(UNIT_TYPE, "another-store")); - final Context otherLisboa = Context.create(clock, other, mock(ScheduledExecutorService.class), - CompletableFuture.completedFuture(dataOf(attributeSwitchback("city", 100))), dataProvider, - eventHandler, null, new DefaultVariableParser(), - new AudienceMatcher(new DefaultAudienceDeserializer())); + final Context otherLisboa = createContext(TestUtils.mapOf(UNIT_TYPE, "another-store"), + dataOf(attributeSwitchback("city", 100))); otherLisboa.setAttribute("city", "Lisboa"); assertArrayEquals(expectedSchedule("Lisboa", 100), peekSchedule(lisboa)); @@ -422,13 +422,9 @@ void clusterCountOrAttributeChangesInvalidateThePeriod() { void unitTypeChangeWithoutClusterUnitTypeRekeysThePeriod() { final String userUid = "user-0002"; final String userHash = new String(Hashing.hashUnit(userUid), StandardCharsets.US_ASCII); - final ContextConfig config = ContextConfig.create(); - config.setUnits(TestUtils.mapOf(UNIT_TYPE, UID, "user_id", userUid)); final Experiment byStore = switchback(ARMS, HOUR); byStore.switchbackClusterUnitType = null; - final Context context = Context.create(clock, config, mock(ScheduledExecutorService.class), - CompletableFuture.completedFuture(dataOf(byStore)), dataProvider, eventHandler, null, - new DefaultVariableParser(), new AudienceMatcher(new DefaultAudienceDeserializer())); + final Context context = createContext(TestUtils.mapOf(UNIT_TYPE, UID, "user_id", userUid), dataOf(byStore)); assertNotEquals(expected(UID_HASH, 100, 0), expected(userHash, 100, 0)); clock.now = ANCHOR + 1; diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java index c6aad8d..b4e4bd0 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackTest.java @@ -62,7 +62,7 @@ class ContextSwitchbackTest { static final int[] EXPECTED_2_ARMS = {0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0}; static final int[] EXPECTED_3_ARMS = {0, 2, 1, 2, 0, 1, 0, 1, 2, 2, 1, 0}; static final int[] EXPECTED_10_ARMS = {5, 3, 8, 9, 0, 7, 4, 6, 1, 2, 2, 4}; - // Same reference fed UID_HASH itself rather than its bucket: the removed per-value clustering. + // Same reference fed UID_HASH itself rather than its bucket: what per-value clustering serves. static final int[] PER_VALUE_3_ARMS = {1, 2, 0, 2, 1, 0, 2, 0, 1, 1, 2, 0}; static final class MutableClock extends Clock { diff --git a/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java b/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java index 1870213..3ba04f4 100644 --- a/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/SwitchbackServerParityTest.java @@ -222,12 +222,10 @@ void attributeSourceMatchesTheCollectorForEveryAttributeValueSeedAndCount() { for (final JsonNode c : bucketVectors.get("attributeKeys")) { final Object value = MAPPER.convertValue(c.get("value"), Object.class); final String key = c.get("key").isNull() ? null : c.get("key").textValue(); - final Map flat = new HashMap(); - flat.put("cluster", value); - final Map nested = new HashMap(); - nested.put("geo", singleton("city", value)); - final Map array = new HashMap(); - array.put("items", TestUtils.listOf(singleton("city", "other"), singleton("city", value))); + final Map flat = TestUtils.mapOf("cluster", value); + final Map nested = TestUtils.mapOf("geo", TestUtils.mapOf("city", value)); + final Map array = TestUtils.mapOf("items", + TestUtils.listOf(TestUtils.mapOf("city", "other"), TestUtils.mapOf("city", value))); final String[] paths = {"cluster", "geo/city", "items/1/city"}; final List> attributes = TestUtils.listOf(flat, nested, array); @@ -302,8 +300,7 @@ void repeatedAttributeNamesResolveToTheLatest() { final JsonNode keys = bucketVectors.get("attributeKeys"); final String first = keys.get(0).get("key").textValue(); final String second = keys.get(1).get("key").textValue(); - final Map attributes = new HashMap(); - attributes.put(" cluster ", first); + final Map attributes = TestUtils.mapOf(" cluster ", first); assertServed(serve(experiment(0L, 3, 50, "cluster"), "u1", attributes), first, 0L, 50, 3, "trimmed name"); final FixedClock clock = new FixedClock(); @@ -324,10 +321,4 @@ void repeatedAttributeNamesResolveToTheLatest() { assertEquals(collectorVariant(second, 0L, 50, 0, 3), capture.exposures.get(0).variant); assertTrue(capture.exposures.get(0).eligible); } - - static Map singleton(String key, Object value) { - final Map map = new HashMap(); - map.put(key, value); - return map; - } } From a095da9b4535a5a9aa756a30a7a5cbd60ad6befb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Thu, 29 Oct 2026 23:41:21 +0000 Subject: [PATCH 33/35] test(switchback): numeric unit UIDs cluster like their canonical string A numeric UID reaches switchback only through the shared unit hash (Hashing.unitToString, JavaScript toFixed(0)), so its schedule, bucket, published unit and exposures match the equivalent string UID at 10 and 100 buckets. Pinned against an independent MD5/murmur3 reference. --- .../sdk/ContextSwitchbackClusterTest.java | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java index b99a853..4b8e3c1 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java @@ -61,7 +61,7 @@ Context createContext(ContextData data) { return createContext(TestUtils.mapOf(UNIT_TYPE, UID), data); } - Context createContext(Map units, ContextData data) { + Context createContext(Map units, ContextData data) { final ContextConfig config = ContextConfig.create(); config.setUnits(units); return Context.create(clock, config, mock(ScheduledExecutorService.class), @@ -124,6 +124,70 @@ void unitSourceHashesThePublishedUnitHashForEveryCount() { } } + // A numeric UID is clustered by its canonical string (Hashing.unitToString, JavaScript toFixed(0)), + // the form ordinary assignment hashes and publish sends, so it shares the schedule, bucket and + // exposures of that string. + @Test + void numericUnitUidClustersLikeItsCanonicalString() { + final Object[][] cases = {{123456789L, "123456789"}, {123456789.0, "123456789"}, + {9007199254740993L, "9007199254740992"}, {125.75, "126"}}; + for (final int count : new int[]{10, 100}) { + final Experiment experiment = switchback(ARMS, HOUR); + experiment.switchbackClusterCount = count; + for (final Object[] pair : cases) { + setUp(); + final Context numeric = createContext(TestUtils.mapOf(UNIT_TYPE, pair[0]), dataOf(experiment)); + final PublishEvent numericEvent = exposeAndPublish(numeric); + final int[] numericSchedule = peekSchedule(numeric); + setUp(); + final Context text = createContext(TestUtils.mapOf(UNIT_TYPE, pair[1]), dataOf(experiment)); + final PublishEvent textEvent = exposeAndPublish(text); + + final String unitHash = new String(Hashing.hashUnit((String) pair[1]), StandardCharsets.US_ASCII); + assertArrayEquals(expectedSchedule(unitHash, count), numericSchedule); + assertArrayEquals(peekSchedule(text), numericSchedule); + assertArrayEquals(textEvent.units, numericEvent.units); + assertArrayEquals(textEvent.exposures, numericEvent.exposures); + assertEquals(unitHash, numericEvent.units[0].uid); + } + } + } + + // Independent reference (Python hashlib MD5 and murmur3 over the platform formulas) for numeric + // UIDs: the unit hash, its bucket and periods 0..11 of that bucket with 3 arms. + @Test + void numericUnitUidMatchesTheIndependentReference() { + final Object[][] cases = { + {123456789L, "JfnnlDI7RTiF9RgfG2JNCw", 10, 0, new int[]{2, 0, 1, 2, 1, 0, 0, 1, 2, 1, 0, 2}}, + {123456789L, "JfnnlDI7RTiF9RgfG2JNCw", 100, 10, new int[]{0, 2, 1, 2, 1, 0, 0, 2, 1, 1, 0, 2}}, + {9007199254740993L, "OngwCmjeKhIQyeNybDy4eg", 10, 7, new int[]{1, 2, 0, 0, 1, 2, 2, 1, 0, 0, 2, 1}}, + {9007199254740993L, "OngwCmjeKhIQyeNybDy4eg", 100, 37, + new int[]{0, 2, 1, 2, 0, 1, 2, 1, 0, 1, 2, 0}}}; + for (final Object[] golden : cases) { + setUp(); + final Experiment experiment = switchback(ARMS, HOUR); + experiment.switchbackClusterCount = (Integer) golden[2]; + final Context context = createContext(TestUtils.mapOf(UNIT_TYPE, golden[0]), dataOf(experiment)); + final int[] schedule = (int[]) golden[4]; + final PublishEvent event = exposeAndPublish(context); + + assertEquals(golden[1], event.units[0].uid); + assertEquals(golden[3], SwitchbackBuckets.bucketOf(SEED_HI, SEED_LO, event.units[0].uid, + (Integer) golden[2])); + assertArrayEquals(new Exposure[]{exposure(schedule[0], ANCHOR, true, false)}, event.exposures); + assertArrayEquals(schedule, peekSchedule(context)); + } + } + + PublishEvent exposeAndPublish(Context context) { + clock.now = ANCHOR; + context.getTreatment("exp_sb"); + context.publish(); + final ArgumentCaptor captor = ArgumentCaptor.forClass(PublishEvent.class); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(Mockito.eq(context), captor.capture()); + return captor.getValue(); + } + @Test void attributeSourceHashesTheResolvedAttributeForEveryCount() { for (final int count : new int[]{10, 50, 100}) { From 93ec6738446fb8f811965f81eae7d30a50273b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Thu, 29 Oct 2026 23:41:21 +0000 Subject: [PATCH 34/35] test(switchback): correct the fixture blob hashes in the provenance comments attribute-path-cases.json is blob 7b1d3fed5985 and bucket-golden-vectors.json is blob c2da44286878; the two comments had them swapped. --- .../com/absmartly/sdk/internal/SwitchbackAttributePathTest.java | 2 +- .../java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java index c0923ff..49bf6db 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackAttributePathTest.java @@ -20,7 +20,7 @@ import com.absmartly.sdk.TestUtils; // Fixture copied byte-for-byte from absmartly/abs -// lib/utils/src/test/resources/switchback/attribute-path-cases.json (git blob c2da44286878). +// lib/utils/src/test/resources/switchback/attribute-path-cases.json (git blob 7b1d3fed5985). class SwitchbackAttributePathTest { static JsonNode loadCases() throws IOException { final InputStream in = SwitchbackAttributePathTest.class.getClassLoader() diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java index 36fdac3..16d5e6d 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/SwitchbackBucketsTest.java @@ -26,7 +26,7 @@ import com.absmartly.sdk.java.nio.charset.StandardCharsets; // Fixture copied byte-for-byte from absmartly/abs -// lib/utils/src/test/resources/switchback/bucket-golden-vectors.json (git blob 7b1d3fed5985). +// lib/utils/src/test/resources/switchback/bucket-golden-vectors.json (git blob c2da44286878). class SwitchbackBucketsTest { static JsonNode loadVectors() throws IOException { final InputStream in = SwitchbackBucketsTest.class.getClassLoader() From 6c87d6fd1e4bdddbcc07be857681c932caf46fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rcio=20Martins?= Date: Thu, 29 Oct 2026 23:41:49 +0000 Subject: [PATCH 35/35] test(switchback): share the publish capture in the cluster tests --- .../sdk/ContextSwitchbackClusterTest.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java index 4b8e3c1..da459af 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextSwitchbackClusterTest.java @@ -74,11 +74,15 @@ void refreshTo(Context context, Experiment experiment) { context.refresh(); } - Exposure[] publishExposures(Context context) { + PublishEvent publishEvent(Context context) { context.publish(); final ArgumentCaptor captor = ArgumentCaptor.forClass(PublishEvent.class); verify(eventHandler, Mockito.timeout(5000).times(1)).publish(Mockito.eq(context), captor.capture()); - return captor.getValue().exposures; + return captor.getValue(); + } + + Exposure[] publishExposures(Context context) { + return publishEvent(context).exposures; } static Experiment attributeSwitchback(String path, int count) { @@ -135,11 +139,9 @@ void numericUnitUidClustersLikeItsCanonicalString() { final Experiment experiment = switchback(ARMS, HOUR); experiment.switchbackClusterCount = count; for (final Object[] pair : cases) { - setUp(); final Context numeric = createContext(TestUtils.mapOf(UNIT_TYPE, pair[0]), dataOf(experiment)); final PublishEvent numericEvent = exposeAndPublish(numeric); final int[] numericSchedule = peekSchedule(numeric); - setUp(); final Context text = createContext(TestUtils.mapOf(UNIT_TYPE, pair[1]), dataOf(experiment)); final PublishEvent textEvent = exposeAndPublish(text); @@ -164,7 +166,6 @@ void numericUnitUidMatchesTheIndependentReference() { {9007199254740993L, "OngwCmjeKhIQyeNybDy4eg", 100, 37, new int[]{0, 2, 1, 2, 0, 1, 2, 1, 0, 1, 2, 0}}}; for (final Object[] golden : cases) { - setUp(); final Experiment experiment = switchback(ARMS, HOUR); experiment.switchbackClusterCount = (Integer) golden[2]; final Context context = createContext(TestUtils.mapOf(UNIT_TYPE, golden[0]), dataOf(experiment)); @@ -182,10 +183,7 @@ void numericUnitUidMatchesTheIndependentReference() { PublishEvent exposeAndPublish(Context context) { clock.now = ANCHOR; context.getTreatment("exp_sb"); - context.publish(); - final ArgumentCaptor captor = ArgumentCaptor.forClass(PublishEvent.class); - verify(eventHandler, Mockito.timeout(5000).times(1)).publish(Mockito.eq(context), captor.capture()); - return captor.getValue(); + return publishEvent(context); } @Test