diff --git a/packages/bucketing/index.ts b/packages/bucketing/index.ts index d454ce28..da876d9d 100644 --- a/packages/bucketing/index.ts +++ b/packages/bucketing/index.ts @@ -5,4 +5,7 @@ * License Apache-2.0 */ export {BucketingManager} from './src/bucketing-manager'; -export {BucketingManagerInterface} from './src/interfaces/bucketing-manager'; +export { + BucketingManagerInterface, + BucketAnchoredRange +} from './src/interfaces/bucketing-manager'; diff --git a/packages/bucketing/src/bucketing-manager.ts b/packages/bucketing/src/bucketing-manager.ts index e4645de3..47160ce6 100644 --- a/packages/bucketing/src/bucketing-manager.ts +++ b/packages/bucketing/src/bucketing-manager.ts @@ -5,12 +5,16 @@ * License Apache-2.0 */ -import {BucketingManagerInterface} from './interfaces/bucketing-manager'; +import { + BucketAnchoredRange, + BucketingManagerInterface +} from './interfaces/bucketing-manager'; import { BucketingAllocation, BucketingHash, - Config + Config, + VariationAllocation } from '@convertcom/js-sdk-types'; import {generateHash} from '@convertcom/js-sdk-utils'; import {LogManagerInterface} from '@convertcom/js-sdk-logger'; @@ -134,4 +138,95 @@ export class BucketingManager implements BucketingManagerInterface { bucketingAllocation: value } as BucketingAllocation; } + + /** + * Build the anchored bucket layout for a set of variation allocations (qs-01 / BUCK-2). + * 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 {VariationAllocation[]} allocations Variation allocations in config order + * @return {BucketAnchoredRange[]} + */ + getBucketRanges(allocations: VariationAllocation[]): BucketAnchoredRange[] { + const totalWeight = allocations.reduce( + (sum, {allocation}) => sum + allocation, + 0 + ); + const ranges: BucketAnchoredRange[] = []; + if (totalWeight <= 0) { + this._loggerManager?.debug?.('BucketingManager.getBucketRanges()', { + allocations: allocations, + totalWeight: totalWeight + }); + return ranges; + } + let cumWeight = 0; + allocations.forEach(({id, allocation, active}) => { + const anchor = (cumWeight / totalWeight) * DEFAULT_MAX_TRAFFIC; + const width = active ? allocation * 100 : 0; + ranges.push({id, anchor, width}); + cumWeight += allocation; + }); + this._loggerManager?.debug?.( + 'BucketingManager.getBucketRanges()', + {allocations: allocations, totalWeight: totalWeight}, + {ranges: ranges} + ); + return ranges; + } + + /** + * Select the variation whose anchored range contains the provided value. + * @param {BucketAnchoredRange[]} ranges Anchored bucket ranges (see {@link getBucketRanges}) + * @param {number} value A bucket value + * @return {string | null} + */ + selectBucketAnchored( + ranges: BucketAnchoredRange[], + value: number + ): string | null { + let variation = null; + ranges.some(({id, anchor, width}) => { + if (value >= anchor && value < anchor + width) { + variation = id; + return true; + } + return false; + }); + this._loggerManager?.debug?.( + 'BucketingManager.selectBucketAnchored()', + {ranges: ranges, value: value}, + {variation: variation} + ); + return variation; + } + + /** + * Get an anchored bucket for the visitor (qs-01 / BUCK-2). Reuses the existing + * visitor-based hash value unchanged, then resolves it through the anchored layout. + * @param {VariationAllocation[]} allocations Variation allocations in config order + * @param {string} visitorId + * @param {BucketingHash=} options + * @param {number=} [options.seed=] + * @param {string=} [options.experienceId=] + * @return {BucketingAllocation | null} + */ + getBucketForVisitorAnchored( + allocations: VariationAllocation[], + visitorId: string, + options?: BucketingHash + ): BucketingAllocation | null { + const value = this.getValueVisitorBased(visitorId, options); + const selectedBucket = this.selectBucketAnchored( + this.getBucketRanges(allocations), + value + ); + if (!selectedBucket) return null; + return { + variationId: selectedBucket, + bucketingAllocation: value + } as BucketingAllocation; + } } diff --git a/packages/bucketing/src/interfaces/bucketing-manager.ts b/packages/bucketing/src/interfaces/bucketing-manager.ts index 43a09a50..e7a5b3fd 100644 --- a/packages/bucketing/src/interfaces/bucketing-manager.ts +++ b/packages/bucketing/src/interfaces/bucketing-manager.ts @@ -4,7 +4,22 @@ * Copyright(c) 2020 Convert Insights, Inc * License Apache-2.0 */ -import {BucketingAllocation, BucketingHash} from '@convertcom/js-sdk-types'; +import { + BucketingAllocation, + BucketingHash, + VariationAllocation +} from '@convertcom/js-sdk-types'; + +/** + * A precomputed anchored bucket range for a single variation. + * `anchor` and `anchor + width` bound the half-open interval `[anchor, anchor + width)` + * (per-10000 traffic space) that maps to `id` in the anchored layout. + */ +export type BucketAnchoredRange = { + id: string; + anchor: number; + width: number; +}; export interface BucketingManagerInterface { selectBucket( @@ -20,4 +35,17 @@ export interface BucketingManagerInterface { visitorId: string, options?: BucketingHash ): BucketingAllocation | null; + + getBucketRanges(allocations: VariationAllocation[]): BucketAnchoredRange[]; + + selectBucketAnchored( + ranges: BucketAnchoredRange[], + value: number + ): string | null; + + getBucketForVisitorAnchored( + allocations: VariationAllocation[], + visitorId: string, + options?: BucketingHash + ): BucketingAllocation | null; } diff --git a/packages/bucketing/tests/bucketing-manager-anchored.tests.ts b/packages/bucketing/tests/bucketing-manager-anchored.tests.ts new file mode 100644 index 00000000..dace21ee --- /dev/null +++ b/packages/bucketing/tests/bucketing-manager-anchored.tests.ts @@ -0,0 +1,337 @@ +/* eslint-disable mocha/consistent-spacing-between-blocks */ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +/** + * qs-01 — anchored bucketing algorithm tests. + * + * Spec of record: _bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-01-anchored-bucketing-layout.md + * "The contract (normative)" section, AC2/AC3/AC4/AC5. + * + * These tests lock the shipped behavior of the three anchored bucketing methods on + * BucketingManager (packages/bucketing/src/bucketing-manager.ts): `getBucketRanges` + * (builds the anchor/width layout from a set of variation allocations), `selectBucketAnchored` + * (resolves a raw bucket value against that layout via a half-open [anchor, anchor + width) + * interval), and `getBucketForVisitorAnchored` (reuses the existing visitor-based hash, + * unmodified, and routes it through the two methods above). Together the suites below verify: + * - the raise-superset property: anchors are computed over the total weight of ALL + * entries so growing an experience's total allocation only ever grows arms and never + * reshuffles an already-bucketed visitor into a different arm (AC2 thirds/superset + * fixtures); + * - half-open boundary semantics at the anchor and anchor + width edges (AC5); + * - zero/inactive-arm handling: stopped or explicit zero-allocation entries keep their + * weight for anchor stability but get a zero-width range so they can never be selected, + * and a totalWeight <= 0 layout yields no bucketing (AC4/AC5); + * - determinism: getBucketForVisitorAnchored returns the same result for the same + * (visitorId, experienceId) and matches selectBucketAnchored(getBucketRanges(...), hash) + * composed directly from the existing hash oracle. + * + * Expected numbers below are derived BY HAND directly from the spec's normative + * pseudocode (anchor = (cumWeight / totalWeight) * 10000; width = active ? allocation * 100 : 0), + * written as literal arithmetic expressions so the doubles are IEEE754-exact and + * independently verifiable against the spec's layout table — never computed by calling + * the SUT. + */ +import 'mocha'; +import {expect} from 'chai'; +import {BucketingManager as bm} from '../src/bucketing-manager'; +import {BucketAnchoredRange} from '../src/interfaces/bucketing-manager'; +import {VariationAllocation} from '@convertcom/js-sdk-types'; + +// --- AC2: anchored thirds fixtures (Distilled.ie incident: 3 equal arms O/V1/V2) --- +// Spec table (qs-01 "Problem"): +// anchored 15%: O [0,500) V1 [3333,3833) V2 [6667,7167) +// anchored 25%: O [0,833) V1 [3333,4166) V2 [6667,7500) +// "15%"/"25%" is the experience's TOTAL coverage split evenly across 3 arms; the table's +// integers are display-rounded, the expected values here are the exact doubles the +// formula produces. +const THIRDS_15: { + label: string; + allocations: VariationAllocation[]; + expectedRanges: BucketAnchoredRange[]; +} = { + label: '15% total coverage (thirds: 5% per arm)', + allocations: [ + {id: 'O', allocation: 5, active: true}, + {id: 'V1', allocation: 5, active: true}, + {id: 'V2', allocation: 5, active: true} + ], + expectedRanges: [ + {id: 'O', anchor: (0 / 15) * 10000, width: 5 * 100}, + {id: 'V1', anchor: (5 / 15) * 10000, width: 5 * 100}, + {id: 'V2', anchor: (10 / 15) * 10000, width: 5 * 100} + ] +}; + +const THIRDS_25: { + label: string; + allocations: VariationAllocation[]; + expectedRanges: BucketAnchoredRange[]; +} = { + label: '25% total coverage (thirds: 25/3% per arm)', + allocations: [ + {id: 'O', allocation: 25 / 3, active: true}, + {id: 'V1', allocation: 25 / 3, active: true}, + {id: 'V2', allocation: 25 / 3, active: true} + ], + expectedRanges: [ + {id: 'O', anchor: (0 / 25) * 10000, width: (25 / 3) * 100}, + {id: 'V1', anchor: (25 / 3 / 25) * 10000, width: (25 / 3) * 100}, + { + id: 'V2', + anchor: ((2 * (25 / 3)) / 25) * 10000, + width: (25 / 3) * 100 + } + ] +}; + +const THIRDS_SCENARIOS = [THIRDS_15, THIRDS_25]; + +// Per-sliver / raise-superset admission table (AC2): one value per growth sliver, plus +// one value already inside the 15% layout to prove it never flips arm at 25%. +const SUPERSET_CASES: Array<{ + value: number; + at15: string | null; + at25: string; + description: string; +}> = [ + { + value: 100, + at15: 'O', + at25: 'O', + description: + 'a visitor already bucketed into O at 15% keeps O at 25% (superset, never flips)' + }, + { + value: 600, + at15: null, + at25: 'O', + description: + 'the O growth sliver [500,833.33) is unbucketed at 15% and newly admitted into O at 25%' + }, + { + value: 4000, + at15: null, + at25: 'V1', + description: + 'the V1 growth sliver (3833.33,4166.67) is unbucketed at 15% and newly admitted into V1 at 25%' + }, + { + value: 7300, + at15: null, + at25: 'V2', + description: + 'the V2 growth sliver (7166.67,7500) is unbucketed at 15% and newly admitted into V2 at 25%' + } +]; + +// AC5: anchor/width boundary semantics on a single-arm range, independent of getBucketRanges. +const BOUNDARY_RANGES: BucketAnchoredRange[] = [ + {id: 'A', anchor: 1000, width: 500} +]; +const BOUNDARY_CASES: Array<{ + value: number; + expected: string | null; + description: string; +}> = [ + {value: 999, expected: null, description: 'value just below anchor is OUT'}, + {value: 1000, expected: 'A', description: 'value === anchor is IN'}, + { + value: 1499, + expected: 'A', + description: 'value just below anchor + width is IN' + }, + { + value: 1500, + expected: null, + description: 'value === anchor + width is OUT (falls through, no arm)' + } +]; + +// AC5: totalWeight <= 0 -> not bucketed (asserted at the top-level getBucketForVisitorAnchored +// seam per the spec's own phrasing: "getBucketForVisitorAnchored (or the range/select path)"). +const ZERO_TOTAL_WEIGHT_CASES: Array<{ + label: string; + allocations: VariationAllocation[]; +}> = [ + {label: 'empty allocations array', allocations: []}, + { + label: 'every entry has allocation 0 (active and inactive)', + allocations: [ + {id: 'A', allocation: 0, active: true}, + {id: 'B', allocation: 0, active: false} + ] + } +]; + +describe('BucketingManager anchored tests (qs-01 / BUCK-2 — contract v12 anchored algorithm)', function () { + // Matches the existing packed-path suite's convention: no explicit type annotation + // is needed since the anchored methods are implemented directly on the + // BucketingManager class. + let bucketingManager; + + beforeEach(function () { + bucketingManager = new bm(); + }); + + describe('getBucketRanges() — AC2 anchored thirds layout (superset property)', function () { + // eslint-disable-next-line mocha/no-setup-in-describe + THIRDS_SCENARIOS.forEach(({label, allocations, expectedRanges}) => { + it(`computes the exact anchors/widths at ${label}`, function () { + expect(bucketingManager.getBucketRanges(allocations)).to.deep.equal( + expectedRanges + ); + }); + }); + }); + + describe('selectBucketAnchored() — AC2 raise-superset and per-sliver admission', function () { + // eslint-disable-next-line mocha/no-setup-in-describe + SUPERSET_CASES.forEach(({value, at15, at25, description}) => { + it(description, function () { + expect( + bucketingManager.selectBucketAnchored(THIRDS_15.expectedRanges, value) + ).to.equal(at15); + expect( + bucketingManager.selectBucketAnchored(THIRDS_25.expectedRanges, value) + ).to.equal(at25); + }); + }); + }); + + describe('selectBucketAnchored() — AC5 anchor/width boundary semantics (half-open interval)', function () { + // eslint-disable-next-line mocha/no-setup-in-describe + BOUNDARY_CASES.forEach(({value, expected, description}) => { + it(description, function () { + expect( + bucketingManager.selectBucketAnchored(BOUNDARY_RANGES, value) + ).to.equal(expected); + }); + }); + + it('selectBucketAnchored on an empty ranges array always yields null', function () { + expect(bucketingManager.selectBucketAnchored([], 4242)).to.equal(null); + }); + }); + + describe('AC5 — defaults', function () { + it('an allocation of 100 (the isNaN(ta) -> 100.0 default, already applied upstream per DataManager convention) is a normal full-width weight', function () { + const allocations: VariationAllocation[] = [ + {id: 'A', allocation: 100, active: true} + ]; + const expectedRanges: BucketAnchoredRange[] = [ + {id: 'A', anchor: 0, width: 10000} + ]; + expect(bucketingManager.getBucketRanges(allocations)).to.deep.equal( + expectedRanges + ); + expect( + bucketingManager.selectBucketAnchored(expectedRanges, 5000) + ).to.equal('A'); + }); + + // eslint-disable-next-line mocha/no-setup-in-describe + ZERO_TOTAL_WEIGHT_CASES.forEach(({label, allocations}) => { + it(`totalWeight <= 0 (${label}) yields null via getBucketForVisitorAnchored`, function () { + expect( + bucketingManager.getBucketForVisitorAnchored(allocations, '01ABCD') + ).to.equal(null); + }); + }); + }); + + describe('AC4 — stops and zero-allocation arms are zero-width but keep their weight', function () { + it('an inactive arm keeps its weight (later arms have byte-identical anchors) but gets zero width and is never selected', function () { + const activeAllocations: VariationAllocation[] = [ + {id: 'O', allocation: 10, active: true}, + {id: 'V1', allocation: 10, active: true}, + {id: 'V2', allocation: 10, active: true} + ]; + const stoppedAllocations: VariationAllocation[] = [ + {id: 'O', allocation: 10, active: true}, + {id: 'V1', allocation: 10, active: false}, + {id: 'V2', allocation: 10, active: true} + ]; + + const activeRanges = bucketingManager.getBucketRanges(activeAllocations); + const stoppedRanges = + bucketingManager.getBucketRanges(stoppedAllocations); + + // Anchor stability under status-based stops: stopping V1 changes only V1's width. + expect(stoppedRanges.map((range) => range.anchor)).to.deep.equal( + activeRanges.map((range) => range.anchor) + ); + expect(stoppedRanges[2]).to.deep.equal(activeRanges[2]); // V2 is byte-identical + + expect(stoppedRanges[1].width).to.equal(0); + expect( + bucketingManager.selectBucketAnchored( + stoppedRanges, + stoppedRanges[1].anchor + ) + ).to.not.equal('V1'); + }); + + it('an explicit allocation: 0 arm is zero-width (never defaults to 100) and is skipped for the next arm sharing its anchor', function () { + const allocations: VariationAllocation[] = [ + {id: 'O', allocation: 10, active: true}, + {id: 'Z', allocation: 0, active: true}, + {id: 'V2', allocation: 10, active: true} + ]; + const ranges: BucketAnchoredRange[] = + bucketingManager.getBucketRanges(allocations); + const zRange = ranges.find((range) => range.id === 'Z'); + const v2Range = ranges.find((range) => range.id === 'V2'); + + expect(zRange.width).to.equal(0); + expect(zRange.anchor).to.equal(v2Range.anchor); + expect( + bucketingManager.selectBucketAnchored(ranges, zRange.anchor) + ).to.equal('V2'); + }); + }); + + describe('getBucketForVisitorAnchored() — reuses the existing visitor hash, routes through getBucketRanges + selectBucketAnchored', function () { + const visitorId = '01ABCD'; + const options = {experienceId: 'exp-anchored-1'}; + + it('is deterministic for the same visitorId/experienceId', function () { + const first = bucketingManager.getBucketForVisitorAnchored( + THIRDS_15.allocations, + visitorId, + options + ); + const second = bucketingManager.getBucketForVisitorAnchored( + THIRDS_15.allocations, + visitorId, + options + ); + expect(second).to.deep.equal(first); + }); + + it('matches selectBucketAnchored(getBucketRanges(allocations), getValueVisitorBased(visitorId, options)) — the existing hash, unmodified', function () { + const value = bucketingManager.getValueVisitorBased(visitorId, options); + const expectedVariationId = bucketingManager.selectBucketAnchored( + bucketingManager.getBucketRanges(THIRDS_15.allocations), + value + ); + const result = bucketingManager.getBucketForVisitorAnchored( + THIRDS_15.allocations, + visitorId, + options + ); + if (expectedVariationId === null) { + expect(result).to.equal(null); + } else { + expect(result).to.deep.equal({ + variationId: expectedVariationId, + bucketingAllocation: value + }); + } + }); + }); +}); diff --git a/packages/bucketing/tests/cross-sdk-bucketing-vectors.json b/packages/bucketing/tests/cross-sdk-bucketing-vectors.json new file mode 100644 index 00000000..d1015509 --- /dev/null +++ b/packages/bucketing/tests/cross-sdk-bucketing-vectors.json @@ -0,0 +1,701 @@ +[ + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 601) now lands in O's RELOCATED band [0,833.33) -> reassigned to O. Documents the Distilled.ie incident: raising total allocation FLIPPED this visitor from V1 to O under the packed cumulative walk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) lands in V2's band [1000,1500) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1213) now lands in V1's RELOCATED band [833.33,1666.67) -> reassigned to V1. Second flip from the same incident (V2 -> V1)", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 877 (visitor thirds-stable-V1-77) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 877) still lands in V1's band [833.33,1666.67) -> V1 unaffected. Contrast vector: not every visitor flips on a packed raise, only those whose value falls inside a relocated sub-range", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1547 (visitor thirds-null-to-V1-25pct-48) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][lower-ejection-contrast] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1547) is newly admitted into V1's band [833.33,1666.67) at 25%. Read in reverse (25% -> 15%), this is AC3's packed lower-skew contrast vector: lowering coverage EJECTS this visitor to null, it is never reassigned to a different arm", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1733 (visitor thirds-null-to-V2-25pct-majority-6) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][incident-skew] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1733) is newly admitted into V2's band [1666.67,2500) at 25%. Documents the incident's uneven skew: the newly opened packed band overwhelmingly favors the LAST arm (V2), not an even 3-way split", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 3134 (visitor thirds-idle-both-packed-3) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 3134) ALSO exceeds the 25% total allocation -> not bucketed at either coverage", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 293) stays in O's SUPERSET band [0,833.33) -> O. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 3617 (visitor thirds-anchored-V1-core-1) lands in V1's band [3333.33,3833.33) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3617) stays in V1's SUPERSET band [3333.33,4166.67) -> V1. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 6871 (visitor thirds-anchored-V2-core-24) lands in V2's band [6666.67,7166.67) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 6871) stays in V2's SUPERSET band [6666.67,7500) -> V2. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) is NOT bucketed (falls between O's band [0,500) and V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 601) is newly admitted into O's growth sliver [500,833.33) at 25%. Contrast with the packed vector for this same value (V1 -> O flip): anchored never reassigns an already-bucketed visitor, it only ever admits from null", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 3899 (visitor thirds-anchored-V1-sliver-15) is NOT bucketed (exceeds V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3899) is newly admitted into V1's growth sliver (3833.33,4166.67) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 7353 (visitor thirds-anchored-V2-sliver-14) is NOT bucketed (exceeds V2's band [6666.67,7166.67))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 7353) is newly admitted into V2's growth sliver (7166.67,7500) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) is NOT bucketed under anchored. Contrast with the packed vectors for this same value (V2 -> V1 flip): anchored has no arm assignment at all here at either coverage, so there is no reassignment risk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 25% (8.333.../each): SAME visitor as above (value 1213) is STILL NOT bucketed under anchored at the higher coverage either", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 5848 (visitor thirds-idle-both-anchored-mid-2) is idle (falls between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 5848) is STILL idle at the higher coverage (still between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 8455 (visitor thirds-idle-both-high-0) exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 8455) STILL exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 102) -> O, unaffected by V1's stop (AC4 anchor stability)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 9807) -> V2's anchor (9000) is byte-identical whether V1 runs or is stopped (AC4)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 4957) -> stopped V1 keeps its weight (anchor stable at 1000) but has zero width, so it is never selected -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": null + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 4957 (visitor anchor-gate-visitor-17) -> V1. Z's explicit zero allocation is never defaulted to 100 and never perturbs V1's anchor; Z is never selected", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 9807 (visitor anchor-gate-visitor-162) -> V2. Z's zero-width entry does not shift V2's anchor since it contributes zero weight", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[nan-default] v12, single arm DEFAULT with traffic_allocation OMITTED (isNaN(ta) -> 100.0 default, full traffic space): any visitor is bucketed into DEFAULT", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 12, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v11, SAME single arm DEFAULT with traffic_allocation OMITTED: packed path also defaults to 100.0 -> DEFAULT (v11 === v12 for the NaN-default single-arm case)", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 11, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 102 (visitor anchor-gate-visitor-106) falls in B's own band [0,500) -> B (isNaN default on A does not swallow values clearly inside B's own range; config order wins ties)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "B" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 9807 (visitor anchor-gate-visitor-162) falls well inside A's defaulted 100-weight band -> A", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "A" + }, + { + "description": "[single-arm-v11-eq-v12] v11, single arm ONLY at traffic_allocation=100: any visitor -> ONLY", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 11, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[single-arm-v11-eq-v12] v12, SAME single arm ONLY at traffic_allocation=100: anchored path -> ONLY (v11 === v12 for a single full-allocation arm)", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 12, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 102) -> O. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 4957) -> V1. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 9807) -> V2. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 999 (visitor boundary-999-25207) is just below V1's anchor (1000) -> O (upper edge of O's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-999-25207", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 1000 (visitor boundary-1000-1145) EQUALS V1's anchor exactly -> V1 (anchor is inclusive: anchor <= value)", + "experienceId": "900000001", + "visitorId": "boundary-1000-1145", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 8999 (visitor boundary-8999-359) is just below V2's anchor (9000) -> V1 (upper edge of V1's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-8999-359", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9000 (visitor boundary-9000-9598) EQUALS V2's anchor exactly -> V2 (anchor is inclusive)", + "experienceId": "900000001", + "visitorId": "boundary-9000-9598", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9999 (visitor boundary-9999-5699) is the maximum representable traffic value, still inside V2's range -> V2", + "experienceId": "900000001", + "visitorId": "boundary-9999-5699", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[total-weight-zero] v12, two arms both traffic_allocation=0 (one running, one stopped): totalWeight is 0 -> not bucketed regardless of visitor", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + }, + { + "description": "[total-weight-zero] v11, SAME two zero-allocation arms: packed path filters both out entirely (empty bucket set) -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + } +] diff --git a/packages/data/src/data-manager.ts b/packages/data/src/data-manager.ts index 210f4c25..90329a92 100644 --- a/packages/data/src/data-manager.ts +++ b/packages/data/src/data-manager.ts @@ -38,10 +38,12 @@ import { GoalData, VisitorSegments, ConfigSegment, + BucketingAllocation, BucketingAttributes, LocationAttributes, ConfigAudienceTypes, VariationStatuses, + VariationAllocation, eventType, GenericListMatchingOptions } from '@convertcom/js-sdk-types'; @@ -544,6 +546,69 @@ export class DataManager implements DataManagerInterface { return null; } + /** + * 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 {ExperienceVariationConfig[]} variations + * @return {Record} + * @private + */ + private _buildPackedBuckets( + variations: ExperienceVariationConfig[] + ): Record { + return variations + .filter((variation) => + variation?.status + ? variation.status === VariationStatuses.RUNNING + : true + ) + .filter( + (variation) => + variation?.traffic_allocation > 0 || // zero-traffic means stopped variation + isNaN(variation?.traffic_allocation) // no allocation means 100% traffic + ) + .reduce((bucket, variation) => { + if (variation?.id) + bucket[variation.id] = variation?.traffic_allocation || 100.0; + return bucket; + }, {}) as Record; + } + + /** + * Build variation allocations for the anchored layout (qs-01 / DATA-1, 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 {@link BucketingManagerInterface.getBucketRanges} + * gives them zero width. See qs-01-anchored-bucketing-layout.md "The contract (normative)". + * @param {ExperienceVariationConfig[]} variations + * @return {VariationAllocation[]} + * @private + */ + private _buildVariationAllocations( + variations: ExperienceVariationConfig[] + ): VariationAllocation[] { + return variations.reduce((allocations, variation) => { + if (!variation?.id) return allocations; + const trafficAllocation = variation.traffic_allocation; + allocations.push({ + id: variation.id, + allocation: isNaN(trafficAllocation) + ? 100.0 + : Number(trafficAllocation), + active: + (variation.status + ? variation.status === VariationStatuses.RUNNING + : true) && + (trafficAllocation > 0 || isNaN(trafficAllocation)) + }); + return allocations; + }, [] as VariationAllocation[]); + } + /** * Retrieve bucketing for Visitor * @param {string} visitorId @@ -618,31 +683,37 @@ export class DataManager implements DataManagerInterface { }) ); } else { - // Build buckets where key is variation id and value is traffic distribution - const buckets = experience.variations - .filter((variation) => - variation?.status - ? variation.status === VariationStatuses.RUNNING - : true - ) - .filter( - (variation) => - variation?.traffic_allocation > 0 || // zero-traffic means stopped variation - isNaN(variation?.traffic_allocation) // no allocation means 100% traffic - ) - .reduce((bucket, variation) => { - if (variation?.id) - bucket[variation.id] = variation?.traffic_allocation || 100.0; - return bucket; - }, {}) as Record; - // Select bucket based for provided visitor id - const bucketing = this._bucketingManager.getBucketForVisitor( - buckets, - visitorId, - this._config?.bucketing?.excludeExperienceIdHash - ? null - : {experienceId: experience.id.toString()} - ); + // qs-01 / DATA-1: anchored-vs-packed GATE. `experience.version > 11` runs the + // anchored (contract v12) layout; the anchored contract activates starting at + // experience version 12. Version <= 11, missing, or non-numeric keeps the + // existing packed cumulative walk unchanged -- this is every currently-served + // production experience, all stamped version 11 (backend + // `CURRENT_EXPERIENCE_VERSION`). Raising the gate to 12 is a deliberate, + // separate backend rollout step; the SDK must never infer the layout from + // anything but this field. See qs-01-anchored-bucketing-layout.md + // "The contract (normative)" for the gate and the allocation-build mapping. + const isAnchoredLayout = Number(experience.version) > 11; + const bucketingHashOptions = this._config?.bucketing + ?.excludeExperienceIdHash + ? null + : {experienceId: experience.id.toString()}; + let buckets: VariationAllocation[] | Record; + let bucketing: BucketingAllocation | null; + if (isAnchoredLayout) { + buckets = this._buildVariationAllocations(experience.variations); + bucketing = this._bucketingManager.getBucketForVisitorAnchored( + buckets, + visitorId, + bucketingHashOptions + ); + } else { + buckets = this._buildPackedBuckets(experience.variations); + bucketing = this._bucketingManager.getBucketForVisitor( + buckets, + visitorId, + bucketingHashOptions + ); + } variationId = variationId || bucketing?.variationId; // variation might be forced bucketingAllocation = bucketing?.bucketingAllocation; // Return bucketing errors if present diff --git a/packages/data/tests/cross-sdk-vectors.tests.ts b/packages/data/tests/cross-sdk-vectors.tests.ts new file mode 100644 index 00000000..9b534979 --- /dev/null +++ b/packages/data/tests/cross-sdk-vectors.tests.ts @@ -0,0 +1,169 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +/** + * qs-01 (GOLD-1) — cross-SDK golden-vector bucketing contract runner. + * + * Spec of record: _bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-01-anchored-bucketing-layout.md + * "Golden-vector fixture" section, AC2/AC3/AC4/AC5/AC6/AC7. + * + * This is the canonical cross-SDK parity fixture's JS reference runner. The fixture + * (packages/bucketing/tests/cross-sdk-bucketing-vectors.json) is authored and owned by the + * JS SDK (this repo, the reference implementation per qs-01) and is copied verbatim by the + * five sibling SDK repos (PHP/Python/Ruby/Android/iOS) for their own parity suites — every + * `expected` value below was computed by THIS implementation and then frozen; changing this + * fixture is a cross-SDK contract change, not a local test tweak. + * + * Home decision: this runner lives in packages/data/tests (not packages/bucketing/tests) + * because exercising the version gate end-to-end requires DataManager, and the workspace's + * dependency direction only goes packages/data -> packages/bucketing (bucketing has no + * dependency on data; see packages/bucketing/package.json's peerDependencies and the root + * build order enums->types->utils->event->bucketing->...->data->...). The fixture asset + * itself stays in packages/bucketing/tests per this feature's earlier instruction (that is + * where the algorithm-level anchored tests already live); this file reads it via a relative + * filesystem path (not a package import), so no new package dependency edge is introduced + * and the existing build order is untouched. See this feature's decision log for the full + * reasoning. + * + * Each vector is driven through DataManager.getBucketingById -- the exact same public seam + * DATA-1's gate tests use -- so every vector exercises the REAL gate + * (`Number(experience.version) > 11`), the real allocation-build mapping, and the real + * bucketing managers, not a re-implementation of the algorithm. + */ +import 'mocha'; +import {expect} from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; +import {BucketingManager as bm} from '@convertcom/js-sdk-bucketing'; +import {RuleManager as rm} from '@convertcom/js-sdk-rules'; +import {EventManager as em} from '@convertcom/js-sdk-event'; +import {ApiManager as am} from '@convertcom/js-sdk-api'; +import {DataManager as dm} from '../src/data-manager'; +import testConfig from './test-config.json'; +import { + Config as ConfigType, + ConfigExperience, + BucketingAttributes +} from '@convertcom/js-sdk-types'; +import {objectDeepMerge} from '@convertcom/js-sdk-utils'; +import {defaultConfig} from '../../js-sdk/src/config/default'; +import {BucketingError} from '@convertcom/js-sdk-enums'; + +interface CrossSdkVariation { + id: string; + traffic_allocation?: number; + status?: 'running' | 'stopped'; +} + +interface CrossSdkVector { + description: string; + experienceId: string; + visitorId: string; + version: number; + variations: CrossSdkVariation[]; + expected: string | null; +} + +// Hoisted once: shared dependency managers + the fixture itself, per this repo's +// SonarCloud new_duplicated_lines_density <= 3% rule (no per-case setup/teardown). +const FIXTURE_PATH = path.resolve( + __dirname, + '../../bucketing/tests/cross-sdk-bucketing-vectors.json' +); +const VECTORS: CrossSdkVector[] = JSON.parse( + fs.readFileSync(FIXTURE_PATH, 'utf8') +); + +const configuration = objectDeepMerge( + testConfig, + defaultConfig, + {} +) as unknown as ConfigType; +const bucketingManager = new bm(configuration); +const ruleManager = new rm(configuration); +const eventManager = new em(configuration); +const apiManager = new am(configuration, {eventManager}); + +// Every vector targets the fresh-bucketing GATE only, matching DATA-1's own gate-test +// attributes: `ignoreLocationProperties` bypasses site_area/locations entirely, an empty +// `visitorProperties` plus an empty `experience.audiences` list satisfies the "unrestricted" +// rule-matching branches, and `enableTracking: false` avoids depending on a live +// track-endpoint server. +const ATTRS: BucketingAttributes = { + visitorProperties: {}, + ignoreLocationProperties: true, + enableTracking: false, + updateVisitorProperties: false +}; + +function runVector(vector: CrossSdkVector) { + const experience = { + id: vector.experienceId, + name: `exp-${vector.experienceId}`, + key: `key-${vector.experienceId}`, + type: 'a/b_fullstack', + audiences: [], + goals: [], + variations: vector.variations, + version: vector.version + } as unknown as ConfigExperience; + + const dataManager = new dm( + { + data: { + account_id: 'cross-sdk-vectors-account', + project: {id: 'cross-sdk-vectors-project'}, + experiences: [experience] + } + } as unknown as ConfigType, + {bucketingManager, ruleManager, eventManager, apiManager} + ); + + return dataManager.getBucketingById( + vector.visitorId, + vector.experienceId, + ATTRS + ); +} + +describe('Cross-SDK golden-vector bucketing contract (qs-01 / GOLD-1, AC7)', function () { + // eslint-disable-next-line mocha/no-setup-in-describe + VECTORS.forEach((vector) => { + it(vector.description, function () { + const result = runVector(vector); + if (vector.expected === null) { + expect(result).to.equal(BucketingError.VARIAION_NOT_DECIDED); + } else { + expect(result) + .to.be.an('object') + .that.has.property('id', vector.expected); + } + }); + }); + + it('loaded every required golden-vector category (AC7 completeness guard)', function () { + const descriptions = VECTORS.map((vector) => vector.description).join( + '\n' + ); + [ + '[packed-regression]', + '[anchored-basic-thirds]', + '[per-sliver-admission]', + '[anchored-idle]', + '[stopped-arm-stability]', + '[ta-zero-width]', + '[nan-default]', + '[single-arm-v11-eq-v12]', + '[100pct-total-v11-eq-v12]', + '[boundary-hit]', + '[total-weight-zero]' + ].forEach((tag) => { + expect(descriptions).to.include(tag); + }); + expect(VECTORS.length).to.be.greaterThan(0); + }); +}); diff --git a/packages/data/tests/data-manager-anchored-gate.tests.ts b/packages/data/tests/data-manager-anchored-gate.tests.ts new file mode 100644 index 00000000..aad9d4a2 --- /dev/null +++ b/packages/data/tests/data-manager-anchored-gate.tests.ts @@ -0,0 +1,413 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +/** + * qs-01 — anchored-vs-packed GATE tests in DataManager._retrieveBucketing. + * + * Spec of record: _bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-01-anchored-bucketing-layout.md + * "The contract (normative)" section (the Gate paragraph + the allocation-build mapping), + * AC1, AC4, AC8, AC9. + * + * These tests lock the shipped gate in DataManager._retrieveBucketing's fresh-bucketing + * branch (packages/data/src/data-manager.ts:685): `Number(experience.version) > 11` routes + * fresh bucketing through the anchored layout (built by `_buildVariationAllocations`, + * data-manager.ts:586, and resolved via `BucketingManager.getBucketForVisitorAnchored`); + * version <= 11, missing/undefined, or non-numeric version keeps the existing packed + * cumulative walk unchanged (built by `_buildPackedBuckets`, data-manager.ts:556, and + * resolved via `BucketingManager.getBucketForVisitor`). Version 11 is the version stamped + * on every experience currently served in production today (backend + * `CURRENT_EXPERIENCE_VERSION`); the anchored contract only activates once the backend + * separately bumps that constant to 12. AC1 asserts the gate actually + * branches by exploiting a real packed-vs-anchored disagreement on the same fixture (see + * below). AC4 asserts stops only zero their own width under the anchored path and never + * move neighboring arms' anchors. AC8/AC9 protect existing behavior that runs entirely + * before/independently of the gate: the stored-decision guard (data-manager.ts:658-665) + * short-circuits before the version check is ever reached, and the returned + * BucketedVariation shape is unchanged across versions. + * + * Fixture derivation methodology: every raw bucket VALUE used below (e.g. 4957, 9807, 102) + * is a real MurmurHash3 output from BucketingManager.getValueVisitorBased -- the same, + * already-implemented, already-unit-tested hash oracle the packed and anchored paths both + * call unmodified (packages/bucketing/src/bucketing-manager.ts:91-112; see + * packages/bucketing/tests/bucketing-manager.tests.ts for its own tests). Values were + * derived once (for the fixed (visitorId, experienceId) string pairs used below, with the + * default hash seed) via that exact method, then frozen as literal numbers so every + * packed/anchored expectation in this file is independently re-derivable by hand from the + * spec's normative formulas (anchor = (cumWeight / totalWeight) * 10000; width = active ? + * allocation * 100 : 0) against the fixed traffic_allocation values declared alongside them, + * without needing to re-run any tool. This is the same class of technique used in + * bucketing-manager-anchored.tests.ts to freeze its THIRDS_15/THIRDS_25 expected ranges + * from the spec's own pseudocode, applied one level up (the raw hash value is looked up + * from the hash oracle instead of hand-computed, since MurmurHash3 output cannot reasonably + * be hand-derived; everything downstream of that single looked-up number -- which bucket it + * falls into under each layout -- is plain arithmetic against the fixed weights, + * independently checkable inline in each fixture's comment). + */ +import 'mocha'; +import {expect} from 'chai'; +import {BucketingManager as bm} from '@convertcom/js-sdk-bucketing'; +import {RuleManager as rm} from '@convertcom/js-sdk-rules'; +import {EventManager as em} from '@convertcom/js-sdk-event'; +import {ApiManager as am} from '@convertcom/js-sdk-api'; +import {DataManager as dm} from '../src/data-manager'; +import testConfig from './test-config.json'; +import { + Config as ConfigType, + ConfigExperience, + ExperienceVariationConfig, + BucketingAttributes +} from '@convertcom/js-sdk-types'; +import {objectDeepMerge} from '@convertcom/js-sdk-utils'; +import {defaultConfig} from '../../js-sdk/src/config/default'; +import {BucketingError} from '@convertcom/js-sdk-enums'; + +// --- Shared dependency managers (mirrors data-manager.tests.ts's construction pattern) --- +const configuration = objectDeepMerge( + testConfig, + defaultConfig, + {} +) as unknown as ConfigType; +const bucketingManager = new bm(configuration); +const ruleManager = new rm(configuration); +const eventManager = new em(configuration); +const apiManager = new am(configuration, {eventManager}); + +// Every getBucketing() call in this file targets the GATE only -- not audience/location +// matching, not the tracking queue, not visitor-property persistence. `ignoreLocationProperties` +// bypasses site_area/locations entirely; a truthy (empty) `visitorProperties` object plus an +// empty `experience.audiences` list satisfies matchRulesByField's "unrestricted" branches +// (data-manager.ts:291-334, :350-416); `enableTracking: false` avoids depending on a live +// track-endpoint server for tests that only assert the returned bucketing decision. +const ATTRS: BucketingAttributes = { + visitorProperties: {}, + ignoreLocationProperties: true, + enableTracking: false, + updateVisitorProperties: false +}; + +function makeVariation( + id: string, + trafficAllocation: number, + status?: 'running' | 'stopped' +): ExperienceVariationConfig { + return { + id, + key: `${id}-key`, + name: id, + traffic_allocation: trafficAllocation, + ...(status ? {status} : {}) + } as unknown as ExperienceVariationConfig; +} + +function makeExperience( + id: string, + version: number | string | undefined, + variations: ExperienceVariationConfig[] +): ConfigExperience { + return { + id, + name: `exp-${id}`, + key: `key-${id}`, + type: 'a/b_fullstack', + audiences: [], + goals: [], + variations, + version + } as unknown as ConfigExperience; +} + +function makeDataManager(experiences: ConfigExperience[]) { + const config = { + data: { + account_id: 'anchor-gate-account', + project: {id: 'anchor-gate-project'}, + experiences + } + } as unknown as ConfigType; + return new dm(config, { + bucketingManager, + ruleManager, + eventManager, + apiManager + }); +} + +function bucketFor( + dataManager: ReturnType, + visitorId: string, + experienceId: string +) { + return dataManager.getBucketingById(visitorId, experienceId, ATTRS); +} + +describe('DataManager anchored-vs-packed GATE tests (qs-01 / DATA-1)', function () { + // --- AC1: gate branching on a real packed-vs-anchored disagreement --- + // Experience: O=2%, V1=47%, V2=1% (totalWeight=50%, sub-100% -- exactly the shape that + // makes packed and anchored diverge, per the spec's own "Problem" table). + // packed cumulative walk: O[0,200) V1[200,4900) V2[4900,5000) + // anchored ranges (T=50): O anchor=0 width=200 -> [0,200) + // V1 anchor=(2/50)*10000=400 width=4700 -> [400,5100) + // V2 anchor=(49/50)*10000=9800 width=100 -> [9800,9900) + // visitorId 'anchor-gate-visitor-17' against experienceId '900000001' hashes (via the + // existing, unmodified BucketingManager.getValueVisitorBased) to the raw value 4957. + // 4957 is in packed's V2 band [4900,5000) -> packed expects 'V2' + // 4957 is in anchored's V1 band [400,5100) -> anchored expects 'V1' + const GATE_EXPERIENCE_ID = '900000001'; + const GATE_VISITOR_ID = 'anchor-gate-visitor-17'; + // Inlined (rather than built via makeVariation()) so this describe-level fixture doesn't + // trip mocha/no-setup-in-describe -- matches the plain-object-literal convention already + // used for fixtures in bucketing-manager-anchored.tests.ts's THIRDS_15/THIRDS_25. + const GATE_VARIATIONS: ExperienceVariationConfig[] = [ + { + id: 'O', + key: 'O-key', + name: 'O', + traffic_allocation: 2, + status: 'running' + }, + { + id: 'V1', + key: 'V1-key', + name: 'V1', + traffic_allocation: 47, + status: 'running' + }, + { + id: 'V2', + key: 'V2-key', + name: 'V2', + traffic_allocation: 1, + status: 'running' + } + ] as unknown as ExperienceVariationConfig[]; + const PACKED_EXPECTED_VARIATION_ID = 'V2'; + const ANCHORED_EXPECTED_VARIATION_ID = 'V1'; + + const GATE_CASES: Array<{ + label: string; + version: number | string | undefined; + expectedVariationId: string; + }> = [ + { + label: + 'version 12 (> 11) routes fresh bucketing through the anchored layout', + version: 12, + expectedVariationId: ANCHORED_EXPECTED_VARIATION_ID + }, + { + label: + 'version 42 (> 11) routes fresh bucketing through the anchored layout', + version: 42, + expectedVariationId: ANCHORED_EXPECTED_VARIATION_ID + }, + { + label: + 'version 11 (not > 11; the exact version stamp every currently-served production ' + + 'experience carries today -- CURRENT_EXPERIENCE_VERSION) routes fresh bucketing ' + + 'through the packed layout', + version: 11, + expectedVariationId: PACKED_EXPECTED_VARIATION_ID + }, + { + label: + 'missing/undefined version routes fresh bucketing through the packed layout', + version: undefined, + expectedVariationId: PACKED_EXPECTED_VARIATION_ID + }, + { + label: + 'non-numeric version (Number(v) -> NaN, NaN > 11 === false) routes fresh bucketing through the packed layout', + version: 'not-a-number', + expectedVariationId: PACKED_EXPECTED_VARIATION_ID + } + ]; + + describe('AC1 -- gate branching (Number(experience.version) > 11); exact boundary: 11 is packed, 12 is anchored', function () { + // eslint-disable-next-line mocha/no-setup-in-describe + GATE_CASES.forEach(({label, version, expectedVariationId}) => { + it(label, function () { + const dataManager = makeDataManager([ + makeExperience(GATE_EXPERIENCE_ID, version, GATE_VARIATIONS) + ]); + const result = bucketFor( + dataManager, + GATE_VISITOR_ID, + GATE_EXPERIENCE_ID + ); + expect(result) + .to.be.an('object') + .that.has.property('id', expectedVariationId); + }); + }); + }); + + // --- AC4: stops don't move anchors --- + describe('AC4 -- stops zero only their own width; other arms are byte-identical before/after', function () { + it('status: stopped (traffic_allocation preserved) zeroes only the width of V1; O and V2 are unaffected', function () { + // O=10%, V1=80%, V2=10% (totalWeight=100% -- at exactly 100%, packed and anchored + // coincide in the RUNNING state, isolating the effect of stopping V1 from any + // sub-100%-driven divergence already covered by the AC1 fixture above). + // RUNNING packed: O[0,1000) V1[1000,9000) V2[9000,10000) + // RUNNING anchored: O[0,1000) V1[1000,9000) V2[9000,10000) (same -- T=100%) + // STOPPED packed (V1 excluded from the buckets entirely): O[0,1000) V2[1000,2000) + // STOPPED anchored (V1 keeps its weight=80 for anchor stability, width=0): + // O anchor=0 width=1000 -> [0,1000) (unchanged) + // V1 anchor=(10/100)*10000=1000 width=0 (inactive) -> never selected + // V2 anchor=(10+80)/100*10000=9000 width=1000 -> [9000,10000) (unchanged) + // Both DataManager instances below are fully independent (separate in-memory stores), + // so reusing the SAME experience id across both is required (not just safe) here: the + // pre-derived hash values below (102, 9807, 4957) were looked up against experienceId + // '900000001' specifically -- MurmurHash3's input is `experienceId + visitorId`, so a + // different id string would hash to different, undetermined values. + const experienceIdRunning = GATE_EXPERIENCE_ID; + const experienceIdStopped = GATE_EXPERIENCE_ID; + const runningVariations = [ + makeVariation('O', 10, 'running'), + makeVariation('V1', 80, 'running'), + makeVariation('V2', 10, 'running') + ]; + const stoppedVariations = [ + makeVariation('O', 10, 'running'), + makeVariation('V1', 80, 'stopped'), + makeVariation('V2', 10, 'running') + ]; + const dataManagerRunning = makeDataManager([ + makeExperience(experienceIdRunning, 12, runningVariations) + ]); + const dataManagerStopped = makeDataManager([ + makeExperience(experienceIdStopped, 12, stoppedVariations) + ]); + + // O witness (value 102, O's own band [0,1000) is always first -> always unaffected). + const oVisitor = 'anchor-gate-visitor-106'; + expect(bucketFor(dataManagerRunning, oVisitor, experienceIdRunning)) + .to.be.an('object') + .that.has.property('id', 'O'); + expect(bucketFor(dataManagerStopped, oVisitor, experienceIdStopped)) + .to.be.an('object') + .that.has.property('id', 'O'); + + // V2 witness (value 9807, V2's anchored band [9000,10000) must stay identical once V1 + // stops; packed's cumulative walk reshuffles V2 down to [1000,2000) once V1 is excluded, + // so this is the assertion that fails today (pre-gate, still packed for version 12). + const v2Visitor = 'anchor-gate-visitor-162'; + expect(bucketFor(dataManagerRunning, v2Visitor, experienceIdRunning)) + .to.be.an('object') + .that.has.property('id', 'V2'); + expect(bucketFor(dataManagerStopped, v2Visitor, experienceIdStopped)) + .to.be.an('object') + .that.has.property('id', 'V2'); + + // V1 witness (value 4957): bucketed into the arm being stopped while it was running, + // and correctly unselectable (zero width) once stopped. + const v1Visitor = GATE_VISITOR_ID; + expect(bucketFor(dataManagerRunning, v1Visitor, experienceIdRunning)) + .to.be.an('object') + .that.has.property('id', 'V1'); + expect( + bucketFor(dataManagerStopped, v1Visitor, experienceIdStopped) + ).to.equal(BucketingError.VARIAION_NOT_DECIDED); + }); + + it('explicit traffic_allocation: 0 is zero-width (never defaults to 100) and does not perturb neighboring anchors', function () { + // Same O/V1/V2 weights and experienceId as the AC1 fixture, with an explicit + // Z (traffic_allocation: 0, status: running) inserted between V1 and V2. A zero-weight + // entry contributes nothing to cumWeight/totalWeight under the spec's formula, so it + // must be mathematically inert to every OTHER arm's anchor -- V1 and V2's expectations + // are identical to the AC1 fixture; Z itself must never be selected. + // totalWeight = 2 + 47 + 0 + 1 = 50 (same as AC1) + // anchored: O[0,200) V1 anchor=400 width=4700 -> [400,5100) + // Z anchor=(49/50)*10000=9800 width=0 (ta=0 -> inactive) -> never selected + // V2 anchor=9800 width=100 -> [9800,9900) (shares Z's anchor, unaffected) + const variationsWithZero = [ + makeVariation('O', 2, 'running'), + makeVariation('V1', 47, 'running'), + makeVariation('Z', 0, 'running'), + makeVariation('V2', 1, 'running') + ]; + const dataManager = makeDataManager([ + makeExperience(GATE_EXPERIENCE_ID, 12, variationsWithZero) + ]); + + const v1Visitor = GATE_VISITOR_ID; // value 4957 -> anchored V1's band + const v2Visitor = 'anchor-gate-visitor-162'; // value 9807 -> anchored V2's band + const oVisitor = 'anchor-gate-visitor-106'; // value 102 -> anchored O's band + + const v1Result = bucketFor(dataManager, v1Visitor, GATE_EXPERIENCE_ID); + const v2Result = bucketFor(dataManager, v2Visitor, GATE_EXPERIENCE_ID); + const oResult = bucketFor(dataManager, oVisitor, GATE_EXPERIENCE_ID); + + expect(v1Result).to.be.an('object').that.has.property('id', 'V1'); + expect(v2Result).to.be.an('object').that.has.property('id', 'V2'); + expect(oResult).to.be.an('object').that.has.property('id', 'O'); + + // Z (traffic_allocation: 0) must never be the bucketed variation for any of the above. + [v1Result, v2Result, oResult].forEach((result) => { + if (result && typeof result === 'object') { + expect((result as {id?: string}).id).to.not.equal('Z'); + } + }); + }); + }); + + // --- AC8: guard precedence --- + describe('AC8 -- a stored decision wins over the anchored path', function () { + it('returns the previously stored variation for a version-12 experience even though the anchored path would pick a different arm', function () { + const dataManager = makeDataManager([ + makeExperience(GATE_EXPERIENCE_ID, 12, GATE_VARIATIONS) + ]); + // Seed a stored decision ('O') that disagrees with what the anchored path would + // naturally compute for this visitor (ANCHORED_EXPECTED_VARIATION_ID === 'V1'). + dataManager.putData(GATE_VISITOR_ID, { + bucketing: {[GATE_EXPERIENCE_ID]: 'O'} + }); + + const result = bucketFor( + dataManager, + GATE_VISITOR_ID, + GATE_EXPERIENCE_ID + ); + expect(result).to.be.an('object').that.has.property('id', 'O'); + expect((result as {id?: string}).id).to.not.equal( + ANCHORED_EXPECTED_VARIATION_ID + ); + }); + }); + + // --- AC9: no event/schema drift --- + describe('AC9 -- BucketedVariation shape is unchanged between version 11 and version 12', function () { + it('returns structurally identical keys for a single-arm, 100%-allocation experience regardless of version', function () { + const singleArmVariations = [makeVariation('A', 100, 'running')]; + const dataManagerV11 = makeDataManager([ + makeExperience('gate-exp-schema-v11', 11, singleArmVariations) + ]); + const dataManagerV12 = makeDataManager([ + makeExperience('gate-exp-schema-v12', 12, singleArmVariations) + ]); + const visitorId = 'anchor-gate-visitor-schema'; + + const resultV11 = bucketFor( + dataManagerV11, + visitorId, + 'gate-exp-schema-v11' + ); + const resultV12 = bucketFor( + dataManagerV12, + visitorId, + 'gate-exp-schema-v12' + ); + + expect(resultV11).to.be.an('object'); + expect(resultV12).to.be.an('object'); + expect( + Object.keys(resultV12 as object).sort((a, b) => a.localeCompare(b)) + ).to.deep.equal( + Object.keys(resultV11 as object).sort((a, b) => a.localeCompare(b)) + ); + }); + }); +}); diff --git a/packages/js-sdk/tests/browser/golden-vectors.spec.ts b/packages/js-sdk/tests/browser/golden-vectors.spec.ts new file mode 100644 index 00000000..15f9dd5b --- /dev/null +++ b/packages/js-sdk/tests/browser/golden-vectors.spec.ts @@ -0,0 +1,154 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +/** + * qs-01 (GOLD-1) — cross-SDK golden-vector bucketing parity, verified inside a real + * headless Chromium browser against the actual built UMD bundle (not Node/Mocha). + * + * Spec of record: _bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-01-anchored-bucketing-layout.md + * "Golden-vector fixture" section, AC6/AC7. + * + * This drives every vector in the SAME canonical fixture consumed by the Node/Mocha runner + * (packages/data/tests/cross-sdk-vectors.tests.ts) — read directly from + * packages/bucketing/tests/cross-sdk-bucketing-vectors.json, never duplicated — through the + * real public SDK surface exposed on the UMD bundle's `window.ConvertSDK` global: + * `new ConvertSDK.default({data}).createContext(visitorId, {}).runExperience(key, attrs)`. + * That is the exact same production call chain the Node runner exercises: + * `Context.runExperience` -> `ExperienceManager.selectVariation` -> + * `DataManager.getBucketing` -> `DataManager._retrieveBucketing` (the anchored-vs-packed + * GATE, `Number(experience.version) > 11`) -> `BucketingManager.getBucketForVisitor(Anchored)`. + * No bucketing/gate logic is re-implemented here — only the fixture's per-vector experience + * shape is assembled in-page and handed to the real bundled SDK. + */ +import {test, expect, Page} from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +interface CrossSdkVariation { + id: string; + traffic_allocation?: number; + status?: 'running' | 'stopped'; +} + +interface CrossSdkVector { + description: string; + experienceId: string; + visitorId: string; + version: number; + variations: CrossSdkVariation[]; + expected: string | null; +} + +interface VectorRunResult { + description: string; + expected: string | null; + actualId: string | null; + isVariationNotDecided: boolean; +} + +// Same fixture the Node/Mocha runner reads (packages/data/tests/cross-sdk-vectors.tests.ts) -- +// resolved via a relative filesystem path so no new package dependency edge is introduced. +const FIXTURE_PATH = path.resolve( + __dirname, + '../../../bucketing/tests/cross-sdk-bucketing-vectors.json' +); +const VECTORS: CrossSdkVector[] = JSON.parse( + fs.readFileSync(FIXTURE_PATH, 'utf8') +); + +// Drives every vector through the real UMD-bundled SDK in a single page context: one +// navigation, one page.evaluate() round-trip constructing a fresh ConvertSDK instance (direct +// `data` config, no network) per vector and calling the real public Context.runExperience() +// seam, then returns the per-vector outcome for assertion on the Node side. +async function runVectorsInBrowser( + page: Page, + vectors: CrossSdkVector[] +): Promise { + await page.goto('/umd.html'); + await page.waitForFunction( + () => typeof (window as any).ConvertSDK?.default === 'function' + ); + return page.evaluate((vecs: CrossSdkVector[]) => { + const w = window as any; + return vecs.map((vector) => { + const config = { + data: { + account_id: 'browser-golden-vectors-account', + project: {id: 'browser-golden-vectors-project'}, + experiences: [ + { + id: vector.experienceId, + name: `exp-${vector.experienceId}`, + key: vector.experienceId, + type: 'a/b_fullstack', + audiences: [], + goals: [], + variations: vector.variations, + version: vector.version + } + ] + } + }; + const sdk = new w.ConvertSDK.default(config); + const context = sdk.createContext(vector.visitorId, {}); + const result = context.runExperience(vector.experienceId, { + visitorProperties: {}, + ignoreLocationProperties: true, + enableTracking: false, + updateVisitorProperties: false + }); + return { + description: vector.description, + expected: vector.expected, + actualId: + result && typeof result === 'object' && 'id' in result + ? (result as {id: string}).id + : null, + isVariationNotDecided: + result === w.ConvertSDK.BucketingError.VARIAION_NOT_DECIDED + }; + }); + }, vectors); +} + +test.describe('Cross-SDK golden-vector bucketing parity (qs-01 / GOLD-1) — real UMD bundle in headless Chromium', () => { + test('all golden vectors resolve identically through the real bundled DataManager gate (packed v11 + anchored v12)', async ({ + page + }) => { + const results = await runVectorsInBrowser(page, VECTORS); + expect(results).toHaveLength(VECTORS.length); + for (const result of results) { + if (result.expected === null) { + expect(result.isVariationNotDecided, result.description).toBe(true); + } else { + expect(result.actualId, result.description).toBe(result.expected); + } + } + }); + + test('loaded every required golden-vector category, matching the Node/Mocha runner completeness guard (AC7)', () => { + const descriptions = VECTORS.map((vector) => vector.description).join( + '\n' + ); + [ + '[packed-regression]', + '[anchored-basic-thirds]', + '[per-sliver-admission]', + '[anchored-idle]', + '[stopped-arm-stability]', + '[ta-zero-width]', + '[nan-default]', + '[single-arm-v11-eq-v12]', + '[100pct-total-v11-eq-v12]', + '[boundary-hit]', + '[total-weight-zero]' + ].forEach((tag) => { + expect(descriptions).toContain(tag); + }); + expect(VECTORS.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/types/index.ts b/packages/types/index.ts index 68ac1a02..4457f6f7 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -25,5 +25,6 @@ export * from './src/SegmentsAttributes'; export * from './src/StoreData'; export * from './src/TrackingEvent'; export * from './src/VariableType'; +export * from './src/VariationAllocation'; export * from './src/VisitorsQueue'; export * from './src/Visitor'; diff --git a/packages/types/src/VariationAllocation.ts b/packages/types/src/VariationAllocation.ts new file mode 100644 index 00000000..b6e43605 --- /dev/null +++ b/packages/types/src/VariationAllocation.ts @@ -0,0 +1,12 @@ +/*! + * Convert JS SDK + * Version 1.0.0 + * Copyright(c) 2020 Convert Insights, Inc + * License Apache-2.0 + */ + +export type VariationAllocation = { + id: string; + allocation: number; + active: boolean; +};