Class: ConvertSdk::BucketingManager Private
- Inherits:
-
Object
- Object
- ConvertSdk::BucketingManager
- Defined in:
- lib/convert_sdk/bucketing_manager.rb
Overview
This class is part of a private API. You should avoid using this class if possible, as it may be removed or be changed in the future.
Deterministic visitor bucketing — the cross-SDK variation-assignment engine.
Given an experience id, a visitor id, and a caller-built buckets hash
(variation id => traffic percentage), this resolves a variation
BYTE-IDENTICALLY to the JS SDK bucketing-manager.ts and the proven PHP
port BucketingManager.php. A visitor MUST bucket into the same variation on
web (JS), PHP, and Ruby — the cross-SDK distribution spec is the CI proof.
The pipeline mirrors JS exactly, link for link:
1. hash input = +experience_id + String(visitor_id)+ (experience FIRST, no
delimiter) — JS +bucketing-manager.ts:97+, PHP +BucketingManager.php:89+.
2. +hash = MurmurHash3.hash(input, seed)+ — the proven Story 1.2 module;
never reimplemented here.
3. +value = ((hash / 4_294_967_296.0) * max_traffic).to_i+ — float division
then multiply then truncate, operation ORDER preserved. Ruby Float is
IEEE-754 double like JS Number, and +Integer()+-via-+to_i+ truncates
toward zero, matching JS +parseInt(String(val), 10)+ at +bm.ts:99+
(behaviourally floor for all non-negative hash values).
4. +select_bucket+ walks variation cumulative ranges in insertion order:
+prev += pct * 100 + redistribute+; the first variation satisfying the
STRICT upper-bound +value < prev+ wins — JS +bm.ts:60-85+, PHP
+BucketingManager.php:50-72+. No covering range => +nil+ (the caller
treats +nil+ as VARIATION_NOT_DECIDED).
Traffic allocation is NOT this class's concern: the caller
(ExperienceManager/DataManager) constructs buckets with only the
traffic-allocated variations before invoking. BucketingManager is
allocation-agnostic and answers one question deterministically: "given this
experience config and this visitor id, which variation?"
Pure in-memory computation (NFR1) — no I/O, no store access. Bucketing
constants (+max_traffic+, hash_seed, max_hash) come from the injected
Config, never inline literals. Logging stays at debug for the decisioning
internals (FR56); never-crash is the caller's contract, but the class rescues
nothing here because its inputs are caller-validated.
Constant Summary collapse
- ANCHORED_MAX_TRAFFIC =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
Fixed anchor-scale constant for the ANCHORED bucketing layout (contract v12, qs-01). Unlike
max_traffic(config-supplied, used only to scale the visitor's HASH value in #value_visitor_based), the anchor/width scale for the anchored range walk is a FIXED module constant in the JS oracle (+bucketing-manager.ts:25+DEFAULT_MAX_TRAFFIC = 10000) — never read from config. Named here (rather than an inline literal in #select_bucket_anchored) so the constant stays traceable to its JS origin without conflating it with the per-config@max_trafficused elsewhere in this class. 10_000
Instance Method Summary collapse
-
#bucket_for_visitor(buckets, visitor_id, experience_id: "", seed: @hash_seed, redistribute: 0) ⇒ Hash{Symbol=>Object}?
private
Resolve a visitor to a variation, returning the assignment and its bucket value, or
nilwhen no variation range covers the visitor. -
#bucket_for_visitor_anchored(variations, visitor_id, experience_id: "", seed: @hash_seed) ⇒ Hash{Symbol=>Object}?
private
Resolve a visitor to a variation under the ANCHORED layout (contract v12), returning the SAME shape as #bucket_for_visitor (AC9 — no return-shape drift):
{variation_id:, bucketing_allocation:}ornil. -
#initialize(config:, log_manager: nil) ⇒ BucketingManager
constructor
private
Build a bucketing engine bound to a Config's frozen bucketing constants.
-
#select_bucket(buckets, value, redistribute = 0) ⇒ String?
private
Select the variation whose cumulative range contains
value. -
#select_bucket_anchored(variations, value) ⇒ String?
private
Select the variation whose ANCHORED range contains
value(contract v12, qs-01/BUCK-2). -
#value_visitor_based(visitor_id, experience_id: "", seed: @hash_seed) ⇒ Integer
private
Compute the deterministic bucket value for a visitor.
Constructor Details
#initialize(config:, log_manager: nil) ⇒ BucketingManager
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Build a bucketing engine bound to a Config's frozen bucketing constants.
57 58 59 60 61 62 |
# File 'lib/convert_sdk/bucketing_manager.rb', line 57 def initialize(config:, log_manager: nil) @max_traffic = config.max_traffic @hash_seed = config.hash_seed @max_hash = config.max_hash.to_f @log_manager = log_manager end |
Instance Method Details
#bucket_for_visitor(buckets, visitor_id, experience_id: "", seed: @hash_seed, redistribute: 0) ⇒ Hash{Symbol=>Object}?
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Resolve a visitor to a variation, returning the assignment and its bucket
value, or nil when no variation range covers the visitor.
129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/convert_sdk/bucketing_manager.rb', line 129 def bucket_for_visitor(buckets, visitor_id, experience_id: "", seed: @hash_seed, redistribute: 0) value = value_visitor_based(visitor_id, experience_id: experience_id, seed: seed) selected = select_bucket(buckets, value, redistribute) @log_manager&.debug( "BucketingManager#bucket_for_visitor: " \ "experience_id=#{experience_id.inspect} visitor_id=#{visitor_id.inspect} " \ "bucket_value=#{value} selected_variation_id=#{selected.inspect}" ) return nil if selected.nil? { variation_id: selected, bucketing_allocation: value } end |
#bucket_for_visitor_anchored(variations, visitor_id, experience_id: "", seed: @hash_seed) ⇒ Hash{Symbol=>Object}?
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Resolve a visitor to a variation under the ANCHORED layout (contract v12),
returning the SAME shape as #bucket_for_visitor (AC9 — no return-shape
drift): {variation_id:, bucketing_allocation:} or nil. Reuses the
existing visitor-hash value unchanged (JS getBucketForVisitorAnchored,
bm.ts:195-215) then resolves it through #select_bucket_anchored.
210 211 212 213 214 215 216 217 218 219 220 221 222 223 |
# File 'lib/convert_sdk/bucketing_manager.rb', line 210 def bucket_for_visitor_anchored(variations, visitor_id, experience_id: "", seed: @hash_seed) value = value_visitor_based(visitor_id, experience_id: experience_id, seed: seed) selected = select_bucket_anchored(variations, value) @log_manager&.debug( "BucketingManager#bucket_for_visitor_anchored: " \ "experience_id=#{experience_id.inspect} visitor_id=#{visitor_id.inspect} " \ "bucket_value=#{value} selected_variation_id=#{selected.inspect}" ) return nil if selected.nil? { variation_id: selected, bucketing_allocation: value } end |
#select_bucket(buckets, value, redistribute = 0) ⇒ String?
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Select the variation whose cumulative range contains value.
Walks buckets in insertion order accumulating +pct * 100 + redistribute+
per entry, returning the first variation id satisfying the strict
upper-bound value < prev. Returns nil when no range covers value
(including an empty buckets hash).
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/convert_sdk/bucketing_manager.rb', line 97 def select_bucket(buckets, value, redistribute = 0) variation = nil # Float accumulator: JS does `prev += buckets[id]*100 + redistribute` in # IEEE-754 double arithmetic (bm.ts:68). Ruby Float is the same double, so # accumulating in Float mirrors JS exactly. value (Integer) < prev (Float) # compares identically to the JS strict upper-bound check. prev = 0.0 buckets.each do |variation_id, percentage| prev += (percentage.to_f * 100) + redistribute if value < prev variation = variation_id break end end @log_manager&.debug( "BucketingManager#select_bucket: " \ "value=#{value} redistribute=#{redistribute} variation=#{variation.inspect}" ) variation end |
#select_bucket_anchored(variations, value) ⇒ String?
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Select the variation whose ANCHORED range contains value (contract v12,
qs-01/BUCK-2). Given the FULL ordered variation config list (active AND
inactive/stopped arms — never pre-filtered), this folds the JS reference's
getBucketRanges + selectBucketAnchored (bm.ts:145-190) into one pass so
the method stays self-contained and independently unit-testable:
allocation = anchored_allocation(ta) — Float(ta, exception: false) coercion; a
nil coercion result (non-numeric/absent ta) defaults to 100.0, mirroring the
JS oracle's `isNaN(ta) ? 100.0 : Number(ta)`
active = anchored_active?(status, allocation) — ((status.nil? || status == "")
? true : status == "running") && allocation.positive?
total_weight = sum(allocation) over ALL entries (active AND inactive)
return nil if total_weight <= 0
cum = 0.0
each entry: anchor = (cum / total_weight) * ANCHORED_MAX_TRAFFIC
width = active ? allocation * 100 : 0
hit iff anchor <= value < anchor + width (first match wins)
cum += allocation
Inactive arms (stopped, or an explicit traffic_allocation: 0) keep their
weight (anchor stability for the OTHER arms) but get zero width, so they
can never themselves be selected. This is a DIFFERENT method from the
packed #select_bucket — that packed walk is untouched for version<=11.
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/convert_sdk/bucketing_manager.rb', line 173 def select_bucket_anchored(variations, value) entries = anchored_allocations(variations) # NAIVE left-to-right fold — NOT +Array#sum+. Ruby's +Array#sum+ uses a # Kahan-Babuska (Neumaier) compensated algorithm for Float arrays, which # can differ from a plain fold at the float64 ULP level. The JS oracle # computes +totalWeight+ via a naive +Array.prototype.reduce+ # (+bucketing-manager.ts:153-156+: +allocations.reduce((sum, {allocation}) # => sum + allocation, 0)+) — every other SDK (PHP/Python/Android/iOS) # matches that same naive fold. A compensated sum here would diverge from # every other SDK's +total_weight+ by 1+ ULP on some inputs, which can # flip which arm a boundary +value+ resolves to (verified: a 3-way # ~33.34/33.33/33.33-style split picks a different arm at its exact # anchor boundary depending on the fold algorithm) — a cross-SDK parity # break, not just a cosmetic rounding difference. total_weight = entries.reduce(0.0) { |acc, entry| acc + entry[:allocation] } variation = total_weight.positive? ? anchored_walk(entries, total_weight, value) : nil @log_manager&.debug( "BucketingManager#select_bucket_anchored: " \ "value=#{value} total_weight=#{total_weight} variation=#{variation.inspect}" ) variation end |
#value_visitor_based(visitor_id, experience_id: "", seed: @hash_seed) ⇒ Integer
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Compute the deterministic bucket value for a visitor.
72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/convert_sdk/bucketing_manager.rb', line 72 def value_visitor_based(visitor_id, experience_id: "", seed: @hash_seed) input = "#{experience_id}#{visitor_id}" hash = MurmurHash3.hash(input, seed) scaled = (hash / @max_hash) * @max_traffic result = scaled.to_i @log_manager&.debug( "BucketingManager#value_visitor_based: " \ "experience_id=#{experience_id.inspect} visitor_id=#{visitor_id.inspect} " \ "seed=#{seed} hash=#{hash} scaled=#{scaled} result=#{result}" ) result end |