flagnetic 0.1.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,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "version"
5
+
6
+ module Flagnetic
7
+ # ControllerHelpers builds the context of the current request; the
8
+ # railtie includes it in every controller and makes flagnetic_context a
9
+ # view helper.
10
+ #
11
+ # Flagnetic.client.bool("new_checkout", default: false, context: flagnetic_context)
12
+ #
13
+ # The context holds the configured unit types:
14
+ #
15
+ # - config.flagnetic.user_unit_type: the unit type of the signed-in user,
16
+ # whose ID config.flagnetic.user_id reads (a callable receiving the
17
+ # controller; by default current_user&.id where the controller has
18
+ # current_user, else session[:user_id]). Absent while signed out.
19
+ # - config.flagnetic.anonymous_unit_type: the unit type of a random ID
20
+ # generated on the first request and kept in the cookie named by
21
+ # config.flagnetic.anonymous_cookie. Signing in keeps it, so the context
22
+ # of a signed-in user carries both IDs and an experiment spanning
23
+ # sign-up keeps its variation; call Flagnetic.client.alias
24
+ # with flagnetic_anonymous_id at sign-up to link them for analysis.
25
+ #
26
+ # Neither is set by default: the SDK never invents a unit type. The
27
+ # attributes are the predeclared sdk and sdk_version, platform and
28
+ # app_version when configured, and whatever config.flagnetic.attributes
29
+ # (a callable receiving the controller) returns, which wins over them.
30
+ module ControllerHelpers
31
+ # SIGNED_IN_USER_ID is the default config.flagnetic.user_id.
32
+ SIGNED_IN_USER_ID = lambda { |controller|
33
+ if controller.respond_to?(:current_user, true)
34
+ controller.__send__(:current_user)&.id
35
+ else
36
+ controller.session[:user_id]
37
+ end
38
+ }
39
+
40
+ # ANONYMOUS_ID_FORMAT is what a kept anonymous ID may look like; anything else
41
+ # in the cookie is replaced by a new one.
42
+ ANONYMOUS_ID_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
43
+
44
+ def self.included(base)
45
+ base.helper_method(:flagnetic_context, :flagnetic_anonymous_id) if base.respond_to?(:helper_method)
46
+ end
47
+
48
+ # flagnetic_context returns the context of the current request, built
49
+ # once per request.
50
+ def flagnetic_context
51
+ @flagnetic_context ||= { units: flagnetic_units(flagnetic_config),
52
+ attributes: flagnetic_attributes(flagnetic_config) }.freeze
53
+ end
54
+
55
+ # flagnetic_anonymous_id returns the anonymous ID of the current
56
+ # visitor, generating it and setting its cookie on the first request,
57
+ # or nil when no anonymous unit type is configured.
58
+ def flagnetic_anonymous_id
59
+ return @flagnetic_anonymous_id if defined?(@flagnetic_anonymous_id)
60
+
61
+ config = flagnetic_config
62
+ @flagnetic_anonymous_id = (flagnetic_cookie_id(config.anonymous_cookie) if config.anonymous_unit_type)
63
+ end
64
+
65
+ private
66
+
67
+ def flagnetic_config = ::Rails.application.config.flagnetic
68
+
69
+ def flagnetic_units(config)
70
+ units = {}
71
+ units[config.anonymous_unit_type.to_s] = flagnetic_anonymous_id if config.anonymous_unit_type
72
+ if config.user_unit_type
73
+ id = config.user_id.call(self)
74
+ units[config.user_unit_type.to_s] = id.to_s if id.present?
75
+ end
76
+ units
77
+ end
78
+
79
+ def flagnetic_attributes(config)
80
+ attributes = { "sdk" => "flagnetic-ruby", "sdk_version" => VERSION }
81
+ attributes["platform"] = config.platform if config.platform
82
+ attributes["app_version"] = config.app_version if config.app_version
83
+ own = config.attributes&.call(self) || {}
84
+ attributes.merge(own.to_h.transform_keys(&:to_s))
85
+ end
86
+
87
+ def flagnetic_cookie_id(name)
88
+ jar = request.cookie_jar
89
+ id = jar[name]
90
+ return id if id.is_a?(String) && id.match?(ANONYMOUS_ID_FORMAT)
91
+
92
+ id = SecureRandom.uuid
93
+ jar.permanent[name] = { value: id, httponly: true, same_site: :lax, secure: request.ssl? }
94
+ id
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ # REASONS are the evaluation reasons a Detail carries. The evaluator
5
+ # produces the first eight; the SDK produces local_override and
6
+ # fallback. A getter never serves error: an evaluator error serves the
7
+ # built-in default with reason fallback.
8
+ REASONS = %i[rule rollout experiment override prerequisite_failed error off fallthrough local_override
9
+ fallback].freeze
10
+
11
+ # STAGES are the steps of the fallback chain. built_in: no ruleset was
12
+ # ever received, getters serve built-in defaults. last_good: the held
13
+ # ruleset came from an earlier success, or from the store, and the most
14
+ # recent attempt failed. fresh: the most recent attempt succeeded, a 304
15
+ # included. An offline client is always fresh once its document loads.
16
+ STAGES = %i[built_in last_good fresh].freeze
17
+
18
+ # ERROR_CODES say why a Detail has reason fallback: the evaluator's four,
19
+ # then type_mismatch (the flag's type differs from the getter's),
20
+ # no_ruleset (stage built_in) and general (an internal failure caught at
21
+ # the API boundary).
22
+ ERROR_CODES = %i[flag_not_found server_only prerequisite_cycle inconsistent_ruleset type_mismatch no_ruleset
23
+ general].freeze
24
+
25
+ # Detail is a served value and why it was served.
26
+ #
27
+ # - value: the served value (true or false, a String, a Numeric, or for a
28
+ # JSON flag the parsed, frozen value), or the built-in default as passed
29
+ # with reason fallback.
30
+ # - reason: one of REASONS. variation and rule_id as the evaluator sets
31
+ # them; error_code with reason fallback.
32
+ # - ruleset_version, received_at: the ruleset evaluated against, nil at
33
+ # stage built_in. The age of a last good ruleset is observable through
34
+ # received_at.
35
+ # - stage: one of STAGES, at the moment of the evaluation.
36
+ # - overridden: a platform override decided the value, directly or
37
+ # through a prerequisite. A local override is reason local_override
38
+ # instead.
39
+ # - experiment: an Eval::ExperimentDetail when the value came from a
40
+ # running experiment, an override landing in one included.
41
+ Detail = Struct.new(:value, :reason, :variation, :rule_id, :error_code, :ruleset_version, :received_at, :stage,
42
+ :overridden, :experiment, keyword_init: true) do
43
+ def initialize(overridden: false, **fields)
44
+ super
45
+ freeze
46
+ end
47
+ end
48
+
49
+ # Status is the state of the fallback chain: the stage, and since when
50
+ # the held ruleset is stale (the time of the first failed attempt since
51
+ # the last success), nil while the most recent attempt succeeded.
52
+ Status = Struct.new(:stage, :stale_since, keyword_init: true) do
53
+ def initialize(**fields)
54
+ super
55
+ freeze
56
+ end
57
+ end
58
+
59
+ # Error is the base of every error the SDK raises. Getters never raise.
60
+ class Error < StandardError; end
61
+
62
+ # LocalOverridesNotAllowed is raised by LocalOverrides#set on a client
63
+ # constructed without allow_overrides: true.
64
+ class LocalOverridesNotAllowed < Error; end
65
+
66
+ # LocalOverrideRefused is raised by LocalOverrides#set when the held
67
+ # ruleset belongs to a protected environment.
68
+ class LocalOverrideRefused < Error; end
69
+
70
+ # OfflineError is raised by Client#reload when the offline document
71
+ # cannot be read or decoded; the held ruleset stays.
72
+ class OfflineError < Error; end
73
+ end
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ # Eval is the evaluator; see flagnetic/eval.rb.
5
+ module Eval
6
+ # Index holds the flag and segment maps and the conditions of a decoded
7
+ # ruleset, compiled once, and what the ruleset holds that this evaluator
8
+ # does not know.
9
+ class Index
10
+ # invalid_serves marks, by rule index, the experiment serves that
11
+ # break the invariants every experiment serve holds (see
12
+ # Compiler#valid_experiment?), so evaluation reaching one answers
13
+ # inconsistent_ruleset without checking them again.
14
+ CompiledFlag = Struct.new(:flag, :variations, :rules, :invalid_serves)
15
+ # CompiledCondition is a condition prepared once at decode time. error
16
+ # is set when it cannot be evaluated against this document; evaluating
17
+ # it then yields inconsistent_ruleset. unsupported is the construct of
18
+ # an error that comes from an operator or condition kind this
19
+ # evaluator does not know.
20
+ CompiledCondition = Struct.new(:kind, :error, :attribute, :names_unit, :match, :segment, :flag, :variation,
21
+ :unsupported)
22
+ # CompiledSegment is a segment's conditions and the unsupported
23
+ # constructs it holds.
24
+ CompiledSegment = Struct.new(:conditions, :unsupported)
25
+
26
+ attr_reader :flags, :segments, :unsupported
27
+
28
+ def initialize(ruleset)
29
+ # A unit type named as an attribute is a string attribute holding
30
+ # that unit's ID.
31
+ units = ruleset.unit_types.to_h { |u| [u, true] }
32
+ types = ruleset.attributes.to_h { |a| [a.key, a.type] }.merge(units.transform_values { "string" })
33
+ compiler = Compiler.new(types, units)
34
+ @segments = ruleset.segments.to_h { |s| [s.key, compiler.segment(s)] }.freeze
35
+ @flags = {}
36
+ @unsupported = {}
37
+ ruleset.flags.each do |f|
38
+ @flags[f.key] = compiler.flag(f)
39
+ found = compiler.unsupported_in(f, @flags[f.key], @segments)
40
+ @unsupported[f.key] = found.freeze unless found.empty?
41
+ end
42
+ @flags.freeze
43
+ @unsupported.freeze
44
+ freeze
45
+ end
46
+ end
47
+
48
+ # The condition kinds and serve kinds this evaluator knows.
49
+ CONDITION_KINDS = %w[attribute in_segment not_in_segment prerequisite].freeze
50
+ SERVE_KINDS = %w[variation rollout experiment].freeze
51
+ private_constant :CONDITION_KINDS, :SERVE_KINDS
52
+
53
+ # Compiler compiles the conditions of one ruleset against its declared
54
+ # attribute types and unit types.
55
+ Compiler = Struct.new(:types, :units) do
56
+ def segment(segment)
57
+ found = []
58
+ conditions = segment.conditions.map do |c|
59
+ next add_unsupported(found, compile(c)) if c.kind == "attribute"
60
+
61
+ error = "segment #{segment.key.inspect} has a #{c.kind.inspect} condition"
62
+ unknown = construct(:condition_kind, c.kind) unless CONDITION_KINDS.include?(c.kind)
63
+ add_unsupported(found, invalid(c, error, unknown))
64
+ end
65
+ Index::CompiledSegment.new(conditions.freeze, found.freeze).freeze
66
+ end
67
+
68
+ def flag(flag)
69
+ variations = flag.variations.to_h { |v| [v.key, v.value] }.freeze
70
+ rules = flag.rules.map { |r| r.conditions.map { |c| compile(c) }.freeze }.freeze
71
+ invalid_serves = flag.rules.map { |r| r.serve.kind == "experiment" && !valid_experiment?(r.serve, variations) }
72
+ Index::CompiledFlag.new(flag, variations, rules, invalid_serves.freeze).freeze
73
+ end
74
+
75
+ # unsupported_in lists the constructs a compiled flag holds that this
76
+ # evaluator does not know, in document order, each once: those of its
77
+ # rules, and of the segments its rules name.
78
+ def unsupported_in(flag, compiled, segments)
79
+ found = []
80
+ flag.rules.each_with_index do |rule, i|
81
+ compiled.rules[i].each do |c|
82
+ add_unsupported(found, c)
83
+ next if c.segment.to_s.empty?
84
+
85
+ segments[c.segment]&.unsupported&.each { |u| found << u unless found.include?(u) }
86
+ end
87
+ kind = rule.serve.kind
88
+ add_construct(found, construct(:serve_kind, kind)) unless SERVE_KINDS.include?(kind)
89
+ end
90
+ found
91
+ end
92
+
93
+ private
94
+
95
+ # valid_experiment? reports whether an experiment serve holds what
96
+ # assigning a unit to a variation relies on: an experiment key, a unit
97
+ # type and both salts; a traffic allocation within 0 to 10000; and at
98
+ # least two weights, none negative, each naming a distinct variation
99
+ # of the flag, summing to exactly 10000. Any other serve would assign
100
+ # units to variations the experiment does not compare, or to none, so
101
+ # every evaluator refuses it the same way instead of each assigning
102
+ # its own way.
103
+ def valid_experiment?(serve, variations)
104
+ return false if [serve.experiment, serve.unit_type, serve.inclusion_salt, serve.variation_salt].any?(&:empty?)
105
+ return false unless (0..BUCKETS).cover?(serve.traffic_allocation) && serve.weights.size >= 2
106
+
107
+ named = serve.weights.map(&:variation)
108
+ named.uniq.size == named.size && named.all? { |v| variations.key?(v) } &&
109
+ serve.weights.none? { |w| w.weight.negative? } && serve.weights.sum(&:weight) == BUCKETS
110
+ end
111
+
112
+ def add_unsupported(found, compiled)
113
+ add_construct(found, compiled.unsupported)
114
+ compiled
115
+ end
116
+
117
+ def add_construct(found, construct)
118
+ found << construct if construct && !found.include?(construct)
119
+ end
120
+
121
+ # construct is the unsupported construct of a kind with a name; none
122
+ # for a missing name: no server sends that, so it is a defect of the
123
+ # ruleset rather than a reason to upgrade.
124
+ def construct(kind, name)
125
+ Construct.new(kind, name).freeze unless name.empty?
126
+ end
127
+
128
+ def invalid(cond, error, unsupported = nil)
129
+ Index::CompiledCondition.new(cond.kind, error, nil, nil, nil, nil, nil, nil, unsupported).freeze
130
+ end
131
+
132
+ def compile(cond)
133
+ case cond.kind
134
+ when "attribute" then compile_attribute(cond)
135
+ when "in_segment", "not_in_segment"
136
+ Index::CompiledCondition.new(cond.kind, nil, nil, false, nil, cond.segment).freeze
137
+ when "prerequisite"
138
+ Index::CompiledCondition.new(cond.kind, nil, nil, false, nil, nil, cond.flag, cond.variation).freeze
139
+ else
140
+ invalid(cond, "unknown condition kind #{cond.kind.inspect}", construct(:condition_kind, cond.kind))
141
+ end
142
+ end
143
+
144
+ def compile_attribute(cond)
145
+ unless Operators::KNOWN.include?(cond.op)
146
+ return invalid(cond, "unknown operator #{cond.op.inspect}", construct(:operator, cond.op))
147
+ end
148
+
149
+ type = types[cond.attribute]
150
+ return invalid(cond, "attribute #{cond.attribute.inspect} is not declared") unless type
151
+
152
+ match = Operators.compile(type, cond.op, cond.value)
153
+ Index::CompiledCondition.new(cond.kind, nil, cond.attribute, units.key?(cond.attribute), match).freeze
154
+ rescue Operators::Error => e
155
+ invalid(cond, "attribute #{cond.attribute.inspect}: #{e.message}")
156
+ end
157
+ end
158
+ private_constant :Index, :Compiler
159
+
160
+ # Evaluation is the state of one evaluate or evaluate_all call: the
161
+ # flags on the current prerequisite path, and every flag result
162
+ # computed so far.
163
+ #
164
+ # For one flag and one context:
165
+ #
166
+ # 1. An absent flag is error flag_not_found.
167
+ # 2. A flag that is off serves its off variation, reason off.
168
+ # 3. The first override whose unit type is in the context with an equal
169
+ # unit ID serves its variation, reason override, override_influenced
170
+ # true. Steps 4 and 5 then run in shadow, ignoring the override; when
171
+ # they end in an experiment that includes the unit, the result
172
+ # carries that experiment's detail with override true and the served
173
+ # variation.
174
+ # 4. Flag-level prerequisites in order: each evaluates the prerequisite
175
+ # flag with the same context and holds when its reason is neither off
176
+ # nor error and it serves the required variation. The first failure
177
+ # serves the off variation, reason prerequisite_failed, naming that
178
+ # prerequisite. A flag met again on the current prerequisite path is
179
+ # error prerequisite_cycle. An error in a prerequisite becomes the
180
+ # dependent flag's error.
181
+ # 5. Rules in order, first match wins; a rule's conditions in order,
182
+ # stopping at the first false one. A rollout or experiment whose unit
183
+ # type is absent, or whose unit falls outside it, lets evaluation
184
+ # continue with the next rule.
185
+ # 6. No rule matched: the fallthrough variation, reason fallthrough.
186
+ # 7. A variation, segment, flag or attribute referenced but missing from
187
+ # the document, or a condition that cannot be evaluated, is error
188
+ # inconsistent_ruleset, only when evaluation reaches it.
189
+ class Evaluation
190
+ # Failure carries an error code out of nested condition evaluation.
191
+ Failure = Struct.new(:code)
192
+ private_constant :Failure
193
+
194
+ def initialize(index, context)
195
+ @index = index
196
+ @context = context
197
+ @visiting = {}
198
+ @memo = {}
199
+ end
200
+
201
+ # flag evaluates a flag by key. A flag reached again while it is on
202
+ # the current prerequisite path is a cycle.
203
+ def flag(key)
204
+ return @memo[key] if @memo.key?(key)
205
+
206
+ f = @index.flags[key]
207
+ return error(:flag_not_found) unless f
208
+ return error(:prerequisite_cycle).__send__(:of_flag, f.flag) if @visiting.key?(key)
209
+
210
+ @visiting[key] = true
211
+ result = compute(f).__send__(:of_flag, f.flag)
212
+ @visiting.delete(key)
213
+ @memo[key] = result
214
+ end
215
+
216
+ private
217
+
218
+ def error(code, influenced = false) # rubocop:disable Style/OptionalBooleanParameter
219
+ Result.new(reason: :error, error_code: code, override_influenced: influenced)
220
+ end
221
+
222
+ def compute(cf)
223
+ f = cf.flag
224
+ return serve(cf, f.off_variation, reason: :off) unless f.on
225
+
226
+ f.overrides.each do |o|
227
+ next unless @context.unit(o.unit_type) == o.unit_id
228
+
229
+ r = serve(cf, o.variation, reason: :override, override_influenced: true)
230
+ return r if r.reason == :error
231
+
232
+ shadow = body(cf)
233
+ return r unless shadow.reason == :experiment
234
+
235
+ x = shadow.experiment.to_h.merge(variation: r.variation, override: true)
236
+ return Result.new(**r.to_h, experiment: ExperimentDetail.new(**x))
237
+ end
238
+ body(cf)
239
+ end
240
+
241
+ # body runs steps 4 to 6: prerequisites, rules and the fallthrough.
242
+ def body(cf)
243
+ f = cf.flag
244
+ influenced = [false]
245
+
246
+ f.prerequisites.each do |p|
247
+ held = prerequisite(p.flag, p.variation, influenced)
248
+ return error(held.code, influenced[0]) if held.is_a?(Failure)
249
+ next if held
250
+
251
+ return serve(cf, f.off_variation, reason: :prerequisite_failed, prerequisite: p.flag,
252
+ override_influenced: influenced[0])
253
+ end
254
+
255
+ f.rules.each_with_index do |rule, i|
256
+ matched = conditions(cf.rules[i], influenced)
257
+ return error(matched.code, influenced[0]) if matched.is_a?(Failure)
258
+ next unless matched
259
+ return error(:inconsistent_ruleset, influenced[0]) if cf.invalid_serves[i]
260
+
261
+ r = serve_rule(cf, rule, influenced[0])
262
+ return r if r
263
+ end
264
+
265
+ serve(cf, f.fallthrough, reason: :fallthrough, override_influenced: influenced[0])
266
+ end
267
+
268
+ # serve_rule serves a matching rule, or returns nil when a rollout or
269
+ # experiment leaves the unit out and evaluation continues.
270
+ def serve_rule(cf, rule, influenced)
271
+ s = rule.serve
272
+ case s.kind
273
+ when "variation"
274
+ serve(cf, s.variation, reason: :rule, rule_id: rule.id, override_influenced: influenced)
275
+ when "rollout"
276
+ id = @context.unit(s.unit_type)
277
+ bucket = id && Murmur3.bucket(s.salt, id)
278
+ return nil unless bucket && bucket < s.percentage
279
+
280
+ serve(cf, s.variation, reason: :rollout, rule_id: rule.id, bucket: bucket, override_influenced: influenced)
281
+ when "experiment"
282
+ experiment(cf, rule, influenced)
283
+ else
284
+ error(:inconsistent_ruleset, influenced)
285
+ end
286
+ end
287
+
288
+ def experiment(cf, rule, influenced)
289
+ s = rule.serve
290
+ id = @context.unit(s.unit_type)
291
+ return nil unless id
292
+
293
+ inclusion = Murmur3.bucket(s.inclusion_salt, id)
294
+ return nil unless inclusion < s.traffic_allocation
295
+
296
+ variation_bucket = Murmur3.bucket(s.variation_salt, id)
297
+ assigned = assign(s.weights, variation_bucket)
298
+ return error(:inconsistent_ruleset, influenced) unless assigned
299
+
300
+ r = serve(cf, assigned, reason: :experiment, rule_id: rule.id, override_influenced: influenced)
301
+ return r if r.reason == :error
302
+
303
+ Result.new(**r.to_h, experiment: ExperimentDetail.new(
304
+ key: s.experiment, variation: assigned, unit_type: s.unit_type, unit_id: id,
305
+ inclusion_salt: s.inclusion_salt, variation_salt: s.variation_salt,
306
+ inclusion_bucket: inclusion, variation_bucket: variation_bucket, override: influenced
307
+ ))
308
+ end
309
+
310
+ # assign walks weights in list order; variation i covers
311
+ # [sum(w_0..w_{i-1}), sum(w_0..w_i)).
312
+ def assign(weights, bucket)
313
+ upper = 0
314
+ weights.each do |w|
315
+ upper += w.weight
316
+ return w.variation if bucket < upper
317
+ end
318
+ nil
319
+ end
320
+
321
+ # serve builds a result serving a variation of the flag, or
322
+ # inconsistent_ruleset when the variation is missing (step 7).
323
+ def serve(cf, variation, **fields)
324
+ unless cf.variations.key?(variation)
325
+ return error(:inconsistent_ruleset,
326
+ fields.fetch(:override_influenced, false))
327
+ end
328
+
329
+ Result.new(value: cf.variations[variation], variation: variation, **fields)
330
+ end
331
+
332
+ # prerequisite evaluates another flag for the same context. It holds
333
+ # when that flag's reason is neither off nor error and it serves the
334
+ # required variation. An error in the prerequisite is a defect of the
335
+ # ruleset and is returned as a Failure, so it surfaces on the
336
+ # dependent flag instead of silently serving its off variation.
337
+ # Influence accumulates into influenced[0].
338
+ def prerequisite(key, variation, influenced)
339
+ f = @index.flags[key]
340
+ return Failure.new(:inconsistent_ruleset) if f.nil? || !f.variations.key?(variation)
341
+
342
+ r = flag(key)
343
+ influenced[0] ||= r.override_influenced
344
+ return Failure.new(r.error_code) if r.reason == :error
345
+
346
+ r.reason != :off && r.variation == variation
347
+ end
348
+
349
+ # conditions evaluates an AND-set in order and stops at the first false
350
+ # condition; later conditions are not evaluated and cannot influence
351
+ # the result.
352
+ def conditions(conds, influenced)
353
+ conds.each do |c|
354
+ held = condition(c, influenced)
355
+ return held if held.is_a?(Failure) || !held
356
+ end
357
+ true
358
+ end
359
+
360
+ def condition(cond, influenced)
361
+ return Failure.new(:inconsistent_ruleset) if cond.error
362
+
363
+ case cond.kind
364
+ when "attribute"
365
+ value = cond.names_unit ? @context.unit(cond.attribute) : @context.attributes[cond.attribute]
366
+ cond.match.call(value)
367
+ when "in_segment", "not_in_segment"
368
+ segment = @index.segments[cond.segment]
369
+ return Failure.new(:inconsistent_ruleset) unless segment
370
+
371
+ inside = conditions(segment.conditions, influenced)
372
+ return inside if inside.is_a?(Failure)
373
+
374
+ inside == (cond.kind == "in_segment")
375
+ else
376
+ prerequisite(cond.flag, cond.variation, influenced)
377
+ end
378
+ end
379
+ end
380
+ private_constant :Evaluation
381
+ end
382
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ module Eval
5
+ # Instant reads dates as integer nanoseconds since the Unix epoch, the
6
+ # way the reference evaluator's time.Parse(time.RFC3339Nano) and
7
+ # time.UnixMilli read them.
8
+ module Instant
9
+ RFC3339 = /\A(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2}):(\d{2})(?:[.,](\d+))?(?:(Z)|([+-])(\d{2}):(\d{2}))\z/
10
+ MAX_EPOCH_MILLIS = 2**53
11
+ private_constant :RFC3339, :MAX_EPOCH_MILLIS
12
+
13
+ module_function
14
+
15
+ # from_context reads a context value: an RFC 3339 string with an
16
+ # offset, an integral number of Unix epoch milliseconds or a Time.
17
+ def from_context(value)
18
+ case value
19
+ when String then parse_rfc3339(value)
20
+ when Time then (value.to_r * 1_000_000_000).floor
21
+ when true, false, nil then nil
22
+ else
23
+ ms = Values.number(value)
24
+ return nil unless ms && ms == ms.truncate && ms.abs <= MAX_EPOCH_MILLIS
25
+
26
+ ms.to_i * 1_000_000
27
+ end
28
+ end
29
+
30
+ # parse_rfc3339 accepts what Go's time.Parse with RFC3339Nano accepts:
31
+ # an upper-case T and Z, a one- or two-digit hour, a fraction after .
32
+ # or , truncated to nanoseconds, and an offset of at most 24:60.
33
+ def parse_rfc3339(str)
34
+ m = str.valid_encoding? && RFC3339.match(str)
35
+ return nil unless m
36
+
37
+ year, month, day, hour, min, sec = m.captures[0, 6].map(&:to_i)
38
+ return nil unless valid_date?(year, month, day) && hour < 24 && min < 60 && sec < 60
39
+
40
+ offset = offset_seconds(m)
41
+ return nil unless offset
42
+
43
+ seconds = Time.utc(year, month, day, hour, min, sec).to_i - offset
44
+ (seconds * 1_000_000_000) + m[7].to_s[0, 9].ljust(9, "0").to_i
45
+ end
46
+
47
+ def offset_seconds(match)
48
+ return 0 if match[8]
49
+
50
+ hours = match[10].to_i
51
+ minutes = match[11].to_i
52
+ return nil if hours > 24 || minutes > 60
53
+
54
+ seconds = (hours * 3600) + (minutes * 60)
55
+ match[9] == "-" ? -seconds : seconds
56
+ end
57
+
58
+ def valid_date?(year, month, day)
59
+ return false unless month.between?(1, 12) && day >= 1
60
+
61
+ days = [31, leap?(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
62
+ day <= days
63
+ end
64
+
65
+ def leap?(year)
66
+ (year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
67
+ end
68
+ end
69
+ private_constant :Instant
70
+ end
71
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ # Eval is the evaluator; see flagnetic/eval.rb.
5
+ module Eval
6
+ # BUCKETS is the number of buckets a unit is hashed into. A percentage in
7
+ # basis points (0 to 10000) is compared against a bucket directly.
8
+ BUCKETS = 10_000
9
+
10
+ # Murmur3 is MurmurHash3 x86 32-bit with seed 0 over the bytes of a
11
+ # string, read as an unsigned integer. Hashing UTF-16 or reading the
12
+ # result as signed gives different buckets and fails the murmur3 vectors.
13
+ module Murmur3
14
+ C1 = 0xcc9e2d51
15
+ C2 = 0x1b873593
16
+ MASK = 0xffffffff
17
+
18
+ module_function
19
+
20
+ # bucket computes the bucket of a unit for a salt:
21
+ #
22
+ # murmur3_32(utf8("{salt}.{unit_id}"), seed 0) mod 10000
23
+ #
24
+ # on the unsigned hash, giving 0 to 9999. A unit is inside
25
+ # percentage p when its bucket is below p. It equals ClickHouse
26
+ # murmurHash3_32(concat(salt, '.', unit_id)) % 10000.
27
+ def bucket(salt, unit_id)
28
+ hash32("#{salt.b}.#{unit_id.b}") % BUCKETS
29
+ end
30
+
31
+ def hash32(data)
32
+ bytes = data.b
33
+ h = 0
34
+ blocks = bytes.bytesize / 4
35
+ bytes.unpack("V#{blocks}").each do |k|
36
+ h ^= mix(k)
37
+ h = rotl(h, 13)
38
+ h = ((h * 5) + 0xe6546b64) & MASK
39
+ end
40
+ tail = bytes.byteslice(blocks * 4, 3).bytes
41
+ unless tail.empty?
42
+ k = tail.each_with_index.sum { |b, i| b << (8 * i) }
43
+ h ^= mix(k)
44
+ end
45
+ fmix(h ^ (bytes.bytesize & MASK))
46
+ end
47
+
48
+ def mix(key)
49
+ k = (key * C1) & MASK
50
+ k = rotl(k, 15)
51
+ (k * C2) & MASK
52
+ end
53
+
54
+ def fmix(hash)
55
+ h = hash
56
+ h ^= h >> 16
57
+ h = (h * 0x85ebca6b) & MASK
58
+ h ^= h >> 13
59
+ h = (h * 0xc2b2ae35) & MASK
60
+ h ^ (h >> 16)
61
+ end
62
+
63
+ def rotl(value, bits)
64
+ ((value << bits) | (value >> (32 - bits))) & MASK
65
+ end
66
+ private_class_method :mix, :fmix, :rotl
67
+ end
68
+ private_constant :Murmur3
69
+ end
70
+ end