lucerna 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,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hashing"
4
+
5
+ module Lucerna
6
+ module Gates
7
+ # The Gates evaluation engine — pure functions over a compiled runtime,
8
+ # no I/O. Ported line-for-line from @lucerna/gates-core
9
+ # (sdks/core/src/evaluate.ts) onto the frozen bucketing contract in
10
+ # Hashing; the golden vectors in sdks/core/src/vectors.json pin parity.
11
+ #
12
+ # The runtime document keeps its wire shape (string keys, camelCase);
13
+ # decisions come out symbol-keyed with reason strings identical to the
14
+ # wire codes.
15
+ #
16
+ # Fail-safe rule: a gate carrying any rule element this engine version
17
+ # doesn't recognize (unknown condition operator, unknown override kind)
18
+ # is answered whole with "unsupported_rule" — flag off, experiment
19
+ # unassigned. An engine never half-evaluates a document it doesn't
20
+ # fully understand.
21
+ module Evaluate
22
+ OPERATORS = %w[is is_not contains gt lt is_set].freeze
23
+ OVERRIDE_BY = %w[user_id domain].freeze
24
+ OVERRIDE_SERVE = %w[on off].freeze
25
+
26
+ module_function
27
+
28
+ # Flag precedence, first hit wins:
29
+ # 1. environment master switch off -> off
30
+ # 2. overrides, in stored order (by user id or email domain)
31
+ # 3. conditions — all must match (a flag's rule is a conjunction)
32
+ # 4. percentage rollout, sticky by user id via the stored salt
33
+ # A rollout between 0 and 100 with no user id serves off — there is
34
+ # nothing to be sticky by.
35
+ def flag(flag, user_id: nil, traits: nil, trace: nil)
36
+ unless flag["enabled"]
37
+ note(trace, "enabled", "flag is off in this environment")
38
+ return { on: false, reason: "flag_off" }
39
+ end
40
+ note(trace, "enabled", "flag is on in this environment")
41
+
42
+ bad_override = flag["overrides"].find do |override|
43
+ !OVERRIDE_BY.include?(override["by"]) || !OVERRIDE_SERVE.include?(override["serve"])
44
+ end
45
+ bad_operator = unsupported_operator(flag["conditions"])
46
+ if bad_override || bad_operator
47
+ note(
48
+ trace,
49
+ "unsupported",
50
+ if bad_operator
51
+ %(condition operator "#{bad_operator}" is not supported by this engine)
52
+ else
53
+ %(override kind "#{bad_override["by"]}/#{bad_override["serve"]}" is not supported by this engine)
54
+ end,
55
+ )
56
+ return { on: false, reason: "unsupported_rule" }
57
+ end
58
+
59
+ traits ||= {}
60
+ domain = identity_domain(traits)&.downcase
61
+ flag["overrides"].each do |override|
62
+ matched =
63
+ if override["by"] == "user_id"
64
+ !user_id.nil? && override["who"] == user_id
65
+ else
66
+ !domain.nil? && override["who"].downcase == domain
67
+ end
68
+ note(
69
+ trace,
70
+ "override",
71
+ %(#{override["by"]} "#{override["who"]}" serves #{override["serve"]}: #{matched ? "matched" : "no match"}),
72
+ )
73
+ return { on: override["serve"] == "on", reason: "override" } if matched
74
+ end
75
+
76
+ unless flag["conditions"].empty?
77
+ all = true
78
+ flag["conditions"].each do |condition|
79
+ matched = matches_condition?(traits, condition)
80
+ note(
81
+ trace,
82
+ "condition",
83
+ %(#{condition["trait"]} #{condition["operator"]} "#{condition["value"]}": #{matched ? "matched" : "no match"}),
84
+ )
85
+ unless matched
86
+ all = false
87
+ break
88
+ end
89
+ end
90
+ return { on: true, reason: "conditions" } if all
91
+ end
92
+
93
+ percentage = flag["rollout"]["percentage"]
94
+ if percentage >= 100
95
+ note(trace, "rollout", "rollout at 100% — everyone is in")
96
+ return { on: true, reason: "rollout" }
97
+ end
98
+ if percentage <= 0
99
+ note(trace, "rollout", "rollout at 0% — no one is in")
100
+ return { on: false, reason: "rollout" }
101
+ end
102
+ if user_id.nil? || user_id.empty?
103
+ note(trace, "rollout", "partial rollout with no userId — nothing to be sticky by")
104
+ return { on: false, reason: "missing_user_id" }
105
+ end
106
+ roll = Hashing.bucket(flag["rollout"]["salt"], user_id)
107
+ on = roll < percentage * 100
108
+ note(
109
+ trace,
110
+ "rollout",
111
+ "bucket #{roll} #{on ? "<" : ">="} threshold #{percentage * 100} (basis points of 10000)",
112
+ )
113
+ { on: on, reason: "rollout" }
114
+ end
115
+
116
+ # Experiment assignment, sticky per (salt, user): eligibility gates
117
+ # first (running, environment, audience), then the holdout and traffic
118
+ # dice, then an allocation-weighted walk over the variants. The salt is
119
+ # per iteration, so restarting an iteration reshuffles every bucket by
120
+ # design.
121
+ def experiment(experiment, audiences, user_id: nil, traits: nil, trace: nil)
122
+ iteration = experiment["iteration"]
123
+
124
+ unless experiment["running"]
125
+ note(trace, "status", "experiment is not running")
126
+ return no_variant(iteration, "not_running")
127
+ end
128
+ note(trace, "status", "experiment is running")
129
+ unless experiment["enabled"]
130
+ note(trace, "environment", "experiment is off in this environment")
131
+ return no_variant(iteration, "environment_off")
132
+ end
133
+ note(trace, "environment", "experiment is on in this environment")
134
+
135
+ traits ||= {}
136
+ audience_id = experiment["audienceId"]
137
+ if audience_id.nil?
138
+ note(trace, "audience", "targets everyone")
139
+ else
140
+ audience = audiences[audience_id]
141
+ unless audience
142
+ note(trace, "audience", %(audience "#{audience_id}" is not in the runtime))
143
+ return no_variant(iteration, "audience_not_found")
144
+ end
145
+ bad_operator = unsupported_operator(audience["conditions"])
146
+ if bad_operator
147
+ note(
148
+ trace,
149
+ "unsupported",
150
+ %(audience condition operator "#{bad_operator}" is not supported by this engine),
151
+ )
152
+ return no_variant(iteration, "unsupported_rule")
153
+ end
154
+ in_audience = matches_audience?(traits, audience)
155
+ note(
156
+ trace,
157
+ "audience",
158
+ %(audience "#{audience_id}" (#{audience["match"]}): #{in_audience ? "matched" : "no match"}),
159
+ )
160
+ return no_variant(iteration, "not_in_audience") unless in_audience
161
+ end
162
+
163
+ if user_id.nil? || user_id.empty?
164
+ note(trace, "identity", "no userId — assignments have nothing to be sticky by")
165
+ return no_variant(iteration, "missing_user_id")
166
+ end
167
+
168
+ holdout = experiment["holdout"]
169
+ if holdout.positive?
170
+ roll = Hashing.bucket("#{experiment["salt"]}:holdout", user_id)
171
+ held = roll < holdout * 100
172
+ note(trace, "holdout", "bucket #{roll} vs holdout #{holdout * 100}: #{held ? "held out" : "in"}")
173
+ return no_variant(iteration, "holdout") if held
174
+ end
175
+ traffic = experiment["traffic"]
176
+ if traffic < 100
177
+ roll = Hashing.bucket("#{experiment["salt"]}:traffic", user_id)
178
+ out = roll >= traffic * 100
179
+ note(trace, "traffic", "bucket #{roll} vs traffic #{traffic * 100}: #{out ? "out" : "in"}")
180
+ return no_variant(iteration, "not_in_traffic") if out
181
+ end
182
+
183
+ # Allocations sum to 100 (enforced on write); the last variant
184
+ # absorbs any rounding gap so the walk always lands.
185
+ roll = Hashing.bucket("#{experiment["salt"]}:variant", user_id)
186
+ cumulative = 0
187
+ experiment["variants"].each do |variant|
188
+ cumulative += variant["allocation"] * 100
189
+ if roll < cumulative
190
+ note(trace, "variant", %(bucket #{roll} lands in "#{variant["name"]}"))
191
+ return assigned(variant, iteration)
192
+ end
193
+ end
194
+ last = experiment["variants"].last
195
+ unless last
196
+ note(trace, "variant", "experiment has no variants")
197
+ return no_variant(iteration, "not_running")
198
+ end
199
+ note(trace, "variant", %(bucket #{roll} absorbed by last variant "#{last["name"]}"))
200
+ assigned(last, iteration)
201
+ end
202
+
203
+ # One identity's decisions over the entire runtime.
204
+ def all(runtime, user_id: nil, traits: nil)
205
+ {
206
+ kills: runtime["kills"].dup,
207
+ flags: runtime["flags"].to_h do |key, value|
208
+ [key, flag(value, user_id: user_id, traits: traits)]
209
+ end,
210
+ experiments: runtime["experiments"].to_h do |key, value|
211
+ [key, experiment(value, runtime["audiences"], user_id: user_id, traits: traits)]
212
+ end,
213
+ }
214
+ end
215
+
216
+ # -- internals ------------------------------------------------------
217
+
218
+ def no_variant(iteration, reason)
219
+ { variant: nil, iteration: iteration, reason: reason }
220
+ end
221
+
222
+ def assigned(variant, iteration)
223
+ {
224
+ variant: { id: variant["id"], name: variant["name"], is_control: variant["isControl"] },
225
+ iteration: iteration,
226
+ reason: "assigned",
227
+ }
228
+ end
229
+
230
+ # Ordering for gt/lt: numeric when both sides parse as numbers,
231
+ # otherwise lexicographic by UTF-16 code units (ISO dates sort
232
+ # correctly). Code-unit comparison — not locale collation — is part
233
+ # of the frozen contract.
234
+ def compare_values(a, b)
235
+ numeric_a = js_number(a)
236
+ numeric_b = js_number(b)
237
+ return numeric_a <=> numeric_b if numeric_a && numeric_b
238
+
239
+ a.encode(Encoding::UTF_16BE).b <=> b.encode(Encoding::UTF_16BE).b
240
+ end
241
+
242
+ # Mirrors JavaScript Number(string) for the realistic trait space:
243
+ # whitespace-trimmed, empty string is 0, otherwise Float parsing.
244
+ def js_number(value)
245
+ stripped = value.strip
246
+ return 0.0 if stripped.empty?
247
+
248
+ Float(stripped)
249
+ rescue ArgumentError
250
+ nil
251
+ end
252
+
253
+ def matches_condition?(traits, condition)
254
+ raw = traits[condition["trait"]]
255
+ value = condition["value"]
256
+ case condition["operator"]
257
+ when "is_set"
258
+ !raw.nil? && raw != ""
259
+ when "is"
260
+ raw == value
261
+ when "is_not"
262
+ raw != value
263
+ when "contains"
264
+ (raw || "").downcase.include?(value.downcase)
265
+ when "gt"
266
+ !raw.nil? && compare_values(raw, value).positive?
267
+ when "lt"
268
+ !raw.nil? && compare_values(raw, value).negative?
269
+ else
270
+ false # unreachable — callers gate on unsupported_operator
271
+ end
272
+ end
273
+
274
+ # The first operator in the list this engine doesn't know, if any.
275
+ def unsupported_operator(conditions)
276
+ conditions.find { |condition| !OPERATORS.include?(condition["operator"]) }&.fetch("operator")
277
+ end
278
+
279
+ # The domain an override's by: "domain" matches: an explicit `domain`
280
+ # trait, falling back to the part of the `email` trait after the
281
+ # last "@".
282
+ def identity_domain(traits)
283
+ domain = traits["domain"]
284
+ return domain if domain && !domain.empty?
285
+
286
+ email = traits["email"]
287
+ return nil if email.nil? || email.empty?
288
+
289
+ at = email.rindex("@")
290
+ at ? email[(at + 1)..] : nil
291
+ end
292
+
293
+ def matches_audience?(traits, audience)
294
+ conditions = audience["conditions"]
295
+ return true if conditions.empty?
296
+
297
+ if audience["match"] == "all"
298
+ conditions.all? { |condition| matches_condition?(traits, condition) }
299
+ else
300
+ conditions.any? { |condition| matches_condition?(traits, condition) }
301
+ end
302
+ end
303
+
304
+ def note(trace, step, detail)
305
+ trace << { step: step, detail: detail } if trace
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../http"
6
+
7
+ module Lucerna
8
+ module Gates
9
+ # Exposure delivery: recorded when the app reads a variant
10
+ # (Client#experiment), deduped, batched, and posted fire-and-forget to
11
+ # POST /sdk/v1/events. Nothing here may ever raise into a read or do
12
+ # network on one — record is a pure queue operation; delivery happens
13
+ # on the poller tick (or close). Failures report through on_error and
14
+ # the batch drops on the floor; the server dedupes idempotently per
15
+ # experiment+iteration+user, so anything we resend is harmless.
16
+ class Exposures
17
+ # Local dedupe window (seconds) — the server dedupes forever; this
18
+ # just saves traffic.
19
+ DEDUPE_TTL = 600.0
20
+ # Queue length at which record hints the caller to wake the poller.
21
+ FLUSH_AT = 20
22
+ # The endpoint accepts at most 100 events per request.
23
+ MAX_REQUEST_BATCH = 100
24
+ # Dedupe-map bound: past this, expired entries are pruned on record.
25
+ MAX_SEEN = 10_000
26
+
27
+ def initialize(url:, headers:, transport:, timeout:, on_error:, dedupe_ttl: DEDUPE_TTL, clock: nil)
28
+ @url = url
29
+ @headers = headers.merge("Content-Type" => "application/json")
30
+ @transport = transport
31
+ @timeout = timeout
32
+ @on_error = on_error
33
+ @dedupe_ttl = dedupe_ttl
34
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
35
+ @mutex = Mutex.new
36
+ @seen = {} # dedupe key -> monotonic time the entry expires
37
+ @pending = []
38
+ end
39
+
40
+ # Called from app threads on the read path: sync, never raises, no
41
+ # network. Returns true when the queue has reached FLUSH_AT and the
42
+ # caller should wake the delivery tick.
43
+ def record(event)
44
+ now = @clock.call
45
+ key = "#{event["experimentKey"]}\n#{event["iteration"]}\n#{event["userId"]}\n#{event["variantId"]}"
46
+ @mutex.synchronize do
47
+ expiry = @seen[key]
48
+ return false if expiry && expiry > now
49
+
50
+ prune(now) if @seen.size >= MAX_SEEN
51
+ @seen[key] = now + @dedupe_ttl
52
+ @pending << event
53
+ @pending.size >= FLUSH_AT
54
+ end
55
+ end
56
+
57
+ def pending_count
58
+ @mutex.synchronize { @pending.size }
59
+ end
60
+
61
+ # Forked children inherit the parent's queue; they must not replay it.
62
+ def clear
63
+ @mutex.synchronize { @pending.clear }
64
+ end
65
+
66
+ # Poller/close threads only. Sends everything queued in ≤100-event
67
+ # requests; a failed request reports and its batch drops.
68
+ def flush
69
+ loop do
70
+ batch = @mutex.synchronize { @pending.shift(MAX_REQUEST_BATCH) }
71
+ break if batch.empty?
72
+
73
+ deliver(batch)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def deliver(batch)
80
+ request = HTTP::Request.new(:post, @url, @headers, JSON.generate({ "events" => batch }))
81
+ response = @transport.call(request, timeout: @timeout)
82
+ if response.status >= 400
83
+ @on_error.call(
84
+ RequestError.new("exposure flush failed with status #{response.status}", status: response.status),
85
+ )
86
+ end
87
+ rescue StandardError => error
88
+ @on_error.call(error)
89
+ end
90
+
91
+ def prune(now)
92
+ @seen.delete_if { |_, expiry| expiry <= now }
93
+ return if @seen.size < MAX_SEEN
94
+
95
+ # Every entry still live: the map is genuinely at capacity, so shed
96
+ # the oldest entries (Hash preserves insertion order) rather than
97
+ # letting one hot process grow without bound.
98
+ @seen.keys.each do |key|
99
+ @seen.delete(key)
100
+ break if @seen.size < MAX_SEEN / 2
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucerna
4
+ module Gates
5
+ # The frozen bucketing contract (never change this):
6
+ #
7
+ # bucket = murmur3_x86_32(utf8("#{salt}:#{unit_id}"), seed 0) % 10000
8
+ #
9
+ # Buckets are basis points in [0, 10000). Salts are stored in the
10
+ # runtime, never derived from keys. Every SDK in every language must
11
+ # reproduce these values bit-for-bit — the golden vectors in
12
+ # sdks/core/src/vectors.json enforce it.
13
+ module Hashing
14
+ MASK = 0xFFFFFFFF
15
+ C1 = 0xcc9e2d51
16
+ C2 = 0x1b873593
17
+
18
+ module_function
19
+
20
+ # MurmurHash3 x86 32-bit over the UTF-8 bytes of +input+, as uint32.
21
+ # 32-bit wraparound is emulated by masking after every multiply/add;
22
+ # Ruby's right shift on the masked non-negative value matches the
23
+ # reference implementation's unsigned shift.
24
+ def murmur3(input, seed = 0)
25
+ data = input.encode(Encoding::UTF_8).bytes
26
+ length = data.length
27
+ h1 = seed & MASK
28
+
29
+ block_end = length - (length % 4)
30
+ index = 0
31
+ while index < block_end
32
+ k1 = data[index] |
33
+ (data[index + 1] << 8) |
34
+ (data[index + 2] << 16) |
35
+ (data[index + 3] << 24)
36
+ k1 = (k1 * C1) & MASK
37
+ k1 = ((k1 << 15) | (k1 >> 17)) & MASK
38
+ k1 = (k1 * C2) & MASK
39
+ h1 ^= k1
40
+ h1 = ((h1 << 13) | (h1 >> 19)) & MASK
41
+ h1 = ((h1 * 5) + 0xe6546b64) & MASK
42
+ index += 4
43
+ end
44
+
45
+ k1 = 0
46
+ remaining = length % 4
47
+ k1 ^= data[index + 2] << 16 if remaining == 3
48
+ k1 ^= data[index + 1] << 8 if remaining >= 2
49
+ if remaining >= 1
50
+ k1 ^= data[index]
51
+ k1 = (k1 * C1) & MASK
52
+ k1 = ((k1 << 15) | (k1 >> 17)) & MASK
53
+ k1 = (k1 * C2) & MASK
54
+ h1 ^= k1
55
+ end
56
+
57
+ h1 ^= length
58
+ h1 ^= h1 >> 16
59
+ h1 = (h1 * 0x85ebca6b) & MASK
60
+ h1 ^= h1 >> 13
61
+ h1 = (h1 * 0xc2b2ae35) & MASK
62
+ h1 ^ (h1 >> 16)
63
+ end
64
+
65
+ # The unit's bucket for a salt, in basis points [0, 10000).
66
+ def bucket(salt, unit_id)
67
+ murmur3("#{salt}:#{unit_id}") % 10_000
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucerna
4
+ module Gates
5
+ module Runtime
6
+ # The newest runtime document shape this engine understands. A
7
+ # document with a higher schemaVersion means the SDK needs an
8
+ # upgrade — keep the current runtime instead of half-reading it.
9
+ SCHEMA_VERSION = 1
10
+
11
+ module_function
12
+
13
+ def acceptable?(document)
14
+ return false unless document.is_a?(Hash)
15
+
16
+ version = document["schemaVersion"]
17
+ return false unless version.is_a?(Numeric) && version <= SCHEMA_VERSION
18
+
19
+ %w[kills flags experiments audiences].all? { |key| document[key].is_a?(Hash) }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ require_relative "errors"
8
+
9
+ module Lucerna
10
+ module HTTP
11
+ Request = Struct.new(:method, :url, :headers, :body)
12
+ Response = Struct.new(:status, :headers, :body)
13
+
14
+ RETRY_BASE = 0.25
15
+ DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
16
+
17
+ # Transport failures worth retrying — the request may never have
18
+ # reached the server.
19
+ RETRYABLE_EXCEPTIONS = [
20
+ SystemCallError,
21
+ SocketError,
22
+ IOError,
23
+ EOFError,
24
+ Timeout::Error, # covers Net::OpenTimeout / Net::ReadTimeout / Net::WriteTimeout
25
+ OpenSSL::SSL::SSLError,
26
+ ].freeze
27
+
28
+ # Default transport: one fresh Net::HTTP connection per call. Fine at
29
+ # a 10s poll cadence; connection reuse is a future optimization. Any
30
+ # object responding to call(request, timeout:) and returning a
31
+ # Response can replace it (the test seam).
32
+ class NetHttpTransport
33
+ def call(request, timeout:)
34
+ uri = URI.parse(request.url)
35
+ http = Net::HTTP.new(uri.host, uri.port)
36
+ http.use_ssl = uri.scheme == "https"
37
+ http.open_timeout = timeout
38
+ http.read_timeout = timeout
39
+ http.write_timeout = timeout
40
+
41
+ klass = request.method == :post ? Net::HTTP::Post : Net::HTTP::Get
42
+ net_request = klass.new(uri, request.headers)
43
+ net_request.body = request.body if request.body
44
+
45
+ net_response = http.request(net_request)
46
+ headers = {}
47
+ net_response.each_header { |key, value| headers[key.downcase] = value }
48
+ Response.new(net_response.code.to_i, headers, net_response.body)
49
+ end
50
+ end
51
+
52
+ module_function
53
+
54
+ # Runs the block (which must return a Response) up to +attempts+ times
55
+ # with jittered exponential backoff. Only network failures and 5xx are
56
+ # retried; any 4xx is a stable answer and raises immediately —
57
+ # AuthError for 401/403. Exhausted attempts raise the last error.
58
+ def with_retry(attempts: 3, sleeper: DEFAULT_SLEEPER)
59
+ attempt = 0
60
+ loop do
61
+ attempt += 1
62
+ begin
63
+ response = yield
64
+ status = response.status
65
+ return response if status < 400
66
+
67
+ error_class = status == 401 || status == 403 ? AuthError : RequestError
68
+ error = error_class.new("request failed with status #{status}", status: status)
69
+ raise error if status < 500 || attempt >= attempts
70
+ rescue *RETRYABLE_EXCEPTIONS => exception
71
+ raise RequestError, "#{exception.class}: #{exception.message}" if attempt >= attempts
72
+ end
73
+ sleeper.call((RETRY_BASE * (2**(attempt - 1))) + (rand * 0.1))
74
+ end
75
+ end
76
+ end
77
+ end