@variantlab/core 0.1.4 → 0.1.6

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,433 @@
1
+ # Targeting DSL
2
+
3
+ How targeting predicates work, why we chose the design we did, and the semantics of each operator.
4
+
5
+ ## Table of contents
6
+
7
+ - [Design goals](#design-goals)
8
+ - [The predicate shape](#the-predicate-shape)
9
+ - [Evaluation semantics](#evaluation-semantics)
10
+ - [Operators](#operators)
11
+ - [The escape hatch](#the-escape-hatch)
12
+ - [Why not a full expression language](#why-not-a-full-expression-language)
13
+ - [Evaluation order](#evaluation-order)
14
+ - [Performance](#performance)
15
+
16
+ ---
17
+
18
+ ## Design goals
19
+
20
+ 1. **Expressible enough** to cover 95% of real-world targeting needs without custom code
21
+ 2. **Simple enough** to read in a PR without documentation
22
+ 3. **Safe** — no code execution, no prototype pollution, no regex ReDoS
23
+ 4. **Fast** — O(1) or O(n) evaluation per experiment, never exponential
24
+ 5. **Declarative** — JSON-shaped data, diffable in Git
25
+ 6. **SSR-deterministic** — pure function of context
26
+
27
+ ---
28
+
29
+ ## The predicate shape
30
+
31
+ ```ts
32
+ interface Targeting {
33
+ platform?: Array<"ios" | "android" | "web" | "node">;
34
+ appVersion?: string; // semver range
35
+ locale?: string[]; // IETF language tags
36
+ screenSize?: Array<"small" | "medium" | "large">;
37
+ routes?: string[]; // glob patterns
38
+ userId?: string[] | { hash: "sha256"; mod: number };
39
+ attributes?: Record<string, string | number | boolean>;
40
+ predicate?: (context: VariantContext) => boolean; // escape hatch, code-only
41
+ }
42
+ ```
43
+
44
+ A `Targeting` object is an **implicit AND** of all specified fields. If no fields are specified, the predicate matches every user.
45
+
46
+ ---
47
+
48
+ ## Evaluation semantics
49
+
50
+ ```
51
+ match(targeting, context) =
52
+ platform_match(targeting.platform, context.platform)
53
+ AND appVersion_match(targeting.appVersion, context.appVersion)
54
+ AND locale_match(targeting.locale, context.locale)
55
+ AND screenSize_match(targeting.screenSize, context.screenSize)
56
+ AND routes_match(targeting.routes, context.route)
57
+ AND userId_match(targeting.userId, context.userId)
58
+ AND attributes_match(targeting.attributes, context.attributes)
59
+ AND predicate(context)
60
+ ```
61
+
62
+ Each sub-match is:
63
+
64
+ - **True if the field is not specified in targeting** (open by default)
65
+ - **True if the specified predicate matches**
66
+ - **False otherwise**
67
+
68
+ An unspecified field in the context does **not** match a specified targeting field. For example:
69
+
70
+ ```json
71
+ "targeting": { "platform": ["ios"] }
72
+ ```
73
+
74
+ If `context.platform` is `undefined`, this targeting fails to match.
75
+
76
+ ### Why implicit AND?
77
+
78
+ Because it's the most common case. 90% of targeting reads as "platform X AND version Y AND route Z". When you need OR, compose multiple experiments or use the `predicate` escape hatch.
79
+
80
+ ### Why no explicit OR operator?
81
+
82
+ We considered:
83
+
84
+ ```json
85
+ "targeting": {
86
+ "or": [
87
+ { "platform": ["ios"] },
88
+ { "platform": ["android"], "appVersion": ">=2.0.0" }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ Rejected because:
94
+
95
+ 1. Most users don't need OR
96
+ 2. Adds ~300 bytes to the evaluator
97
+ 3. Encourages complex configs that should be split into multiple experiments
98
+ 4. The `predicate` escape hatch handles the rare cases
99
+
100
+ ---
101
+
102
+ ## Operators
103
+
104
+ ### `platform`
105
+
106
+ ```ts
107
+ platform?: Array<"ios" | "android" | "web" | "node">;
108
+ ```
109
+
110
+ Set membership. Matches if `context.platform` is in the array.
111
+
112
+ **Values**:
113
+
114
+ - `"ios"` — iOS, iPadOS
115
+ - `"android"` — Android
116
+ - `"web"` — any browser environment (including desktop web, mobile web, PWA)
117
+ - `"node"` — server-side (SSR, edge runtimes)
118
+
119
+ **Implementation**: O(1) lookup.
120
+
121
+ ### `appVersion`
122
+
123
+ ```ts
124
+ appVersion?: string; // semver range
125
+ ```
126
+
127
+ Semver range matching. Matches if `context.appVersion` satisfies the range.
128
+
129
+ **Supported syntax** (subset of npm semver):
130
+
131
+ - Comparators: `=`, `<`, `<=`, `>`, `>=`
132
+ - Caret: `^1.2.0` (>= 1.2.0 < 2.0.0)
133
+ - Tilde: `~1.2.0` (>= 1.2.0 < 1.3.0)
134
+ - Range: `1.2.0 - 2.0.0`
135
+ - Compound: `>=1.0.0 <2.0.0`
136
+ - OR ranges: `>=1.0.0 <2.0.0 || >=3.0.0`
137
+
138
+ **Not supported**:
139
+
140
+ - Prerelease comparisons (`1.2.0-beta.1`)
141
+ - Build metadata (`1.2.0+sha.abc`)
142
+ - `x` wildcards (`1.2.x`)
143
+
144
+ If you need these, use the `predicate` escape hatch.
145
+
146
+ **Implementation**: Hand-rolled parser, ~250 bytes. Linear time.
147
+
148
+ ### `locale`
149
+
150
+ ```ts
151
+ locale?: string[]; // IETF language tags
152
+ ```
153
+
154
+ Matches if `context.locale` matches any entry. Two match modes:
155
+
156
+ - **Exact**: `"en-US"` matches `"en-US"` only
157
+ - **Prefix**: `"en"` matches `"en"`, `"en-US"`, `"en-GB"`, etc.
158
+
159
+ **Implementation**: String prefix comparison.
160
+
161
+ ### `screenSize`
162
+
163
+ ```ts
164
+ screenSize?: Array<"small" | "medium" | "large">;
165
+ ```
166
+
167
+ Set membership on pre-bucketed screen sizes. Adapter packages derive the bucket from screen dimensions:
168
+
169
+ - `"small"`: `max(width, height) < 700 px`
170
+ - `"medium"`: `700 ≤ max(width, height) < 1200 px`
171
+ - `"large"`: `max(width, height) ≥ 1200 px`
172
+
173
+ Thresholds are configurable at engine creation.
174
+
175
+ **Why bucket instead of exact pixels?** Buckets keep configs stable across device zooms, responsive changes, and orientation changes. Also makes configs readable ("target small screens") instead of cryptic ("target < 700px").
176
+
177
+ ### `routes`
178
+
179
+ ```ts
180
+ routes?: string[]; // glob patterns
181
+ ```
182
+
183
+ Matches if `context.route` matches any pattern. Supported glob syntax:
184
+
185
+ - Exact: `/about`
186
+ - Wildcard segment: `/blog/*`
187
+ - Wildcard deep: `/docs/**`
188
+ - Parameter: `/user/:id`
189
+ - Trailing slash insensitive
190
+
191
+ **Not supported**:
192
+
193
+ - Character classes: `/[abc]/`
194
+ - Braces: `/foo/{a,b}/`
195
+ - Negation: `/foo/!(bar)/`
196
+
197
+ If you need these, use the `predicate` escape hatch.
198
+
199
+ **Implementation**: Linear-time matcher, ~150 bytes. No regex backtracking risk.
200
+
201
+ ### `userId`
202
+
203
+ ```ts
204
+ userId?: string[] | { hash: "sha256"; mod: number };
205
+ ```
206
+
207
+ Two modes:
208
+
209
+ #### Explicit list
210
+
211
+ ```json
212
+ "userId": ["alice", "bob", "charlie"]
213
+ ```
214
+
215
+ Matches if `context.userId` is in the list. Max 10,000 entries. Useful for:
216
+
217
+ - Whitelist betas
218
+ - Internal user testing
219
+ - Specific user debugging
220
+
221
+ #### Hash bucket
222
+
223
+ ```json
224
+ "userId": { "hash": "sha256", "mod": 10 }
225
+ ```
226
+
227
+ Matches if `sha256(userId) % 100 < mod`. In this example, 10% of users match.
228
+
229
+ **Why sha256?** Uniform distribution and available via Web Crypto API in every runtime. Slightly slower than a simple hash but security-relevant (we don't want attackers to easily guess which users will match).
230
+
231
+ **Performance**: sha256 takes ~1 µs in modern engines. Negligible at the frequency of variant evaluation.
232
+
233
+ ### `attributes`
234
+
235
+ ```ts
236
+ attributes?: Record<string, string | number | boolean>;
237
+ ```
238
+
239
+ Exact-match predicate on `context.attributes`. Every specified key must match exactly.
240
+
241
+ ```json
242
+ "attributes": {
243
+ "plan": "premium",
244
+ "region": "us-west",
245
+ "betaOptIn": true
246
+ }
247
+ ```
248
+
249
+ Matches if:
250
+
251
+ - `context.attributes.plan === "premium"` AND
252
+ - `context.attributes.region === "us-west"` AND
253
+ - `context.attributes.betaOptIn === true`
254
+
255
+ **Implementation**: Linear scan. Keys are validated against the reserved-words list (`__proto__`, `constructor`, `prototype`).
256
+
257
+ **Why no comparison operators?** Because you can't represent "plan != premium" cleanly in JSON without inventing a sub-DSL. The `predicate` escape hatch handles it in ~5 lines of JS.
258
+
259
+ ---
260
+
261
+ ## The escape hatch
262
+
263
+ ```ts
264
+ targeting: {
265
+ predicate: (context) => context.daysSinceInstall > 7 && context.isPremium
266
+ }
267
+ ```
268
+
269
+ The `predicate` field is a function that takes the runtime context and returns a boolean. It is:
270
+
271
+ - **Only available in application code**, never in JSON configs
272
+ - **Not validated** — the user is responsible for writing safe code
273
+ - **ANDed with** the other targeting fields
274
+
275
+ This exists because 5% of real-world cases need custom logic:
276
+
277
+ - Time-based targeting (hours since install, days until expiry)
278
+ - Device characteristics beyond screen size (GPU tier, RAM)
279
+ - External data (experiments based on server state)
280
+ - Complex attribute comparisons (numeric thresholds, regex matching)
281
+
282
+ ### Why not allow predicates in JSON?
283
+
284
+ Because JSON predicates mean either:
285
+
286
+ 1. A full expression language (heavy, security risk)
287
+ 2. A limited DSL (inevitably grows a new operator per month)
288
+ 3. `eval` of string JS (unacceptable — violates design principle 4)
289
+
290
+ None of these are good. The function escape hatch is the cleanest.
291
+
292
+ ### When to use `predicate`
293
+
294
+ - You need AND-OR-NOT combinations
295
+ - You need numeric comparisons
296
+ - You need to read from application state
297
+ - You need to check device capabilities
298
+
299
+ ### When NOT to use `predicate`
300
+
301
+ - The built-in operators cover your case (use them, they're cheaper and reviewable in PRs)
302
+ - The logic belongs in the product, not the experiment (refactor)
303
+ - You're trying to implement RBAC (use a real auth system)
304
+
305
+ ---
306
+
307
+ ## Why not a full expression language
308
+
309
+ We considered shipping a full predicate language like:
310
+
311
+ ```json
312
+ "targeting": {
313
+ "expr": "platform == 'ios' && appVersion >= '2.0.0' && attributes.plan in ['premium', 'pro']"
314
+ }
315
+ ```
316
+
317
+ Rejected for these reasons:
318
+
319
+ 1. **Parser weight** — a safe expression parser is 2-5 KB gzipped
320
+ 2. **Security surface** — parsers have bugs, bugs become CVEs
321
+ 3. **Review burden** — expressions in JSON are harder to diff in PRs than structured data
322
+ 4. **Diminishing returns** — 95% of cases are covered by the current operators
323
+ 5. **The escape hatch** — users with exotic needs already have `predicate`
324
+
325
+ Existing tools that use expression languages (Unleash, LaunchDarkly) pay this price and get complex targeting. We pay nothing and get 95% of the value.
326
+
327
+ ---
328
+
329
+ ## Evaluation order
330
+
331
+ To short-circuit fast, the engine evaluates predicates in this order:
332
+
333
+ 1. **`enabled` kill switch** (O(1))
334
+ 2. **`startDate` / `endDate`** (O(1))
335
+ 3. **`platform`** (O(n), n ≤ 4)
336
+ 4. **`screenSize`** (O(n), n ≤ 3)
337
+ 5. **`locale`** (O(n))
338
+ 6. **`appVersion`** (O(n), n = range tokens)
339
+ 7. **`routes`** (O(n × m), n = patterns, m = path segments)
340
+ 8. **`attributes`** (O(n))
341
+ 9. **`userId`** (O(n) for list; O(hash) for bucket)
342
+ 10. **`predicate`** (O(?) — unknown, runs last)
343
+
344
+ Fast rejects first, expensive last.
345
+
346
+ ---
347
+
348
+ ## Performance
349
+
350
+ ### Benchmarks (preliminary, to be refined)
351
+
352
+ On a modern laptop (M2 Pro, Node 20):
353
+
354
+ - Full targeting evaluation with all operators: ~2 µs
355
+ - Empty targeting: ~50 ns
356
+ - sha256 user hash bucket: ~1 µs
357
+ - Route glob matching: ~200 ns per pattern
358
+
359
+ At typical app load — 10 experiments, 5-10 hook calls per render — targeting evaluation is well below 100 µs total. Negligible.
360
+
361
+ ### Caching
362
+
363
+ The engine caches variant assignments per `(userId, experimentId)` so targeting is re-evaluated only when context changes (e.g., route changes, user logs in).
364
+
365
+ ### Hot-path optimization
366
+
367
+ - No allocations on the hot path after warmup
368
+ - No regex in the hot path
369
+ - Integer comparisons for semver, not string comparison
370
+ - Pre-compiled route patterns at config load time
371
+
372
+ ---
373
+
374
+ ## Examples
375
+
376
+ ### Target iOS users on small screens running the latest version
377
+
378
+ ```json
379
+ "targeting": {
380
+ "platform": ["ios"],
381
+ "screenSize": ["small"],
382
+ "appVersion": ">=2.0.0"
383
+ }
384
+ ```
385
+
386
+ ### Target 10% of users deterministically
387
+
388
+ ```json
389
+ "targeting": {
390
+ "userId": { "hash": "sha256", "mod": 10 }
391
+ }
392
+ ```
393
+
394
+ ### Target premium users in Bengali locale
395
+
396
+ ```json
397
+ "targeting": {
398
+ "locale": ["bn"],
399
+ "attributes": { "plan": "premium" }
400
+ }
401
+ ```
402
+
403
+ ### Time-based targeting (application code)
404
+
405
+ ```ts
406
+ const targeting = {
407
+ platform: ["ios", "android"],
408
+ predicate: (ctx) => {
409
+ const installDate = new Date(ctx.attributes.installDate as string);
410
+ const daysSinceInstall = (Date.now() - installDate.getTime()) / 86400000;
411
+ return daysSinceInstall >= 7 && daysSinceInstall <= 30;
412
+ }
413
+ };
414
+ ```
415
+
416
+ ---
417
+
418
+ ## Future extensions (not in v0.1)
419
+
420
+ - **Rollout curves** — 0% → 50% → 100% over time via `startDate`/`endDate` interpolation
421
+ - **Cohort targeting** — user cohorts defined by custom criteria stored alongside experiments
422
+ - **Multi-variate interactions** — targeting that depends on other experiment assignments
423
+ - **Geolocation targeting** — country/region from IP, via adapter-provided context
424
+
425
+ All of these are post-v1.0 considerations and require API stability first.
426
+
427
+ ---
428
+
429
+ ## See also
430
+
431
+ - [`docs/design/config-format.md`](./config-format.md) — the enclosing config format
432
+ - [`API.md`](../../API.md) — the `Targeting` TypeScript interface
433
+ - [`docs/research/bundle-size-analysis.md`](../research/bundle-size-analysis.md) — why we hand-rolled the matchers