Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions Sources/ConvertSwiftSDKCore/Bucketing/AnchoredBucketing.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
89 changes: 84 additions & 5 deletions Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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 "<experienceId><visitorId>" 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 ?? ""
)
Comment thread
abbaseya marked this conversation as resolved.

// 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
}
}
12 changes: 8 additions & 4 deletions Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading