Skip to content

Commit 0452cd9

Browse files
ld-kyeepellyg-ld
andcommitted
Experiment Allocation Changes (#150)
* WIP - from sam's pairing session * starting sdk changes * adding tests and making sure everything works * adding more tests * removing the singleton for fallthrough * Revert "removing the singleton for fallthrough" This reverts commit dff7adbb809ecc63118d0fbff9742a88a039c679. * taking a different approach to keep things immutable * adding tests for untracked * remove unnecessary comment * making sure to return two values in all code paths Co-authored-by: pellyg-ld <gpelly@launchdarkly.com>
1 parent c0be90f commit 0452cd9

File tree

8 files changed

+294
-28
lines changed

8 files changed

+294
-28
lines changed

lib/ldclient-rb/evaluation_detail.rb

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ class EvaluationReason
120120
# or deleted. If {#kind} is not {#RULE_MATCH}, this will be `nil`.
121121
attr_reader :rule_id
122122

123+
# A boolean or nil value representing if the rule or fallthrough has an experiment rollout.
124+
attr_reader :in_experiment
125+
123126
# The key of the prerequisite flag that did not return the desired variation. If {#kind} is not
124127
# {#PREREQUISITE_FAILED}, this will be `nil`.
125128
attr_reader :prerequisite_key
@@ -136,8 +139,12 @@ def self.off
136139

137140
# Returns an instance whose {#kind} is {#FALLTHROUGH}.
138141
# @return [EvaluationReason]
139-
def self.fallthrough
140-
@@fallthrough
142+
def self.fallthrough(in_experiment=false)
143+
if in_experiment
144+
@@fallthrough_with_experiment
145+
else
146+
@@fallthrough
147+
end
141148
end
142149

143150
# Returns an instance whose {#kind} is {#TARGET_MATCH}.
@@ -153,10 +160,16 @@ def self.target_match
153160
# @param rule_id [String] unique string identifier for the matched rule
154161
# @return [EvaluationReason]
155162
# @raise [ArgumentError] if `rule_index` is not a number or `rule_id` is not a string
156-
def self.rule_match(rule_index, rule_id)
163+
def self.rule_match(rule_index, rule_id, in_experiment=false)
157164
raise ArgumentError.new("rule_index must be a number") if !(rule_index.is_a? Numeric)
158165
raise ArgumentError.new("rule_id must be a string") if !rule_id.nil? && !(rule_id.is_a? String) # in test data, ID could be nil
159-
new(:RULE_MATCH, rule_index, rule_id, nil, nil)
166+
167+
if in_experiment
168+
er = new(:RULE_MATCH, rule_index, rule_id, nil, nil, true)
169+
else
170+
er = new(:RULE_MATCH, rule_index, rule_id, nil, nil)
171+
end
172+
er
160173
end
161174

162175
# Returns an instance whose {#kind} is {#PREREQUISITE_FAILED}.
@@ -204,11 +217,17 @@ def to_s
204217
def inspect
205218
case @kind
206219
when :RULE_MATCH
207-
"RULE_MATCH(#{@rule_index},#{@rule_id})"
220+
if @in_experiment
221+
"RULE_MATCH(#{@rule_index},#{@rule_id},#{@in_experiment})"
222+
else
223+
"RULE_MATCH(#{@rule_index},#{@rule_id})"
224+
end
208225
when :PREREQUISITE_FAILED
209226
"PREREQUISITE_FAILED(#{@prerequisite_key})"
210227
when :ERROR
211228
"ERROR(#{@error_kind})"
229+
when :FALLTHROUGH
230+
@in_experiment ? "FALLTHROUGH(#{@in_experiment})" : @kind.to_s
212231
else
213232
@kind.to_s
214233
end
@@ -225,11 +244,21 @@ def as_json(*) # parameter is unused, but may be passed if we're using the json
225244
# as_json and then modify the result.
226245
case @kind
227246
when :RULE_MATCH
228-
{ kind: @kind, ruleIndex: @rule_index, ruleId: @rule_id }
247+
if @in_experiment
248+
{ kind: @kind, ruleIndex: @rule_index, ruleId: @rule_id, in_experiment: @in_experiment }
249+
else
250+
{ kind: @kind, ruleIndex: @rule_index, ruleId: @rule_id }
251+
end
229252
when :PREREQUISITE_FAILED
230253
{ kind: @kind, prerequisiteKey: @prerequisite_key }
231254
when :ERROR
232255
{ kind: @kind, errorKind: @error_kind }
256+
when :FALLTHROUGH
257+
if @in_experiment
258+
{ kind: @kind, in_experiment: @in_experiment }
259+
else
260+
{ kind: @kind }
261+
end
233262
else
234263
{ kind: @kind }
235264
end
@@ -263,14 +292,15 @@ def [](key)
263292

264293
private
265294

266-
def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind)
295+
def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind, in_experiment=nil)
267296
@kind = kind.to_sym
268297
@rule_index = rule_index
269298
@rule_id = rule_id
270299
@rule_id.freeze if !rule_id.nil?
271300
@prerequisite_key = prerequisite_key
272301
@prerequisite_key.freeze if !prerequisite_key.nil?
273302
@error_kind = error_kind
303+
@in_experiment = in_experiment
274304
end
275305

276306
private_class_method :new
@@ -279,6 +309,7 @@ def self.make_error(error_kind)
279309
new(:ERROR, nil, nil, nil, error_kind)
280310
end
281311

312+
@@fallthrough_with_experiment = new(:FALLTHROUGH, nil, nil, nil, nil, true)
282313
@@fallthrough = new(:FALLTHROUGH, nil, nil, nil, nil)
283314
@@off = new(:OFF, nil, nil, nil, nil)
284315
@@target_match = new(:TARGET_MATCH, nil, nil, nil, nil)

lib/ldclient-rb/impl/evaluator.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def segment_rule_match_user(rule, user, segment_key, salt)
190190
return true if !rule[:weight]
191191

192192
# All of the clauses are met. See if the user buckets in
193-
bucket = EvaluatorBucketing.bucket_user(user, segment_key, rule[:bucketBy].nil? ? "key" : rule[:bucketBy], salt)
193+
bucket = EvaluatorBucketing.bucket_user(user, segment_key, rule[:bucketBy].nil? ? "key" : rule[:bucketBy], salt, nil)
194194
weight = rule[:weight].to_f / 100000.0
195195
return bucket < weight
196196
end
@@ -213,7 +213,13 @@ def get_off_value(flag, reason)
213213
end
214214

215215
def get_value_for_variation_or_rollout(flag, vr, user, reason)
216-
index = EvaluatorBucketing.variation_index_for_user(flag, vr, user)
216+
index, in_experiment = EvaluatorBucketing.variation_index_for_user(flag, vr, user)
217+
#if in experiment is true, set reason to a different reason instance/singleton with in_experiment set
218+
if in_experiment && reason.kind == :FALLTHROUGH
219+
reason = EvaluationReason::fallthrough(in_experiment)
220+
elsif in_experiment && reason.kind == :RULE_MATCH
221+
reason = EvaluationReason::rule_match(reason.rule_index, reason.rule_id, in_experiment)
222+
end
217223
if index.nil?
218224
@logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": variation/rollout object with no variation or rollout")
219225
return Evaluator.error_result(EvaluationReason::ERROR_MALFORMED_FLAG)

lib/ldclient-rb/impl/evaluator_bucketing.rb

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,39 @@ module EvaluatorBucketing
1010
# @param user [Object] the user properties
1111
# @return [Number] the variation index, or nil if there is an error
1212
def self.variation_index_for_user(flag, rule, user)
13+
in_experiment = nil
14+
1315
variation = rule[:variation]
14-
return variation if !variation.nil? # fixed variation
16+
return variation, in_experiment if !variation.nil? # fixed variation
1517
rollout = rule[:rollout]
16-
return nil if rollout.nil?
18+
return nil, in_experiment if rollout.nil?
1719
variations = rollout[:variations]
1820
if !variations.nil? && variations.length > 0 # percentage rollout
1921
rollout = rule[:rollout]
2022
bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy]
21-
bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt])
23+
24+
seed = rollout[:seed]
25+
bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt], seed) # may not be present
2226
sum = 0;
2327
variations.each do |variate|
28+
if rule[:rollout][:kind] == "experiment" && !variate[:untracked]
29+
in_experiment = true
30+
end
31+
2432
sum += variate[:weight].to_f / 100000.0
33+
2534
if bucket < sum
26-
return variate[:variation]
35+
return variate[:variation], in_experiment
2736
end
2837
end
2938
# The user's bucket value was greater than or equal to the end of the last bucket. This could happen due
3039
# to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag
3140
# data could contain buckets that don't actually add up to 100000. Rather than returning an error in
3241
# this case (or changing the scaling, which would potentially change the results for *all* users), we
3342
# will simply put the user in the last bucket.
34-
variations[-1][:variation]
43+
[ variations[-1][:variation], in_experiment ]
3544
else # the rule isn't well-formed
36-
nil
45+
[ nil, in_experiment ]
3746
end
3847
end
3948

@@ -44,7 +53,7 @@ def self.variation_index_for_user(flag, rule, user)
4453
# @param bucket_by [String|Symbol] the name of the user attribute to be used for bucketing
4554
# @param salt [String] the feature flag's or segment's salt value
4655
# @return [Number] the bucket value, from 0 inclusive to 1 exclusive
47-
def self.bucket_user(user, key, bucket_by, salt)
56+
def self.bucket_user(user, key, bucket_by, salt, seed)
4857
return nil unless user[:key]
4958

5059
id_hash = bucketable_string_value(EvaluatorOperators.user_value(user, bucket_by))
@@ -56,7 +65,11 @@ def self.bucket_user(user, key, bucket_by, salt)
5665
id_hash += "." + user[:secondary].to_s
5766
end
5867

59-
hash_key = "%s.%s.%s" % [key, salt, id_hash]
68+
if seed
69+
hash_key = "%d.%s" % [seed, id_hash]
70+
else
71+
hash_key = "%s.%s.%s" % [key, salt, id_hash]
72+
end
6073

6174
hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14]
6275
hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF)

lib/ldclient-rb/impl/event_factory.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ def context_to_context_kind(user)
103103

104104
def is_experiment(flag, reason)
105105
return false if !reason
106+
107+
if reason.in_experiment
108+
return true
109+
end
110+
106111
case reason[:kind]
107112
when 'RULE_MATCH'
108113
index = reason[:ruleIndex]
@@ -115,6 +120,7 @@ def is_experiment(flag, reason)
115120
end
116121
false
117122
end
123+
118124
end
119125
end
120126
end

spec/impl/evaluator_bucketing_spec.rb

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,43 @@
44
subject { LaunchDarkly::Impl::EvaluatorBucketing }
55

66
describe "bucket_user" do
7+
describe "seed exists" do
8+
let(:seed) { 61 }
9+
it "gets the expected bucket values for seed" do
10+
user = { key: "userKeyA" }
11+
bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
12+
expect(bucket).to be_within(0.0000001).of(0.09801207);
13+
14+
user = { key: "userKeyB" }
15+
bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
16+
expect(bucket).to be_within(0.0000001).of(0.14483777);
17+
18+
user = { key: "userKeyC" }
19+
bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", seed)
20+
expect(bucket).to be_within(0.0000001).of(0.9242641);
21+
end
22+
23+
it "should return the same bucket if the seed and user is the same" do
24+
user = { key: "userKeyA" }
25+
bucket1 = subject.bucket_user(user, "hashKey", "bucket_by", "saltyA", seed)
26+
bucket2 = subject.bucket_user(user, "hashKey1", "bucket_by", "saltyB", seed)
27+
bucket3 = subject.bucket_user(user, "hashKey2", "bucket_by", "saltyC", seed)
28+
expect(bucket1).to eq(bucket2)
29+
expect(bucket2).to eq(bucket3)
30+
end
31+
end
32+
733
it "gets expected bucket values for specific keys" do
834
user = { key: "userKeyA" }
9-
bucket = subject.bucket_user(user, "hashKey", "key", "saltyA")
35+
bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", nil)
1036
expect(bucket).to be_within(0.0000001).of(0.42157587);
1137

1238
user = { key: "userKeyB" }
13-
bucket = subject.bucket_user(user, "hashKey", "key", "saltyA")
39+
bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", nil)
1440
expect(bucket).to be_within(0.0000001).of(0.6708485);
1541

1642
user = { key: "userKeyC" }
17-
bucket = subject.bucket_user(user, "hashKey", "key", "saltyA")
43+
bucket = subject.bucket_user(user, "hashKey", "key", "saltyA", nil)
1844
expect(bucket).to be_within(0.0000001).of(0.10343106);
1945
end
2046

@@ -26,8 +52,8 @@
2652
intAttr: 33333
2753
}
2854
}
29-
stringResult = subject.bucket_user(user, "hashKey", "stringAttr", "saltyA")
30-
intResult = subject.bucket_user(user, "hashKey", "intAttr", "saltyA")
55+
stringResult = subject.bucket_user(user, "hashKey", "stringAttr", "saltyA", nil)
56+
intResult = subject.bucket_user(user, "hashKey", "intAttr", "saltyA", nil)
3157

3258
expect(intResult).to be_within(0.0000001).of(0.54771423)
3359
expect(intResult).to eq(stringResult)
@@ -40,7 +66,7 @@
4066
floatAttr: 33.5
4167
}
4268
}
43-
result = subject.bucket_user(user, "hashKey", "floatAttr", "saltyA")
69+
result = subject.bucket_user(user, "hashKey", "floatAttr", "saltyA", nil)
4470
expect(result).to eq(0.0)
4571
end
4672

@@ -52,7 +78,7 @@
5278
boolAttr: true
5379
}
5480
}
55-
result = subject.bucket_user(user, "hashKey", "boolAttr", "saltyA")
81+
result = subject.bucket_user(user, "hashKey", "boolAttr", "saltyA", nil)
5682
expect(result).to eq(0.0)
5783
end
5884
end
@@ -65,7 +91,7 @@
6591

6692
# First verify that with our test inputs, the bucket value will be greater than zero and less than 100000,
6793
# so we can construct a rollout whose second bucket just barely contains that value
68-
bucket_value = (subject.bucket_user(user, flag_key, "key", salt) * 100000).truncate()
94+
bucket_value = (subject.bucket_user(user, flag_key, "key", salt, nil) * 100000).truncate()
6995
expect(bucket_value).to be > 0
7096
expect(bucket_value).to be < 100000
7197

@@ -83,7 +109,7 @@
83109
}
84110
flag = { key: flag_key, salt: salt }
85111

86-
result_variation = subject.variation_index_for_user(flag, rule, user)
112+
result_variation, _ = subject.variation_index_for_user(flag, rule, user)
87113
expect(result_variation).to be matched_variation
88114
end
89115

@@ -92,7 +118,7 @@
92118
flag_key = "flagkey"
93119
salt = "salt"
94120

95-
bucket_value = (subject.bucket_user(user, flag_key, "key", salt) * 100000).truncate()
121+
bucket_value = (subject.bucket_user(user, flag_key, "key", salt, nil) * 100000).truncate()
96122

97123
# We'll construct a list of variations that stops right at the target bucket value
98124
rule = {
@@ -104,7 +130,7 @@
104130
}
105131
flag = { key: flag_key, salt: salt }
106132

107-
result_variation = subject.variation_index_for_user(flag, rule, user)
133+
result_variation, _ = subject.variation_index_for_user(flag, rule, user)
108134
expect(result_variation).to be 0
109135
end
110136
end

spec/impl/evaluator_rule_spec.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,38 @@ module Impl
9191
result = basic_evaluator.evaluate(flag, user, factory)
9292
expect(result.detail.reason).to eq(EvaluationReason::rule_match(0, 'ruleid'))
9393
end
94+
95+
describe "experiment rollout behavior" do
96+
it "sets the in_experiment value if rollout kind is experiment " do
97+
rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
98+
rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: false } ] } }
99+
flag = boolean_flag_with_rules([rule])
100+
user = { key: "userkey", secondary: 999 }
101+
result = basic_evaluator.evaluate(flag, user, factory)
102+
expect(result.detail.reason.to_json).to include('"in_experiment":true')
103+
expect(result.detail.reason.in_experiment).to eq(true)
104+
end
105+
106+
it "does not set the in_experiment value if rollout kind is not experiment " do
107+
rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
108+
rollout: { kind: 'rollout', variations: [ { weight: 100000, variation: 1, untracked: false } ] } }
109+
flag = boolean_flag_with_rules([rule])
110+
user = { key: "userkey", secondary: 999 }
111+
result = basic_evaluator.evaluate(flag, user, factory)
112+
expect(result.detail.reason.to_json).to_not include('"in_experiment":true')
113+
expect(result.detail.reason.in_experiment).to eq(nil)
114+
end
115+
116+
it "does not set the in_experiment value if rollout kind is experiment and untracked is true" do
117+
rule = { id: 'ruleid', clauses: [{ attribute: 'key', op: 'in', values: ['userkey'] }],
118+
rollout: { kind: 'experiment', variations: [ { weight: 100000, variation: 1, untracked: true } ] } }
119+
flag = boolean_flag_with_rules([rule])
120+
user = { key: "userkey", secondary: 999 }
121+
result = basic_evaluator.evaluate(flag, user, factory)
122+
expect(result.detail.reason.to_json).to_not include('"in_experiment":true')
123+
expect(result.detail.reason.in_experiment).to eq(nil)
124+
end
125+
end
94126
end
95127
end
96128
end

0 commit comments

Comments
 (0)