diff --git a/Sources/ConvertSwiftSDKCore/Bucketing/AnchoredBucketing.swift b/Sources/ConvertSwiftSDKCore/Bucketing/AnchoredBucketing.swift new file mode 100644 index 0000000..e72184e --- /dev/null +++ b/Sources/ConvertSwiftSDKCore/Bucketing/AnchoredBucketing.swift @@ -0,0 +1,99 @@ +// Sources/ConvertSwiftSDKCore/Bucketing/AnchoredBucketing.swift +// ANCHORED bucketing layout (qs-01, cross-SDK bucketing contract v12) — Phase 2 / GREEN. +// +// Selected per experience by `BucketingManager.bucketVersionGated(...)`: `experience.version > 11` +// routes here; `version <= 11` / missing / non-numeric stays on the EXISTING packed walk in +// `BucketingManager.bucket(...)` (untouched, AC6). Spec of record (do NOT re-derive the algorithm +// here): `2026-06-09-convert-ios-sdk/qs-01-anchored-bucketing-layout.md` — mirrors the JS +// reference exactly (`data-manager.ts:591-610` `_buildVariationAllocations`, +// `bucketing-manager.ts:152-204` `getBucketRanges`/`selectBucketAnchored`): +// +// allocations = experience.variations (config order), entries with no `id` DROPPED (never +// counted toward totalWeight — matches `_buildVariationAllocations`'s `if (!variation?.id) +// return allocations`) → +// { id, allocation: isNaN(ta) || ta absent ? 100.0 : ta, +// active: (status == nil || status == RUNNING) && (ta > 0 || isNaN(ta) || ta absent) } +// totalWeight = sum of allocation over ALL remaining entries (active AND inactive) +// if totalWeight <= 0 → not bucketed +// cumWeight = 0 +// for each entry in order: +// anchor = (cumWeight / totalWeight) * 10000.0 +// width = entry.active ? entry.allocation * 100.0 : 0.0 +// if value >= anchor && value < anchor + width → return entry.id +// cumWeight += entry.allocation +// return nil +// +// All arithmetic is `Double` (IEEE754) throughout, matching JS `Number` semantics — no +// `Decimal`/`Float80`. `value` is the shared bucket-value projection (seed 9999, same +// MurmurHash3 + scaling as the packed path) — reused unchanged, never recomputed here. + +import Foundation + +/// Namespace for the ANCHORED bucketing pass (contract v12+). Stateless — a pure selector, mirroring +/// `BucketingManager.selectBucket` for the packed pass, so it stays trivially testable with no +/// collaborators (no `EventSink`/`Logger`; the caller owns mapping the result back onto a +/// `Variation` and the tracking enqueue). +internal enum AnchoredBucketing { + /// One variation's resolved allocation weight and active/inactive flag under the anchored + /// layout — a named struct (not a tuple) so `large_tuple` stays satisfied. + private struct Allocation { + let id: String + let allocation: Double + let active: Bool + } + + /// Selects the variation id that `value` (a `0..<10000` bucket-unit, computed identically to + /// the packed path — hash/seed/scaling are untouched) falls into under the ANCHORED layout. + /// + /// - Parameters: + /// - variations: The experience's variations, in CONFIG ORDER, **unfiltered** — every entry + /// (active and inactive, with or without a `traffic_allocation`) must be passed through; + /// the anchored algorithm itself interprets active/inactive and NaN/absent allocation, + /// unlike the packed pass's pre-filtered `eligible` walk. + /// - value: The visitor's bucket value (`0..<10000`). + /// - Returns: The selected variation id, or `nil` when not bucketed. + static func selectBucket( + variations: [Components.Schemas.ExperienceVariationConfig], + value: Int + ) -> String? { + let allocations = buildAllocations(variations) + let totalWeight = allocations.reduce(0.0) { $0 + $1.allocation } + guard totalWeight > 0 else { + return nil + } + + let doubleValue = Double(value) + var cumWeight = 0.0 + for entry in allocations { + let anchor = (cumWeight / totalWeight) * Double(Defaults.maxTraffic) + let width = entry.active ? entry.allocation * 100.0 : 0.0 + if doubleValue >= anchor && doubleValue < anchor + width { + return entry.id + } + cumWeight += entry.allocation + } + return nil + } + + /// Builds the per-variation `{id, allocation, active}` triples (config order, entries with no + /// `id` dropped) that `selectBucket` walks — the direct mirror of the JS reference's + /// `_buildVariationAllocations`. + private static func buildAllocations( + _ variations: [Components.Schemas.ExperienceVariationConfig] + ) -> [Allocation] { + variations.compactMap { variation in + guard let id = variation.id else { + return nil + } + let allocation: Double + if let rawAllocation = variation.traffic_allocation, !rawAllocation.isNaN { + allocation = rawAllocation + } else { + allocation = 100.0 + } + let statusActive = variation.status == nil || variation.status == .running + let active = statusActive && allocation > 0 + return Allocation(id: id, allocation: allocation, active: active) + } + } +} diff --git a/Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift b/Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift index 0057fed..65673ba 100644 --- a/Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift +++ b/Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift @@ -82,14 +82,23 @@ internal struct BucketingManager { Double(hashValue) / Double(Defaults.maxHash) * Double(Defaults.maxTraffic) ) - // 5. Keep only variations that carry BOTH an id and a traffic_allocation — a variation - // missing either can't be bucketed into. `traffic_allocation` is a 0–100 PERCENTAGE - // (see SCALE NOTE), so it is scaled `×100` into the 0..<10000 bucket-unit space the - // selector accumulates in — matching the JS/Android SDKs. Order is preserved. + // 5. Keep only variations that carry an id — a variation missing one can't be bucketed + // into. An omitted/NaN `traffic_allocation` defaults to 100.0 (qs-01 Phase 2 GREEN + // resolution — matches the JS reference's packed builder, + // `data-manager.ts:575`, `bucket[id] = traffic_allocation || 100.0`, whose include + // filter at L568-572 treats `isNaN(ta)` as included). `traffic_allocation` is a 0–100 + // PERCENTAGE (see SCALE NOTE), so it is scaled `×100` into the 0..<10000 bucket-unit + // space the selector accumulates in — matching the JS/Android SDKs. Order is preserved. let eligible: [WeightedVariation] = (experience.variations ?? []).compactMap { variation in - guard let key = variation.id, let allocation = variation.traffic_allocation else { + guard let key = variation.id else { return nil } + let allocation: Double + if let rawAllocation = variation.traffic_allocation, !rawAllocation.isNaN { + allocation = rawAllocation + } else { + allocation = 100.0 + } return WeightedVariation(key: key, weight: Int(allocation * 100), config: variation) } let weights = eligible.map { (key: $0.key, weight: $0.weight) } @@ -141,3 +150,73 @@ internal struct BucketingManager { return nil } } + +// MARK: - Version gate (qs-01, cross-SDK bucketing contract v12) + +extension BucketingManager { + /// Routes to the ANCHORED pass (contract `version > 11`) via ``AnchoredBucketing``, or + /// delegates VERBATIM to the existing packed ``bucket(visitorId:experience:enableTracking:)`` + /// above for `version <= 11` / missing / non-numeric (a `Double?` can never decode a + /// non-numeric wire value as non-nil, so "non-numeric" collapses into "missing" at this + /// layer; a `NaN` version — not reachable via JSON but defensively handled — also falls + /// through here since every comparison against `NaN` is `false`). The packed `eligible` walk + /// and ``selectBucket(weights:value:)`` above are UNTOUCHED (AC6) — this is a pure ADDITIONAL + /// branch, never a modification of the packed one. + /// + /// On a successful anchored selection, maps the id back onto its config and enqueues exactly + /// one `.bucketing` event when `enableTracking` — mirroring the packed pass's steps 6-9 + /// (AC9: unchanged event shape). No selection (not-bucketed) degrades to `nil`, enqueuing + /// nothing, same as the packed pass. + func bucketVersionGated( + visitorId: String, + experience: Components.Schemas.ConfigExperience, + enableTracking: Bool = true + ) async -> Variation? { + guard let version = experience.version, version > 11 else { + return await bucket(visitorId: visitorId, experience: experience, enableTracking: enableTracking) + } + + // An experience with no id cannot be hashed or attributed — degrade to nil (mirrors + // packed step 1). + guard let experienceId = experience.id else { + return nil + } + + // Hash "" with the shared seed, project onto 0..<10000 — + // byte-for-byte identical to `bucket(...)`'s steps 2-4. + let input = Array("\(experienceId)\(visitorId)".utf8) + let hashValue = MurmurHash3.hash(input, seed: Defaults.hashSeed) + let bucketValue = Int( + Double(hashValue) / Double(Defaults.maxHash) * Double(Defaults.maxTraffic) + ) + + // Select under the ANCHORED layout — every variation passed through unfiltered (the + // anchored algorithm itself interprets active/inactive and NaN/absent allocation). + let allVariations = experience.variations ?? [] + guard let selectedId = AnchoredBucketing.selectBucket(variations: allVariations, value: bucketValue) else { + return nil + } + + // Map the selected id back onto its config and build the result variation (mirrors + // packed step 7). + guard let selected = allVariations.first(where: { $0.id == selectedId }) else { + return nil + } + let variation = Variation( + id: selectedId, + key: selected.key ?? "", + experienceId: experienceId, + experienceKey: experience.key ?? "" + ) + + // Emit exactly one bucketing event when tracking is enabled; otherwise stay silent + // (mirrors packed step 8, AC9's unchanged event shape). + if enableTracking { + let data = BucketingEventData(experienceId: experienceId, variationId: selectedId) + await eventSink.enqueue(.bucketing(data), for: visitorId, segments: nil) + } + + // Return the resolved variation (mirrors packed step 9). + return variation + } +} diff --git a/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift b/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift index 73e50a9..0dbdaa4 100644 --- a/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift +++ b/Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift @@ -12,8 +12,10 @@ // the bd-d4p empty-list rule); a non-empty set's rules are flattened and OR-combined across // every attached audience, then evaluated against `attributes` (a fail returns nil). // 4. LOCATION gate: the same shape over `locations` against `locationProperties` (empty ⇒ pass). -// 5. BUCKET via ``BucketingManager/bucket(visitorId:experience:enableTracking:)`` — that call -// owns the single bucketing enqueue (driven by `enableTracking`); this type NEVER enqueues. +// 5. BUCKET via ``BucketingManager/bucketVersionGated(visitorId:experience:enableTracking:)`` +// (qs-01: routes `version > 11` to the ANCHORED layout, `version <= 11`/missing delegates +// verbatim to the packed walk) — that call owns the single bucketing enqueue (driven by +// `enableTracking`); this type NEVER enqueues. // 6. PERSIST the new decision; FIRE `.bucketing` on the bus — only on a NEW decision. // // SCOPE (bd-d4p) — the audience/location combine is a FLAT OR across the attached objects' @@ -169,8 +171,10 @@ public struct ExperienceManager: Sendable { return nil } - // 5. BUCKET — this performs the single enqueue when `enableTracking`; a miss returns nil. - guard let variation = await bucketingManager.bucket( + // 5. BUCKET — routed through the version gate (qs-01): `version > 11` runs the ANCHORED + // layout, `version <= 11`/missing delegates verbatim to the packed walk (AC6). This + // performs the single enqueue when `enableTracking`; a miss returns nil. + guard let variation = await bucketingManager.bucketVersionGated( visitorId: visitorId, experience: full, enableTracking: enableTracking ) else { return nil diff --git a/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingGateAndBoundaryTests.swift b/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingGateAndBoundaryTests.swift new file mode 100644 index 0000000..68ec154 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingGateAndBoundaryTests.swift @@ -0,0 +1,338 @@ +// Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingGateAndBoundaryTests.swift +// Anchored bucketing layout test suite for qs-01 (cross-SDK bucketing contract v12). +// Spec of record: `2026-06-09-convert-ios-sdk/qs-01-anchored-bucketing-layout.md`. +// +// Sibling of `AnchoredBucketingParityTests.swift` (the 59-vector golden-fixture sweep, AC7). +// THIS file covers every AC the fixture sweep alone doesn't isolate: +// * AC1 — the `version` gate (>11 anchored, <=11/missing packed). +// * AC4 — stopped-arm zero-width (weight preserved, anchors unmoved) + explicit `ta:0` != 100. +// * AC5 — NaN/absent-ta defaults to 100.0 weight; totalWeight<=0 -> nil; anchor/width boundary +// inclusivity (`value == anchor` IN, `value == anchor + width` OUT). +// * AC6 — the packed pass is not just "produces the same answer" but DELEGATES verbatim. +// * AC8 — a sticky decision wins over both layouts (a structural lock, not new behavior). +// * AC9 — a successful anchored bucket preserves the unchanged `.bucketing` event shape. +// +// ── File-scope data types (not nested in the `@Suite` struct) ───────────────────────────── +// `BoundaryVariationSpec`/`BoundaryVector` and the boundary-vector arrays live at file scope so +// the `@Suite` struct's body stays under SwiftLint's `type_body_length` gate, and so the whole +// file stays under `file_length` — splitting data from behavior, not duplicating either. +// +// ── SonarQube `new_duplicated_lines_density` discipline ─────────────────────────────────── +// ONE parameterized `@Test(arguments:)` drives the AC1 gate sweep, a SECOND drives the full +// AC4/AC5 boundary sweep — mirroring `BucketingManagerTests.selectBucketAccumulateFirstWins` for +// the packed selector. Every manager goes through `makeManager`. + +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +/// One hand-built ANCHORED scenario: a variation spec plus the `value` to select at and the +/// expected result — a named struct (not a tuple) so `large_tuple` stays satisfied. +struct BoundaryVariationSpec: Sendable { + let id: String + let trafficAllocation: Double? + let status: Components.Schemas.VariationStatuses? +} + +/// One direct `AnchoredBucketing.selectBucket` boundary vector — hand-computed from the spec's +/// normative pseudocode (NOT derived from the golden-vector fixture, which already covers the +/// end-to-end hash-driven path; these isolate the pure per-value selection math at controlled +/// `value`s the fixture cannot target directly). +struct AnchoredBoundaryVector: Sendable { + let description: String + let variations: [BoundaryVariationSpec] + let value: Int + let expected: String? +} + +/// Two arms, 30/70 split, both `running`. `totalWeight = 100`; A covers `[0,3000)`, B covers +/// `[3000,10000)` — exercises AC5's `value == anchor` (IN) / `value == anchor + width` (OUT) +/// boundary at the shared edge (`3000`), where A's upper bound and B's anchor coincide. +private let thirtySeventyBoundaries: [AnchoredBoundaryVector] = [ + AnchoredBoundaryVector( + description: "AC5 — value == A's anchor (0) is IN", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 70, status: .running) + ], + value: 0, + expected: "A" + ), + AnchoredBoundaryVector( + description: "AC5 — value just below A's anchor + width (2999) is still IN for A", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 70, status: .running) + ], + value: 2_999, + expected: "A" + ), + AnchoredBoundaryVector( + description: "AC5 — value == A's anchor + width (3000) is OUT for A and IN for B " + + "(B's anchor coincides at 3000)", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 70, status: .running) + ], + value: 3_000, + expected: "B" + ), + AnchoredBoundaryVector( + description: "AC5 — value at the top edge of the bucket space (9999) is IN for B", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 70, status: .running) + ], + value: 9_999, + expected: "B" + ) +] + +/// Three arms 30/30/40; B is `stopped` (ta preserved). AC4: B keeps its WEIGHT (C's anchor still +/// lands at 6000, as if B were active) but gets ZERO width — so `[3000,6000)` is a dead zone +/// (not-bucketed), never reassigned to A or C. +private let stoppedArmBoundaries: [AnchoredBoundaryVector] = [ + AnchoredBoundaryVector( + description: "AC4 — value just below the stopped arm's anchor (2999) is IN for A", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 30, status: .stopped), + BoundaryVariationSpec(id: "C", trafficAllocation: 40, status: .running) + ], + value: 2_999, + expected: "A" + ), + AnchoredBoundaryVector( + description: "AC4 — value at the stopped arm's anchor (3000) falls in its zero-width " + + "dead zone -> not bucketed; the anchor did NOT move to close the gap", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 30, status: .stopped), + BoundaryVariationSpec(id: "C", trafficAllocation: 40, status: .running) + ], + value: 3_000, + expected: nil + ), + AnchoredBoundaryVector( + description: "AC4 — the dead zone persists right up to the next active arm's anchor (5999)", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 30, status: .stopped), + BoundaryVariationSpec(id: "C", trafficAllocation: 40, status: .running) + ], + value: 5_999, + expected: nil + ), + AnchoredBoundaryVector( + description: "AC4 — C's anchor (6000) starts exactly where B's PRESERVED weight ends -> IN", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 30, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 30, status: .stopped), + BoundaryVariationSpec(id: "C", trafficAllocation: 40, status: .running) + ], + value: 6_000, + expected: "C" + ) +] + +/// AC4 (explicit `ta: 0`, never `stopped`) + AC5 (NaN/absent default, and totalWeight <= 0). +private let defaultAndZeroWeightBoundaries: [AnchoredBoundaryVector] = [ + AnchoredBoundaryVector( + description: "AC4 — explicit ta:0 (status running, NOT stopped) is ZERO width, never " + + "100: the whole space falls to B", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 0, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 100, status: .running) + ], + value: 0, + expected: "B" + ), + AnchoredBoundaryVector( + description: "AC5 — NaN/absent traffic_allocation defaults to 100.0 weight: a sole " + + "omitted-ta arm covers the whole space", + variations: [ + BoundaryVariationSpec(id: "SOLE", trafficAllocation: nil, status: .running) + ], + value: 9_999, + expected: "SOLE" + ), + AnchoredBoundaryVector( + description: "AC5 — totalWeight <= 0 (all-zero arms) is not-bucketed regardless of value", + variations: [ + BoundaryVariationSpec(id: "A", trafficAllocation: 0, status: .running), + BoundaryVariationSpec(id: "B", trafficAllocation: 0, status: .stopped) + ], + value: 0, + expected: nil + ) +] + +/// The full AC4/AC5 boundary sweep — every scenario above, in one flat array. +private let anchoredBoundaryVectors: [AnchoredBoundaryVector] = + thirtySeventyBoundaries + stoppedArmBoundaries + defaultAndZeroWeightBoundaries + +@Suite("AnchoredBucketingGateAndBoundary") +struct AnchoredBucketingGateAndBoundaryTests { + + // MARK: - Shared builders (SonarQube 3% new-duplicated-lines gate) + + /// Builds the subject with a recording event sink and a no-op logger — every test that needs + /// a manager goes through this so construction is declared exactly once. + private func makeManager(eventSink: MockEventSink = MockEventSink()) -> BucketingManager { + BucketingManager(eventSink: eventSink, logger: MockLogger()) + } + + /// A single-variation, single-arm, sole-100%-allocation `running` experience at the given + /// `version` — used by both the AC1 gate-branching sweep and the AC9 event-shape test. A + /// 100%-allocation arm is guaranteed to bucket EVERY visitor under both layouts (packed: + /// `[0,10000)`; anchored: anchor `0`, width `10000`), so it isolates "did the gate route + /// somewhere that buckets" from any hash/weight-math edge case. + private func makeSingleFullAllocationExperience(version: Double?) -> Components.Schemas.ConfigExperience { + Components.Schemas.ConfigExperience( + id: "gate-exp", + version: version, + variations: [ + Components.Schemas.ExperienceVariationConfig( + id: "only", traffic_allocation: 100, status: .running + ) + ] + ) + } + + /// Builds the `ExperienceVariationConfig` array a boundary vector describes. + private func makeVariations(_ specs: [BoundaryVariationSpec]) -> [Components.Schemas.ExperienceVariationConfig] { + specs.map { spec in + Components.Schemas.ExperienceVariationConfig( + id: spec.id, traffic_allocation: spec.trafficAllocation, status: spec.status + ) + } + } + + // MARK: - AC1 — gate branching + + /// AC1: `version > 11` routes to ANCHORED; `version <= 11` or missing routes to PACKED. Every + /// case uses the sole 100%-allocation arm (see `makeSingleFullAllocationExperience`), whose + /// correct/final answer is `"only"` under EITHER layout — so a non-`"only"` result proves the + /// gate routed somewhere broken. The v12 case resolves through the real + /// `AnchoredBucketing.selectBucket`; the packed cases resolve through AC6's verbatim + /// delegation to the existing `bucket(...)`. + @Test( + "AC1 — version gate: >11 routes to ANCHORED, <=11/missing routes to PACKED", + arguments: [ + (version: 12.0, label: "v12 (>11) -> anchored"), + (version: 11.0, label: "v11 -> packed (the inert-on-ship production stamp)"), + (version: 5.0, label: "v5 (<11) -> packed"), + (version: nil, label: "missing version -> packed") + ] as [(version: Double?, label: String)] + ) + func gateBranchesOnVersion(version: Double?, label: String) async { + let experience = makeSingleFullAllocationExperience(version: version) + let manager = makeManager() + let result = await manager.bucketVersionGated( + visitorId: "any-visitor", experience: experience, enableTracking: false + ) + #expect(result?.id == "only", Comment(rawValue: label)) + } + + // MARK: - AC4 / AC5 — anchored selector boundaries, defaults, and stops + + /// AC4/AC5, driven directly against the pure `AnchoredBucketing.selectBucket` selector (no + /// hash, no `BucketingManager`) at hand-picked `value`s the golden-vector fixture cannot + /// target precisely. Every case — whether `expected` is a real variation id or `nil` — + /// resolves through the real anchored selector. + @Test( + "AC4/AC5 — anchored selector boundaries, defaults, and stops", + arguments: anchoredBoundaryVectors + ) + func anchoredBoundaries(_ vector: AnchoredBoundaryVector) { + let selected = AnchoredBucketing.selectBucket( + variations: makeVariations(vector.variations), value: vector.value + ) + #expect(selected == vector.expected, Comment(rawValue: vector.description)) + } + + // MARK: - AC6 — packed regression lock (delegation, not just outcome) + + /// AC6: for `version <= 11`, `bucketVersionGated` must delegate VERBATIM to the existing + /// `bucket(...)` — not merely produce the same answer by coincidence. Calling both with the + /// same experience/visitor and asserting identical results locks the delegation itself. + @Test("AC6 — v11 delegates verbatim: bucketVersionGated matches bucket() bit-for-bit") + func packedRegressionLockDelegatesVerbatim() async { + let experience = Components.Schemas.ConfigExperience( + id: "pack-exp", + version: 11, + variations: [ + Components.Schemas.ExperienceVariationConfig(id: "a", traffic_allocation: 50, status: .running), + Components.Schemas.ExperienceVariationConfig(id: "b", traffic_allocation: 50, status: .running) + ] + ) + let manager = makeManager() + let direct = await manager.bucket(visitorId: "visitor-x", experience: experience, enableTracking: false) + let gated = await manager.bucketVersionGated( + visitorId: "visitor-x", experience: experience, enableTracking: false + ) + #expect(gated?.id == direct?.id, "v11 must route bucketVersionGated -> bucket() untouched") + } + + // MARK: - AC8 — sticky decision wins over both layouts + + /// AC8: a pre-seeded sticky decision short-circuits `ExperienceManager.selectVariation` at + /// step 2, strictly BEFORE step 5's bucket call — so it wins regardless of which layout step 5 + /// would otherwise have run. This is a STRUCTURAL guarantee already true today + /// (`selectVariation` never even inspects `experience.version` before the sticky check), and + /// stays true once Phase 2 rewires step 5 to call `bucketVersionGated` instead of `bucket()` + /// directly — the sticky check sits strictly earlier in the pipeline either way. Passes today; + /// it is a lock, not a new-behavior assertion. + @Test("AC8 — a stored (sticky) decision wins over both bucketing layouts") + func stickyDecisionWinsOverBothLayouts() async throws { + let config = try ProjectConfigFixtures.singleExperienceConfig( + experienceId: "exp-1", key: "sticky-exp", variationId: "sticky-var" + ) + let store = DecisionStore(logger: MockLogger(), fileStore: MockFileStore()) + await store.saveDecision(variationId: "sticky-var", experienceId: "exp-1", storeKey: "a-p-v1") + let sink = MockEventSink() + let subject = ExperienceManager( + ruleManager: RuleManager(logger: MockLogger()), + bucketingManager: BucketingManager(eventSink: sink, logger: MockLogger()), + decisionStore: store, + eventBus: EventBus(), + logger: MockLogger() + ) + + let variation = await subject.selectVariation( + forKey: "sticky-exp", + in: config, + visitorId: "v1", + accountId: "a", + projectId: "p", + attributes: [:], + locationProperties: [:], + enableTracking: true + ) + + #expect(variation?.id == "sticky-var") + let events = await sink.recordedEvents() + #expect(events.isEmpty, "a sticky hit must never reach ANY bucketing pass (packed or anchored)") + } + + // MARK: - AC9 — no event/API drift + + /// AC9: a successful ANCHORED bucket must enqueue exactly ONE `.bucketing`-tagged event — + /// same shape as the packed pass. The real selector resolves the variation, and + /// `bucketVersionGated`'s result-mapping/enqueue plumbing emits the event on that success. + @Test("AC9 — a successful anchored bucket enqueues exactly one unchanged-shape bucketing event") + func anchoredBucketPreservesEventShape() async { + let sink = MockEventSink() + let experience = makeSingleFullAllocationExperience(version: 12) + let manager = makeManager(eventSink: sink) + + let variation = await manager.bucketVersionGated( + visitorId: "any-visitor", experience: experience, enableTracking: true + ) + + #expect(variation?.id == "only") + let events = await sink.recordedEvents() + #expect(events.count == 1) + #expect(events.first?.eventType == "bucketing") + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingParityTests.swift b/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingParityTests.swift new file mode 100644 index 0000000..cbc3e70 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingParityTests.swift @@ -0,0 +1,136 @@ +// Tests/ConvertSwiftSDKCoreTests/Bucketing/AnchoredBucketingParityTests.swift +// Cross-SDK anchored/packed bucketing parity suite for qs-01 (cross-SDK bucketing contract v12). +// Spec of record: `2026-06-09-convert-ios-sdk/qs-01-anchored-bucketing-layout.md`. +// +// ── Why a NEW file (not an extension of HashParityTests.swift) ─────────────────────────── +// `HashParityTests.swift` drives `MurmurHash3` + `BucketingManager.selectBucket` (the PACKED +// selector) directly over `hash-parity-vectors.json` (hash+selectBucket only, no version gate, no +// ConfigExperience). `cross-sdk-bucketing-vectors.json` is structurally different: it carries a +// full `{experienceId, visitorId, version, variations:[{id, traffic_allocation, status?}]}` shape +// and must be driven end-to-end through the version-gated entry point +// (`BucketingManager.bucketVersionGated`), asserting the resolved variation id/nil. Different +// fixture shape, different subject under test → a separate file. The AC1/AC4/AC5/AC6/AC8/AC9 +// focused tests (not derivable from this fixture alone) live in the sibling +// `AnchoredBucketingGateAndBoundaryTests.swift` — kept out of THIS file to stay under SwiftLint's +// `file_length`/`type_body_length` gates. +// +// ── Decodable types at FILE scope, not nested in the `@Suite` struct ────────────────────── +// `VariationVector`/`Vector` sit at file scope (not nested inside `AnchoredBucketingParityTests`) +// so `VariationVector`'s `CodingKeys` enum is only ONE level of nesting deep — nesting them inside +// the suite struct as well would put `CodingKeys` two levels deep, tripping SwiftLint's `nesting` +// rule (max 1 level). +// +// ── Parity coverage ─────────────────────────────────────────────────────────────────────── +// All 59 golden vectors resolve through `bucketVersionGated`: v12 (anchored, `version > 11`) +// vectors route to `AnchoredBucketing.selectBucket`; v11 (packed) vectors delegate verbatim to +// the existing `bucket(...)` (AC6). The packed `eligible` walk (`BucketingManager.bucket`, step 5) +// defaults an omitted/NaN `traffic_allocation` to 100.0, matching the anchored pass and the JS +// reference's `data-manager.ts:575` builder — see `qs-01-decision-log.md` for the write-up. +// +// ── SonarQube `new_duplicated_lines_density` discipline ─────────────────────────────────── +// ONE parameterized `@Test(arguments:)` drives all 59 golden vectors — no per-vector duplication. + +import Foundation +import Testing +@testable import ConvertSwiftSDKCore + +/// One variation entry inside a golden vector's `variations` array. `trafficAllocation` mirrors +/// the wire's snake_case `traffic_allocation` via explicit `CodingKeys` (kept camelCase in Swift, +/// unlike the generated schema's own snake_case property, to stay SwiftLint-clean in a +/// non-generated file). `status` is decoded as the raw wire String and mapped onto +/// `Components.Schemas.VariationStatuses` when building a real config. +struct AnchoredVariationVector: Decodable, Sendable { + let id: String + let trafficAllocation: Double? + let status: String? + + enum CodingKeys: String, CodingKey { + case id + case trafficAllocation = "traffic_allocation" + case status + } +} + +/// One golden vector, decoded straight from `cross-sdk-bucketing-vectors.json`. `expected` is the +/// resolved variation id, or `nil` for a not-bucketed vector. +struct AnchoredBucketingVector: Decodable, Sendable { + let description: String + let experienceId: String + let visitorId: String + let version: Double + let variations: [AnchoredVariationVector] + let expected: String? +} + +@Suite("AnchoredBucketingParity") +struct AnchoredBucketingParityTests { + + /// The decoded golden vectors, loaded from the `Fixtures/` resource directory (same bundling + /// mechanism as `HashParityTests.vectors` — `resources: [.copy("Fixtures")]` on the + /// `ConvertSwiftSDKCoreTests` target in `Package.swift`). Fully defensive load (`try?` + /// throughout, `?? []` on failure): the lint gate forbids `!`/`try!`/`fatalError` + /// (`force_unwrapping`), and a static `let` initializer cannot `throw`. The `fixtureLoaded` + /// guard test below converts a failed/partial load into a LOUD failure instead of a + /// vacuously-passing empty parameterized suite. + static let vectors: [AnchoredBucketingVector] = { + guard + let url = Bundle.module.url( + forResource: "cross-sdk-bucketing-vectors", + withExtension: "json", + subdirectory: "Fixtures" + ), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([AnchoredBucketingVector].self, from: data) + else { + return [] + } + return decoded + }() + + /// Guard test: the fixture loaded and carries the full committed 59-vector set (AC7). If the + /// bundled resource is missing or fails to decode, `vectors` is empty and the parameterized + /// parity test below would pass vacuously — this asserts the count so that case fails LOUDLY. + @Test("fixture loaded — all 59 committed cross-SDK vectors decode") + func fixtureLoaded() { + #expect( + Self.vectors.count >= 59, + "expected >= 59 cross-SDK vectors, loaded \(Self.vectors.count) — fixture missing or failed to decode" + ) + } + + /// Builds a `Components.Schemas.ConfigExperience` from one golden vector, preserving config + /// order and passing EVERY variation through unfiltered (active and inactive, with or without + /// `traffic_allocation`) — the anchored pass interprets activity itself; only the packed + /// `eligible` walk pre-filters. + private func makeExperience(from vector: AnchoredBucketingVector) -> Components.Schemas.ConfigExperience { + let variations = vector.variations.map { entry in + Components.Schemas.ExperienceVariationConfig( + id: entry.id, + traffic_allocation: entry.trafficAllocation, + status: entry.status.flatMap(Components.Schemas.VariationStatuses.init(rawValue:)) + ) + } + return Components.Schemas.ConfigExperience( + id: vector.experienceId, + version: vector.version, + variations: variations + ) + } + + /// THE parity assertion (AC7). For each vector: build the experience, run it through + /// `bucketVersionGated` (the version-gated entry point qs-01 introduces), and assert the + /// resolved variation id — or `nil` for a not-bucketed vector — matches `expected`. One body + /// covers all 59 vectors (no per-vector duplication). `enableTracking: false` — this suite + /// asserts SELECTION, not the enqueue (that is AC9's job, isolated in the sibling file). + @Test("cross-SDK anchored/packed parity vector (AC7)", arguments: vectors) + func parity(_ vector: AnchoredBucketingVector) async { + let experience = makeExperience(from: vector) + let manager = BucketingManager(eventSink: MockEventSink(), logger: MockLogger()) + let result = await manager.bucketVersionGated( + visitorId: vector.visitorId, experience: experience, enableTracking: false + ) + let message = "\(vector.description): got \(String(describing: result?.id)), " + + "expected \(String(describing: vector.expected))" + #expect(result?.id == vector.expected, Comment(rawValue: message)) + } +} diff --git a/Tests/ConvertSwiftSDKCoreTests/Fixtures/cross-sdk-bucketing-vectors.json b/Tests/ConvertSwiftSDKCoreTests/Fixtures/cross-sdk-bucketing-vectors.json new file mode 100644 index 0000000..d101550 --- /dev/null +++ b/Tests/ConvertSwiftSDKCoreTests/Fixtures/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 + } +]