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.
@@ -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