billdogeng 1.0.2.pre.beta.3
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 +7 -0
- data/README.md +173 -0
- data/lib/billdogeng/analytics.rb +530 -0
- data/lib/billdogeng/client.rb +272 -0
- data/lib/billdogeng/errors.rb +20 -0
- data/lib/billdogeng/flags.rb +518 -0
- data/lib/billdogeng/llm.rb +59 -0
- data/lib/billdogeng/messaging.rb +44 -0
- data/lib/billdogeng/murmur.rb +86 -0
- data/lib/billdogeng/quota_limited.rb +52 -0
- data/lib/billdogeng/surveys.rb +117 -0
- data/lib/billdogeng/targeting.rb +278 -0
- data/lib/billdogeng/transport.rb +182 -0
- data/lib/billdogeng/version.rb +5 -0
- data/lib/billdogeng.rb +37 -0
- metadata +90 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BilldogEng
|
|
4
|
+
# murmurhash3 (32-bit, x86, seed-able) — ported verbatim from the canonical
|
|
5
|
+
# web reference `packages/ab-testing/src/ABTest.ts`.
|
|
6
|
+
#
|
|
7
|
+
# This is the cross-platform bucketing primitive for local feature-flag
|
|
8
|
+
# evaluation. The exact same algorithm runs on web / iOS / Android / every
|
|
9
|
+
# server SDK so that `murmurhash3("{key}.{distinctId}") % 100` produces an
|
|
10
|
+
# identical bucket everywhere — a user is deterministically in or out of a
|
|
11
|
+
# rollout regardless of which platform evaluated the flag.
|
|
12
|
+
#
|
|
13
|
+
# Implementation notes (must not change without re-pinning the vectors):
|
|
14
|
+
# - UTF-8 bytes
|
|
15
|
+
# - 32-bit multiply with truncation (`Math.imul` analogue via UINT32_MASK)
|
|
16
|
+
# - unsigned 32-bit result (`h1 >>> 0` analogue)
|
|
17
|
+
module Murmur
|
|
18
|
+
UINT32_MASK = 0xFFFFFFFF
|
|
19
|
+
C1 = 0xCC9E2D51
|
|
20
|
+
C2 = 0x1B873593
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# 32-bit multiply matching JS `Math.imul` semantics: multiply, truncate to
|
|
25
|
+
# the low 32 bits. (We keep results unsigned; the algebra is identical to
|
|
26
|
+
# the signed two's-complement arithmetic in the JS/iOS/Android references.)
|
|
27
|
+
def imul(a, b)
|
|
28
|
+
(a * b) & UINT32_MASK
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Left rotate a 32-bit value.
|
|
32
|
+
def rotl32(x, r)
|
|
33
|
+
((x << r) | (x >> (32 - r))) & UINT32_MASK
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param key [String] arbitrary input (hashed as UTF-8 bytes)
|
|
37
|
+
# @param seed [Integer] hash seed (default 0)
|
|
38
|
+
# @return [Integer] unsigned 32-bit hash
|
|
39
|
+
def murmurhash3(key, seed = 0)
|
|
40
|
+
key_bytes = key.to_s.dup.force_encoding(Encoding::UTF_8).bytes
|
|
41
|
+
h1 = seed & UINT32_MASK
|
|
42
|
+
len = key_bytes.length
|
|
43
|
+
blocks = len / 4
|
|
44
|
+
|
|
45
|
+
blocks.times do |i|
|
|
46
|
+
k1 = (key_bytes[i * 4] |
|
|
47
|
+
(key_bytes[i * 4 + 1] << 8) |
|
|
48
|
+
(key_bytes[i * 4 + 2] << 16) |
|
|
49
|
+
(key_bytes[i * 4 + 3] << 24)) & UINT32_MASK
|
|
50
|
+
k1 = imul(k1, C1)
|
|
51
|
+
k1 = rotl32(k1, 15)
|
|
52
|
+
k1 = imul(k1, C2)
|
|
53
|
+
h1 ^= k1
|
|
54
|
+
h1 = rotl32(h1, 13)
|
|
55
|
+
h1 = (imul(h1, 5) + 0xE6546B64) & UINT32_MASK
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
k1 = 0
|
|
59
|
+
remainder = len % 4
|
|
60
|
+
offset = blocks * 4
|
|
61
|
+
# Intentional fall-through, mirroring the C / JS switch.
|
|
62
|
+
k1 ^= key_bytes[offset + 2] << 16 if remainder >= 3
|
|
63
|
+
k1 ^= key_bytes[offset + 1] << 8 if remainder >= 2
|
|
64
|
+
if remainder >= 1
|
|
65
|
+
k1 = (k1 ^ key_bytes[offset]) & UINT32_MASK
|
|
66
|
+
k1 = imul(k1, C1)
|
|
67
|
+
k1 = rotl32(k1, 15)
|
|
68
|
+
k1 = imul(k1, C2)
|
|
69
|
+
h1 ^= k1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
h1 ^= len
|
|
73
|
+
h1 ^= h1 >> 16
|
|
74
|
+
h1 = imul(h1, 0x85EBCA6B)
|
|
75
|
+
h1 ^= h1 >> 13
|
|
76
|
+
h1 = imul(h1, 0xC2B2AE35)
|
|
77
|
+
h1 ^= h1 >> 16
|
|
78
|
+
h1 & UINT32_MASK
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Deterministic 0..99 bucket for a `"{key}.{distinctId}"` style seed.
|
|
82
|
+
def bucket_of(seed)
|
|
83
|
+
murmurhash3(seed) % 100
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BilldogEng
|
|
4
|
+
# Client half of the `quota_limited` contract.
|
|
5
|
+
#
|
|
6
|
+
# The ingestion endpoints answer an exhausted allowance with HTTP 200 and a
|
|
7
|
+
# TOP-LEVEL `quota_limited` array naming the drained meters, rather than an
|
|
8
|
+
# error status — so a customer's app is never broken by their BillDog meter
|
|
9
|
+
# filling up (see supabase/functions/_shared/quota-limited.ts for the
|
|
10
|
+
# reasoning; PostHog's capture endpoint does the same).
|
|
11
|
+
#
|
|
12
|
+
# The cost of that choice is that a 2xx no longer proves the data was stored.
|
|
13
|
+
# Every caller that treats 2xx as success MUST consult this, or it will keep
|
|
14
|
+
# transmitting into a closed meter and report success for discarded data.
|
|
15
|
+
#
|
|
16
|
+
# Mirrors packages/core/src/quotaLimited.ts (canonical) and the Android
|
|
17
|
+
# AnalyticsUploadPolicy helpers of the same name.
|
|
18
|
+
module QuotaLimited
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The drained meters named by a parsed 2xx body, or [] when it is not a refusal.
|
|
22
|
+
#
|
|
23
|
+
# Deliberately tolerant: the argument is a response body the SDK does not
|
|
24
|
+
# control, so anything unexpected reads as "not limited" rather than raising
|
|
25
|
+
# inside the flush path.
|
|
26
|
+
def metrics(body)
|
|
27
|
+
return [] unless body.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
limited = body["quota_limited"] || body[:quota_limited]
|
|
30
|
+
return [] unless limited.is_a?(Array)
|
|
31
|
+
|
|
32
|
+
limited.map(&:to_s).reject(&:empty?)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# True when a parsed 2xx body reports that its payload was dropped for quota reasons.
|
|
36
|
+
def limited?(body)
|
|
37
|
+
!metrics(body).empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One-line explanation for a developer's log — never for an end user, since it
|
|
41
|
+
# describes the ACCOUNT OWNER's billing state. Prefers the server's own prose and
|
|
42
|
+
# falls back to naming the meters, so the reason a client went quiet is never a
|
|
43
|
+
# bare boolean.
|
|
44
|
+
def reason(body)
|
|
45
|
+
message = body.is_a?(Hash) ? (body["message"] || body[:message]) : nil
|
|
46
|
+
return message if message.is_a?(String) && !message.empty?
|
|
47
|
+
|
|
48
|
+
names = metrics(body)
|
|
49
|
+
"allowance exhausted for: #{names.empty? ? "unknown" : names.join(", ")} — this data was not stored"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BilldogEng
|
|
4
|
+
# Surveys DATA API (no UI rendering). Wraps the `bdsurvey-*` edge functions.
|
|
5
|
+
#
|
|
6
|
+
# Lifecycle: list → fetch → start → record_partial* → submit (or abandon).
|
|
7
|
+
# The same `:idempotency_key` SHOULD be carried across start and submit so
|
|
8
|
+
# retries are safe.
|
|
9
|
+
#
|
|
10
|
+
# Answers mirror the bdsurvey-submit shape:
|
|
11
|
+
# { question_id:, choice_id:, answer_text:, answer_number:, answer_json: }
|
|
12
|
+
#
|
|
13
|
+
# Context accepts (all optional): :respondent_id, :customer_id, :anonymous_id,
|
|
14
|
+
# :session_id, :platform, :device_info, :duration_ms, :context,
|
|
15
|
+
# :collector_id, :collector_type, :idempotency_key
|
|
16
|
+
class Surveys
|
|
17
|
+
def initialize(transport, api_key:)
|
|
18
|
+
@transport = transport
|
|
19
|
+
@api_key = api_key
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# List active surveys eligible for the (optionally identified) user.
|
|
23
|
+
def list(distinct_id = nil, anonymous_id = nil)
|
|
24
|
+
body = { "api_key" => @api_key }
|
|
25
|
+
body["customer_id"] = distinct_id if distinct_id
|
|
26
|
+
body["anonymous_id"] = anonymous_id if anonymous_id
|
|
27
|
+
data = @transport.request(path: "/bdsurvey-list", body: body, headers: auth_header, gzip: false)
|
|
28
|
+
(data.is_a?(Hash) ? data["surveys"] : nil) || []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fetch the full configuration for a single survey.
|
|
32
|
+
def fetch(survey_id, distinct_id = nil, anonymous_id = nil)
|
|
33
|
+
body = { "api_key" => @api_key, "survey_id" => survey_id }
|
|
34
|
+
body["customer_id"] = distinct_id if distinct_id
|
|
35
|
+
body["anonymous_id"] = anonymous_id if anonymous_id
|
|
36
|
+
@transport.request(path: "/bdsurvey-fetch", body: body, headers: auth_header, gzip: false)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Begin a survey response. Returns the body containing `respondent_id`.
|
|
40
|
+
def start(survey_id, context = {})
|
|
41
|
+
@transport.request(
|
|
42
|
+
path: "/bdsurvey-submit",
|
|
43
|
+
body: build_body("start", survey_id, nil, context),
|
|
44
|
+
headers: auth_header,
|
|
45
|
+
gzip: false,
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Progressively persist a partial set of answers under an in-progress respondent.
|
|
50
|
+
def record_partial(survey_id, respondent_id, answers, context = {})
|
|
51
|
+
ctx = context.merge(respondent_id: respondent_id)
|
|
52
|
+
@transport.request(
|
|
53
|
+
path: "/bdsurvey-submit",
|
|
54
|
+
body: build_body("partial", survey_id, answers, ctx),
|
|
55
|
+
headers: auth_header,
|
|
56
|
+
gzip: false,
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Submit the final answers, completing the response.
|
|
61
|
+
def submit(survey_id, answers, context = {})
|
|
62
|
+
@transport.request(
|
|
63
|
+
path: "/bdsurvey-submit",
|
|
64
|
+
body: build_body("submit", survey_id, answers, context),
|
|
65
|
+
headers: auth_header,
|
|
66
|
+
gzip: false,
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Mark an in-progress response as abandoned.
|
|
71
|
+
def abandon(survey_id, respondent_id)
|
|
72
|
+
@transport.request(
|
|
73
|
+
path: "/bdsurvey-submit",
|
|
74
|
+
body: build_body("abandon", survey_id, nil, respondent_id: respondent_id),
|
|
75
|
+
headers: auth_header,
|
|
76
|
+
gzip: false,
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def auth_header
|
|
83
|
+
{ "X-BillDog-API-Key" => @api_key }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_body(action, survey_id, answers, context)
|
|
87
|
+
ctx = context || {}
|
|
88
|
+
body = {
|
|
89
|
+
"action" => action,
|
|
90
|
+
"api_key" => @api_key,
|
|
91
|
+
"survey_id" => survey_id,
|
|
92
|
+
}
|
|
93
|
+
body["answers"] = normalize_answers(answers) if answers
|
|
94
|
+
body["respondent_id"] = ctx[:respondent_id] if ctx[:respondent_id]
|
|
95
|
+
body["customer_id"] = ctx[:customer_id] if ctx[:customer_id]
|
|
96
|
+
body["anonymous_id"] = ctx[:anonymous_id] if ctx[:anonymous_id]
|
|
97
|
+
body["session_id"] = ctx[:session_id] if ctx[:session_id]
|
|
98
|
+
body["platform"] = ctx[:platform] if ctx[:platform]
|
|
99
|
+
body["device_info"] = ctx[:device_info] if ctx[:device_info]
|
|
100
|
+
body["duration_ms"] = ctx[:duration_ms] unless ctx[:duration_ms].nil?
|
|
101
|
+
body["context"] = ctx[:context] if ctx[:context]
|
|
102
|
+
body["collector_id"] = ctx[:collector_id] if ctx[:collector_id]
|
|
103
|
+
body["collector_type"] = ctx[:collector_type] if ctx[:collector_type]
|
|
104
|
+
body["idempotency_key"] = ctx[:idempotency_key] if ctx[:idempotency_key]
|
|
105
|
+
body
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Answers may arrive symbol- or string-keyed; emit string keys on the wire.
|
|
109
|
+
def normalize_answers(answers)
|
|
110
|
+
Array(answers).map do |a|
|
|
111
|
+
next a unless a.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
a.each_with_object({}) { |(k, v), acc| acc[k.is_a?(Symbol) ? k.to_s : k] = v }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module BilldogEng
|
|
7
|
+
# targeting.rb — the canonical audience-rule evaluator, ported from the backend.
|
|
8
|
+
#
|
|
9
|
+
# This is a port of `evaluateTargetingRulePure` + `compare` in
|
|
10
|
+
# `supabase/functions/_shared/edge-serving/pure.ts`. It is pinned to
|
|
11
|
+
# tests/fixtures/flag-targeting.json, which is GENERATED FROM that backend evaluator. It is never
|
|
12
|
+
# checked against another SDK — nine hand-ports of the murmur hash once all agreed with each other and
|
|
13
|
+
# all disagreed with the server, for months, precisely because each SDK's tests were written against a
|
|
14
|
+
# sibling SDK.
|
|
15
|
+
#
|
|
16
|
+
# WHY THIS EXISTS. The SDK previously received a flat `{attribute, operator, value}` array, which could
|
|
17
|
+
# express none of: an OR combinator, a date window, a paused rule, group membership, or most of the
|
|
18
|
+
# operator set. Every flag using one of those was deferred to the server even though the SDK held
|
|
19
|
+
# everything needed to answer it. That flat shape was also a SECOND condition DSL, duplicated across
|
|
20
|
+
# nine SDKs. It is gone: the SDK now receives the rule verbatim in the one canonical DSL and resolves
|
|
21
|
+
# it here.
|
|
22
|
+
#
|
|
23
|
+
# THE COLD CONTRACT. Some condition types (`segment`, `cohort`, `survey_answer`,
|
|
24
|
+
# `event_fired_in_window`, `group_property`) are answerable only by querying the database. We cannot
|
|
25
|
+
# resolve them and we must not guess: a failed lookup silently reading as "no match" is how targeting
|
|
26
|
+
# breaks quietly. Those conditions return COLD, which propagates to `Verdict#cold`, and the caller falls
|
|
27
|
+
# back to the server. In practice the backend already marks such flags `requires_server_evaluation` so
|
|
28
|
+
# they never reach this code — the COLD path here is the belt to that braces, and it fails SAFE.
|
|
29
|
+
module Targeting
|
|
30
|
+
# DB-backed condition types. Not resolvable in-process. Mirrors COLD_CONDITION_TYPES in pure.ts.
|
|
31
|
+
#
|
|
32
|
+
# Group PROPERTIES read the `groups` table. Group MEMBERSHIP ("group") does not — it comes from the
|
|
33
|
+
# caller-supplied groups map — so it stays purely evaluable.
|
|
34
|
+
COLD_CONDITION_TYPES = Set.new(
|
|
35
|
+
%w[segment cohort survey_answer event_fired_in_window group_property],
|
|
36
|
+
).freeze
|
|
37
|
+
|
|
38
|
+
# Internal sentinel for a condition that only the database can answer.
|
|
39
|
+
COLD = :__billdog_cold__
|
|
40
|
+
|
|
41
|
+
# The result of evaluating a rule.
|
|
42
|
+
#
|
|
43
|
+
# `cold` is NOT `matched == false`: "we cannot decide here" and "this user is not in the audience"
|
|
44
|
+
# are different answers, and collapsing the former into the latter is how targeting breaks quietly.
|
|
45
|
+
# A cold verdict means the caller MUST re-resolve against the server.
|
|
46
|
+
Verdict = Struct.new(:matched, :cold) do
|
|
47
|
+
def matched?
|
|
48
|
+
matched ? true : false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def cold?
|
|
52
|
+
cold ? true : false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Destructuring aid: `matched, cold = verdict.to_a`
|
|
56
|
+
def to_a
|
|
57
|
+
[matched, cold]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
module_function
|
|
62
|
+
|
|
63
|
+
# Operator comparison — byte-for-byte port of `compare()` in pure.ts.
|
|
64
|
+
# This is the SDK's ONE operator matcher; nothing else may re-implement it.
|
|
65
|
+
def compare(operator, actual, expected)
|
|
66
|
+
actual_string = to_comparable(actual)
|
|
67
|
+
|
|
68
|
+
return !actual_string.nil? && !actual_string.empty? if operator == "exists"
|
|
69
|
+
return actual_string.nil? || actual_string.empty? if operator == "not_exists"
|
|
70
|
+
return false if actual_string.nil?
|
|
71
|
+
|
|
72
|
+
case operator
|
|
73
|
+
when "is", "equals"
|
|
74
|
+
actual_string == js_string(expected)
|
|
75
|
+
when "is_not", "not_equals"
|
|
76
|
+
actual_string != js_string(expected)
|
|
77
|
+
when "any_of"
|
|
78
|
+
expected.is_a?(Array) && expected.map { |e| js_string(e) }.include?(actual_string)
|
|
79
|
+
when "not_any_of"
|
|
80
|
+
expected.is_a?(Array) && !expected.map { |e| js_string(e) }.include?(actual_string)
|
|
81
|
+
when "contains"
|
|
82
|
+
actual_string.include?(js_string(expected))
|
|
83
|
+
when "not_contains"
|
|
84
|
+
!actual_string.include?(js_string(expected))
|
|
85
|
+
when "greater_than", "gt"
|
|
86
|
+
compare_date_or_number(actual, expected) { |a, b| a > b }
|
|
87
|
+
when "less_than", "lt"
|
|
88
|
+
compare_date_or_number(actual, expected) { |a, b| a < b }
|
|
89
|
+
when "greater_than_or_equal", "gte"
|
|
90
|
+
compare_date_or_number(actual, expected) { |a, b| a >= b }
|
|
91
|
+
when "less_than_or_equal", "lte"
|
|
92
|
+
compare_date_or_number(actual, expected) { |a, b| a <= b }
|
|
93
|
+
else
|
|
94
|
+
false
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Evaluate an audience rule against a context.
|
|
99
|
+
#
|
|
100
|
+
# @param rule [Hash] the canonical rule: status / enabled / start_date / end_date / segment_ids /
|
|
101
|
+
# conditions {logic, rules}. String- or symbol-keyed.
|
|
102
|
+
# @param ctx [Hash] country / platform / app_version / sdk_version / custom_attributes / groups /
|
|
103
|
+
# experiment_variants. String- or symbol-keyed.
|
|
104
|
+
# @return [Verdict] `matched` when fully determined from context; `cold` when a DB-backed condition is
|
|
105
|
+
# load-bearing and the caller MUST re-resolve via the server. Note an OR rule already satisfied by a
|
|
106
|
+
# context condition is decided WITHOUT the server, even if it also contains a DB-backed condition.
|
|
107
|
+
def evaluate_targeting_rule(rule, ctx)
|
|
108
|
+
rule = {} unless rule.is_a?(Hash)
|
|
109
|
+
status = dig_key(rule, "status")
|
|
110
|
+
enabled = dig_key(rule, "enabled")
|
|
111
|
+
return Verdict.new(false, false) if enabled == false || status == "paused" || status == "archived"
|
|
112
|
+
|
|
113
|
+
now = Time.now
|
|
114
|
+
start_date = dig_key(rule, "start_date")
|
|
115
|
+
end_date = dig_key(rule, "end_date")
|
|
116
|
+
return Verdict.new(false, false) if start_date && parse_boundary(start_date, now) > now
|
|
117
|
+
return Verdict.new(false, false) if end_date && parse_boundary(end_date, now) < now
|
|
118
|
+
|
|
119
|
+
# Legacy `segment_ids` — fail CLOSED. These are a DB-backed allow-list we cannot read, and treating
|
|
120
|
+
# them as absent would hand the flag to everyone.
|
|
121
|
+
segment_ids = dig_key(rule, "segment_ids")
|
|
122
|
+
return Verdict.new(false, false) if segment_ids.is_a?(Array) && !segment_ids.empty?
|
|
123
|
+
|
|
124
|
+
conditions = dig_key(rule, "conditions")
|
|
125
|
+
conditions = {} unless conditions.is_a?(Hash)
|
|
126
|
+
rules = dig_key(conditions, "rules")
|
|
127
|
+
rules = [] unless rules.is_a?(Array)
|
|
128
|
+
logic = dig_key(conditions, "logic") == "OR" ? "OR" : "AND"
|
|
129
|
+
return Verdict.new(true, false) if rules.empty?
|
|
130
|
+
|
|
131
|
+
saw_cold = false
|
|
132
|
+
pure_results = []
|
|
133
|
+
rules.each do |condition|
|
|
134
|
+
r = evaluate_condition(condition, ctx)
|
|
135
|
+
if r == COLD
|
|
136
|
+
saw_cold = true
|
|
137
|
+
else
|
|
138
|
+
pure_results << r
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if logic == "OR"
|
|
143
|
+
return Verdict.new(true, false) if pure_results.any? { |r| r }
|
|
144
|
+
|
|
145
|
+
return saw_cold ? Verdict.new(false, true) : Verdict.new(false, false)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# AND
|
|
149
|
+
return Verdict.new(false, false) if pure_results.any? { |r| r == false }
|
|
150
|
+
|
|
151
|
+
saw_cold ? Verdict.new(false, true) : Verdict.new(true, false)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ─── Internals ────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
# @return [true, false, COLD]
|
|
157
|
+
def evaluate_condition(condition, ctx)
|
|
158
|
+
return false unless condition.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
type = dig_key(condition, "type")
|
|
161
|
+
operator = dig_key(condition, "operator")
|
|
162
|
+
value = dig_key(condition, "value")
|
|
163
|
+
|
|
164
|
+
return COLD if COLD_CONDITION_TYPES.include?(type)
|
|
165
|
+
|
|
166
|
+
case type
|
|
167
|
+
when "group"
|
|
168
|
+
# Group MEMBERSHIP is resolvable from the caller-supplied groups map alone.
|
|
169
|
+
group_type = dig_key(condition, "group_type") || dig_key(condition, "field")
|
|
170
|
+
return false unless group_type
|
|
171
|
+
|
|
172
|
+
compare(operator, dig_key(dig_key(ctx, "groups"), group_type), value)
|
|
173
|
+
when "experiment_variant"
|
|
174
|
+
experiment_id = dig_key(condition, "field") || dig_key(condition, "attribute_key")
|
|
175
|
+
variants = dig_key(ctx, "experiment_variants")
|
|
176
|
+
return false if experiment_id.nil? || variants.nil?
|
|
177
|
+
|
|
178
|
+
compare(operator, dig_key(variants, experiment_id), value)
|
|
179
|
+
when "attribute", "custom_attribute"
|
|
180
|
+
key = dig_key(condition, "field") || dig_key(condition, "attribute_key")
|
|
181
|
+
return false unless key
|
|
182
|
+
|
|
183
|
+
compare(operator, dig_key(dig_key(ctx, "custom_attributes"), key), value)
|
|
184
|
+
when "country", "platform", "app_version", "sdk_version"
|
|
185
|
+
compare(operator, dig_key(ctx, type), value)
|
|
186
|
+
when "subscription_status"
|
|
187
|
+
compare(operator, dig_key(dig_key(ctx, "custom_attributes"), "subscription_status"), value)
|
|
188
|
+
when "entitlement"
|
|
189
|
+
compare(operator, dig_key(dig_key(ctx, "custom_attributes"), "entitlement_id"), value)
|
|
190
|
+
else
|
|
191
|
+
# Derived/unknown types yield false, matching the backend.
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Hash lookup tolerant of string OR symbol keys — definitions may arrive either way (the wire is JSON;
|
|
197
|
+
# a Ruby caller injecting definitions by hand naturally writes symbols).
|
|
198
|
+
def dig_key(hash, key)
|
|
199
|
+
return nil unless hash.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
return hash[key] if hash.key?(key)
|
|
202
|
+
|
|
203
|
+
if key.is_a?(String)
|
|
204
|
+
sym = key.to_sym
|
|
205
|
+
return hash[sym] if hash.key?(sym)
|
|
206
|
+
elsif key.is_a?(Symbol)
|
|
207
|
+
str = key.to_s
|
|
208
|
+
return hash[str] if hash.key?(str)
|
|
209
|
+
end
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# String(value) with nil for nil/undefined — mirrors pure.ts `toComparable`.
|
|
214
|
+
def to_comparable(value)
|
|
215
|
+
value.nil? ? nil : js_string(value)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# JS `String(x)` semantics for the shapes that reach us from JSON.
|
|
219
|
+
def js_string(value)
|
|
220
|
+
return "null" if value.nil?
|
|
221
|
+
return value.join(",") if value.is_a?(Array)
|
|
222
|
+
|
|
223
|
+
value.to_s
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ISO date-or-number comparison (mirrors the pure.ts gt/lt/gte/lte branches):
|
|
227
|
+
# if BOTH parse as dates, compare epoch ms; else compare numbers.
|
|
228
|
+
def compare_date_or_number(actual, expected)
|
|
229
|
+
act_date = try_parse_date(actual)
|
|
230
|
+
exp_date = try_parse_date(expected)
|
|
231
|
+
if act_date && exp_date
|
|
232
|
+
yield(act_date, exp_date)
|
|
233
|
+
else
|
|
234
|
+
# The backend does Number(STRING(actual)) — it stringifies FIRST. That is not a detail: it makes
|
|
235
|
+
# Number(String(true)) == Number("true") == NaN, so a boolean attribute never satisfies an
|
|
236
|
+
# ordering rule. Converting the RAW value instead gives true -> 1.0, which quietly puts every
|
|
237
|
+
# boolean-valued user on the WRONG side of the threshold. `expected` stays raw, because JS applies
|
|
238
|
+
# Number() to it directly (Number(true) IS 1).
|
|
239
|
+
yield(to_number(js_string(actual)), to_number(expected))
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Date detection = a string matching ^\d{4}-\d{2}-\d{2} AND a valid parse, so a plain number like "10"
|
|
244
|
+
# is never mistaken for a date. Returns epoch ms, or nil. Mirrors pure.ts `tryParseDate`.
|
|
245
|
+
def try_parse_date(value)
|
|
246
|
+
return (value.to_time.to_f * 1000).to_i if value.is_a?(DateTime) || value.is_a?(Date)
|
|
247
|
+
return (value.to_f * 1000).to_i if value.is_a?(Time)
|
|
248
|
+
return nil unless value.is_a?(String) && value =~ /\A\d{4}-\d{2}-\d{2}/
|
|
249
|
+
|
|
250
|
+
begin
|
|
251
|
+
(DateTime.parse(value).to_time.to_f * 1000).to_i
|
|
252
|
+
rescue ArgumentError, TypeError
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# JS `Number(x)` semantics: an empty/blank string is 0, anything unparseable is NaN (and every
|
|
258
|
+
# comparison against NaN is false, in Ruby exactly as in JS).
|
|
259
|
+
def to_number(value)
|
|
260
|
+
return 1.0 if value == true
|
|
261
|
+
return 0.0 if value == false
|
|
262
|
+
return 0.0 if value.is_a?(String) && value.strip.empty?
|
|
263
|
+
|
|
264
|
+
Float(value)
|
|
265
|
+
rescue ArgumentError, TypeError
|
|
266
|
+
Float::NAN
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# A rule's start/end boundary. Anything unparseable is treated as absent (never blocks the rule),
|
|
270
|
+
# matching `new Date(x)` producing an Invalid Date, whose comparisons are all false.
|
|
271
|
+
def parse_boundary(value, now)
|
|
272
|
+
ms = try_parse_date(value)
|
|
273
|
+
return now if ms.nil? # unparseable → neither in the future nor in the past
|
|
274
|
+
|
|
275
|
+
Time.at(ms / 1000.0)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|