growthbook 1.3.0 → 2.0.0
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/lib/growthbook/conditions.rb +409 -102
- data/lib/growthbook/context.rb +70 -20
- data/lib/growthbook/feature_repository.rb +24 -2
- data/lib/growthbook/feature_result.rb +10 -2
- data/lib/growthbook/feature_rule.rb +10 -1
- data/lib/growthbook/fnv.rb +5 -2
- data/lib/growthbook/inline_experiment.rb +7 -1
- data/lib/growthbook/payload_reducer.rb +278 -0
- data/lib/growthbook/util.rb +36 -0
- data/lib/growthbook.rb +47 -0
- metadata +35 -6
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Growthbook
|
|
4
|
+
# internal use only
|
|
5
|
+
# Shrinks a features payload for a user whose attributes are only partly known,
|
|
6
|
+
# e.g. on a server bootstrapping a client SDK. The result evaluates the same as
|
|
7
|
+
# the full payload for anyone who agrees with the known attributes.
|
|
8
|
+
class PayloadReducer
|
|
9
|
+
# @param payload [Hash] A decrypted features payload, as returned by the SDK API
|
|
10
|
+
# @param attributes [Hash] The attributes known for the user
|
|
11
|
+
# @param include [Hash] Keep only what matches every filter: projects, tags, customFields
|
|
12
|
+
# @param exclude [Hash] Drop what matches any filter: projects, tags, customFields
|
|
13
|
+
# @param prerequisites ['keep', 'filter'] With 'keep', prerequisites of kept features
|
|
14
|
+
# and experiments are kept even when the filters drop them
|
|
15
|
+
def initialize(payload, attributes:, include:, exclude:, prerequisites:)
|
|
16
|
+
@payload = payload
|
|
17
|
+
@attributes = attributes
|
|
18
|
+
@include = include
|
|
19
|
+
@exclude = exclude
|
|
20
|
+
@filter_prerequisites = prerequisites.to_s == 'filter'
|
|
21
|
+
@saved_groups = payload['savedGroups'] || {}
|
|
22
|
+
@all_features = payload['features'] || {}
|
|
23
|
+
@added = {}
|
|
24
|
+
@as_written = {}
|
|
25
|
+
@on_cycle = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reduce
|
|
29
|
+
@all_features.each do |id, feature|
|
|
30
|
+
add(id) if matches?(feature.is_a?(Hash) ? feature['metadata'] : nil)
|
|
31
|
+
end
|
|
32
|
+
experiments = (@payload['experiments'] || []).select { |e| matches?(e['metadata']) }.map { |e| prune(e, false) }
|
|
33
|
+
experiments.each { |e| add_parents(e) }
|
|
34
|
+
|
|
35
|
+
reduced = @payload.merge('features' => @added, 'experiments' => experiments)
|
|
36
|
+
# Only when there were any, as with the payload's other optional keys
|
|
37
|
+
if @payload['contextualBandits']
|
|
38
|
+
reduced['contextualBandits'] = contextual_bandits(experiments)
|
|
39
|
+
else
|
|
40
|
+
reduced.delete('contextualBandits')
|
|
41
|
+
end
|
|
42
|
+
used = [@added, experiments, reduced['contextualBandits']]
|
|
43
|
+
# Walking the payload is only needed when there are groups to find
|
|
44
|
+
unless @saved_groups.empty?
|
|
45
|
+
mark_as_written(used, true, {})
|
|
46
|
+
groups = used_saved_groups(used)
|
|
47
|
+
end
|
|
48
|
+
reduced['savedGroups'] = groups || {}
|
|
49
|
+
reduced
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Only an array counts, as with `$in`, and any one of its values is enough
|
|
55
|
+
def has?(actual, want)
|
|
56
|
+
want.is_a?(Array) && Growthbook::Conditions.in?(actual, want)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A rule without projects applies wherever its feature is delivered
|
|
60
|
+
def rule_in_projects?(meta)
|
|
61
|
+
@include['projects'].nil? || meta.nil? || meta['projects'].nil? || has?(meta['projects'], @include['projects'])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A feature or experiment has to list one of the projects, or be in all of
|
|
65
|
+
# them. One without projects is in none, like one without tags.
|
|
66
|
+
def in_projects?(meta)
|
|
67
|
+
@include['projects'].nil? || meta['allProjects'] || has?(meta['projects'], @include['projects'])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Kept when it matches every `include` filter and no `exclude` filter. Only
|
|
71
|
+
# listed projects are excluded, so one in all projects stays.
|
|
72
|
+
def matches?(meta)
|
|
73
|
+
meta ||= {}
|
|
74
|
+
fields = meta['customFields'] || {}
|
|
75
|
+
in_fields = @include['customFields'] || {}
|
|
76
|
+
out_fields = @exclude['customFields'] || {}
|
|
77
|
+
in_projects?(meta) &&
|
|
78
|
+
(@include['tags'].nil? || has?(meta['tags'], @include['tags'])) &&
|
|
79
|
+
in_fields.all? { |k, want| has?(fields[k], want) } &&
|
|
80
|
+
!has?(meta['projects'], @exclude['projects']) &&
|
|
81
|
+
!has?(meta['tags'], @exclude['tags']) &&
|
|
82
|
+
out_fields.none? { |k, want| has?(fields[k], want) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Reduces a rule or an auto experiment
|
|
86
|
+
def prune(item, rule)
|
|
87
|
+
condition = item['condition'] ? Growthbook::Conditions.prune_condition(@attributes, item['condition'], @saved_groups) : nil
|
|
88
|
+
item = item.dup
|
|
89
|
+
if condition == true
|
|
90
|
+
item.delete('condition')
|
|
91
|
+
elsif condition == false
|
|
92
|
+
# Kept, since sticky buckets and forced variations skip an experiment's
|
|
93
|
+
# condition, and a rule's prerequisites are checked before it
|
|
94
|
+
item['condition'] = { '$not' => {} }
|
|
95
|
+
# A force rule that never passes never returns its value. An auto
|
|
96
|
+
# experiment's forced variation still applies to a sticky bucketed user.
|
|
97
|
+
item['force'] = nil if rule && item.key?('force')
|
|
98
|
+
elsif condition
|
|
99
|
+
item['condition'] = condition
|
|
100
|
+
end
|
|
101
|
+
item
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Adds the prerequisites the client evaluates for a rule or experiment, even
|
|
105
|
+
# when the filters drop them. With 'filter', only the filters decide, except
|
|
106
|
+
# for holdouts, which aren't app features and are always kept.
|
|
107
|
+
def add_parents(item)
|
|
108
|
+
(item['parentConditions'] || []).each do |parent|
|
|
109
|
+
id = parent['id']
|
|
110
|
+
add(id) if !@filter_prerequisites || id.to_s.start_with?('$holdout:')
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def add(id)
|
|
115
|
+
feature = @all_features[id]
|
|
116
|
+
return if !feature.is_a?(Hash) || @added.key?(id)
|
|
117
|
+
|
|
118
|
+
# Rules scoped to other projects aren't served. A prerequisite from another
|
|
119
|
+
# project keeps its own, as on the server.
|
|
120
|
+
scoped = in_projects?(feature['metadata'] || {})
|
|
121
|
+
rules = feature['rules']&.select { |r| !scoped || rule_in_projects?(r['metadata']) }&.map { |r| prune(r, true) }
|
|
122
|
+
# Stored before its parents are added, so a prerequisite cycle ends
|
|
123
|
+
@added[id] = rules ? feature.merge('rules' => rules) : feature
|
|
124
|
+
(rules || []).each { |r| add_parents(r) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The contextual bandits that kept rules and experiments use. A context whose
|
|
128
|
+
# condition never passes can't be the first that does, so it goes.
|
|
129
|
+
def contextual_bandits(experiments)
|
|
130
|
+
all_bandits = @payload['contextualBandits'] || {}
|
|
131
|
+
bandits = {}
|
|
132
|
+
(@added.values.flat_map { |f| f['rules'] || [] } + experiments).each do |item|
|
|
133
|
+
ref = item['contextualBanditRef']
|
|
134
|
+
next if !ref || bandits.key?(ref) || !all_bandits.key?(ref)
|
|
135
|
+
|
|
136
|
+
bandit = all_bandits[ref]
|
|
137
|
+
# Evaluation tolerates a definition that isn't a Hash, so it stays
|
|
138
|
+
unless bandit.is_a?(Hash)
|
|
139
|
+
bandits[ref] = bandit
|
|
140
|
+
next
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
contexts = (bandit['contexts'] || []).flat_map do |c|
|
|
144
|
+
condition = Growthbook::Conditions.prune_condition(@attributes, c['condition'] || {}, @saved_groups)
|
|
145
|
+
next [] if condition == false
|
|
146
|
+
|
|
147
|
+
[c.merge('condition' => condition == true ? {} : condition)]
|
|
148
|
+
end
|
|
149
|
+
bandits[ref] = bandit.merge('contexts' => contexts)
|
|
150
|
+
end
|
|
151
|
+
bandits
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A list's values are attribute values, never references to other groups
|
|
155
|
+
def can_reference?(group)
|
|
156
|
+
group.is_a?(Hash) && group['type'] != 'list'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Each saved group whose id appears as a string anywhere in `value`, with
|
|
160
|
+
# condition groups reduced. This needs no list of the operators that
|
|
161
|
+
# reference groups.
|
|
162
|
+
def used_saved_groups(value, groups = {})
|
|
163
|
+
case value
|
|
164
|
+
when String
|
|
165
|
+
return groups if !@saved_groups.key?(value) || groups.key?(value)
|
|
166
|
+
|
|
167
|
+
# Groups evaluated against something other than the user, or on a
|
|
168
|
+
# cycle, keep their condition as written
|
|
169
|
+
reducible = !@as_written.key?(value) && !on_cycle.key?(value)
|
|
170
|
+
condition = reducible ? Growthbook::Conditions.prune_saved_group(@attributes, { 'id' => value }, @saved_groups) : nil
|
|
171
|
+
groups[value] = condition.is_a?(Hash) ? @saved_groups[value].merge('condition' => condition) : @saved_groups[value]
|
|
172
|
+
used_saved_groups(groups[value], groups) if can_reference?(@saved_groups[value])
|
|
173
|
+
when Hash
|
|
174
|
+
value.each_value { |v| used_saved_groups(v, groups) }
|
|
175
|
+
when Array
|
|
176
|
+
value.each { |v| used_saved_groups(v, groups) }
|
|
177
|
+
end
|
|
178
|
+
groups
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Marks the groups that keep their condition as written: those evaluated
|
|
182
|
+
# against something other than the user (each item in $elemMatch, or the
|
|
183
|
+
# parent's value in a prerequisite), and the groups those reference
|
|
184
|
+
def mark_as_written(value, user, seen)
|
|
185
|
+
case value
|
|
186
|
+
when String
|
|
187
|
+
return if !@saved_groups.key?(value) || @as_written.key?(value)
|
|
188
|
+
return if user && seen.key?(value)
|
|
189
|
+
|
|
190
|
+
(user ? seen : @as_written)[value] = true
|
|
191
|
+
mark_as_written(@saved_groups[value], user, seen) if can_reference?(@saved_groups[value])
|
|
192
|
+
when Hash
|
|
193
|
+
value.each { |k, v| mark_as_written(v, user && k != '$elemMatch' && k != 'parentConditions', seen) }
|
|
194
|
+
when Array
|
|
195
|
+
value.each { |v| mark_as_written(v, user, seen) }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A group on a cycle can't be reduced on its own: its result depends on
|
|
200
|
+
# which group the evaluation entered the cycle from. The groups on a cycle
|
|
201
|
+
# are found once, as strongly connected components with more than one
|
|
202
|
+
# group or a group that references itself.
|
|
203
|
+
def on_cycle
|
|
204
|
+
@on_cycle ||= groups_on_cycles
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Iterative Tarjan's algorithm over the group reference graph, so each
|
|
208
|
+
# group's entry is scanned once however many groups share it
|
|
209
|
+
def groups_on_cycles
|
|
210
|
+
edges = {}
|
|
211
|
+
@saved_groups.each do |id, group|
|
|
212
|
+
edges[id] = can_reference?(group) ? group_ids(group).uniq : []
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
index = {}
|
|
216
|
+
low = {}
|
|
217
|
+
on_stack = {}
|
|
218
|
+
stack = []
|
|
219
|
+
result = {}
|
|
220
|
+
counter = 0
|
|
221
|
+
edges.each_key do |root|
|
|
222
|
+
next if index.key?(root)
|
|
223
|
+
|
|
224
|
+
work = [[root, 0]]
|
|
225
|
+
index[root] = low[root] = counter
|
|
226
|
+
counter += 1
|
|
227
|
+
stack << root
|
|
228
|
+
on_stack[root] = true
|
|
229
|
+
until work.empty?
|
|
230
|
+
node, i = work.last
|
|
231
|
+
children = edges[node]
|
|
232
|
+
if i < children.length
|
|
233
|
+
work.last[1] = i + 1
|
|
234
|
+
child = children[i]
|
|
235
|
+
if !index.key?(child)
|
|
236
|
+
index[child] = low[child] = counter
|
|
237
|
+
counter += 1
|
|
238
|
+
stack << child
|
|
239
|
+
on_stack[child] = true
|
|
240
|
+
work << [child, 0]
|
|
241
|
+
elsif on_stack[child]
|
|
242
|
+
low[node] = [low[node], index[child]].min
|
|
243
|
+
end
|
|
244
|
+
next
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
work.pop
|
|
248
|
+
low[work.last[0]] = [low[work.last[0]], low[node]].min unless work.empty?
|
|
249
|
+
next unless low[node] == index[node]
|
|
250
|
+
|
|
251
|
+
component = []
|
|
252
|
+
loop do
|
|
253
|
+
member = stack.pop
|
|
254
|
+
on_stack.delete(member)
|
|
255
|
+
component << member
|
|
256
|
+
break if member == node
|
|
257
|
+
end
|
|
258
|
+
next unless component.length > 1 || children.include?(node)
|
|
259
|
+
|
|
260
|
+
component.each { |member| result[member] = true }
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
result
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def group_ids(value, found = [])
|
|
267
|
+
case value
|
|
268
|
+
when String
|
|
269
|
+
found << value if @saved_groups.key?(value)
|
|
270
|
+
when Hash
|
|
271
|
+
value.each_value { |v| group_ids(v, found) }
|
|
272
|
+
when Array
|
|
273
|
+
value.each { |v| group_ids(v, found) }
|
|
274
|
+
end
|
|
275
|
+
found
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
data/lib/growthbook/util.rb
CHANGED
|
@@ -141,5 +141,41 @@ module Growthbook
|
|
|
141
141
|
def self.in_range?(num, range)
|
|
142
142
|
num >= range[0] && num < range[1]
|
|
143
143
|
end
|
|
144
|
+
|
|
145
|
+
# Formats a number like JavaScript's String(number) (ECMAScript Number::toString),
|
|
146
|
+
# e.g. 2.0 -> "2", 0.000002 -> "0.000002", 1e21 -> "1e+21".
|
|
147
|
+
#
|
|
148
|
+
# @param number [Numeric] An Integer is formatted as the double JavaScript would parse
|
|
149
|
+
# @return [String]
|
|
150
|
+
def self.js_number_to_s(number)
|
|
151
|
+
x = number.to_f
|
|
152
|
+
return 'NaN' if x.nan?
|
|
153
|
+
return x.positive? ? 'Infinity' : '-Infinity' if x.infinite?
|
|
154
|
+
return '0' if x.zero?
|
|
155
|
+
return "-#{js_number_to_s(-x)}" if x.negative?
|
|
156
|
+
|
|
157
|
+
# Float#to_s gives the same shortest round-trip digits as JavaScript, but
|
|
158
|
+
# switches to scientific notation at different thresholds
|
|
159
|
+
mantissa, exponent = x.to_s.split('e')
|
|
160
|
+
whole, fraction = mantissa.to_s.split('.')
|
|
161
|
+
all_digits = "#{whole}#{fraction}"
|
|
162
|
+
digits = all_digits.sub(/\A0+/, '')
|
|
163
|
+
# x = 0.digits * 10**point
|
|
164
|
+
point = whole.to_s.length + exponent.to_i - (all_digits.length - digits.length)
|
|
165
|
+
digits = digits.sub(/0+\z/, '')
|
|
166
|
+
format_js_digits(digits, point)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Lays out digits d1..dk with x = 0.d1..dk * 10**point, as ECMAScript Number::toString does
|
|
170
|
+
def self.format_js_digits(digits, point)
|
|
171
|
+
k = digits.length
|
|
172
|
+
return digits + ('0' * (point - k)) if k <= point && point <= 21
|
|
173
|
+
return "#{digits[0, point]}.#{digits[point..-1]}" if point.positive? && point <= 21
|
|
174
|
+
return "0.#{'0' * -point}#{digits}" if point > -6 && point <= 0
|
|
175
|
+
|
|
176
|
+
e = point - 1
|
|
177
|
+
fraction = k > 1 ? ".#{digits[1..-1]}" : ''
|
|
178
|
+
"#{digits[0]}#{fraction}e#{e.negative? ? '-' : '+'}#{e.abs}"
|
|
179
|
+
end
|
|
144
180
|
end
|
|
145
181
|
end
|
data/lib/growthbook.rb
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
# The GrowthBook SDK
|
|
4
4
|
module Growthbook
|
|
5
|
+
# Partially evaluate a targeting condition when only some attributes are known,
|
|
6
|
+
# e.g. on a server. Attributes missing from `attributes` are unknown.
|
|
7
|
+
#
|
|
8
|
+
# @param attributes [Hash] The known attributes
|
|
9
|
+
# @param condition [Hash] The targeting condition
|
|
10
|
+
# @param saved_groups [Hash] The payload's saved groups
|
|
11
|
+
# @return [true, false, Hash] true or false once the result can't depend on the
|
|
12
|
+
# unknown attributes, otherwise the condition minus every branch already decided
|
|
13
|
+
def self.prune_condition(attributes, condition, saved_groups = {})
|
|
14
|
+
Growthbook::Conditions.prune_condition(
|
|
15
|
+
Growthbook::Conditions.parse_condition(attributes || {}),
|
|
16
|
+
Growthbook::Conditions.parse_condition(condition),
|
|
17
|
+
saved_groups || {}
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Shrink a features payload for a user whose attributes are only partly known,
|
|
22
|
+
# e.g. on a server that bootstraps a client-side SDK. The result evaluates the
|
|
23
|
+
# same as the full payload for anyone who agrees with the known attributes.
|
|
24
|
+
#
|
|
25
|
+
# @param payload [Hash] A decrypted features payload, as returned by the SDK API
|
|
26
|
+
# (features, experiments, savedGroups, contextualBandits)
|
|
27
|
+
# @param attributes [Hash] The attributes known for the user
|
|
28
|
+
# @param include [Hash] Keep only features and experiments that match every filter:
|
|
29
|
+
# `projects`, `tags` (arrays, any one value is enough) and `customFields` (a hash of field to array of values)
|
|
30
|
+
# @param exclude [Hash] Drop features and experiments that match any filter, with the same keys as `include`
|
|
31
|
+
# @param prerequisites ['keep', 'filter'] With 'keep' (the default), the prerequisites of kept features and
|
|
32
|
+
# experiments are kept even when the filters drop them. With 'filter', only holdouts are.
|
|
33
|
+
# @return [Hash] The reduced payload, with every other key of `payload` unchanged
|
|
34
|
+
def self.reduce_payload(payload, attributes: {}, include: {}, exclude: {}, prerequisites: 'keep')
|
|
35
|
+
Growthbook::PayloadReducer.new(
|
|
36
|
+
Growthbook::Conditions.parse_condition(payload),
|
|
37
|
+
attributes: Growthbook::Conditions.parse_condition(attributes || {}),
|
|
38
|
+
include: payload_filters(include),
|
|
39
|
+
exclude: payload_filters(exclude),
|
|
40
|
+
prerequisites: prerequisites
|
|
41
|
+
).reduce
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Filters with string keys, accepting `custom_fields` for `customFields`
|
|
45
|
+
def self.payload_filters(filters)
|
|
46
|
+
filters = Growthbook::Conditions.parse_condition(filters || {})
|
|
47
|
+
filters['customFields'] = filters.delete('custom_fields') if filters.key?('custom_fields')
|
|
48
|
+
filters
|
|
49
|
+
end
|
|
50
|
+
private_class_method :payload_filters
|
|
5
51
|
end
|
|
6
52
|
|
|
7
53
|
require 'growthbook/conditions'
|
|
@@ -14,6 +60,7 @@ require 'growthbook/feature_rule'
|
|
|
14
60
|
require 'growthbook/fnv'
|
|
15
61
|
require 'growthbook/inline_experiment'
|
|
16
62
|
require 'growthbook/inline_experiment_result'
|
|
63
|
+
require 'growthbook/payload_reducer'
|
|
17
64
|
require 'growthbook/tracking_callback'
|
|
18
65
|
require 'growthbook/util'
|
|
19
66
|
require 'growthbook/feature_usage_callback'
|
metadata
CHANGED
|
@@ -1,15 +1,43 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: growthbook
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GrowthBook
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-11-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: base64
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: bigdecimal
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0'
|
|
13
41
|
- !ruby/object:Gem::Dependency
|
|
14
42
|
name: rspec
|
|
15
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -98,6 +126,7 @@ files:
|
|
|
98
126
|
- lib/growthbook/fnv.rb
|
|
99
127
|
- lib/growthbook/inline_experiment.rb
|
|
100
128
|
- lib/growthbook/inline_experiment_result.rb
|
|
129
|
+
- lib/growthbook/payload_reducer.rb
|
|
101
130
|
- lib/growthbook/sticky_bucket_service.rb
|
|
102
131
|
- lib/growthbook/tracking_callback.rb
|
|
103
132
|
- lib/growthbook/util.rb
|
|
@@ -106,7 +135,7 @@ licenses:
|
|
|
106
135
|
- MIT
|
|
107
136
|
metadata:
|
|
108
137
|
rubygems_mfa_required: 'true'
|
|
109
|
-
post_install_message:
|
|
138
|
+
post_install_message:
|
|
110
139
|
rdoc_options: []
|
|
111
140
|
require_paths:
|
|
112
141
|
- lib
|
|
@@ -121,8 +150,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
121
150
|
- !ruby/object:Gem::Version
|
|
122
151
|
version: '0'
|
|
123
152
|
requirements: []
|
|
124
|
-
rubygems_version: 3.4.
|
|
125
|
-
signing_key:
|
|
153
|
+
rubygems_version: 3.4.19
|
|
154
|
+
signing_key:
|
|
126
155
|
specification_version: 4
|
|
127
156
|
summary: GrowthBook SDK for Ruby
|
|
128
157
|
test_files: []
|