diff --git a/packages/Bucketing/src/BucketingManager.php b/packages/Bucketing/src/BucketingManager.php index caadfb9..b9da19f 100644 --- a/packages/Bucketing/src/BucketingManager.php +++ b/packages/Bucketing/src/BucketingManager.php @@ -137,4 +137,120 @@ public function getBucketForVisitor(array $buckets, string $visitorId, ?array $o 'bucketingAllocation' => $value, ]; } + + /** + * Build the anchored bucket layout for a set of variation allocations (qs-01). + * + * Anchors are computed over the total weight of ALL entries (active and inactive) so + * that raising an experience's total allocation only ever grows arms (superset + * property) and never reshuffles an already-bucketed visitor into a different arm. + * Inactive (or explicit zero-allocation) entries keep their weight for anchor + * stability but get a zero-width range so they can never be selected. + * + * @param array $allocations Variation allocations in config order + * @return array + */ + public function getBucketRanges(array $allocations): array + { + $totalWeight = array_reduce( + $allocations, + fn (float $sum, array $allocation) => $sum + $allocation['allocation'], + 0.0 + ); + + $ranges = []; + + if ($totalWeight <= 0) { + if ($this->logManager) { + $this->logManager->debug('BucketingManager.getBucketRanges()', [ + 'allocations' => $allocations, + 'totalWeight' => $totalWeight, + ]); + } + + return $ranges; + } + + $cumWeight = 0.0; + foreach ($allocations as $allocation) { + $anchor = ($cumWeight / $totalWeight) * $this->maxTraffic; + $width = $allocation['active'] ? $allocation['allocation'] * 100 : 0.0; + $ranges[] = [ + 'id' => $allocation['id'], + 'anchor' => $anchor, + 'width' => $width, + ]; + $cumWeight += $allocation['allocation']; + } + + if ($this->logManager) { + $this->logManager->debug('BucketingManager.getBucketRanges()', [ + 'allocations' => $allocations, + 'totalWeight' => $totalWeight, + ], ['ranges' => $ranges]); + } + + return $ranges; + } + + /** + * Select the variation whose anchored range contains the provided value. + * + * @param array $ranges Anchored bucket ranges (see getBucketRanges()) + * @param float $value A normalized bucket value in [0, maxTraffic) + * @return string|null The selected variation ID, or null if no match + */ + public function selectBucketAnchored(array $ranges, float $value): ?string + { + $variation = null; + + foreach ($ranges as $range) { + if ($value >= $range['anchor'] && $value < $range['anchor'] + $range['width']) { + $variation = $range['id']; + break; + } + } + + if ($this->logManager) { + $this->logManager->debug('BucketingManager.selectBucketAnchored()', [ + 'ranges' => $ranges, + 'value' => $value, + ], ['variation' => $variation]); + } + + return $variation; + } + + /** + * Get an anchored bucket for the visitor (qs-01). Reuses the existing + * visitor-based hash value unchanged, then resolves it through the anchored layout. + * + * @param array $allocations Variation allocations in config order + * @param string $visitorId The visitor's unique identifier + * @param array{seed?: int, experienceId?: string}|null $options Optional overrides + * @return array{variationId: string, bucketingAllocation: int}|null Assignment result or null + */ + public function getBucketForVisitorAnchored(array $allocations, string $visitorId, ?array $options = null): ?array + { + $value = $this->getValueVisitorBased($visitorId, $options); + $selectedBucket = $this->selectBucketAnchored($this->getBucketRanges($allocations), (float)$value); + + if ($this->logManager) { + $this->logManager->debug('BucketingManager.getBucketForVisitorAnchored()', [ + 'visitorId' => $visitorId, + 'experienceId' => $options['experienceId'] ?? '', + 'bucketValue' => $value, + 'selectedVariationId' => $selectedBucket, + ]); + } + + if (!$selectedBucket) { + return null; + } + + return [ + 'variationId' => $selectedBucket, + 'bucketingAllocation' => $value, + ]; + } } diff --git a/packages/Bucketing/src/Interfaces/BucketingManagerInterface.php b/packages/Bucketing/src/Interfaces/BucketingManagerInterface.php index 0315194..181f19b 100644 --- a/packages/Bucketing/src/Interfaces/BucketingManagerInterface.php +++ b/packages/Bucketing/src/Interfaces/BucketingManagerInterface.php @@ -40,4 +40,38 @@ public function getValueVisitorBased(string $visitorId, ?array $options = null): * @return array{variationId: string, bucketingAllocation: int}|null Assignment result or null */ public function getBucketForVisitor(array $buckets, string $visitorId, ?array $options = null): ?array; + + /** + * Build the anchored bucket layout for a set of variation allocations (qs-01). + * + * Anchors are computed over the total weight of ALL entries (active and inactive) so + * that raising an experience's total allocation only ever grows arms (superset + * property) and never reshuffles an already-bucketed visitor into a different arm. + * Inactive (or explicit zero-allocation) entries keep their weight for anchor + * stability but get a zero-width range so they can never be selected. + * + * @param array $allocations Variation allocations in config order + * @return array + */ + public function getBucketRanges(array $allocations): array; + + /** + * Select the variation whose anchored range contains the provided value. + * + * @param array $ranges Anchored bucket ranges (see getBucketRanges()) + * @param float $value A normalized bucket value in [0, maxTraffic) + * @return string|null The selected variation ID, or null if no match + */ + public function selectBucketAnchored(array $ranges, float $value): ?string; + + /** + * Get an anchored bucket for the visitor (qs-01). Reuses the existing + * visitor-based hash value unchanged, then resolves it through the anchored layout. + * + * @param array $allocations Variation allocations in config order + * @param string $visitorId The visitor's unique identifier + * @param array{seed?: int, experienceId?: string}|null $options Optional overrides + * @return array{variationId: string, bucketingAllocation: int}|null Assignment result or null + */ + public function getBucketForVisitorAnchored(array $allocations, string $visitorId, ?array $options = null): ?array; } diff --git a/packages/Data/src/DataManager.php b/packages/Data/src/DataManager.php index 8440d7b..c356a32 100644 --- a/packages/Data/src/DataManager.php +++ b/packages/Data/src/DataManager.php @@ -576,6 +576,86 @@ private function _getBucketingByField( return null; } + /** + * Shared "running + non-zero-traffic" active predicate used by BOTH the packed + * (buildPackedBuckets) and anchored (buildVariationAllocations) layout builders, + * so both layouts agree on activeness. + * + * @param array $variation + */ + private function isVariationActive(array $variation): bool + { + return (isset($variation['status']) ? $variation['status'] === VariationStatuses::RUNNING : true) && + (array_key_exists('traffic_allocation', $variation) ? + ($variation['traffic_allocation'] > 0 || !is_numeric($variation['traffic_allocation'])) : + true); + } + + /** + * Build buckets where key is variation id and value is traffic distribution + * (existing packed layout, experience version <= 11, missing, or non-numeric; + * byte-for-byte unchanged). Version 11 is the version stamped on every experience + * currently served in production (backend CURRENT_EXPERIENCE_VERSION), so this is + * the active path for all currently-running experiments. + * + * @param array> $variations + * @return array + * @private + */ + private function buildPackedBuckets(array $variations): array + { + return array_reduce( + array_filter( + $variations, + fn ($variation) => $this->isVariationActive($variation) + ), + function ($carry, $variation) { + if (!empty($variation['id'])) { + $carry[$variation['id']] = $variation['traffic_allocation'] ?? 100.0; + } + return $carry; + }, + [] + ); + } + + /** + * Build variation allocations for the anchored layout (qs-01, contract v12). + * Activates only once the served experience version is > 11 (i.e. >= 12, once the + * backend bumps CURRENT_EXPERIENCE_VERSION past its current value of 11). + * Inactive arms (stopped, or explicit zero traffic_allocation) keep their weight for + * anchor stability but are marked inactive so BucketingManager::getBucketRanges() + * gives them zero width. Uses the shared isVariationActive() predicate, also used + * by buildPackedBuckets(), so both layouts agree on activeness. See + * qs-01-anchored-bucketing-layout.md "The contract (normative)". + * + * @param array> $variations + * @return array + * @private + */ + private function buildVariationAllocations(array $variations): array + { + $allocations = []; + + foreach ($variations as $variation) { + if (empty($variation['id'])) { + continue; + } + + $trafficAllocation = array_key_exists('traffic_allocation', $variation) + ? $variation['traffic_allocation'] + : null; + + $allocations[] = [ + 'id' => (string)$variation['id'], + 'allocation' => is_numeric($trafficAllocation) ? (float)$trafficAllocation : 100.0, + 'active' => $this->isVariationActive($variation), + ]; + } + + return $allocations; + } + /** * Retrieve variation for visitor * @@ -651,33 +731,35 @@ private function _retrieveBucketing( ) ); } else { - // Build buckets from variations - $buckets = array_reduce( - array_filter( - $experience->getVariations(), - fn ($variation) => - (isset($variation['status']) ? $variation['status'] === VariationStatuses::RUNNING : true) && - (array_key_exists('traffic_allocation', $variation) ? - ($variation['traffic_allocation'] > 0 || !is_numeric($variation['traffic_allocation'])) : - true) - ), - function ($carry, $variation) { - if (!empty($variation['id'])) { - $carry[$variation['id']] = $variation['traffic_allocation'] ?? 100.0; - } - return $carry; - }, - [] - ); + // qs-01: anchored-vs-packed GATE. `experience.version > 11` runs the anchored + // layout (contract v12); version <= 11, missing, or non-numeric keeps the + // existing packed cumulative walk unchanged -- this is every currently-served + // production experience (backend CURRENT_EXPERIENCE_VERSION = 11). The SDK + // must never infer the layout from anything but this field. See + // qs-01-anchored-bucketing-layout.md "The contract (normative)". + $version = $experience->getVersion(); + $isAnchoredLayout = is_numeric($version) && (float)$version > 11; + // Determine bucket for visitor $bucketingParams = $this->_config->bucketing->excludeExperienceIdHash ?? false ? null : ['experienceId' => (string)$experience->getId()]; - $bucketing = $this->_bucketingManager->getBucketForVisitor( - $buckets, - $visitorId, - $bucketingParams - ); + + if ($isAnchoredLayout) { + $buckets = $this->buildVariationAllocations($experience->getVariations()); + $bucketing = $this->_bucketingManager->getBucketForVisitorAnchored( + $buckets, + $visitorId, + $bucketingParams + ); + } else { + $buckets = $this->buildPackedBuckets($experience->getVariations()); + $bucketing = $this->_bucketingManager->getBucketForVisitor( + $buckets, + $visitorId, + $bucketingParams + ); + } $variationId = $variationId ?? $bucketing['variationId'] ?? null; $bucketingAllocation = $bucketing['bucketingAllocation'] ?? null; diff --git a/packages/Data/tests/AnchoredBucketingLayoutTest.php b/packages/Data/tests/AnchoredBucketingLayoutTest.php new file mode 100644 index 0000000..483b541 --- /dev/null +++ b/packages/Data/tests/AnchoredBucketingLayoutTest.php @@ -0,0 +1,435 @@ +11 branch, + * AC2's per-sliver admission at version 12, AC3, AC4, AC5, AC8's version-12 sub-case, AC9's + * anchored not-bucketed sub-case) are EXPECTED to fail until BucketingManager / DataManager's + * fresh-bucketing branch implement the version gate and anchored algorithm. Packed-path + * assertions (AC1's version<=11/missing/non-numeric branches, AC6) are expected to pass now. + * + * Spec: _bmad-output/planning-artifacts/2026-03-13-convert-php-sdk/qs-01-anchored-bucketing-layout.md + */ +class AnchoredBucketingLayoutTest extends TestCase +{ + private const EXPERIENCE_ID = '900000001'; + + /** + * Builds a fresh DataManager wired with exactly one experience (id = EXPERIENCE_ID) + * carrying the given $variations and $version. Fresh per call so no visitor ever + * carries a stored decision across assertions unless the test deliberately calls + * putData() on the returned instance first (see the AC8 test). + * + * @param array> $variations + */ + private function makeDataManager(array $variations, int|float|string|null $version, string $experienceId = self::EXPERIENCE_ID): DataManager + { + return new DataManager( + new Config([ + 'environment' => 'production', + 'data' => new ConfigResponseData([ + 'account_id' => 'test-account', + 'project' => ['id' => 'test-project'], + 'experiences' => [[ + 'id' => $experienceId, + 'key' => $experienceId . '-key', + 'name' => 'Anchored Bucketing AC Test Experience', + 'version' => $version, + 'variations' => $variations, + ]], + ]), + ]), + new BucketingManager(), + $this->createMock(RuleManagerInterface::class), + $this->createMock(EventManagerInterface::class), + $this->createMock(ApiManagerInterface::class), + new LogManager() + ); + } + + /** + * @param array> $variations + */ + private function bucketFreshVisitor( + array $variations, + string $visitorId, + int|float|string|null $version, + string $experienceId = self::EXPERIENCE_ID + ): array|RuleError|BucketingError|null { + return $this->makeDataManager($variations, $version, $experienceId)->getBucketingById( + $visitorId, + $experienceId, + new BucketingAttributes([ + 'ignoreLocationProperties' => true, + 'enableTracking' => false, + ]) + ); + } + + private function assertVariation(string $expectedVariationId, array|RuleError|BucketingError|null $result, string $message = ''): void + { + $this->assertIsArray($result, $message); + $this->assertSame($expectedVariationId, $result['id'], $message); + } + + private function assertNotBucketed(array|RuleError|BucketingError|null $result, string $message = ''): void + { + $this->assertSame(BucketingError::VariationNotDecided, $result, $message); + } + + /** + * Three equal-share running arms (O/V1/V2), each carrying $trafficAllocation. Covers + * both the 15% (5/5/5) and 25% (8.333.../each) configs used throughout the AC2/AC3/AC6/ + * AC8 tests — only the per-arm share differs between call sites. + * + * @return array> + */ + private function thirds(float $trafficAllocation): array + { + return [ + ['id' => 'O', 'traffic_allocation' => $trafficAllocation, 'status' => 'running'], + ['id' => 'V1', 'traffic_allocation' => $trafficAllocation, 'status' => 'running'], + ['id' => 'V2', 'traffic_allocation' => $trafficAllocation, 'status' => 'running'], + ]; + } + + /** + * The O=10/V1=80/V2=10 config used across AC4/AC5/AC9. $statusOverrides maps a + * variation id to a status override (e.g. ['V1' => 'stopped']); any id not present + * defaults to 'running'. + * + * @param array $statusOverrides + * @return array> + */ + private function tenEightyTen(array $statusOverrides = []): array + { + $allocations = ['O' => 10, 'V1' => 80, 'V2' => 10]; + + $variations = []; + foreach ($allocations as $id => $trafficAllocation) { + $variations[] = [ + 'id' => $id, + 'traffic_allocation' => $trafficAllocation, + 'status' => $statusOverrides[$id] ?? 'running', + ]; + } + + return $variations; + } + + // --- AC1: gate branching ---------------------------------------------------------- + + /** + * Vectors #1 (v11 -> V1) and #19 (v12, IDENTICAL variations/visitor -> not bucketed). + * Only the `version` field differs; only the routed layout should explain the + * different outcome. + */ + public function testAc1Version12RoutesToAnchoredAndVersion11RoutesToPacked(): void + { + $variations = $this->thirds(5); + $visitorId = 'thirds-flip-V1-to-O-66'; // raw bucket value 601, per vector #1/#19 + + $this->assertVariation('V1', $this->bucketFreshVisitor($variations, $visitorId, 11), 'version 11 (packed) must still select V1 per vector #1'); + $this->assertNotBucketed($this->bucketFreshVisitor($variations, $visitorId, 12), 'version 12 (anchored) must NOT bucket this visitor per vector #19'); + } + + /** + * Vector #0 data (v11 -> O) reused at a missing and a non-numeric version: both must + * behave exactly as version 11 (packed), per AC1's "inert-on-ship" guarantee. + */ + public function testAc1MissingOrNonNumericVersionRoutesToPacked(): void + { + $variations = $this->thirds(5); + $visitorId = 'thirds-core-O-1'; // raw bucket value 293, per vector #0 + + $this->assertVariation('O', $this->bucketFreshVisitor($variations, $visitorId, null), 'missing version must route to packed (vector #0 outcome)'); + $this->assertVariation('O', $this->bucketFreshVisitor($variations, $visitorId, 'not-a-number'), 'non-numeric version must route to packed (vector #0 outcome)'); + } + + // --- AC2: raise is a superset ------------------------------------------------------- + + /** + * Vectors #13/#14, #15/#16, #17/#18: visitors already inside an arm at 15% (5/5/5) + * keep the SAME arm at 25% (8.333.../each) under anchored. + */ + public function testAc2RaiseKeepsAlreadyBucketedVisitorsInTheSameArm(): void + { + $fifteenPercent = $this->thirds(5); + $twentyFivePercent = $this->thirds(8.333333333333334); + + $cases = [ + 'thirds-core-O-1' => 'O', // vectors #13/#14, raw value 293 + 'thirds-anchored-V1-core-1' => 'V1', // vectors #15/#16, raw value 3617 + 'thirds-anchored-V2-core-24' => 'V2', // vectors #17/#18, raw value 6871 + ]; + + foreach ($cases as $visitorId => $expectedArm) { + $this->assertVariation($expectedArm, $this->bucketFreshVisitor($fifteenPercent, $visitorId, 12), "$visitorId must be in $expectedArm at 15%"); + $this->assertVariation($expectedArm, $this->bucketFreshVisitor($twentyFivePercent, $visitorId, 12), "$visitorId must STAY in $expectedArm at 25% (superset, AC2)"); + } + } + + /** + * Vectors #19/#20, #21/#22, #23/#24: visitors NOT bucketed at 15% are newly admitted + * into the raised arm's growth sliver at 25% — without disturbing any other arm. + */ + public function testAc2RaiseAdmitsNewVisitorsIntoTheGrowthSliverOnly(): void + { + $fifteenPercent = $this->thirds(5); + $twentyFivePercent = $this->thirds(8.333333333333334); + + $cases = [ + 'thirds-flip-V1-to-O-66' => 'O', // vectors #19/#20, raw value 601 + 'thirds-anchored-V1-sliver-15' => 'V1', // vectors #21/#22, raw value 3899 + 'thirds-anchored-V2-sliver-14' => 'V2', // vectors #23/#24, raw value 7353 + ]; + + foreach ($cases as $visitorId => $expectedArm) { + $this->assertNotBucketed($this->bucketFreshVisitor($fifteenPercent, $visitorId, 12), "$visitorId must NOT be bucketed at 15%"); + $this->assertVariation($expectedArm, $this->bucketFreshVisitor($twentyFivePercent, $visitorId, 12), "$visitorId must be admitted into $expectedArm's growth sliver at 25%"); + } + } + + // --- AC3: lower ejects evenly and never flips --------------------------------------- + + /** + * Vectors #25/#26: a visitor that is idle (not bucketed) at 25% stays idle when + * lowered to 15% — it is never incorrectly admitted or flipped into an arm. + */ + public function testAc3LowerNeverFlipsAnIdleVisitorIntoAnArm(): void + { + $twentyFivePercent = $this->thirds(8.333333333333334); + $fifteenPercent = $this->thirds(5); + $visitorId = 'thirds-flip-V2-to-V1-5'; // raw bucket value 1213 + + $this->assertNotBucketed($this->bucketFreshVisitor($twentyFivePercent, $visitorId, 12), 'must be idle at 25% per vector #26'); + $this->assertNotBucketed($this->bucketFreshVisitor($fifteenPercent, $visitorId, 12), 'must STILL be idle at 15% (never flips into an arm), per vector #25'); + } + + /** + * Vectors #19-#24 read in the lowering direction: a visitor admitted at 25% is + * ejected to not-bucketed at 15% — never reassigned to a different arm. + */ + public function testAc3LowerEjectsAdmittedVisitorsWithoutReassigningThem(): void + { + $twentyFivePercent = $this->thirds(8.333333333333334); + $fifteenPercent = $this->thirds(5); + + $visitorIds = ['thirds-flip-V1-to-O-66', 'thirds-anchored-V1-sliver-15', 'thirds-anchored-V2-sliver-14']; + + foreach ($visitorIds as $visitorId) { + $atHighCoverage = $this->bucketFreshVisitor($twentyFivePercent, $visitorId, 12); + $this->assertIsArray($atHighCoverage, "$visitorId must be bucketed at 25%"); + $this->assertNotBucketed($this->bucketFreshVisitor($fifteenPercent, $visitorId, 12), "$visitorId must be EJECTED (not reassigned) at 15%"); + } + } + + // --- AC4: stops don't move anchors -------------------------------------------------- + + /** + * Vectors #31-#36: stopping V1 (ta preserved) must not affect O's or V2's anchors, + * and must zero-width V1 itself (never selected while stopped). + */ + public function testAc4StoppingOneArmDoesNotMoveOtherArmsAnchorsAndZeroWidthsTheStoppedArm(): void + { + $allRunning = $this->tenEightyTen(); + $v1Stopped = $this->tenEightyTen(['V1' => 'stopped']); + + // vectors #31/#32: O unaffected by V1's stop + $this->assertVariation('O', $this->bucketFreshVisitor($allRunning, 'anchor-gate-visitor-106', 12)); + $this->assertVariation('O', $this->bucketFreshVisitor($v1Stopped, 'anchor-gate-visitor-106', 12), 'O must be byte-identical whether V1 runs or is stopped'); + + // vectors #33/#34: V2's anchor (9000) is byte-identical whether V1 runs or is stopped + $this->assertVariation('V2', $this->bucketFreshVisitor($allRunning, 'anchor-gate-visitor-162', 12)); + $this->assertVariation('V2', $this->bucketFreshVisitor($v1Stopped, 'anchor-gate-visitor-162', 12), "V2's anchor must not move when V1 stops"); + + // vectors #35/#36: V1 itself becomes zero-width (not bucketed) once stopped, anchor preserved + $this->assertVariation('V1', $this->bucketFreshVisitor($allRunning, 'anchor-gate-visitor-17', 12)); + $this->assertNotBucketed($this->bucketFreshVisitor($v1Stopped, 'anchor-gate-visitor-17', 12), 'stopped V1 keeps its weight/anchor but has zero width'); + } + + /** + * Vectors #37-#39: an explicit traffic_allocation=0 arm (Z) is zero-width — never + * treated as 100% default — and never perturbs its sibling arms' anchors. + */ + public function testAc4ExplicitZeroTrafficAllocationIsNeverTreatedAs100Percent(): void + { + $variations = [ + ['id' => 'O', 'traffic_allocation' => 2, 'status' => 'running'], + ['id' => 'V1', 'traffic_allocation' => 47, 'status' => 'running'], + ['id' => 'Z', 'traffic_allocation' => 0, 'status' => 'running'], + ['id' => 'V2', 'traffic_allocation' => 1, 'status' => 'running'], + ]; + + $this->assertVariation('O', $this->bucketFreshVisitor($variations, 'anchor-gate-visitor-106', 12)); + $this->assertVariation('V1', $this->bucketFreshVisitor($variations, 'anchor-gate-visitor-17', 12), "Z's explicit zero allocation must never be defaulted to 100 nor perturb V1's anchor"); + $this->assertVariation('V2', $this->bucketFreshVisitor($variations, 'anchor-gate-visitor-162', 12), 'Z must never be selected and must not shift V2\'s anchor'); + } + + // --- AC5: defaults & boundaries ------------------------------------------------------ + + /** + * Vectors #40, #42, #43, #44, #45: NaN/absent traffic_allocation defaults to a 100.0 + * weight (never zero, never excluded from the total). + */ + public function testAc5MissingTrafficAllocationDefaultsToOneHundredWeight(): void + { + // vector #40: single arm, ta omitted -> full traffic space + $this->assertVariation('DEFAULT', $this->bucketFreshVisitor( + [['id' => 'DEFAULT', 'status' => 'running']], + 'nan-default-visitor', + 12 + )); + + // vectors #42/#43: B=5, A omitted (defaults to 100) - A's defaulted weight must not + // swallow values that clearly belong inside B's own explicit band. + $twoArms = [ + ['id' => 'B', 'traffic_allocation' => 5, 'status' => 'running'], + ['id' => 'A', 'status' => 'running'], + ]; + $this->assertVariation('B', $this->bucketFreshVisitor($twoArms, 'anchor-gate-visitor-106', 12), "B's own band must win for values inside it"); + $this->assertVariation('A', $this->bucketFreshVisitor($twoArms, 'anchor-gate-visitor-162', 12), "A's defaulted 100-weight band must cover the rest"); + + // vectors #44/#45: single full-allocation arm is identical under v11 and v12 + $single = [['id' => 'ONLY', 'traffic_allocation' => 100, 'status' => 'running']]; + $this->assertVariation('ONLY', $this->bucketFreshVisitor($single, 'single-arm-visitor', 11)); + $this->assertVariation('ONLY', $this->bucketFreshVisitor($single, 'single-arm-visitor', 12)); + } + + /** + * Vectors #57/#58: total weight <= 0 (all arms zero-allocation) is never bucketed, + * regardless of visitor, under either layout. + */ + public function testAc5TotalWeightZeroIsNeverBucketed(): void + { + $variations = [ + ['id' => 'A', 'traffic_allocation' => 0, 'status' => 'running'], + ['id' => 'B', 'traffic_allocation' => 0, 'status' => 'stopped'], + ]; + + $this->assertNotBucketed($this->bucketFreshVisitor($variations, 'anchor-gate-visitor-106', 12), 'totalWeight <= 0 must never bucket (anchored)'); + $this->assertNotBucketed($this->bucketFreshVisitor($variations, 'anchor-gate-visitor-106', 11), 'totalWeight <= 0 must never bucket (packed)'); + } + + /** + * Vectors #52-#56: an anchor is INCLUSIVE (`value == anchor` is IN) while the far edge + * of a band is EXCLUSIVE (`value == anchor + width` is OUT, landing in the next arm). + */ + public function testAc5BoundaryValuesAreInclusiveAtAnchorAndExclusiveAtAnchorPlusWidth(): void + { + $variations = $this->tenEightyTen(); + + $this->assertVariation('O', $this->bucketFreshVisitor($variations, 'boundary-999-25207', 12), 'value 999 is just below V1\'s anchor (1000) -> stays in O'); + $this->assertVariation('V1', $this->bucketFreshVisitor($variations, 'boundary-1000-1145', 12), 'value 1000 EQUALS V1\'s anchor -> anchor is inclusive'); + $this->assertVariation('V1', $this->bucketFreshVisitor($variations, 'boundary-8999-359', 12), 'value 8999 is just below V2\'s anchor (9000) -> stays in V1'); + $this->assertVariation('V2', $this->bucketFreshVisitor($variations, 'boundary-9000-9598', 12), 'value 9000 EQUALS V2\'s anchor -> anchor is inclusive'); + $this->assertVariation('V2', $this->bucketFreshVisitor($variations, 'boundary-9999-5699', 12), 'value 9999 is the maximum representable traffic value, still inside V2'); + } + + // --- AC6: packed regression lock ----------------------------------------------------- + + /** + * Vectors #0, #3, #5, #7, #9, #11 (v11, unchanged packed table): the packed walk must + * remain bit-identical for version <= 11 — this is expected to PASS right now (no src + * change has been made). + */ + public function testAc6PackedPathIsUnchangedForVersion11(): void + { + $fifteenPercent = $this->thirds(5); + + $cases = [ + 'thirds-core-O-1' => 'O', // vector #0, raw value 293 + 'thirds-flip-V1-to-O-66' => 'V1', // vector #1, raw value 601 + 'thirds-flip-V2-to-V1-5' => 'V2', // vector #3, raw value 1213 + 'thirds-stable-V1-77' => 'V1', // vector #5, raw value 877 + ]; + + foreach ($cases as $visitorId => $expectedArm) { + $this->assertVariation($expectedArm, $this->bucketFreshVisitor($fifteenPercent, $visitorId, 11), "packed v11 regression: $visitorId -> $expectedArm"); + } + + // vector #11: exceeds the 15% total allocation -> not bucketed under packed + $this->assertNotBucketed($this->bucketFreshVisitor($fifteenPercent, 'thirds-idle-both-packed-3', 11)); + } + + // --- AC8: stored decision wins over both layouts -------------------------------------- + + /** + * A visitor with an existing stored decision must keep it regardless of whether the + * experience routes to packed (version 11) or anchored (version 12) — even when a + * fresh computation for that visitor would produce a DIFFERENT (or no) arm. + */ + public function testAc8StoredDecisionWinsOverFreshComputationForBothLayouts(): void + { + $variations = $this->thirds(5); + $visitorId = 'thirds-flip-V1-to-O-66'; // fresh compute: V1 at v11 (vector #1), not-bucketed at v12 (vector #19) + + foreach ([11, 12] as $version) { + $dataManager = $this->makeDataManager($variations, $version); + $dataManager->putData($visitorId, ['bucketing' => [self::EXPERIENCE_ID => 'V2']]); + + $result = $dataManager->getBucketingById( + $visitorId, + self::EXPERIENCE_ID, + new BucketingAttributes(['ignoreLocationProperties' => true, 'enableTracking' => false]) + ); + + $this->assertVariation('V2', $result, "stored decision must win over fresh computation at version $version"); + } + } + + // --- AC9: no event/API drift ---------------------------------------------------------- + + /** + * The bucketed-variation array shape (keys) and the not-bucketed sentinel type must be + * identical regardless of which layout (packed or anchored) produced the result. + */ + public function testAc9ReturnShapeAndNotBucketedSentinelAreUnchangedRegardlessOfLayout(): void + { + $expectedKeys = [ + 'experienceId', 'experienceName', 'experienceKey', 'bucketingAllocation', + 'id', 'name', 'key', 'traffic_allocation', 'status', 'changes', + ]; + + // 100%-total config: packed and anchored provably coincide (vectors #46/#47), so + // this isolates the SHAPE assertion from any layout-correctness concern. + $fullyAllocated = $this->tenEightyTen(); + + foreach ([11, 12] as $version) { + $result = $this->bucketFreshVisitor($fullyAllocated, 'anchor-gate-visitor-106', $version); + $this->assertIsArray($result, "version $version must return a bucketed array for this fully-allocated config"); + $this->assertSame($expectedKeys, array_keys($result), "return shape must be identical regardless of layout (version $version)"); + } + + // Not-bucketed sentinel must stay BucketingError::VariationNotDecided under anchored too. + $v1Stopped = $this->tenEightyTen(['V1' => 'stopped']); + $this->assertNotBucketed( + $this->bucketFreshVisitor($v1Stopped, 'anchor-gate-visitor-17', 12), + 'not-bucketed sentinel type must be unchanged under anchored (vector #36)' + ); + } +} diff --git a/tests/CrossSdk/AnchoredBucketingGoldenVectorTest.php b/tests/CrossSdk/AnchoredBucketingGoldenVectorTest.php new file mode 100644 index 0000000..4375058 --- /dev/null +++ b/tests/CrossSdk/AnchoredBucketingGoldenVectorTest.php @@ -0,0 +1,136 @@ + 11) cases are EXPECTED to fail until + * the anchored layout is implemented in BucketingManager / DataManager's fresh-bucketing + * branch. Packed (version <= 11) cases are expected to pass unchanged (AC6 regression lock). + * + * Spec: _bmad-output/planning-artifacts/2026-03-13-convert-php-sdk/qs-01-anchored-bucketing-layout.md + */ +class AnchoredBucketingGoldenVectorTest extends TestCase +{ + private const FIXTURE_PATH = __DIR__ . '/cross-sdk-bucketing-vectors.json'; + + /** + * @return iterable}> + */ + public static function vectorProvider(): iterable + { + $vectors = json_decode(file_get_contents(self::FIXTURE_PATH), true); + + foreach ($vectors as $index => $vector) { + $label = sprintf( + '#%d [v%d] %s', + $index, + $vector['version'], + mb_substr($vector['description'], 0, 90) + ); + yield $label => [$vector]; + } + } + + /** + * @param array{description: string, experienceId: string, visitorId: string, version: int|float, variations: array>, expected: string|null} $vector + */ + #[DataProvider('vectorProvider')] + public function testGoldenVectorMatchesExpectedVariation(array $vector): void + { + $result = $this->bucketVisitor( + $vector['variations'], + $vector['visitorId'], + $vector['version'], + $vector['experienceId'] + ); + + if ($vector['expected'] === null) { + $this->assertSame( + BucketingError::VariationNotDecided, + $result, + $vector['description'] + ); + return; + } + + $this->assertIsArray($result, $vector['description']); + $this->assertSame($vector['expected'], $result['id'], $vector['description']); + } + + /** + * Drives ONE vector through the real fresh-bucketing path: a brand-new DataManager per + * vector (so no visitor ever carries a stored decision across rows — every row is an + * independent "first encounter", matching how the fixture rows are authored), the real + * BucketingManager (real MurmurHash3 + real bucket math, unmocked), and + * DataManager::getBucketingById() — the actual production entry point that resolves a + * visitor into a variation for an experience, including whatever version-gated + * packed/anchored branch it contains. + * + * @param array> $variations + */ + private function bucketVisitor( + array $variations, + string $visitorId, + int|float $version, + string $experienceId + ): array|RuleError|BucketingError|null { + $dataManager = new DataManager( + new Config([ + 'environment' => 'production', + 'data' => new ConfigResponseData([ + 'account_id' => 'cross-sdk-test-account', + 'project' => ['id' => 'cross-sdk-test-project'], + 'experiences' => [[ + 'id' => $experienceId, + 'key' => $experienceId . '-key', + 'name' => 'Cross-SDK Anchored Bucketing Vector', + 'version' => $version, + 'variations' => $variations, + ]], + ]), + ]), + new BucketingManager(), + $this->createMock(RuleManagerInterface::class), + $this->createMock(EventManagerInterface::class), + $this->createMock(ApiManagerInterface::class), + new LogManager() + ); + + return $dataManager->getBucketingById( + $visitorId, + $experienceId, + new BucketingAttributes([ + 'ignoreLocationProperties' => true, + 'enableTracking' => false, + ]) + ); + } +} diff --git a/tests/CrossSdk/cross-sdk-bucketing-vectors.json b/tests/CrossSdk/cross-sdk-bucketing-vectors.json new file mode 100644 index 0000000..d101550 --- /dev/null +++ b/tests/CrossSdk/cross-sdk-bucketing-vectors.json @@ -0,0 +1,701 @@ +[ + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 601) now lands in O's RELOCATED band [0,833.33) -> reassigned to O. Documents the Distilled.ie incident: raising total allocation FLIPPED this visitor from V1 to O under the packed cumulative walk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) lands in V2's band [1000,1500) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1213) now lands in V1's RELOCATED band [833.33,1666.67) -> reassigned to V1. Second flip from the same incident (V2 -> V1)", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 877 (visitor thirds-stable-V1-77) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 877) still lands in V1's band [833.33,1666.67) -> V1 unaffected. Contrast vector: not every visitor flips on a packed raise, only those whose value falls inside a relocated sub-range", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1547 (visitor thirds-null-to-V1-25pct-48) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][lower-ejection-contrast] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1547) is newly admitted into V1's band [833.33,1666.67) at 25%. Read in reverse (25% -> 15%), this is AC3's packed lower-skew contrast vector: lowering coverage EJECTS this visitor to null, it is never reassigned to a different arm", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1733 (visitor thirds-null-to-V2-25pct-majority-6) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][incident-skew] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1733) is newly admitted into V2's band [1666.67,2500) at 25%. Documents the incident's uneven skew: the newly opened packed band overwhelmingly favors the LAST arm (V2), not an even 3-way split", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 3134 (visitor thirds-idle-both-packed-3) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 3134) ALSO exceeds the 25% total allocation -> not bucketed at either coverage", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 293) stays in O's SUPERSET band [0,833.33) -> O. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 3617 (visitor thirds-anchored-V1-core-1) lands in V1's band [3333.33,3833.33) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3617) stays in V1's SUPERSET band [3333.33,4166.67) -> V1. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 6871 (visitor thirds-anchored-V2-core-24) lands in V2's band [6666.67,7166.67) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 6871) stays in V2's SUPERSET band [6666.67,7500) -> V2. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) is NOT bucketed (falls between O's band [0,500) and V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 601) is newly admitted into O's growth sliver [500,833.33) at 25%. Contrast with the packed vector for this same value (V1 -> O flip): anchored never reassigns an already-bucketed visitor, it only ever admits from null", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 3899 (visitor thirds-anchored-V1-sliver-15) is NOT bucketed (exceeds V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3899) is newly admitted into V1's growth sliver (3833.33,4166.67) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 7353 (visitor thirds-anchored-V2-sliver-14) is NOT bucketed (exceeds V2's band [6666.67,7166.67))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 7353) is newly admitted into V2's growth sliver (7166.67,7500) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) is NOT bucketed under anchored. Contrast with the packed vectors for this same value (V2 -> V1 flip): anchored has no arm assignment at all here at either coverage, so there is no reassignment risk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 25% (8.333.../each): SAME visitor as above (value 1213) is STILL NOT bucketed under anchored at the higher coverage either", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 5848 (visitor thirds-idle-both-anchored-mid-2) is idle (falls between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 5848) is STILL idle at the higher coverage (still between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 8455 (visitor thirds-idle-both-high-0) exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 8455) STILL exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 102) -> O, unaffected by V1's stop (AC4 anchor stability)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 9807) -> V2's anchor (9000) is byte-identical whether V1 runs or is stopped (AC4)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 4957) -> stopped V1 keeps its weight (anchor stable at 1000) but has zero width, so it is never selected -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": null + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 4957 (visitor anchor-gate-visitor-17) -> V1. Z's explicit zero allocation is never defaulted to 100 and never perturbs V1's anchor; Z is never selected", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 9807 (visitor anchor-gate-visitor-162) -> V2. Z's zero-width entry does not shift V2's anchor since it contributes zero weight", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[nan-default] v12, single arm DEFAULT with traffic_allocation OMITTED (isNaN(ta) -> 100.0 default, full traffic space): any visitor is bucketed into DEFAULT", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 12, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v11, SAME single arm DEFAULT with traffic_allocation OMITTED: packed path also defaults to 100.0 -> DEFAULT (v11 === v12 for the NaN-default single-arm case)", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 11, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 102 (visitor anchor-gate-visitor-106) falls in B's own band [0,500) -> B (isNaN default on A does not swallow values clearly inside B's own range; config order wins ties)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "B" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 9807 (visitor anchor-gate-visitor-162) falls well inside A's defaulted 100-weight band -> A", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "A" + }, + { + "description": "[single-arm-v11-eq-v12] v11, single arm ONLY at traffic_allocation=100: any visitor -> ONLY", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 11, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[single-arm-v11-eq-v12] v12, SAME single arm ONLY at traffic_allocation=100: anchored path -> ONLY (v11 === v12 for a single full-allocation arm)", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 12, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 102) -> O. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 4957) -> V1. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 9807) -> V2. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 999 (visitor boundary-999-25207) is just below V1's anchor (1000) -> O (upper edge of O's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-999-25207", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 1000 (visitor boundary-1000-1145) EQUALS V1's anchor exactly -> V1 (anchor is inclusive: anchor <= value)", + "experienceId": "900000001", + "visitorId": "boundary-1000-1145", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 8999 (visitor boundary-8999-359) is just below V2's anchor (9000) -> V1 (upper edge of V1's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-8999-359", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9000 (visitor boundary-9000-9598) EQUALS V2's anchor exactly -> V2 (anchor is inclusive)", + "experienceId": "900000001", + "visitorId": "boundary-9000-9598", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9999 (visitor boundary-9999-5699) is the maximum representable traffic value, still inside V2's range -> V2", + "experienceId": "900000001", + "visitorId": "boundary-9999-5699", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[total-weight-zero] v12, two arms both traffic_allocation=0 (one running, one stopped): totalWeight is 0 -> not bucketed regardless of visitor", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + }, + { + "description": "[total-weight-zero] v11, SAME two zero-allocation arms: packed path filters both out entirely (empty bucket set) -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + } +]