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.
@@ -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
@@ -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: 1.3.0
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: 2024-04-13 00:00:00.000000000 Z
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.10
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: []