amplitude-experiment 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/amplitude-experiment.gemspec +1 -1
- data/lib/amplitude-experiment.rb +1 -6
- data/lib/experiment/evaluation/evaluation.rb +250 -248
- data/lib/experiment/evaluation/flag.rb +90 -88
- data/lib/experiment/evaluation/murmur3.rb +90 -86
- data/lib/experiment/evaluation/select.rb +10 -8
- data/lib/experiment/evaluation/semantic_version.rb +39 -35
- data/lib/experiment/evaluation/topological_sort.rb +46 -49
- data/lib/experiment/evaluation.rb +6 -0
- data/lib/experiment/local/client.rb +3 -1
- data/lib/experiment/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6562a2b13fb38d240e3d83762e442a558478db59f3fff36bdff10b6589cfab0d
|
|
4
|
+
data.tar.gz: 432ea0ff64724c882ad97aae3ce95ae8d8a203f9c90bfe34afd5629e00dbba28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 507368b7f4db8a28dfc866e0df203f724a1d2911af19dab24fada0bb7327e362d185057c891d534421c7816ec12953ad6555a0252b9bfa7d4922ffaea16bc935
|
|
7
|
+
data.tar.gz: d7ad16548702c918919a9d3b4afe874d396c8c5e98fb5010ebb3f8c0f0b476ad86d4568ed4660a74040a5ee85d4a9e01197560039b19d94659ec0e38d67b0868
|
|
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
|
|
|
18
18
|
spec.require_paths = ['lib']
|
|
19
19
|
spec.extra_rdoc_files = ['README.md']
|
|
20
20
|
|
|
21
|
-
spec.add_dependency 'concurrent-ruby', '~> 1.2
|
|
21
|
+
spec.add_dependency 'concurrent-ruby', '~> 1.2'
|
|
22
22
|
|
|
23
23
|
spec.add_development_dependency 'psych', '~> 4.0'
|
|
24
24
|
spec.add_development_dependency 'rake', '~> 13.0'
|
data/lib/amplitude-experiment.rb
CHANGED
|
@@ -27,12 +27,7 @@ require 'experiment/cohort/cohort_storage'
|
|
|
27
27
|
require 'experiment/cohort/cohort_sync_config'
|
|
28
28
|
require 'experiment/deployment/deployment_runner'
|
|
29
29
|
require 'experiment/util/poller'
|
|
30
|
-
require 'experiment/evaluation
|
|
31
|
-
require 'experiment/evaluation/flag'
|
|
32
|
-
require 'experiment/evaluation/murmur3'
|
|
33
|
-
require 'experiment/evaluation/select'
|
|
34
|
-
require 'experiment/evaluation/semantic_version'
|
|
35
|
-
require 'experiment/evaluation/topological_sort'
|
|
30
|
+
require 'experiment/evaluation'
|
|
36
31
|
|
|
37
32
|
# Amplitude Experiment Module
|
|
38
33
|
module AmplitudeExperiment
|
|
@@ -1,311 +1,313 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
3
|
+
module AmplitudeExperiment
|
|
4
|
+
module Evaluation
|
|
5
|
+
# Engine for evaluating feature flags based on context
|
|
6
|
+
class Engine
|
|
7
|
+
def evaluate(context, flags)
|
|
8
|
+
results = {}
|
|
9
|
+
target = {
|
|
10
|
+
'context' => context,
|
|
11
|
+
'result' => results
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
flags.each do |flag|
|
|
15
|
+
variant = evaluate_flag(target, flag)
|
|
16
|
+
results[flag.key] = variant if variant
|
|
17
|
+
end
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
results
|
|
20
|
+
end
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def evaluate_flag(target, flag)
|
|
25
|
+
result = nil
|
|
26
|
+
flag.segments.each do |segment|
|
|
27
|
+
result = evaluate_segment(target, flag, segment)
|
|
28
|
+
next unless result
|
|
29
|
+
|
|
30
|
+
# Merge all metadata into the result
|
|
31
|
+
metadata = {}
|
|
32
|
+
metadata.merge!(flag.metadata) if flag.metadata
|
|
33
|
+
metadata.merge!(segment.metadata) if segment.metadata
|
|
34
|
+
metadata.merge!(result.metadata) if result.metadata
|
|
35
|
+
result.metadata = metadata
|
|
36
|
+
break
|
|
37
|
+
end
|
|
38
|
+
result
|
|
36
39
|
end
|
|
37
|
-
result
|
|
38
|
-
end
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
def evaluate_segment(target, flag, segment)
|
|
42
|
+
if segment.conditions
|
|
43
|
+
match = evaluate_conditions(target, segment.conditions)
|
|
44
|
+
if match
|
|
45
|
+
variant_key = bucket(target, segment)
|
|
46
|
+
variant_key ? flag.variants[variant_key] : nil
|
|
47
|
+
end
|
|
48
|
+
else
|
|
49
|
+
# Null conditions always match
|
|
44
50
|
variant_key = bucket(target, segment)
|
|
45
51
|
variant_key ? flag.variants[variant_key] : nil
|
|
46
52
|
end
|
|
47
|
-
else
|
|
48
|
-
# Null conditions always match
|
|
49
|
-
variant_key = bucket(target, segment)
|
|
50
|
-
variant_key ? flag.variants[variant_key] : nil
|
|
51
53
|
end
|
|
52
|
-
end
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
def evaluate_conditions(target, conditions)
|
|
56
|
+
# Outer list logic is "or" (||)
|
|
57
|
+
conditions.any? do |inner_conditions|
|
|
58
|
+
match = true
|
|
59
|
+
inner_conditions.each do |condition|
|
|
60
|
+
match = match_condition(target, condition)
|
|
61
|
+
break unless match
|
|
62
|
+
end
|
|
63
|
+
match
|
|
61
64
|
end
|
|
62
|
-
match
|
|
63
65
|
end
|
|
64
|
-
end
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
else
|
|
77
|
-
prop_value_string = coerce_string(prop_value)
|
|
78
|
-
if prop_value_string
|
|
79
|
-
match_string(prop_value_string, condition.op, condition.values)
|
|
67
|
+
def match_condition(target, condition)
|
|
68
|
+
prop_value = Evaluation.select(target, condition.selector)
|
|
69
|
+
# Special matching for null properties and set type prop values and operators
|
|
70
|
+
if !prop_value
|
|
71
|
+
match_null(condition.op, condition.values)
|
|
72
|
+
elsif set_operator?(condition.op)
|
|
73
|
+
prop_value_string_list = coerce_string_array(prop_value)
|
|
74
|
+
return false unless prop_value_string_list
|
|
75
|
+
|
|
76
|
+
match_set(prop_value_string_list, condition.op, condition.values)
|
|
80
77
|
else
|
|
81
|
-
|
|
78
|
+
prop_value_string = coerce_string(prop_value)
|
|
79
|
+
if prop_value_string
|
|
80
|
+
match_string(prop_value_string, condition.op, condition.values)
|
|
81
|
+
else
|
|
82
|
+
false
|
|
83
|
+
end
|
|
82
84
|
end
|
|
83
85
|
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def get_hash(key)
|
|
87
|
-
Murmur3.hash32x86(key)
|
|
88
|
-
end
|
|
89
86
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
# Null bucket means segment is fully rolled out
|
|
93
|
-
return segment.variant
|
|
87
|
+
def get_hash(key)
|
|
88
|
+
Murmur3.hash32x86(key)
|
|
94
89
|
end
|
|
95
90
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
def bucket(target, segment)
|
|
92
|
+
unless segment.bucket
|
|
93
|
+
# Null bucket means segment is fully rolled out
|
|
94
|
+
return segment.variant
|
|
95
|
+
end
|
|
101
96
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
97
|
+
bucketing_value = coerce_string(Evaluation.select(target, segment.bucket.selector))
|
|
98
|
+
if !bucketing_value || bucketing_value.empty?
|
|
99
|
+
# Null or empty bucketing value cannot be bucketed
|
|
100
|
+
return segment.variant
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
key_to_hash = "#{segment.bucket.salt}/#{bucketing_value}"
|
|
104
|
+
hash = get_hash(key_to_hash)
|
|
105
|
+
allocation_value = hash % 100
|
|
106
|
+
distribution_value = (hash / 100).floor
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
segment.bucket.allocations.each do |allocation|
|
|
109
|
+
allocation_start = allocation.range[0]
|
|
110
|
+
allocation_end = allocation.range[1]
|
|
111
|
+
next unless allocation_value >= allocation_start && allocation_value < allocation_end
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
allocation.distributions.each do |distribution|
|
|
114
|
+
distribution_start = distribution.range[0]
|
|
115
|
+
distribution_end = distribution.range[1]
|
|
116
|
+
return distribution.variant if distribution_value >= distribution_start && distribution_value < distribution_end
|
|
117
|
+
end
|
|
116
118
|
end
|
|
117
|
-
end
|
|
118
119
|
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
segment.variant
|
|
121
|
+
end
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
123
|
+
def match_null(op, filter_values)
|
|
124
|
+
contains_none = contains_none?(filter_values)
|
|
125
|
+
case op
|
|
126
|
+
when Operator::IS, Operator::CONTAINS, Operator::LESS_THAN,
|
|
127
|
+
Operator::LESS_THAN_EQUALS, Operator::GREATER_THAN,
|
|
128
|
+
Operator::GREATER_THAN_EQUALS, Operator::VERSION_LESS_THAN,
|
|
129
|
+
Operator::VERSION_LESS_THAN_EQUALS, Operator::VERSION_GREATER_THAN,
|
|
130
|
+
Operator::VERSION_GREATER_THAN_EQUALS, Operator::SET_IS,
|
|
131
|
+
Operator::SET_CONTAINS, Operator::SET_CONTAINS_ANY
|
|
132
|
+
contains_none
|
|
133
|
+
when Operator::IS_NOT, Operator::DOES_NOT_CONTAIN,
|
|
134
|
+
Operator::SET_DOES_NOT_CONTAIN, Operator::SET_DOES_NOT_CONTAIN_ANY
|
|
135
|
+
!contains_none
|
|
136
|
+
else
|
|
137
|
+
false
|
|
138
|
+
end
|
|
137
139
|
end
|
|
138
|
-
end
|
|
139
140
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
141
|
+
def match_set(prop_values, op, filter_values)
|
|
142
|
+
case op
|
|
143
|
+
when Operator::SET_IS
|
|
144
|
+
set_equals?(prop_values, filter_values)
|
|
145
|
+
when Operator::SET_IS_NOT
|
|
146
|
+
!set_equals?(prop_values, filter_values)
|
|
147
|
+
when Operator::SET_CONTAINS
|
|
148
|
+
matches_set_contains_all?(prop_values, filter_values)
|
|
149
|
+
when Operator::SET_DOES_NOT_CONTAIN
|
|
150
|
+
!matches_set_contains_all?(prop_values, filter_values)
|
|
151
|
+
when Operator::SET_CONTAINS_ANY
|
|
152
|
+
matches_set_contains_any?(prop_values, filter_values)
|
|
153
|
+
when Operator::SET_DOES_NOT_CONTAIN_ANY
|
|
154
|
+
!matches_set_contains_any?(prop_values, filter_values)
|
|
155
|
+
else
|
|
156
|
+
false
|
|
157
|
+
end
|
|
156
158
|
end
|
|
157
|
-
end
|
|
158
159
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
160
|
+
def match_string(prop_value, op, filter_values)
|
|
161
|
+
case op
|
|
162
|
+
when Operator::IS
|
|
163
|
+
matches_is?(prop_value, filter_values)
|
|
164
|
+
when Operator::IS_NOT
|
|
165
|
+
!matches_is?(prop_value, filter_values)
|
|
166
|
+
when Operator::CONTAINS
|
|
167
|
+
matches_contains?(prop_value, filter_values)
|
|
168
|
+
when Operator::DOES_NOT_CONTAIN
|
|
169
|
+
!matches_contains?(prop_value, filter_values)
|
|
170
|
+
when Operator::LESS_THAN, Operator::LESS_THAN_EQUALS,
|
|
171
|
+
Operator::GREATER_THAN, Operator::GREATER_THAN_EQUALS
|
|
172
|
+
matches_comparable?(prop_value, op, filter_values,
|
|
173
|
+
method(:parse_number),
|
|
174
|
+
method(:comparator))
|
|
175
|
+
when Operator::VERSION_LESS_THAN, Operator::VERSION_LESS_THAN_EQUALS,
|
|
176
|
+
Operator::VERSION_GREATER_THAN, Operator::VERSION_GREATER_THAN_EQUALS
|
|
177
|
+
matches_comparable?(prop_value, op, filter_values,
|
|
178
|
+
SemanticVersion.method(:parse),
|
|
179
|
+
method(:comparator))
|
|
180
|
+
when Operator::REGEX_MATCH
|
|
181
|
+
matches_regex?(prop_value, filter_values)
|
|
182
|
+
when Operator::REGEX_DOES_NOT_MATCH
|
|
183
|
+
!matches_regex?(prop_value, filter_values)
|
|
184
|
+
else
|
|
185
|
+
false
|
|
186
|
+
end
|
|
185
187
|
end
|
|
186
|
-
end
|
|
187
188
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
def matches_is?(prop_value, filter_values)
|
|
190
|
+
if contains_booleans?(filter_values)
|
|
191
|
+
lower = prop_value.downcase
|
|
192
|
+
return filter_values.any? { |value| value.downcase == lower } if %w[true false].include?(lower)
|
|
193
|
+
end
|
|
194
|
+
filter_values.any? { |value| prop_value == value }
|
|
192
195
|
end
|
|
193
|
-
filter_values.any? { |value| prop_value == value }
|
|
194
|
-
end
|
|
195
196
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
def matches_contains?(prop_value, filter_values)
|
|
198
|
+
filter_values.any? do |filter_value|
|
|
199
|
+
prop_value.downcase.include?(filter_value.downcase)
|
|
200
|
+
end
|
|
199
201
|
end
|
|
200
|
-
end
|
|
201
202
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
def matches_comparable?(prop_value, op, filter_values, type_transformer, type_comparator)
|
|
204
|
+
prop_value_transformed = type_transformer.call(prop_value)
|
|
205
|
+
filter_values_transformed = filter_values
|
|
206
|
+
.map { |filter_value| type_transformer.call(filter_value) }
|
|
207
|
+
.compact
|
|
208
|
+
|
|
209
|
+
if !prop_value_transformed || filter_values_transformed.empty?
|
|
210
|
+
filter_values.any? { |filter_value| comparator(prop_value, op, filter_value) }
|
|
211
|
+
else
|
|
212
|
+
filter_values_transformed.any? do |filter_value_transformed|
|
|
213
|
+
type_comparator.call(prop_value_transformed, op, filter_value_transformed)
|
|
214
|
+
end
|
|
213
215
|
end
|
|
214
216
|
end
|
|
215
|
-
end
|
|
216
217
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
218
|
+
def comparator(prop_value, op, filter_value)
|
|
219
|
+
case op
|
|
220
|
+
when Operator::LESS_THAN, Operator::VERSION_LESS_THAN
|
|
221
|
+
prop_value < filter_value
|
|
222
|
+
when Operator::LESS_THAN_EQUALS, Operator::VERSION_LESS_THAN_EQUALS
|
|
223
|
+
prop_value <= filter_value
|
|
224
|
+
when Operator::GREATER_THAN, Operator::VERSION_GREATER_THAN
|
|
225
|
+
prop_value > filter_value
|
|
226
|
+
when Operator::GREATER_THAN_EQUALS, Operator::VERSION_GREATER_THAN_EQUALS
|
|
227
|
+
prop_value >= filter_value
|
|
228
|
+
else
|
|
229
|
+
false
|
|
230
|
+
end
|
|
229
231
|
end
|
|
230
|
-
end
|
|
231
232
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
def matches_regex?(prop_value, filter_values)
|
|
234
|
+
filter_values.any? { |filter_value| !!(Regexp.new(filter_value) =~ prop_value) }
|
|
235
|
+
end
|
|
235
236
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
def contains_none?(filter_values)
|
|
238
|
+
filter_values.any? { |filter_value| filter_value == '(none)' }
|
|
239
|
+
end
|
|
239
240
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
241
|
+
def contains_booleans?(filter_values)
|
|
242
|
+
filter_values.any? do |filter_value|
|
|
243
|
+
case filter_value.downcase
|
|
244
|
+
when 'true', 'false'
|
|
245
|
+
true
|
|
246
|
+
else
|
|
247
|
+
false
|
|
248
|
+
end
|
|
247
249
|
end
|
|
248
250
|
end
|
|
249
|
-
end
|
|
250
251
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
252
|
+
def parse_number(value)
|
|
253
|
+
Float(value)
|
|
254
|
+
rescue StandardError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
256
257
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
def coerce_string(value)
|
|
259
|
+
return nil if value.nil?
|
|
260
|
+
return value.to_json if value.is_a?(Hash)
|
|
260
261
|
|
|
261
|
-
|
|
262
|
-
|
|
262
|
+
value.to_s
|
|
263
|
+
end
|
|
263
264
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
265
|
+
def coerce_string_array(value)
|
|
266
|
+
if value.is_a?(Array)
|
|
267
|
+
value.map { |e| coerce_string(e) }.compact
|
|
268
|
+
else
|
|
269
|
+
string_value = value.to_s
|
|
270
|
+
begin
|
|
271
|
+
parsed_value = JSON.parse(string_value)
|
|
272
|
+
if parsed_value.is_a?(Array)
|
|
273
|
+
parsed_value.map { |e| coerce_string(e) }.compact
|
|
274
|
+
else
|
|
275
|
+
s = coerce_string(string_value)
|
|
276
|
+
s ? [s] : nil
|
|
277
|
+
end
|
|
278
|
+
rescue JSON::ParserError
|
|
274
279
|
s = coerce_string(string_value)
|
|
275
280
|
s ? [s] : nil
|
|
276
281
|
end
|
|
277
|
-
rescue JSON::ParserError
|
|
278
|
-
s = coerce_string(string_value)
|
|
279
|
-
s ? [s] : nil
|
|
280
282
|
end
|
|
281
283
|
end
|
|
282
|
-
end
|
|
283
284
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
def set_operator?(op)
|
|
286
|
+
case op
|
|
287
|
+
when Operator::SET_IS, Operator::SET_IS_NOT,
|
|
288
|
+
Operator::SET_CONTAINS, Operator::SET_DOES_NOT_CONTAIN,
|
|
289
|
+
Operator::SET_CONTAINS_ANY, Operator::SET_DOES_NOT_CONTAIN_ANY
|
|
290
|
+
true
|
|
291
|
+
else
|
|
292
|
+
false
|
|
293
|
+
end
|
|
292
294
|
end
|
|
293
|
-
end
|
|
294
295
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
296
|
+
def set_equals?(xa, ya)
|
|
297
|
+
xs = Set.new(xa)
|
|
298
|
+
ys = Set.new(ya)
|
|
299
|
+
xs.size == ys.size && ys.all? { |y| xs.include?(y) }
|
|
300
|
+
end
|
|
300
301
|
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
def matches_set_contains_all?(prop_values, filter_values)
|
|
303
|
+
return false if prop_values.length < filter_values.length
|
|
303
304
|
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
filter_values.all? { |filter_value| matches_is?(filter_value, prop_values) }
|
|
306
|
+
end
|
|
306
307
|
|
|
307
|
-
|
|
308
|
-
|
|
308
|
+
def matches_set_contains_any?(prop_values, filter_values)
|
|
309
|
+
filter_values.any? { |filter_value| matches_is?(filter_value, prop_values) }
|
|
310
|
+
end
|
|
309
311
|
end
|
|
310
312
|
end
|
|
311
313
|
end
|
|
@@ -2,122 +2,124 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
|
-
module
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
module AmplitudeExperiment
|
|
6
|
+
module Evaluation
|
|
7
|
+
class Distribution
|
|
8
|
+
attr_accessor :variant, :range
|
|
9
|
+
|
|
10
|
+
def self.from_hash(hash)
|
|
11
|
+
new.tap do |dist|
|
|
12
|
+
dist.variant = hash['variant']
|
|
13
|
+
dist.range = hash['range']
|
|
14
|
+
end
|
|
13
15
|
end
|
|
14
16
|
end
|
|
15
|
-
end
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
class Allocation
|
|
19
|
+
attr_accessor :range, :distributions
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
def self.from_hash(hash)
|
|
22
|
+
new.tap do |alloc|
|
|
23
|
+
alloc.range = hash['range']
|
|
24
|
+
alloc.distributions = hash['distributions']&.map { |d| Distribution.from_hash(d) }
|
|
25
|
+
end
|
|
24
26
|
end
|
|
25
27
|
end
|
|
26
|
-
end
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
class Condition
|
|
30
|
+
attr_accessor :selector, :op, :values
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
def self.from_hash(hash)
|
|
33
|
+
new.tap do |cond|
|
|
34
|
+
cond.selector = hash['selector']
|
|
35
|
+
cond.op = hash['op']
|
|
36
|
+
cond.values = hash['values']
|
|
37
|
+
end
|
|
36
38
|
end
|
|
37
39
|
end
|
|
38
|
-
end
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
class Bucket
|
|
42
|
+
attr_accessor :selector, :salt, :allocations
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
def self.from_hash(hash)
|
|
45
|
+
new.tap do |bucket|
|
|
46
|
+
bucket.selector = hash['selector']
|
|
47
|
+
bucket.salt = hash['salt']
|
|
48
|
+
bucket.allocations = hash['allocations']&.map { |a| Allocation.from_hash(a) }
|
|
49
|
+
end
|
|
48
50
|
end
|
|
49
51
|
end
|
|
50
|
-
end
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
class Segment
|
|
54
|
+
attr_accessor :bucket, :conditions, :variant, :metadata
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
def self.from_hash(hash)
|
|
57
|
+
new.tap do |segment|
|
|
58
|
+
segment.bucket = hash['bucket'] && Bucket.from_hash(hash['bucket'])
|
|
59
|
+
segment.conditions = hash['conditions']&.map { |c| c.map { |inner| Condition.from_hash(inner) } }
|
|
60
|
+
segment.variant = hash['variant']
|
|
61
|
+
segment.metadata = hash['metadata']
|
|
62
|
+
end
|
|
61
63
|
end
|
|
62
64
|
end
|
|
63
|
-
end
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
class Variant
|
|
67
|
+
attr_accessor :key, :value, :payload, :metadata
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
def [](key)
|
|
70
|
+
instance_variable_get("@#{key}")
|
|
71
|
+
end
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
def self.from_hash(hash)
|
|
74
|
+
new.tap do |variant|
|
|
75
|
+
variant.key = hash['key']
|
|
76
|
+
variant.value = hash['value']
|
|
77
|
+
variant.payload = hash['payload']
|
|
78
|
+
variant.metadata = hash['metadata']
|
|
79
|
+
end
|
|
78
80
|
end
|
|
79
81
|
end
|
|
80
|
-
end
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
class Flag
|
|
84
|
+
attr_accessor :key, :variants, :segments, :dependencies, :metadata
|
|
85
|
+
|
|
86
|
+
def self.from_hash(hash)
|
|
87
|
+
new.tap do |flag|
|
|
88
|
+
flag.key = hash['key']
|
|
89
|
+
flag.variants = hash['variants'].transform_values { |v| Variant.from_hash(v) }
|
|
90
|
+
flag.segments = hash['segments'].map { |s| Segment.from_hash(s) }
|
|
91
|
+
flag.dependencies = hash['dependencies']
|
|
92
|
+
flag.metadata = hash['metadata']
|
|
93
|
+
end
|
|
94
|
+
end
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
flag.variants = hash['variants'].transform_values { |v| Variant.from_hash(v) }
|
|
89
|
-
flag.segments = hash['segments'].map { |s| Segment.from_hash(s) }
|
|
90
|
-
flag.dependencies = hash['dependencies']
|
|
91
|
-
flag.metadata = hash['metadata']
|
|
96
|
+
# Used for testing
|
|
97
|
+
def ==(other)
|
|
98
|
+
key == other.key
|
|
92
99
|
end
|
|
93
100
|
end
|
|
94
101
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
module Operator
|
|
103
|
+
IS = 'is'
|
|
104
|
+
IS_NOT = 'is not'
|
|
105
|
+
CONTAINS = 'contains'
|
|
106
|
+
DOES_NOT_CONTAIN = 'does not contain'
|
|
107
|
+
LESS_THAN = 'less'
|
|
108
|
+
LESS_THAN_EQUALS = 'less or equal'
|
|
109
|
+
GREATER_THAN = 'greater'
|
|
110
|
+
GREATER_THAN_EQUALS = 'greater or equal'
|
|
111
|
+
VERSION_LESS_THAN = 'version less'
|
|
112
|
+
VERSION_LESS_THAN_EQUALS = 'version less or equal'
|
|
113
|
+
VERSION_GREATER_THAN = 'version greater'
|
|
114
|
+
VERSION_GREATER_THAN_EQUALS = 'version greater or equal'
|
|
115
|
+
SET_IS = 'set is'
|
|
116
|
+
SET_IS_NOT = 'set is not'
|
|
117
|
+
SET_CONTAINS = 'set contains'
|
|
118
|
+
SET_DOES_NOT_CONTAIN = 'set does not contain'
|
|
119
|
+
SET_CONTAINS_ANY = 'set contains any'
|
|
120
|
+
SET_DOES_NOT_CONTAIN_ANY = 'set does not contain any'
|
|
121
|
+
REGEX_MATCH = 'regex match'
|
|
122
|
+
REGEX_DOES_NOT_MATCH = 'regex does not match'
|
|
98
123
|
end
|
|
99
124
|
end
|
|
100
|
-
|
|
101
|
-
module Operator
|
|
102
|
-
IS = 'is'
|
|
103
|
-
IS_NOT = 'is not'
|
|
104
|
-
CONTAINS = 'contains'
|
|
105
|
-
DOES_NOT_CONTAIN = 'does not contain'
|
|
106
|
-
LESS_THAN = 'less'
|
|
107
|
-
LESS_THAN_EQUALS = 'less or equal'
|
|
108
|
-
GREATER_THAN = 'greater'
|
|
109
|
-
GREATER_THAN_EQUALS = 'greater or equal'
|
|
110
|
-
VERSION_LESS_THAN = 'version less'
|
|
111
|
-
VERSION_LESS_THAN_EQUALS = 'version less or equal'
|
|
112
|
-
VERSION_GREATER_THAN = 'version greater'
|
|
113
|
-
VERSION_GREATER_THAN_EQUALS = 'version greater or equal'
|
|
114
|
-
SET_IS = 'set is'
|
|
115
|
-
SET_IS_NOT = 'set is not'
|
|
116
|
-
SET_CONTAINS = 'set contains'
|
|
117
|
-
SET_DOES_NOT_CONTAIN = 'set does not contain'
|
|
118
|
-
SET_CONTAINS_ANY = 'set contains any'
|
|
119
|
-
SET_DOES_NOT_CONTAIN_ANY = 'set does not contain any'
|
|
120
|
-
REGEX_MATCH = 'regex match'
|
|
121
|
-
REGEX_DOES_NOT_MATCH = 'regex does not match'
|
|
122
|
-
end
|
|
123
125
|
end
|
|
@@ -1,104 +1,108 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Implements 32-bit x86 MurmurHash3
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
module AmplitudeExperiment
|
|
5
|
+
module Evaluation
|
|
6
|
+
class Murmur3
|
|
7
|
+
C1_32 = -0x3361d2af
|
|
8
|
+
C2_32 = 0x1b873593
|
|
9
|
+
R1_32 = 15
|
|
10
|
+
R2_32 = 13
|
|
11
|
+
M_32 = 5
|
|
12
|
+
N_32 = -0x19ab949c
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
class << self
|
|
15
|
+
def hash32x86(input, seed = 0)
|
|
16
|
+
data = string_to_utf8_bytes(input)
|
|
17
|
+
length = data.length
|
|
18
|
+
n_blocks = length >> 2
|
|
19
|
+
hash = seed
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
# Process body
|
|
22
|
+
n_blocks.times do |i|
|
|
23
|
+
index = i << 2
|
|
24
|
+
k = read_int_le(data, index)
|
|
25
|
+
hash = mix32(k, hash)
|
|
26
|
+
end
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
# Process tail
|
|
29
|
+
index = n_blocks << 2
|
|
30
|
+
k1 = 0
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
32
|
+
case length - index
|
|
33
|
+
when 3
|
|
34
|
+
k1 ^= data[index + 2] << 16
|
|
35
|
+
k1 ^= data[index + 1] << 8
|
|
36
|
+
k1 ^= data[index]
|
|
37
|
+
k1 = (k1 * C1_32) & 0xffffffff
|
|
38
|
+
k1 = rotate_left(k1, R1_32)
|
|
39
|
+
k1 = (k1 * C2_32) & 0xffffffff
|
|
40
|
+
hash ^= k1
|
|
41
|
+
when 2
|
|
42
|
+
k1 ^= data[index + 1] << 8
|
|
43
|
+
k1 ^= data[index]
|
|
44
|
+
k1 = (k1 * C1_32) & 0xffffffff
|
|
45
|
+
k1 = rotate_left(k1, R1_32)
|
|
46
|
+
k1 = (k1 * C2_32) & 0xffffffff
|
|
47
|
+
hash ^= k1
|
|
48
|
+
when 1
|
|
49
|
+
k1 ^= data[index]
|
|
50
|
+
k1 = (k1 * C1_32) & 0xffffffff
|
|
51
|
+
k1 = rotate_left(k1, R1_32)
|
|
52
|
+
k1 = (k1 * C2_32) & 0xffffffff
|
|
53
|
+
hash ^= k1
|
|
54
|
+
end
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
hash ^= length
|
|
57
|
+
fmix32(hash) & 0xffffffff
|
|
58
|
+
end
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
private
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
def mix32(k, hash)
|
|
63
|
+
k = (k * C1_32) & 0xffffffff
|
|
64
|
+
k = rotate_left(k, R1_32)
|
|
65
|
+
k = (k * C2_32) & 0xffffffff
|
|
66
|
+
hash ^= k
|
|
67
|
+
hash = rotate_left(hash, R2_32)
|
|
68
|
+
((hash * M_32) + N_32) & 0xffffffff
|
|
69
|
+
end
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
def fmix32(hash)
|
|
72
|
+
hash ^= hash >> 16
|
|
73
|
+
hash = (hash * -0x7a143595) & 0xffffffff
|
|
74
|
+
hash ^= hash >> 13
|
|
75
|
+
hash = (hash * -0x3d4d51cb) & 0xffffffff
|
|
76
|
+
hash ^= hash >> 16
|
|
77
|
+
hash
|
|
78
|
+
end
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
def rotate_left(x, n, width = 32)
|
|
81
|
+
n %= width if n > width
|
|
82
|
+
mask = (0xffffffff << (width - n)) & 0xffffffff
|
|
83
|
+
r = ((x & mask) >> (width - n)) & 0xffffffff
|
|
84
|
+
((x << n) | r) & 0xffffffff
|
|
85
|
+
end
|
|
84
86
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
def read_int_le(data, index = 0)
|
|
88
|
+
n = (data[index] << 24) |
|
|
89
|
+
(data[index + 1] << 16) |
|
|
90
|
+
(data[index + 2] << 8) |
|
|
91
|
+
data[index + 3]
|
|
92
|
+
reverse_bytes(n)
|
|
93
|
+
end
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
def reverse_bytes(n)
|
|
96
|
+
((n & -0x1000000) >> 24) |
|
|
97
|
+
((n & 0x00ff0000) >> 8) |
|
|
98
|
+
((n & 0x0000ff00) << 8) |
|
|
99
|
+
((n & 0x000000ff) << 24)
|
|
100
|
+
end
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
def string_to_utf8_bytes(str)
|
|
103
|
+
str.encode('UTF-8').bytes
|
|
104
|
+
end
|
|
105
|
+
end
|
|
102
106
|
end
|
|
103
107
|
end
|
|
104
108
|
end
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Selects a value from a nested object using an array of selector keys
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
module AmplitudeExperiment
|
|
5
|
+
module Evaluation
|
|
6
|
+
def self.select(selectable, selector)
|
|
7
|
+
return nil if selector.nil? || selector.empty?
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
selector.each do |selector_element|
|
|
10
|
+
return nil if selector_element.nil? || selectable.nil?
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
selectable = selectable[selector_element]
|
|
13
|
+
end
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
selectable.nil? ? nil : selectable
|
|
16
|
+
end
|
|
15
17
|
end
|
|
16
18
|
end
|
|
@@ -1,52 +1,56 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
module AmplitudeExperiment
|
|
4
|
+
module Evaluation
|
|
5
|
+
class SemanticVersion
|
|
6
|
+
include Comparable
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
attr_reader :major, :minor, :patch, :pre_release
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
MAJOR_MINOR_REGEX = '(\d+)\.(\d+)'
|
|
11
|
+
PATCH_REGEX = '(\d+)'
|
|
12
|
+
PRERELEASE_REGEX = '(-(([-\w]+\.?)*))?'
|
|
13
|
+
VERSION_PATTERN = /^#{MAJOR_MINOR_REGEX}(\.#{PATCH_REGEX}#{PRERELEASE_REGEX})?$/.freeze
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
def initialize(major, minor, patch, pre_release = nil)
|
|
16
|
+
@major = major
|
|
17
|
+
@minor = minor
|
|
18
|
+
@patch = patch
|
|
19
|
+
@pre_release = pre_release
|
|
20
|
+
end
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
def self.parse(version)
|
|
23
|
+
return nil if version.nil?
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
match = VERSION_PATTERN.match(version)
|
|
26
|
+
return nil unless match
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
major = match[1].to_i
|
|
29
|
+
minor = match[2].to_i
|
|
30
|
+
patch = match[4]&.to_i || 0
|
|
31
|
+
pre_release = match[5]
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
new(major, minor, patch, pre_release)
|
|
34
|
+
end
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
def <=>(other)
|
|
37
|
+
return nil unless other.is_a?(SemanticVersion)
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
result = major <=> other.major
|
|
40
|
+
return result unless result.zero?
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
result = minor <=> other.minor
|
|
43
|
+
return result unless result.zero?
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
result = patch <=> other.patch
|
|
46
|
+
return result unless result.zero?
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
return 1 if !pre_release && other.pre_release
|
|
49
|
+
return -1 if pre_release && !other.pre_release
|
|
50
|
+
return 0 if !pre_release && !other.pre_release
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
pre_release <=> other.pre_release
|
|
53
|
+
end
|
|
54
|
+
end
|
|
51
55
|
end
|
|
52
56
|
end
|
|
@@ -1,56 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
attr_accessor :path
|
|
5
|
-
|
|
6
|
-
def initialize(path)
|
|
7
|
-
super("Detected a cycle between flags #{path}")
|
|
8
|
-
self.path = path
|
|
9
|
-
end
|
|
10
|
-
end
|
|
3
|
+
require_relative '../error'
|
|
11
4
|
|
|
12
5
|
# Performs topological sorting of feature flags based on their dependencies
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
6
|
+
module AmplitudeExperiment
|
|
7
|
+
module Evaluation
|
|
8
|
+
class TopologicalSort
|
|
9
|
+
# Sort flags topologically based on their dependencies
|
|
10
|
+
def self.sort(flags, flag_keys = nil)
|
|
11
|
+
available = flags.clone
|
|
12
|
+
result = []
|
|
13
|
+
starting_keys = flag_keys.nil? || flag_keys.empty? ? flags.keys : flag_keys
|
|
14
|
+
|
|
15
|
+
starting_keys.each do |flag_key|
|
|
16
|
+
traversal = parent_traversal(flag_key, available)
|
|
17
|
+
result.concat(traversal) if traversal
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
result
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Perform depth-first traversal of flag dependencies
|
|
24
|
+
def self.parent_traversal(flag_key, available, path = [])
|
|
25
|
+
flag = available[flag_key]
|
|
26
|
+
return nil unless flag
|
|
27
|
+
|
|
28
|
+
# No dependencies - return flag and remove from available
|
|
29
|
+
if !flag.dependencies || flag.dependencies.empty?
|
|
30
|
+
available.delete(flag.key)
|
|
31
|
+
return [flag]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check for cycles
|
|
35
|
+
path.push(flag.key)
|
|
36
|
+
result = []
|
|
37
|
+
|
|
38
|
+
flag.dependencies.each do |parent_key|
|
|
39
|
+
raise CycleError, path if path.any? { |p| p == parent_key }
|
|
40
|
+
|
|
41
|
+
traversal = parent_traversal(parent_key, available, path)
|
|
42
|
+
result.concat(traversal) if traversal
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
result.push(flag)
|
|
46
|
+
path.pop
|
|
47
|
+
available.delete(flag.key)
|
|
48
|
+
|
|
49
|
+
result
|
|
50
|
+
end
|
|
37
51
|
end
|
|
38
|
-
|
|
39
|
-
# Check for cycles
|
|
40
|
-
path.push(flag.key)
|
|
41
|
-
result = []
|
|
42
|
-
|
|
43
|
-
flag.dependencies.each do |parent_key|
|
|
44
|
-
raise CycleError, path if path.any? { |p| p == parent_key }
|
|
45
|
-
|
|
46
|
-
traversal = parent_traversal(parent_key, available, path)
|
|
47
|
-
result.concat(traversal) if traversal
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
result.push(flag)
|
|
51
|
-
path.pop
|
|
52
|
-
available.delete(flag.key)
|
|
53
|
-
|
|
54
|
-
result
|
|
55
52
|
end
|
|
56
53
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
require 'uri'
|
|
2
2
|
require 'logger'
|
|
3
|
+
|
|
3
4
|
require_relative '../../amplitude'
|
|
5
|
+
require_relative '../evaluation'
|
|
4
6
|
|
|
5
7
|
module AmplitudeExperiment
|
|
6
8
|
FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = 'mutual-exclusion-group'.freeze
|
|
@@ -63,7 +65,7 @@ module AmplitudeExperiment
|
|
|
63
65
|
flags = @flag_config_storage.flag_configs
|
|
64
66
|
return {} if flags.nil?
|
|
65
67
|
|
|
66
|
-
sorted_flags = TopologicalSort.sort(flags, flag_keys)
|
|
68
|
+
sorted_flags = Evaluation::TopologicalSort.sort(flags, flag_keys)
|
|
67
69
|
required_cohorts_in_storage(sorted_flags)
|
|
68
70
|
user = enrich_user_with_cohorts(user, flags) if @config.cohort_sync_config
|
|
69
71
|
context = AmplitudeExperiment.user_to_evaluation_context(user)
|
data/lib/experiment/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: amplitude-experiment
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.7.
|
|
4
|
+
version: 1.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Amplitude
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-
|
|
11
|
+
date: 2025-12-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 1.2
|
|
19
|
+
version: '1.2'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 1.2
|
|
26
|
+
version: '1.2'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: psych
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -197,6 +197,7 @@ files:
|
|
|
197
197
|
- lib/experiment/cookie.rb
|
|
198
198
|
- lib/experiment/deployment/deployment_runner.rb
|
|
199
199
|
- lib/experiment/error.rb
|
|
200
|
+
- lib/experiment/evaluation.rb
|
|
200
201
|
- lib/experiment/evaluation/evaluation.rb
|
|
201
202
|
- lib/experiment/evaluation/flag.rb
|
|
202
203
|
- lib/experiment/evaluation/murmur3.rb
|