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
116 changes: 116 additions & 0 deletions packages/Bucketing/src/BucketingManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,120 @@ public function getBucketForVisitor(array $buckets, string $visitorId, ?array $o
'bucketingAllocation' => $value,
];
}

/**
* Build the anchored bucket layout for a set of variation allocations (qs-01).
*
* Anchors are computed over the total weight of ALL entries (active and inactive) so
* that raising an experience's total allocation only ever grows arms (superset
* property) and never reshuffles an already-bucketed visitor into a different arm.
* Inactive (or explicit zero-allocation) entries keep their weight for anchor
* stability but get a zero-width range so they can never be selected.
*
* @param array<int, array{id: string, allocation: float, active: bool}> $allocations Variation allocations in config order
* @return array<int, array{id: string, anchor: float, width: float}>
*/
public function getBucketRanges(array $allocations): array
{
$totalWeight = array_reduce(
$allocations,
fn (float $sum, array $allocation) => $sum + $allocation['allocation'],
0.0
);

$ranges = [];

if ($totalWeight <= 0) {
if ($this->logManager) {
$this->logManager->debug('BucketingManager.getBucketRanges()', [
'allocations' => $allocations,
'totalWeight' => $totalWeight,
]);
}

return $ranges;
}

$cumWeight = 0.0;
foreach ($allocations as $allocation) {
$anchor = ($cumWeight / $totalWeight) * $this->maxTraffic;
$width = $allocation['active'] ? $allocation['allocation'] * 100 : 0.0;
$ranges[] = [
'id' => $allocation['id'],
'anchor' => $anchor,
'width' => $width,
];
$cumWeight += $allocation['allocation'];
}

if ($this->logManager) {
$this->logManager->debug('BucketingManager.getBucketRanges()', [
'allocations' => $allocations,
'totalWeight' => $totalWeight,
], ['ranges' => $ranges]);
}

return $ranges;
}

/**
* Select the variation whose anchored range contains the provided value.
*
* @param array<int, array{id: string, anchor: float, width: float}> $ranges Anchored bucket ranges (see getBucketRanges())
* @param float $value A normalized bucket value in [0, maxTraffic)
* @return string|null The selected variation ID, or null if no match
*/
public function selectBucketAnchored(array $ranges, float $value): ?string
{
$variation = null;

foreach ($ranges as $range) {
if ($value >= $range['anchor'] && $value < $range['anchor'] + $range['width']) {
$variation = $range['id'];
break;
}
}

if ($this->logManager) {
$this->logManager->debug('BucketingManager.selectBucketAnchored()', [
'ranges' => $ranges,
'value' => $value,
], ['variation' => $variation]);
}

return $variation;
}

/**
* Get an anchored bucket for the visitor (qs-01). Reuses the existing
* visitor-based hash value unchanged, then resolves it through the anchored layout.
*
* @param array<int, array{id: string, allocation: float, active: bool}> $allocations Variation allocations in config order
* @param string $visitorId The visitor's unique identifier
* @param array{seed?: int, experienceId?: string}|null $options Optional overrides
* @return array{variationId: string, bucketingAllocation: int}|null Assignment result or null
*/
public function getBucketForVisitorAnchored(array $allocations, string $visitorId, ?array $options = null): ?array
{
$value = $this->getValueVisitorBased($visitorId, $options);
$selectedBucket = $this->selectBucketAnchored($this->getBucketRanges($allocations), (float)$value);

if ($this->logManager) {
$this->logManager->debug('BucketingManager.getBucketForVisitorAnchored()', [
'visitorId' => $visitorId,
'experienceId' => $options['experienceId'] ?? '',
'bucketValue' => $value,
'selectedVariationId' => $selectedBucket,
]);
}

if (!$selectedBucket) {
return null;
}

return [
'variationId' => $selectedBucket,
'bucketingAllocation' => $value,
];
}
}
34 changes: 34 additions & 0 deletions packages/Bucketing/src/Interfaces/BucketingManagerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,38 @@ public function getValueVisitorBased(string $visitorId, ?array $options = null):
* @return array{variationId: string, bucketingAllocation: int}|null Assignment result or null
*/
public function getBucketForVisitor(array $buckets, string $visitorId, ?array $options = null): ?array;

/**
* Build the anchored bucket layout for a set of variation allocations (qs-01).
*
* Anchors are computed over the total weight of ALL entries (active and inactive) so
* that raising an experience's total allocation only ever grows arms (superset
* property) and never reshuffles an already-bucketed visitor into a different arm.
* Inactive (or explicit zero-allocation) entries keep their weight for anchor
* stability but get a zero-width range so they can never be selected.
*
* @param array<int, array{id: string, allocation: float, active: bool}> $allocations Variation allocations in config order
* @return array<int, array{id: string, anchor: float, width: float}>
*/
public function getBucketRanges(array $allocations): array;

/**
* Select the variation whose anchored range contains the provided value.
*
* @param array<int, array{id: string, anchor: float, width: float}> $ranges Anchored bucket ranges (see getBucketRanges())
* @param float $value A normalized bucket value in [0, maxTraffic)
* @return string|null The selected variation ID, or null if no match
*/
public function selectBucketAnchored(array $ranges, float $value): ?string;

/**
* Get an anchored bucket for the visitor (qs-01). Reuses the existing
* visitor-based hash value unchanged, then resolves it through the anchored layout.
*
* @param array<int, array{id: string, allocation: float, active: bool}> $allocations Variation allocations in config order
* @param string $visitorId The visitor's unique identifier
* @param array{seed?: int, experienceId?: string}|null $options Optional overrides
* @return array{variationId: string, bucketingAllocation: int}|null Assignment result or null
*/
public function getBucketForVisitorAnchored(array $allocations, string $visitorId, ?array $options = null): ?array;
}
128 changes: 105 additions & 23 deletions packages/Data/src/DataManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,86 @@ private function _getBucketingByField(
return null;
}

/**
* Shared "running + non-zero-traffic" active predicate used by BOTH the packed
* (buildPackedBuckets) and anchored (buildVariationAllocations) layout builders,
* so both layouts agree on activeness.
*
* @param array<string, mixed> $variation
*/
private function isVariationActive(array $variation): bool
{
return (isset($variation['status']) ? $variation['status'] === VariationStatuses::RUNNING : true) &&
(array_key_exists('traffic_allocation', $variation) ?
($variation['traffic_allocation'] > 0 || !is_numeric($variation['traffic_allocation'])) :
true);
}

/**
* Build buckets where key is variation id and value is traffic distribution
* (existing packed layout, experience version <= 11, missing, or non-numeric;
* byte-for-byte unchanged). Version 11 is the version stamped on every experience
* currently served in production (backend CURRENT_EXPERIENCE_VERSION), so this is
* the active path for all currently-running experiments.
*
* @param array<int, array<string, mixed>> $variations
* @return array<string, float|int>
* @private
*/
private function buildPackedBuckets(array $variations): array
{
return array_reduce(
array_filter(
$variations,
fn ($variation) => $this->isVariationActive($variation)
),
function ($carry, $variation) {
if (!empty($variation['id'])) {
$carry[$variation['id']] = $variation['traffic_allocation'] ?? 100.0;
}
return $carry;
},
[]
);
}

/**
* Build variation allocations for the anchored layout (qs-01, contract v12).
* Activates only once the served experience version is > 11 (i.e. >= 12, once the
* backend bumps CURRENT_EXPERIENCE_VERSION past its current value of 11).
* Inactive arms (stopped, or explicit zero traffic_allocation) keep their weight for
* anchor stability but are marked inactive so BucketingManager::getBucketRanges()
* gives them zero width. Uses the shared isVariationActive() predicate, also used
* by buildPackedBuckets(), so both layouts agree on activeness. See
* qs-01-anchored-bucketing-layout.md "The contract (normative)".
*
* @param array<int, array<string, mixed>> $variations
* @return array<int, array{id: string, allocation: float, active: bool}>
* @private
*/
private function buildVariationAllocations(array $variations): array
{
$allocations = [];

foreach ($variations as $variation) {
if (empty($variation['id'])) {
continue;
}

$trafficAllocation = array_key_exists('traffic_allocation', $variation)
? $variation['traffic_allocation']
: null;

$allocations[] = [
'id' => (string)$variation['id'],
'allocation' => is_numeric($trafficAllocation) ? (float)$trafficAllocation : 100.0,
Comment thread
abbaseya marked this conversation as resolved.
'active' => $this->isVariationActive($variation),
];
}

return $allocations;
}

/**
* Retrieve variation for visitor
*
Expand Down Expand Up @@ -651,33 +731,35 @@ private function _retrieveBucketing(
)
);
} else {
// Build buckets from variations
$buckets = array_reduce(
array_filter(
$experience->getVariations(),
fn ($variation) =>
(isset($variation['status']) ? $variation['status'] === VariationStatuses::RUNNING : true) &&
(array_key_exists('traffic_allocation', $variation) ?
($variation['traffic_allocation'] > 0 || !is_numeric($variation['traffic_allocation'])) :
true)
),
function ($carry, $variation) {
if (!empty($variation['id'])) {
$carry[$variation['id']] = $variation['traffic_allocation'] ?? 100.0;
}
return $carry;
},
[]
);
// qs-01: anchored-vs-packed GATE. `experience.version > 11` runs the anchored
// layout (contract v12); version <= 11, missing, or non-numeric keeps the
// existing packed cumulative walk unchanged -- this is every currently-served
// production experience (backend CURRENT_EXPERIENCE_VERSION = 11). The SDK
// must never infer the layout from anything but this field. See
// qs-01-anchored-bucketing-layout.md "The contract (normative)".
$version = $experience->getVersion();
$isAnchoredLayout = is_numeric($version) && (float)$version > 11;

// Determine bucket for visitor
$bucketingParams = $this->_config->bucketing->excludeExperienceIdHash ?? false
? null
: ['experienceId' => (string)$experience->getId()];
$bucketing = $this->_bucketingManager->getBucketForVisitor(
$buckets,
$visitorId,
$bucketingParams
);

if ($isAnchoredLayout) {
$buckets = $this->buildVariationAllocations($experience->getVariations());
$bucketing = $this->_bucketingManager->getBucketForVisitorAnchored(
$buckets,
$visitorId,
$bucketingParams
);
} else {
$buckets = $this->buildPackedBuckets($experience->getVariations());
$bucketing = $this->_bucketingManager->getBucketForVisitor(
$buckets,
$visitorId,
$bucketingParams
);
}

$variationId = $variationId ?? $bucketing['variationId'] ?? null;
$bucketingAllocation = $bucketing['bucketingAllocation'] ?? null;
Expand Down
Loading
Loading