@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,481 @@
1
+ # Targeting
2
+
3
+ How variantlab decides whether a user is eligible for an experiment. This doc is a user-facing guide; the formal semantics are in [`docs/design/targeting-dsl.md`](../design/targeting-dsl.md).
4
+
5
+ ## Table of contents
6
+
7
+ - [Mental model](#mental-model)
8
+ - [The VariantContext](#the-variantcontext)
9
+ - [Targeting fields](#targeting-fields)
10
+ - [Targeting by platform](#targeting-by-platform)
11
+ - [Targeting by app version](#targeting-by-app-version)
12
+ - [Targeting by locale](#targeting-by-locale)
13
+ - [Targeting by screen size](#targeting-by-screen-size)
14
+ - [Targeting by route](#targeting-by-route)
15
+ - [Targeting by user ID](#targeting-by-user-id)
16
+ - [Targeting by custom attributes](#targeting-by-custom-attributes)
17
+ - [The predicate escape hatch](#the-predicate-escape-hatch)
18
+ - [Combining targeting rules](#combining-targeting-rules)
19
+ - [How to debug targeting](#how-to-debug-targeting)
20
+
21
+ ---
22
+
23
+ ## Mental model
24
+
25
+ Targeting answers one question: **"Is this user eligible for this experiment?"**
26
+
27
+ If the answer is no, the user sees the default variant. If yes, the assignment logic (random, sticky-hash, weighted) picks a variant.
28
+
29
+ The targeting predicate is a pure function of the `VariantContext`. Given the same context, the same experiment always targets the same way. This is what makes SSR work.
30
+
31
+ ---
32
+
33
+ ## The VariantContext
34
+
35
+ The context is the input to every targeting decision. It is populated by the framework adapter automatically, but can be overridden:
36
+
37
+ ```ts
38
+ interface VariantContext {
39
+ platform?: "ios" | "android" | "web" | "node";
40
+ appVersion?: string; // "2.3.1"
41
+ locale?: string; // "bn-BD"
42
+ screenSize?: "small" | "medium" | "large";
43
+ route?: string; // "/feed"
44
+ userId?: string; // hashed internally
45
+ attributes?: Record<string, string | number | boolean>;
46
+ }
47
+ ```
48
+
49
+ ### Auto-populated fields
50
+
51
+ Each adapter fills in what it can:
52
+
53
+ - **`@variantlab/react-native`**: platform, appVersion (from `expo-constants` or `react-native-device-info`), locale, screenSize, route
54
+ - **`@variantlab/next`**: platform (`web` or `node`), locale, route
55
+ - **`@variantlab/vue`**: platform (`web`), locale
56
+ - **`@variantlab/core`**: nothing — you provide everything
57
+
58
+ ### Manually setting context
59
+
60
+ ```tsx
61
+ <VariantLabProvider
62
+ initialContext={{
63
+ userId: "user-123",
64
+ attributes: { plan: "premium", betaOptIn: true },
65
+ }}
66
+ >
67
+ <App />
68
+ </VariantLabProvider>
69
+ ```
70
+
71
+ Merging: manual context is merged over auto-populated context. Manual wins on conflict.
72
+
73
+ ### Updating context at runtime
74
+
75
+ ```ts
76
+ const engine = useVariantLabEngine();
77
+ engine.updateContext({ userId: await getUserId() });
78
+ ```
79
+
80
+ Every experiment is re-evaluated on context change. Assignments are cached per `(userId, experimentId)` so targeting runs once per user per experiment unless context changes.
81
+
82
+ ---
83
+
84
+ ## Targeting fields
85
+
86
+ All fields optional. If no targeting is specified, **every user matches**.
87
+
88
+ When multiple fields are specified, they are **ANDed together**. For OR semantics, use the `predicate` escape hatch or split into multiple experiments.
89
+
90
+ ---
91
+
92
+ ## Targeting by platform
93
+
94
+ ```json
95
+ "targeting": { "platform": ["ios", "android"] }
96
+ ```
97
+
98
+ Matches users whose `context.platform` is in the list.
99
+
100
+ **Use cases**:
101
+
102
+ - Mobile-only features
103
+ - Platform-specific rollouts (iOS-first launches)
104
+ - Excluding SSR (no `node` in the list)
105
+
106
+ ---
107
+
108
+ ## Targeting by app version
109
+
110
+ ```json
111
+ "targeting": { "appVersion": ">=2.0.0" }
112
+ ```
113
+
114
+ Matches users whose `context.appVersion` satisfies the semver range.
115
+
116
+ ### Supported ranges
117
+
118
+ - `1.2.3` (exact)
119
+ - `>=1.2.3`
120
+ - `^1.2.0` (1.2.0 <= v < 2.0.0)
121
+ - `~1.2.0` (1.2.0 <= v < 1.3.0)
122
+ - `1.2.0 - 1.5.0`
123
+ - `>=1.0.0 <2.0.0 || >=3.0.0`
124
+
125
+ ### Common patterns
126
+
127
+ ```json
128
+ // Only users on the latest version
129
+ "appVersion": ">=2.5.0"
130
+
131
+ // Exclude a broken version
132
+ "appVersion": ">=2.0.0 <2.3.5 || >=2.3.7"
133
+
134
+ // New feature behind a minimum version
135
+ "appVersion": ">=3.0.0"
136
+ ```
137
+
138
+ ### Reading the version
139
+
140
+ Adapters auto-populate from:
141
+
142
+ - React Native: `Constants.expoConfig?.version` or `DeviceInfo.getVersion()`
143
+ - Next.js: user-supplied (no built-in way)
144
+ - Web: user-supplied via build-time env var (e.g., `process.env.NEXT_PUBLIC_APP_VERSION`)
145
+
146
+ ---
147
+
148
+ ## Targeting by locale
149
+
150
+ ```json
151
+ "targeting": { "locale": ["en", "bn"] }
152
+ ```
153
+
154
+ Matches if `context.locale` matches any entry. Two match modes:
155
+
156
+ - **Prefix** (recommended): `"en"` matches `"en"`, `"en-US"`, `"en-GB"`
157
+ - **Exact**: `"en-US"` matches only `"en-US"`
158
+
159
+ ### Use cases
160
+
161
+ - Language-specific experiments
162
+ - Regional rollouts
163
+ - Translation A/B tests
164
+
165
+ ### Example
166
+
167
+ ```json
168
+ {
169
+ "id": "casual-bengali-copy",
170
+ "targeting": { "locale": ["bn"] },
171
+ "variants": [
172
+ { "id": "formal", "value": "অনুগ্রহ করে লগইন করুন" },
173
+ { "id": "casual", "value": "লগইন করুন" }
174
+ ]
175
+ }
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Targeting by screen size
181
+
182
+ ```json
183
+ "targeting": { "screenSize": ["small"] }
184
+ ```
185
+
186
+ Matches devices in the specified buckets:
187
+
188
+ - `"small"` — `max(width, height) < 700 px`
189
+ - `"medium"` — `700 ≤ max(width, height) < 1200 px`
190
+ - `"large"` — `max(width, height) ≥ 1200 px`
191
+
192
+ ### Why buckets?
193
+
194
+ - Stable across zoom and orientation changes
195
+ - Readable in configs ("small" vs "< 700px")
196
+ - Easy to target exact device classes
197
+
198
+ ### Customizing thresholds
199
+
200
+ ```ts
201
+ createEngine(config, {
202
+ screenSize: {
203
+ smallMax: 640,
204
+ mediumMax: 1024,
205
+ },
206
+ });
207
+ ```
208
+
209
+ ### Use cases
210
+
211
+ - Card layout experiments (the Drishtikon origin story)
212
+ - Responsive UI tests
213
+ - Touch target size variations
214
+
215
+ ---
216
+
217
+ ## Targeting by route
218
+
219
+ ```json
220
+ "targeting": { "routes": ["/feed", "/article/*"] }
221
+ ```
222
+
223
+ Matches if `context.route` matches any glob pattern.
224
+
225
+ ### Glob syntax
226
+
227
+ - `/about` — exact
228
+ - `/blog/*` — single segment wildcard
229
+ - `/docs/**` — multi-segment wildcard
230
+ - `/user/:id` — parameter (matches any single segment)
231
+
232
+ ### Trailing slash insensitive
233
+
234
+ `/about` matches both `/about` and `/about/`.
235
+
236
+ ### Use cases
237
+
238
+ - Feature flags scoped to specific screens
239
+ - Performance experiments only on heavy pages
240
+ - Debug overlay filtering
241
+
242
+ ### How the route is read
243
+
244
+ Framework adapters auto-populate the route:
245
+
246
+ - **Expo Router**: via `usePathname` or `useRouter().pathname`
247
+ - **React Navigation**: via `useRoute()` + config parser
248
+ - **Next.js App Router**: via `usePathname` from `next/navigation`
249
+ - **Next.js Pages Router**: via `useRouter().pathname`
250
+ - **Remix**: via `useLocation().pathname`
251
+ - **Nuxt**: via `useRoute().path`
252
+ - **SvelteKit**: via `$page.url.pathname`
253
+
254
+ ---
255
+
256
+ ## Targeting by user ID
257
+
258
+ Two modes.
259
+
260
+ ### Explicit list
261
+
262
+ ```json
263
+ "targeting": { "userId": ["alice", "bob", "charlie"] }
264
+ ```
265
+
266
+ Matches if `context.userId` is in the list exactly.
267
+
268
+ **Max 10,000 entries.** Larger lists slow down config loading.
269
+
270
+ **Use cases**: beta whitelists, internal testing, VIP features.
271
+
272
+ ### Hash bucket
273
+
274
+ ```json
275
+ "targeting": { "userId": { "hash": "sha256", "mod": 10 } }
276
+ ```
277
+
278
+ Matches if `sha256(userId) % 100 < mod`. In this example, 10% of users match.
279
+
280
+ **Use cases**: percentage rollouts, deterministic subsetting.
281
+
282
+ **Why sha256?** Uniform distribution and available everywhere via Web Crypto API.
283
+
284
+ ---
285
+
286
+ ## Targeting by custom attributes
287
+
288
+ ```json
289
+ "targeting": {
290
+ "attributes": {
291
+ "plan": "premium",
292
+ "region": "us-west",
293
+ "betaOptIn": true
294
+ }
295
+ }
296
+ ```
297
+
298
+ Every specified key must match exactly. Values can be strings, numbers, or booleans.
299
+
300
+ ### Setting attributes
301
+
302
+ ```ts
303
+ <VariantLabProvider
304
+ initialContext={{
305
+ attributes: {
306
+ plan: user.subscription,
307
+ region: user.region,
308
+ betaOptIn: user.betaOptIn,
309
+ signupDaysAgo: daysSince(user.createdAt),
310
+ },
311
+ }}
312
+ >
313
+ ```
314
+
315
+ ### No operators
316
+
317
+ Attribute matching is exact equality only. For greater-than, less-than, pattern matching, or OR logic, use the `predicate` escape hatch.
318
+
319
+ ---
320
+
321
+ ## The predicate escape hatch
322
+
323
+ For complex targeting that doesn't fit the declarative DSL, provide a function:
324
+
325
+ ```ts
326
+ const engine = createEngine(config, {
327
+ experimentOverrides: {
328
+ "new-feature": {
329
+ targeting: {
330
+ platform: ["ios", "android"],
331
+ predicate: (ctx) =>
332
+ typeof ctx.attributes?.signupDaysAgo === "number" &&
333
+ ctx.attributes.signupDaysAgo >= 7 &&
334
+ ctx.attributes.signupDaysAgo <= 30,
335
+ },
336
+ },
337
+ },
338
+ });
339
+ ```
340
+
341
+ ### Constraints
342
+
343
+ - **Code-only** — cannot be specified in JSON
344
+ - **Pure** — no side effects, no async
345
+ - **Fast** — runs on every evaluation; keep it O(1)
346
+ - **ANDed** — combined with the declarative fields
347
+
348
+ ### When to use it
349
+
350
+ - Numeric comparisons
351
+ - Time-based rules
352
+ - Dependency on external state
353
+ - OR / NOT logic
354
+
355
+ ### When NOT to use it
356
+
357
+ - When the declarative operators cover your case (use them — they're debuggable in PRs)
358
+ - For large logic blocks (refactor into the app, use a simpler flag)
359
+
360
+ ---
361
+
362
+ ## Combining targeting rules
363
+
364
+ All fields are implicit AND:
365
+
366
+ ```json
367
+ "targeting": {
368
+ "platform": ["ios"],
369
+ "appVersion": ">=2.0.0",
370
+ "locale": ["en"],
371
+ "screenSize": ["small"],
372
+ "routes": ["/feed"],
373
+ "attributes": { "plan": "premium" }
374
+ }
375
+ ```
376
+
377
+ This matches: iOS users on app v2.0.0+, English locale, small screens, on `/feed`, with a premium plan.
378
+
379
+ ### Getting OR logic
380
+
381
+ Split into multiple experiments:
382
+
383
+ ```json
384
+ // Two experiments with the same variants but different targeting
385
+ {
386
+ "id": "new-checkout-premium",
387
+ "targeting": { "attributes": { "plan": "premium" } },
388
+ "variants": [...]
389
+ },
390
+ {
391
+ "id": "new-checkout-pro",
392
+ "targeting": { "attributes": { "plan": "pro" } },
393
+ "variants": [...]
394
+ }
395
+ ```
396
+
397
+ Or use the `predicate`:
398
+
399
+ ```ts
400
+ targeting: {
401
+ predicate: (ctx) =>
402
+ ctx.attributes?.plan === "premium" ||
403
+ ctx.attributes?.plan === "pro",
404
+ }
405
+ ```
406
+
407
+ ### Mutually exclusive experiments
408
+
409
+ Use the `mutex` field:
410
+
411
+ ```json
412
+ { "id": "card-layout-a", "mutex": "card-layout", ... },
413
+ { "id": "card-layout-b", "mutex": "card-layout", ... }
414
+ ```
415
+
416
+ A user who matches both is assigned to exactly one, deterministically hashed by experiment ID.
417
+
418
+ ---
419
+
420
+ ## How to debug targeting
421
+
422
+ ### Debug overlay
423
+
424
+ The overlay shows three groups:
425
+
426
+ 1. **Active** — user is enrolled
427
+ 2. **Targeted but not enrolled** — targeting passed but assignment chose default (e.g., due to weights)
428
+ 3. **Not targeted** — targeting failed, showing which field caused the failure
429
+
430
+ Each card has an "Explain" button showing the evaluation trace:
431
+
432
+ ```
433
+ news-card-layout:
434
+ platform: ios ✅ (matched)
435
+ appVersion: >=2.0.0 ✅ (2.3.1 satisfies)
436
+ screenSize: small ❌ (got "large")
437
+ → FAIL: screenSize
438
+ ```
439
+
440
+ ### CLI
441
+
442
+ ```bash
443
+ variantlab eval experiments.json \
444
+ --context '{"platform":"ios","appVersion":"2.0.0"}' \
445
+ --experiment news-card-layout
446
+ ```
447
+
448
+ Outputs the full evaluation trace.
449
+
450
+ ### Engine method
451
+
452
+ ```ts
453
+ const engine = createEngine(config);
454
+ const explanation = engine.explain("news-card-layout", context);
455
+ console.log(explanation);
456
+ // { matched: false, reason: "screenSize", ... }
457
+ ```
458
+
459
+ ---
460
+
461
+ ## Performance
462
+
463
+ Targeting evaluation is hot-path code. We budget ~2 µs per experiment on modern hardware.
464
+
465
+ Optimizations:
466
+
467
+ - Kill-switch check before any targeting (O(1))
468
+ - Platform check second (O(1) set lookup)
469
+ - Cheap checks before expensive ones
470
+ - Assignment cache by `(userId, experimentId)` to avoid re-evaluation
471
+ - Pre-compiled route globs at config load time
472
+
473
+ At 100 experiments and 10 hooks per render, targeting is well under 1 ms of frame budget. Not a concern.
474
+
475
+ ---
476
+
477
+ ## See also
478
+
479
+ - [`targeting-dsl.md`](../design/targeting-dsl.md) — formal semantics
480
+ - [`config-format.md`](../design/config-format.md) — field reference
481
+ - [`debug-overlay.md`](./debug-overlay.md) — live debugging