Class: ConvertSdk::BucketingManager Private

Inherits:
Object
  • Object
show all
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_traffic used elsewhere in this class.

10_000

Instance Method Summary collapse

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.

Parameters:

  • config (Config)

    supplies max_traffic, hash_seed, max_hash.

  • log_manager (LogManager, nil) (defaults to: nil)

    optional debug logger for decisioning internals; absent in lean unit contexts.



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.

Parameters:

  • buckets (Hash{String=>Numeric})

    variation id => traffic percentage.

  • visitor_id (#to_s)

    the visitor identifier.

  • experience_id (String) (defaults to: "")

    the experience identifier (default "").

  • seed (Integer) (defaults to: @hash_seed)

    MurmurHash3 seed (default Config hash seed).

  • redistribute (Numeric) (defaults to: 0)

    per-bucket widening offset (default 0).

Returns:

  • (Hash{Symbol=>Object}, nil)

    {variation_id:, bucketing_allocation:} or nil (caller treats nil as VARIATION_NOT_DECIDED).



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.

Parameters:

  • variations (Array<Hash>)

    the FULL ordered variation config list (active AND inactive arms) — see #select_bucket_anchored.

  • visitor_id (#to_s)

    the visitor identifier.

  • experience_id (String) (defaults to: "")

    the experience identifier (default "").

  • seed (Integer) (defaults to: @hash_seed)

    MurmurHash3 seed (default Config hash seed).

Returns:

  • (Hash{Symbol=>Object}, nil)

    {variation_id:, bucketing_allocation:} or nil (caller treats nil as VARIATION_NOT_DECIDED).



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).

Parameters:

  • buckets (Hash{String=>Numeric})

    variation id => traffic percentage.

  • value (Integer)

    a bucket value in [0, max_traffic).

  • redistribute (Numeric) (defaults to: 0)

    per-bucket widening offset (default 0).

Returns:

  • (String, nil)

    the selected variation id, or nil.



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.

Parameters:

  • variations (Array<Hash>)

    the FULL ordered variation config list (+"id"+, "traffic_allocation", "status") — inactive arms included.

  • value (Integer)

    a bucket value in [0, ANCHORED_MAX_TRAFFIC).

Returns:

  • (String, nil)

    the selected variation id, or nil (including when total_weight <= 0 or the list is empty).



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.

Parameters:

  • visitor_id (#to_s)

    the visitor identifier (coerced via String() before hashing, matching JS String(visitorId)).

  • experience_id (String) (defaults to: "")

    the experience identifier; prefixed to the visitor id to form the hash input. Defaults to "".

  • seed (Integer) (defaults to: @hash_seed)

    MurmurHash3 seed; defaults to the Config hash seed.

Returns:

  • (Integer)

    the bucket value in [0, max_traffic).



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