@runbandit/sdk 2.0.1 → 2.1.2
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.
- package/dist/index.d.ts +6 -582
- package/dist/index.js +8 -60
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -65,14 +65,6 @@ interface Experiment {
|
|
|
65
65
|
endAt?: Date;
|
|
66
66
|
placement?: Placement;
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Storefront slots a theme app extension block can occupy.
|
|
70
|
-
*
|
|
71
|
-
* Closed set: theme block settings are a static schema compiled into the
|
|
72
|
-
* extension, so a placement cannot be added without shipping a new extension
|
|
73
|
-
* version. Keep in sync with the `placement` select in
|
|
74
|
-
* extensions/bandit-storefront/blocks/bandit_text.liquid.
|
|
75
|
-
*/
|
|
76
68
|
declare const PLACEMENTS: readonly ["hero_headline", "hero_subhead", "cta_primary", "banner_text", "badge_text"];
|
|
77
69
|
type Placement = (typeof PLACEMENTS)[number];
|
|
78
70
|
interface Treatment {
|
|
@@ -87,46 +79,13 @@ interface Treatment {
|
|
|
87
79
|
}
|
|
88
80
|
interface AlgorithmResult {
|
|
89
81
|
treatmentId: string;
|
|
90
|
-
/**
|
|
91
|
-
* @deprecated Per-algorithm, not comparable across algorithms, and about to
|
|
92
|
-
* be redefined in a future major version. Historically a single raw
|
|
93
|
-
* posterior draw for the selected arm (e.g. one Beta or LinUCB-squash
|
|
94
|
-
* sample) - in [0,1], but a *volatile* one: it is a single sample, not an
|
|
95
|
-
* estimate, so it swings arm-to-arm and request-to-request even when the
|
|
96
|
-
* underlying posteriors haven't moved. Kept for backward compatibility with
|
|
97
|
-
* documented example values (`api/assignments/route.doc.ts`,
|
|
98
|
-
* `src/lib/api-reference.ts`, `specs/assignments.md`). New consumers should
|
|
99
|
-
* use `probabilityBest` instead. See
|
|
100
|
-
* `docs/plans/2026-09-14-gaussian-thompson-migration-plan.md` task 4 for
|
|
101
|
-
* why this is an additive field rather than an in-place redefinition.
|
|
102
|
-
*/
|
|
103
82
|
confidence: number;
|
|
104
|
-
/**
|
|
105
|
-
* P(this arm is the best arm), estimated by Monte Carlo sampling of the
|
|
106
|
-
* arms' posteriors many times and tallying wins (see
|
|
107
|
-
* `MultiArmBandit.getSelectionProbabilities()`). In [0,1], sums to ~1
|
|
108
|
-
* across an experiment's arms, and is far less volatile than `confidence`
|
|
109
|
-
* since it averages over many draws instead of reporting one.
|
|
110
|
-
*
|
|
111
|
-
* Not every algorithm can produce this cheaply on the assignment hot path:
|
|
112
|
-
* - `ThompsonSampling` populates it (reduced sample count - see comment at
|
|
113
|
-
* the call site for the cost tradeoff).
|
|
114
|
-
* - `ContinuousThompson` and `FunnelBandit` support Monte Carlo estimation
|
|
115
|
-
* via their own `getSelectionProbabilities()` / repeated `selectArm()`
|
|
116
|
-
* calls, but neither is wired into `probabilityBest` on the live
|
|
117
|
-
* assignment path (todo - see migration plan task 7 rollout).
|
|
118
|
-
* - `LinearContextualBandit` does not populate this field at all: LinUCB is
|
|
119
|
-
* not a posterior sampler, so there is nothing to Monte Carlo over. The
|
|
120
|
-
* field is simply absent (`undefined`), not a stand-in zero.
|
|
121
|
-
*/
|
|
122
83
|
probabilityBest?: number;
|
|
123
84
|
}
|
|
124
85
|
interface BanditResult {
|
|
125
86
|
assignmentId: string;
|
|
126
87
|
treatmentId: string;
|
|
127
|
-
/** @deprecated see `AlgorithmResult.confidence`. */
|
|
128
88
|
confidence: number;
|
|
129
|
-
/** See `AlgorithmResult.probabilityBest`. */
|
|
130
89
|
probabilityBest?: number;
|
|
131
90
|
metadata?: Record<string, any>;
|
|
132
91
|
}
|
|
@@ -154,55 +113,15 @@ interface VariantConfig {
|
|
|
154
113
|
content: string;
|
|
155
114
|
metadata?: TreatmentMetadata & Record<string, any>;
|
|
156
115
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Bandit algorithm families.
|
|
159
|
-
*
|
|
160
|
-
* Every family is Thompson-style: a Bayesian posterior per arm, sampled at
|
|
161
|
-
* selection time. How much of the past that posterior remembers is controlled
|
|
162
|
-
* separately, by `ForgettingMode`.
|
|
163
|
-
*/
|
|
164
116
|
declare enum BanditAlgorithm {
|
|
165
117
|
THOMPSON_SAMPLING = "thompson_sampling",
|
|
166
118
|
CONTEXTUAL_LINEAR = "contextual_linear"
|
|
167
119
|
}
|
|
168
|
-
/**
|
|
169
|
-
* Which posterior a Thompson-family experiment samples from.
|
|
170
|
-
*
|
|
171
|
-
* See the migration plan (docs/plans/2026-09-14-gaussian-thompson-migration-plan.md,
|
|
172
|
-
* task 7): this is a per-experiment rollout flag, not a replacement of
|
|
173
|
-
* `BanditAlgorithm`. `BETA` is the existing Beta-Binomial posterior
|
|
174
|
-
* (`ThompsonSampling`); `NORMAL_GAMMA` is the extracted Normal-Gamma one
|
|
175
|
-
* (`ContinuousThompson`). Missing/undefined means `BETA` - every experiment
|
|
176
|
-
* written before this field existed must keep selecting exactly as it does
|
|
177
|
-
* today.
|
|
178
|
-
*/
|
|
179
120
|
declare enum Posterior {
|
|
180
121
|
BETA = "beta",
|
|
181
122
|
NORMAL_GAMMA = "normal_gamma"
|
|
182
123
|
}
|
|
183
|
-
/**
|
|
184
|
-
* Map a stored posterior string onto a supported one, defaulting missing or
|
|
185
|
-
* unrecognised values to `BETA` - the behaviour every existing experiment
|
|
186
|
-
* already has. Only `create` (new experiments) or an explicit switch should
|
|
187
|
-
* ever produce `NORMAL_GAMMA`.
|
|
188
|
-
*/
|
|
189
124
|
declare function normalizePosterior(posterior: string | null | undefined): Posterior;
|
|
190
|
-
/**
|
|
191
|
-
* How an algorithm forgets old observations.
|
|
192
|
-
*
|
|
193
|
-
* Standard bandits assume the arms are stationary: a conversion from six months
|
|
194
|
-
* ago counts exactly as much as one from this morning. Real treatments are
|
|
195
|
-
* restless - creative fatigues, seasons turn, traffic mix shifts - so the two
|
|
196
|
-
* non-stationary modes below down-weight or drop stale evidence.
|
|
197
|
-
*
|
|
198
|
-
* - NONE stationary; every observation counts forever
|
|
199
|
-
* - DISCOUNTED observation weight decays as 0.5^(age / halfLifeMs)
|
|
200
|
-
* - SLIDING_WINDOW observations outside the last `windowMs` are dropped entirely
|
|
201
|
-
*
|
|
202
|
-
* Both non-stationary modes measure age in wall-clock time rather than in
|
|
203
|
-
* observation count, so a quiet experiment forgets at the same rate as a busy
|
|
204
|
-
* one.
|
|
205
|
-
*/
|
|
206
125
|
declare enum ForgettingMode {
|
|
207
126
|
NONE = "none",
|
|
208
127
|
DISCOUNTED = "discounted",
|
|
@@ -210,31 +129,13 @@ declare enum ForgettingMode {
|
|
|
210
129
|
}
|
|
211
130
|
interface ForgettingConfig {
|
|
212
131
|
mode: ForgettingMode;
|
|
213
|
-
/** Half-life in milliseconds. DISCOUNTED only. */
|
|
214
132
|
halfLifeMs?: number;
|
|
215
|
-
/** Window length in milliseconds. SLIDING_WINDOW only. */
|
|
216
133
|
windowMs?: number;
|
|
217
134
|
}
|
|
218
|
-
/** Default half-life for DISCOUNTED: one week. */
|
|
219
135
|
declare const DEFAULT_HALF_LIFE_MS: number;
|
|
220
|
-
/** Default window for SLIDING_WINDOW: two weeks. */
|
|
221
136
|
declare const DEFAULT_WINDOW_MS: number;
|
|
222
|
-
/** The stationary default applied when an experiment has no forgetting config. */
|
|
223
137
|
declare const STATIONARY: ForgettingConfig;
|
|
224
|
-
/**
|
|
225
|
-
* Fill in the defaults for a partially-specified forgetting config.
|
|
226
|
-
* An unrecognised or missing mode degrades to NONE rather than throwing, so a
|
|
227
|
-
* stale row written by an older deploy still selects treatments.
|
|
228
|
-
*/
|
|
229
138
|
declare function resolveForgetting(config?: Partial<ForgettingConfig> | null): ForgettingConfig;
|
|
230
|
-
/**
|
|
231
|
-
* Map a stored algorithm string onto a supported algorithm.
|
|
232
|
-
*
|
|
233
|
-
* Retired names (epsilon_greedy, ucb1) map onto standard Thompson Sampling,
|
|
234
|
-
* which reads the same successes/trials counters they did, so a running
|
|
235
|
-
* experiment keeps its learned history. contextual_neural maps onto
|
|
236
|
-
* contextual_linear, which is what it already fell back to at runtime.
|
|
237
|
-
*/
|
|
238
139
|
declare function normalizeAlgorithm(algorithm: string | null | undefined): BanditAlgorithm;
|
|
239
140
|
declare enum ContentType {
|
|
240
141
|
PLAIN_TEXT = "plain_text",
|
|
@@ -285,7 +186,6 @@ interface AssignmentRequest {
|
|
|
285
186
|
userId: string;
|
|
286
187
|
context?: ContextualFeatures;
|
|
287
188
|
sessionId?: string;
|
|
288
|
-
/** Browser page the assignment was requested from (origin + pathname). */
|
|
289
189
|
pageUrl?: string;
|
|
290
190
|
}
|
|
291
191
|
interface TrackNamedEventRequest {
|
|
@@ -306,7 +206,8 @@ interface Funnel {
|
|
|
306
206
|
name: string;
|
|
307
207
|
description?: string;
|
|
308
208
|
status: FunnelStatus;
|
|
309
|
-
|
|
209
|
+
inactivityTimeoutMs?: number;
|
|
210
|
+
revisionWindowMs?: number;
|
|
310
211
|
companyId: string;
|
|
311
212
|
createdAt: Date;
|
|
312
213
|
updatedAt: Date;
|
|
@@ -361,6 +262,8 @@ interface Bundle {
|
|
|
361
262
|
funnelId?: string;
|
|
362
263
|
assignmentTtl: number;
|
|
363
264
|
conversionWindowMs: number;
|
|
265
|
+
inactivityTimeoutMs?: number;
|
|
266
|
+
revisionWindowMs?: number;
|
|
364
267
|
companyId: string;
|
|
365
268
|
createdAt: Date;
|
|
366
269
|
updatedAt: Date;
|
|
@@ -382,33 +285,14 @@ interface BundleAssignmentResponse {
|
|
|
382
285
|
bundleAssignmentId: string;
|
|
383
286
|
slots: Record<string, BundleSlotAssignment>;
|
|
384
287
|
}
|
|
385
|
-
/**
|
|
386
|
-
* One treatment's published allocation within a snapshot experiment.
|
|
387
|
-
*
|
|
388
|
-
* This is the wire contract between the snapshot publisher (dashboard API)
|
|
389
|
-
* and its only consumer (the SDK). Declared once here so both sides share
|
|
390
|
-
* the same shape instead of drifting apart.
|
|
391
|
-
*/
|
|
392
288
|
interface SnapshotTreatment {
|
|
393
289
|
id: string;
|
|
394
290
|
name?: string;
|
|
395
291
|
config: unknown;
|
|
396
|
-
/** Probability the next visitor is assigned this arm. Sums to ~1 per experiment. */
|
|
397
292
|
pct: number;
|
|
398
293
|
}
|
|
399
294
|
interface SnapshotExperiment {
|
|
400
|
-
/**
|
|
401
|
-
* 'snapshot' means the client may decide locally from `pct`. 'network' means
|
|
402
|
-
* it must call /api/assignments, because the experiment's state cannot be
|
|
403
|
-
* reduced to a static distribution.
|
|
404
|
-
*/
|
|
405
295
|
mode: 'snapshot' | 'network';
|
|
406
|
-
/**
|
|
407
|
-
* Assignment lifetime in SECONDS (the unit `assignments.expiresAt` uses).
|
|
408
|
-
* The client holds a decision this long so that repeated pageviews in one
|
|
409
|
-
* session report one impression, matching the server path's one assignment
|
|
410
|
-
* per (experiment, user).
|
|
411
|
-
*/
|
|
412
296
|
assignmentTtl: number;
|
|
413
297
|
treatments: SnapshotTreatment[];
|
|
414
298
|
}
|
|
@@ -427,118 +311,21 @@ declare function calculateConfidenceInterval(successes: number, trials: number,
|
|
|
427
311
|
};
|
|
428
312
|
declare function isValidEmail(email: string): boolean;
|
|
429
313
|
declare function sanitizeMetadata(metadata: Record<string, any>): Record<string, any>;
|
|
430
|
-
/**
|
|
431
|
-
* Generates a URL-safe subdomain from a company name
|
|
432
|
-
* @param name Company name
|
|
433
|
-
* @returns URL-safe subdomain
|
|
434
|
-
*/
|
|
435
314
|
declare function generateSubdomain(name: string): string;
|
|
436
|
-
/**
|
|
437
|
-
* Validates if a subdomain is properly formatted
|
|
438
|
-
* @param subdomain Subdomain to validate
|
|
439
|
-
* @returns true if valid, false otherwise
|
|
440
|
-
*/
|
|
441
315
|
declare function isValidSubdomain(subdomain: string): boolean;
|
|
442
|
-
/**
|
|
443
|
-
* Extracts subdomain from hostname
|
|
444
|
-
* @param hostname Full hostname (e.g., 'acme.example.com')
|
|
445
|
-
* @param baseDomain Base domain (e.g., 'example.com')
|
|
446
|
-
* @returns Subdomain or null if not found
|
|
447
|
-
*/
|
|
448
316
|
declare function extractSubdomain(hostname: string, baseDomain: string): string | null;
|
|
449
|
-
/**
|
|
450
|
-
* Builds full URL with subdomain
|
|
451
|
-
* @param subdomain Subdomain
|
|
452
|
-
* @param baseDomain Base domain
|
|
453
|
-
* @param path Optional path
|
|
454
|
-
* @param protocol Protocol (default: https)
|
|
455
|
-
* @returns Full URL
|
|
456
|
-
*/
|
|
457
317
|
declare function buildSubdomainUrl(subdomain: string, baseDomain: string, path?: string, protocol?: string): string;
|
|
458
318
|
|
|
459
|
-
/**
|
|
460
|
-
* The email tracking token.
|
|
461
|
-
*
|
|
462
|
-
* Every tracked link in an email carries one of these as a query parameter.
|
|
463
|
-
* It is the impression record: which campaign, which recipient, which arm was
|
|
464
|
-
* chosen in every slot, and which link was clicked - all of it self-contained
|
|
465
|
-
* and signed, so no row has to be written at send time.
|
|
466
|
-
*
|
|
467
|
-
* Three constraints shape the format, and all three are unusual:
|
|
468
|
-
*
|
|
469
|
-
* 1. **It must be mintable offline.** In `plan` mode the customer's own SDK
|
|
470
|
-
* mints tokens for millions of recipients with no network calls, so the
|
|
471
|
-
* token cannot be a database id we hand out. Minting is a pure function of
|
|
472
|
-
* (payload, key).
|
|
473
|
-
*
|
|
474
|
-
* 2. **It must outlive everything else in the system.** A link minted today
|
|
475
|
-
* may be clicked in three years. That makes this format a public contract
|
|
476
|
-
* with no deprecation path: campaign link tables are never deleted, slot
|
|
477
|
-
* order is never changed, and several signing keys stay valid at once so a
|
|
478
|
-
* rotation cannot break links already sitting in inboxes.
|
|
479
|
-
*
|
|
480
|
-
* 3. **It travels somewhere hostile.** URLs get forwarded, logged by proxies
|
|
481
|
-
* and leaked through `Referer`. Nothing in here may be a secret, and
|
|
482
|
-
* nothing in here may be trusted before the signature is checked.
|
|
483
|
-
*
|
|
484
|
-
* See specs/email.md, "Tokens".
|
|
485
|
-
*/
|
|
486
|
-
/**
|
|
487
|
-
* The query parameter tracked links are tagged with.
|
|
488
|
-
*
|
|
489
|
-
* Kept distinct from the well-known ad-tech parameters (`fbclid`, `gclid`,
|
|
490
|
-
* `utm_*`): Safari's Link Tracking Protection and Firefox's strict ETP strip
|
|
491
|
-
* params from a curated list of known trackers, and sharing a name with one of
|
|
492
|
-
* them would mean being stripped by association.
|
|
493
|
-
*/
|
|
494
319
|
declare const EMAIL_TOKEN_PARAM = "bt";
|
|
495
|
-
/**
|
|
496
|
-
* One signing key. Several are valid concurrently - see `verifyEmailToken`.
|
|
497
|
-
*
|
|
498
|
-
* `version` is carried in the token so verification can select the right key
|
|
499
|
-
* directly instead of trying each in turn, which would leak timing information
|
|
500
|
-
* and get slower with every rotation.
|
|
501
|
-
*/
|
|
502
320
|
interface SigningKey {
|
|
503
321
|
version: number;
|
|
504
322
|
secret: string;
|
|
505
323
|
}
|
|
506
|
-
/**
|
|
507
|
-
* The signed body of a token. Field names are single characters because this
|
|
508
|
-
* is base64'd into a URL that also has to survive an email client's line
|
|
509
|
-
* wrapping - the verbosity is not worth the bytes.
|
|
510
|
-
*/
|
|
511
324
|
interface EmailTokenPayload {
|
|
512
|
-
/** Campaign id. */
|
|
513
325
|
c: string;
|
|
514
|
-
/** Pseudonymous recipient - see `hashRecipient`. Never a raw identifier. */
|
|
515
326
|
r: string;
|
|
516
|
-
/**
|
|
517
|
-
* The arm chosen in each slot, POSITIONALLY: `k[i]` is the arm index chosen
|
|
518
|
-
* for the campaign's `slots[i]`.
|
|
519
|
-
*
|
|
520
|
-
* Positional rather than a `{ slotId: armId }` map because ids are ~32
|
|
521
|
-
* characters each and a six-slot email would spend over 200 characters
|
|
522
|
-
* restating what the campaign already knows. The cost is that a campaign's
|
|
523
|
-
* slot order becomes load-bearing forever, which is why reordering slots
|
|
524
|
-
* after a send is rejected at the campaign level.
|
|
525
|
-
*
|
|
526
|
-
* The whole combination is here, not just the clicked slot, because a click
|
|
527
|
-
* has to credit every slot the recipient saw - and because in `plan` mode
|
|
528
|
-
* this token IS the impression record, reconstructable from a first click
|
|
529
|
-
* even if the send-time manifest never arrived.
|
|
530
|
-
*/
|
|
531
327
|
k: number[];
|
|
532
|
-
/**
|
|
533
|
-
* Index into the campaign's link table - never a URL.
|
|
534
|
-
*
|
|
535
|
-
* A token carrying its own destination would be an open redirect the moment
|
|
536
|
-
* the signature check had a bug, and would make every token long enough to
|
|
537
|
-
* matter. An index can only ever address a link the campaign already
|
|
538
|
-
* declared.
|
|
539
|
-
*/
|
|
540
328
|
l: number;
|
|
541
|
-
/** Signing key version. Set by `mintEmailToken`; do not pass it in. */
|
|
542
329
|
v?: number;
|
|
543
330
|
}
|
|
544
331
|
type VerifiedToken = {
|
|
@@ -548,210 +335,43 @@ type VerifiedToken = {
|
|
|
548
335
|
valid: false;
|
|
549
336
|
reason: 'malformed' | 'unknown_key' | 'bad_signature';
|
|
550
337
|
};
|
|
551
|
-
/**
|
|
552
|
-
* Pseudonymise a recipient identifier.
|
|
553
|
-
*
|
|
554
|
-
* Deliberately a one-way HMAC rather than something we can reverse. We do not
|
|
555
|
-
* need to: the destination page is the customer's own, and their SDK resolves
|
|
556
|
-
* the real identity through `identify()` on arrival. What Bandit needs is a
|
|
557
|
-
* stable key to attribute a click and to dedupe first-touch, and a hash serves
|
|
558
|
-
* that without ever putting a customer's user ids in a URL that gets
|
|
559
|
-
* forwarded, proxy-logged and leaked through `Referer`.
|
|
560
|
-
*
|
|
561
|
-
* Keyed on a company-level secret rather than a campaign one, so the same
|
|
562
|
-
* recipient hashes identically across every campaign that company sends. That
|
|
563
|
-
* is what makes cross-campaign frequency and fatigue analysis possible later -
|
|
564
|
-
* an unsubscribe is usually the result of the fifth email, not the first.
|
|
565
|
-
*
|
|
566
|
-
* Truncated to 16 bytes: 128 bits is far beyond collision range for any real
|
|
567
|
-
* list, and the 22 characters saved matter in a URL.
|
|
568
|
-
*/
|
|
569
338
|
declare function hashRecipient(recipientId: string, companySecret: string): Promise<string>;
|
|
570
|
-
/**
|
|
571
|
-
* Mint a token. Pure function of (payload, key) - the same inputs always
|
|
572
|
-
* produce the same token, which is what lets the SDK mint offline in `plan`
|
|
573
|
-
* mode and the server arrive independently at the identical value.
|
|
574
|
-
*/
|
|
575
339
|
declare function mintEmailToken(payload: EmailTokenPayload, key: SigningKey): Promise<string>;
|
|
576
|
-
/**
|
|
577
|
-
* Verify a token against every currently-held key.
|
|
578
|
-
*
|
|
579
|
-
* Never throws. This is called from a redirect endpoint and a click ingest
|
|
580
|
-
* route, both of which take their input straight off a URL - a crash there is
|
|
581
|
-
* a broken link in a real person's inbox, and the input is attacker-controlled
|
|
582
|
-
* by definition.
|
|
583
|
-
*
|
|
584
|
-
* Pass every key still in rotation, oldest included. Dropping a key
|
|
585
|
-
* permanently breaks every link ever minted under it, and those links live in
|
|
586
|
-
* inboxes we cannot reach.
|
|
587
|
-
*/
|
|
588
340
|
declare function verifyEmailToken(token: string, keys: SigningKey[]): Promise<VerifiedToken>;
|
|
589
|
-
/**
|
|
590
|
-
* Append a token to a destination URL.
|
|
591
|
-
*
|
|
592
|
-
* Appends rather than replaces, which is the whole reason the default tracking
|
|
593
|
-
* mode needs no redirect service, no DNS setup and no custom certificate: the
|
|
594
|
-
* link still points at the customer's own domain, and their existing Bandit
|
|
595
|
-
* SDK reads the parameter on page load. Established practice - every ESP and
|
|
596
|
-
* marketer already decorates outbound links this way.
|
|
597
|
-
*
|
|
598
|
-
* Existing query parameters and fragments are preserved; a fragment must stay
|
|
599
|
-
* last or the browser will treat the token as part of it.
|
|
600
|
-
*/
|
|
601
341
|
declare function appendEmailToken(url: string, token: string): string;
|
|
602
342
|
|
|
603
|
-
/**
|
|
604
|
-
* `plan` mode - assignment for bulk sends, with no per-recipient round trip.
|
|
605
|
-
*
|
|
606
|
-
* `resolve` and `batch` both make an HTTP call carrying recipients. That is
|
|
607
|
-
* fine to ten thousand and untenable at two million: the customer's send
|
|
608
|
-
* pipeline would spend its entire budget waiting on us.
|
|
609
|
-
*
|
|
610
|
-
* So instead of answering per recipient, we hand over the *policy*. The plan
|
|
611
|
-
* carries each slot's arm set and the selection probabilities the bandit's
|
|
612
|
-
* posterior currently implies, plus a salt and the campaign's signing key. The
|
|
613
|
-
* SDK then derives each recipient's arm locally and mints their token offline -
|
|
614
|
-
* no network, perfectly parallel, as fast as the customer's own loop.
|
|
615
|
-
*
|
|
616
|
-
* Two properties make this work, and both are load-bearing:
|
|
617
|
-
*
|
|
618
|
-
* **Determinism.** The sampler here is the single implementation. The server
|
|
619
|
-
* and the customer's SDK must derive the identical arm for the identical
|
|
620
|
-
* recipient, or the impression manifest reports one thing and the tokens say
|
|
621
|
-
* another. It is pure, keyed only on (salt, recipientId, slot), and pinned by
|
|
622
|
-
* test.
|
|
623
|
-
*
|
|
624
|
-
* **Staleness is bounded by chunking.** A plan is one snapshot of the
|
|
625
|
-
* posterior, so a whole blast served from a single plan is one learning epoch.
|
|
626
|
-
* `refreshEvery` lets the caller re-request every N recipients, turning a 2M
|
|
627
|
-
* send into a few dozen epochs instead of one - the customer's own dial
|
|
628
|
-
* between throughput and adaptivity.
|
|
629
|
-
*/
|
|
630
343
|
interface PlanArm {
|
|
631
344
|
treatmentId: string;
|
|
632
|
-
/**
|
|
633
|
-
* Position of this arm in its slot's treatment list.
|
|
634
|
-
*
|
|
635
|
-
* This is what a token carries, so it must be the same index the render and
|
|
636
|
-
* click paths resolve against - not this array's position, which is only
|
|
637
|
-
* incidentally the same.
|
|
638
|
-
*/
|
|
639
345
|
armIndex: number;
|
|
640
346
|
}
|
|
641
347
|
interface PlanSlot {
|
|
642
348
|
experimentId: string;
|
|
643
|
-
/** Slot name, for substituting into the template. */
|
|
644
349
|
name: string;
|
|
645
350
|
arms: PlanArm[];
|
|
646
|
-
/**
|
|
647
|
-
* P(each arm is best), positionally aligned with `arms`, summing to ~1.
|
|
648
|
-
*
|
|
649
|
-
* Thompson sampling's allocation, computed once from the posterior rather
|
|
650
|
-
* than re-sampled per recipient. Sampling locally from these probabilities
|
|
651
|
-
* is distributionally identical to sampling the posterior directly, which is
|
|
652
|
-
* what makes the offline derivation legitimate rather than an approximation.
|
|
653
|
-
*/
|
|
654
351
|
probabilities: number[];
|
|
655
|
-
/** Arm content, positionally aligned with `arms`. */
|
|
656
352
|
configs: unknown[];
|
|
657
353
|
}
|
|
658
354
|
interface EmailPlan {
|
|
659
355
|
campaignId: string;
|
|
660
356
|
slots: PlanSlot[];
|
|
661
357
|
linkTable: string[];
|
|
662
|
-
/**
|
|
663
|
-
* Per-plan randomisation. Two campaigns must not assign the same recipient
|
|
664
|
-
* the same way merely because the probabilities happen to match, or the
|
|
665
|
-
* population splits into permanent cohorts and cross-campaign comparisons
|
|
666
|
-
* quietly stop being independent.
|
|
667
|
-
*/
|
|
668
358
|
salt: string;
|
|
669
|
-
/**
|
|
670
|
-
* The campaign's current signing key, so the SDK can mint offline.
|
|
671
|
-
*
|
|
672
|
-
* This is the customer's own campaign secret, sent to their server - not to
|
|
673
|
-
* a browser. It lets them mint tokens for their own campaign, which they
|
|
674
|
-
* could already do by calling the click endpoint directly; it does not let
|
|
675
|
-
* them touch anyone else's.
|
|
676
|
-
*/
|
|
677
359
|
signingKey: {
|
|
678
360
|
version: number;
|
|
679
361
|
secret: string;
|
|
680
362
|
};
|
|
681
|
-
/** Re-request after this many recipients to pick up what the send has learned. */
|
|
682
363
|
refreshEvery: number;
|
|
683
|
-
/** When this plan stops being usable. */
|
|
684
364
|
expiresAt: number;
|
|
685
365
|
}
|
|
686
|
-
/**
|
|
687
|
-
* A uniform draw in [0,1) for one recipient and slot.
|
|
688
|
-
*
|
|
689
|
-
* HMAC rather than a plain hash so the mapping cannot be reproduced by anyone
|
|
690
|
-
* without the salt - otherwise a recipient could work out which arm they were
|
|
691
|
-
* in, and more importantly a third party could reconstruct the whole
|
|
692
|
-
* assignment for a campaign they observed one token from.
|
|
693
|
-
*
|
|
694
|
-
* Six bytes gives 2^48 distinct values, far finer than any arm allocation
|
|
695
|
-
* needs, and avoids the precision loss of stuffing 64 bits into a double.
|
|
696
|
-
*/
|
|
697
366
|
declare function uniformFor(salt: string, recipientId: string, slotIndex: number): Promise<number>;
|
|
698
|
-
/**
|
|
699
|
-
* Pick an arm index from a probability vector given a uniform draw.
|
|
700
|
-
*
|
|
701
|
-
* Separated from the draw so the selection itself is synchronous and trivially
|
|
702
|
-
* testable against hand-written vectors.
|
|
703
|
-
*
|
|
704
|
-
* Degenerate vectors are handled rather than trusted: probabilities that do
|
|
705
|
-
* not quite sum to 1 (Monte Carlo estimates never do) must not leave a
|
|
706
|
-
* recipient unassigned, so the last arm absorbs the remainder.
|
|
707
|
-
*/
|
|
708
367
|
declare function pickArm(probabilities: number[], uniform: number): number;
|
|
709
|
-
/** The arm each slot assigns this recipient, positionally by slot. */
|
|
710
368
|
declare function sampleCombination(plan: EmailPlan, recipientId: string): Promise<number[]>;
|
|
711
|
-
/**
|
|
712
|
-
* Tally a plan-mode send into per-arm impression counts.
|
|
713
|
-
*
|
|
714
|
-
* Aggregated because a send is a burst: the bandit needs a denominator, not a
|
|
715
|
-
* row per recipient, and the per-recipient detail already lives in each token.
|
|
716
|
-
*/
|
|
717
369
|
declare function impressionManifest(plan: EmailPlan, recipientIds: string[]): Promise<Array<{
|
|
718
370
|
experimentId: string;
|
|
719
371
|
treatmentId: string;
|
|
720
372
|
count: number;
|
|
721
373
|
}>>;
|
|
722
374
|
|
|
723
|
-
/**
|
|
724
|
-
* Email as a structured document.
|
|
725
|
-
*
|
|
726
|
-
* The other authoring mode takes the customer's own HTML template and
|
|
727
|
-
* substitutes slots into it. This one inverts that: the email is authored in
|
|
728
|
-
* Bandit as a block tree, and every block property is a candidate slot. Four
|
|
729
|
-
* slots with four options each is sixteen authored pieces and 256 possible
|
|
730
|
-
* emails, none of which anyone hand-writes.
|
|
731
|
-
*
|
|
732
|
-
* This is about AUTHORING, not learning - cross-campaign learning works
|
|
733
|
-
* identically either way. What it buys is that a marketer can run a
|
|
734
|
-
* multi-armed email without owning an HTML template at all.
|
|
735
|
-
*
|
|
736
|
-
* ## Why this renderer is hand-written
|
|
737
|
-
*
|
|
738
|
-
* MJML is the obvious choice and was the original plan. Two things rule it
|
|
739
|
-
* out. The dashboard runs on Cloudflare Workers, where MJML's several
|
|
740
|
-
* megabytes sit badly against the bundle limit; and the same rendering has to
|
|
741
|
-
* be available in `@runbandit/sdk`, which ships with no runtime dependencies
|
|
742
|
-
* because customers did not agree to a transitive tree in order to send an
|
|
743
|
-
* email. One small renderer serving both beats two that can disagree.
|
|
744
|
-
*
|
|
745
|
-
* ## Why the output looks like 1999
|
|
746
|
-
*
|
|
747
|
-
* Because email clients do. Outlook renders through Word, which ignores
|
|
748
|
-
* `div`-based layout, flexbox and grid entirely; Gmail strips `<style>` blocks
|
|
749
|
-
* in several contexts. Tables with inline styles are not nostalgia, they are
|
|
750
|
-
* the only layout primitive that survives everywhere - and a button that needs
|
|
751
|
-
* a background colour in Outlook needs a VML rectangle, which is why the
|
|
752
|
-
* button block emits a conditional comment.
|
|
753
|
-
*/
|
|
754
|
-
/** A value that comes from a bandit slot rather than being authored directly. */
|
|
755
375
|
interface SlotBinding {
|
|
756
376
|
$slot: string;
|
|
757
377
|
}
|
|
@@ -795,28 +415,18 @@ interface SpacerBlock {
|
|
|
795
415
|
}
|
|
796
416
|
type EmailBlock = HeadingBlock | TextBlock | ButtonBlock | ImageBlock | DividerBlock | SpacerBlock;
|
|
797
417
|
interface EmailDocument {
|
|
798
|
-
/** Subject line - a slot like any other, and usually the highest-leverage one. */
|
|
799
418
|
subject: Bound<string>;
|
|
800
419
|
preheader?: Bound<string>;
|
|
801
420
|
blocks: EmailBlock[];
|
|
802
421
|
backgroundColor?: string;
|
|
803
422
|
contentWidth?: number;
|
|
804
423
|
}
|
|
805
|
-
/** Every slot name the document refers to, in document order, de-duplicated. */
|
|
806
424
|
declare function extractDocumentSlots(document: EmailDocument): string[];
|
|
807
425
|
interface RenderedDocument {
|
|
808
426
|
subject: string;
|
|
809
427
|
html: string;
|
|
810
|
-
/** Slots the document referenced that no value was supplied for. */
|
|
811
428
|
missingSlots: string[];
|
|
812
429
|
}
|
|
813
|
-
/**
|
|
814
|
-
* Render a document to email-safe HTML.
|
|
815
|
-
*
|
|
816
|
-
* The output is deliberately a complete document with the table scaffolding
|
|
817
|
-
* every client needs, not a fragment: there is no "outer template" the
|
|
818
|
-
* customer supplies in this mode, which is the entire point of it.
|
|
819
|
-
*/
|
|
820
430
|
declare function renderEmailDocument(document: EmailDocument, slots: Record<string, string>): RenderedDocument;
|
|
821
431
|
|
|
822
432
|
interface BanditsClientConfig {
|
|
@@ -833,6 +443,7 @@ interface Assignment {
|
|
|
833
443
|
experimentId: string;
|
|
834
444
|
userId: string;
|
|
835
445
|
config: any;
|
|
446
|
+
funnelId?: string;
|
|
836
447
|
}
|
|
837
448
|
declare class BanditsClient {
|
|
838
449
|
private config;
|
|
@@ -841,11 +452,6 @@ declare class BanditsClient {
|
|
|
841
452
|
private flushTimer?;
|
|
842
453
|
constructor(config: BanditsClientConfig);
|
|
843
454
|
getAssignment(experimentId: string, userId: string, context?: ContextualFeatures): Promise<Assignment>;
|
|
844
|
-
/**
|
|
845
|
-
* Track a named event for session-based attribution (recommended)
|
|
846
|
-
* Events are matched to bandits by sessionId — no assignmentId needed
|
|
847
|
-
* Set immediate: true to fire synchronously (survives navigation)
|
|
848
|
-
*/
|
|
849
455
|
trackNamedEvent(event: {
|
|
850
456
|
name: string;
|
|
851
457
|
category?: string;
|
|
@@ -855,38 +461,12 @@ declare class BanditsClient {
|
|
|
855
461
|
immediate?: boolean;
|
|
856
462
|
}): void;
|
|
857
463
|
flush(): Promise<void>;
|
|
858
|
-
/**
|
|
859
|
-
* Link an anonymous user ID to a durable identity (e.g. email, account ID).
|
|
860
|
-
* All historical assignments and events for the anonymous ID are re-attributed
|
|
861
|
-
* to the identified user. Future calls to getAssignment() with either ID will
|
|
862
|
-
* resolve to the same assignment.
|
|
863
|
-
*
|
|
864
|
-
* Requires admin authentication — the client must be configured with a
|
|
865
|
-
* personal access token (PAT) instead of an API key, and companyId must
|
|
866
|
-
* be provided. This is a management operation, not an SDK client operation.
|
|
867
|
-
*
|
|
868
|
-
* @param anonymousId - The temporary/anonymous user ID (e.g. crypto.randomUUID())
|
|
869
|
-
* @param identifiedUserId - The permanent user ID (e.g. email, database ID)
|
|
870
|
-
* @param companyId - The company ID (required for PAT auth)
|
|
871
|
-
* @param mergeReason - Why the identification is happening
|
|
872
|
-
*/
|
|
873
464
|
identify(anonymousId: string, identifiedUserId: string, companyId: string, mergeReason?: 'user_signup' | 'email_verification' | 'social_login' | 'account_recovery' | 'manual'): Promise<void>;
|
|
874
|
-
|
|
875
|
-
* Record a funnel payout event.
|
|
876
|
-
* Call this when the terminal conversion happens (e.g., purchase).
|
|
877
|
-
*
|
|
878
|
-
* @param funnelId - The funnel ID
|
|
879
|
-
* @param userId - The user who converted
|
|
880
|
-
* @param value - The payout value (e.g., purchase amount in dollars)
|
|
881
|
-
*/
|
|
465
|
+
label(userId: string, labels: Record<string, string | boolean>): Promise<void>;
|
|
882
466
|
trackFunnelReward(funnelId: string, userId: string, value: number): Promise<void>;
|
|
883
467
|
getBundle(bundleId: string, userId: string, context?: ContextualFeatures): Promise<BundleAssignmentResponse>;
|
|
884
468
|
trackBundleReward(bundleId: string, userId: string, value: number): Promise<void>;
|
|
885
469
|
private generateId;
|
|
886
|
-
/**
|
|
887
|
-
* Normalize the apiUrl by adding /api prefix if not present.
|
|
888
|
-
* Supports both 'https://runbandit.com' and 'https://runbandit.com/api'.
|
|
889
|
-
*/
|
|
890
470
|
private normalizeApiUrl;
|
|
891
471
|
private getOrCreateSessionId;
|
|
892
472
|
private makeRequest;
|
|
@@ -903,28 +483,12 @@ interface SyncAssignment {
|
|
|
903
483
|
interface SnapshotClientConfig {
|
|
904
484
|
apiUrl: string;
|
|
905
485
|
publishableKey: string;
|
|
906
|
-
/** Defaults to window.localStorage. Injectable for tests and SSR. */
|
|
907
486
|
storage?: Storage;
|
|
908
|
-
/** Injectable clock. Defaults to Date.now. */
|
|
909
487
|
now?: () => number;
|
|
910
|
-
/** Session lifetime in ms. Must match the BanditsClient using the same storage. */
|
|
911
488
|
sessionTtl?: number;
|
|
912
|
-
/**
|
|
913
|
-
* A snapshot embedded in the page at render time. When present there is no
|
|
914
|
-
* cold start at all - the decision happens with zero network calls, which is
|
|
915
|
-
* the whole point of the design.
|
|
916
|
-
*/
|
|
917
489
|
inlineSnapshot?: CompanySnapshot;
|
|
918
490
|
fetchImpl?: typeof fetch;
|
|
919
491
|
}
|
|
920
|
-
/**
|
|
921
|
-
* Pick an arm from a published allocation.
|
|
922
|
-
*
|
|
923
|
-
* One uniform draw against the cumulative distribution. This is equivalent to
|
|
924
|
-
* Thompson sampling because `pct` *is* the distribution Thompson sampling
|
|
925
|
-
* draws from - the Monte Carlo ran server-side, once, instead of here on every
|
|
926
|
-
* pageview.
|
|
927
|
-
*/
|
|
928
492
|
declare function pickTreatment(treatments: SnapshotTreatment[], draw: number): SnapshotTreatment | null;
|
|
929
493
|
declare class SnapshotClient {
|
|
930
494
|
private config;
|
|
@@ -936,203 +500,63 @@ declare class SnapshotClient {
|
|
|
936
500
|
private sessionId;
|
|
937
501
|
private inFlight;
|
|
938
502
|
constructor(config: SnapshotClientConfig);
|
|
939
|
-
/**
|
|
940
|
-
* Decide an arm with no network call, or return null.
|
|
941
|
-
*
|
|
942
|
-
* Null means "this caller must fall back" - a cold cache, an expired one, an
|
|
943
|
-
* unknown experiment, or one whose state cannot be served from a static
|
|
944
|
-
* document. Returning null rather than the control is deliberate: the caller
|
|
945
|
-
* renders control itself, and no impression is reported for it. Reporting a
|
|
946
|
-
* forced-control impression would inflate the control arm's trials with
|
|
947
|
-
* decisions the bandit never made.
|
|
948
|
-
*/
|
|
949
503
|
getAssignmentSync(experimentId: string, userId: string): SyncAssignment | null;
|
|
950
|
-
/** Fetch a fresh snapshot and cache it. Safe to call on every pageview. */
|
|
951
504
|
refresh(): Promise<void>;
|
|
952
|
-
/**
|
|
953
|
-
* Post queued impressions.
|
|
954
|
-
*
|
|
955
|
-
* The queue is only cleared on success. A failed flush leaves the reports in
|
|
956
|
-
* storage, so a pageview that happened offline is still counted when the
|
|
957
|
-
* next one succeeds - the trial really did occur, and dropping it would bias
|
|
958
|
-
* the arm's denominator downward.
|
|
959
|
-
*/
|
|
960
505
|
flush(): Promise<void>;
|
|
961
506
|
private doFlush;
|
|
962
|
-
/** The session id these reports are attributed under. */
|
|
963
507
|
getSessionId(): string;
|
|
964
508
|
pendingReportCount(): number;
|
|
965
|
-
/** The cached snapshot, or null when absent or past its ttl. */
|
|
966
509
|
private freshSnapshot;
|
|
967
510
|
private readSnapshot;
|
|
968
511
|
private persistSnapshot;
|
|
969
512
|
private readQueue;
|
|
970
|
-
/** Stored decisions, pruned of expired entries. */
|
|
971
513
|
private readDecisions;
|
|
972
514
|
private writeDecisions;
|
|
973
515
|
private persistQueue;
|
|
974
516
|
}
|
|
975
517
|
|
|
976
|
-
/**
|
|
977
|
-
* Session identity, shared by every client in this package.
|
|
978
|
-
*
|
|
979
|
-
* Reward attribution joins `assignments` by `sessionId`, so a decision made by
|
|
980
|
-
* `SnapshotClient` and an event tracked by `BanditsClient` must agree on the
|
|
981
|
-
* id or the conversion never lands on the impression. One implementation,
|
|
982
|
-
* used by both, is the only way that rule stays true.
|
|
983
|
-
*/
|
|
984
518
|
declare const SESSION_ID_KEY = "bandits_session_id";
|
|
985
519
|
declare const SESSION_CREATED_AT_KEY = "bandits_session_created_at";
|
|
986
|
-
/** 24 hours, in milliseconds. */
|
|
987
520
|
declare const DEFAULT_SESSION_TTL_MS = 86400000;
|
|
988
521
|
interface SessionOptions {
|
|
989
|
-
/** Where the session lives. Null (no browser storage) means ephemeral. */
|
|
990
522
|
storage?: Storage | null;
|
|
991
|
-
/** Injectable clock, in milliseconds. */
|
|
992
523
|
now?: () => number;
|
|
993
524
|
ttlMs?: number;
|
|
994
525
|
}
|
|
995
|
-
/**
|
|
996
|
-
* Return the current session id, minting and persisting a new one when there
|
|
997
|
-
* is none or the stored one has aged past `ttlMs`.
|
|
998
|
-
*/
|
|
999
526
|
declare function getOrCreateSessionId(options?: SessionOptions): string;
|
|
1000
527
|
|
|
1001
528
|
interface RewriteOptions {
|
|
1002
|
-
/**
|
|
1003
|
-
* The campaign's ordered destination URLs. A token carries an index into
|
|
1004
|
-
* THIS array, not into the order links happen to appear in the document -
|
|
1005
|
-
* otherwise moving a paragraph would repoint every token already sent.
|
|
1006
|
-
*/
|
|
1007
529
|
linkTable: string[];
|
|
1008
|
-
/** One token per link table entry, positionally aligned. */
|
|
1009
530
|
tokens: string[];
|
|
1010
531
|
}
|
|
1011
532
|
interface RewriteResult {
|
|
1012
533
|
html: string;
|
|
1013
|
-
/** How many hrefs were tokenised. */
|
|
1014
534
|
rewritten: number;
|
|
1015
|
-
/**
|
|
1016
|
-
* Trackable links found in the document but absent from the link table.
|
|
1017
|
-
*
|
|
1018
|
-
* Reported rather than guessed at: a link we cannot attribute is left
|
|
1019
|
-
* exactly as authored, because a dead link is far worse than an untracked
|
|
1020
|
-
* one. Surfacing them is what lets the campaign append the missing entries
|
|
1021
|
-
* (appending is always allowed; reordering is not).
|
|
1022
|
-
*/
|
|
1023
535
|
untracked: string[];
|
|
1024
536
|
}
|
|
1025
|
-
/**
|
|
1026
|
-
* Every trackable destination in the document, in order, de-duplicated.
|
|
1027
|
-
*
|
|
1028
|
-
* Used at campaign creation to build the link table that tokens index into.
|
|
1029
|
-
*/
|
|
1030
537
|
declare function extractEmailLinks(html: string): string[];
|
|
1031
|
-
/**
|
|
1032
|
-
* Tokenise every trackable link in the document.
|
|
1033
|
-
*
|
|
1034
|
-
* Tokens are appended, not substituted, so the link still points at the
|
|
1035
|
-
* customer's own domain: no redirect service, no DNS record, no certificate,
|
|
1036
|
-
* and no shared link-domain reputation to poison. Their landing page's
|
|
1037
|
-
* existing Bandit SDK reads the parameter on load.
|
|
1038
|
-
*
|
|
1039
|
-
* Synchronous and pure: minting is asynchronous (WebCrypto), so the caller
|
|
1040
|
-
* mints one token per link table entry first and passes them in. That keeps
|
|
1041
|
-
* this function trivially testable and lets a batch send mint once per
|
|
1042
|
-
* recipient rather than once per link per parse.
|
|
1043
|
-
*/
|
|
1044
538
|
declare function rewriteEmailLinks(html: string, options: RewriteOptions): RewriteResult;
|
|
1045
539
|
|
|
1046
540
|
interface RenderEmailOptions {
|
|
1047
|
-
/** Chosen content per slot, keyed by slot name, from /api/email/render. */
|
|
1048
541
|
slots: Record<string, string>;
|
|
1049
|
-
/** The campaign's ordered destination URLs. */
|
|
1050
542
|
linkTable: string[];
|
|
1051
|
-
/** One token per link table entry, minted for this recipient. */
|
|
1052
543
|
tokens: string[];
|
|
1053
544
|
}
|
|
1054
545
|
interface RenderedEmail {
|
|
1055
546
|
html: string;
|
|
1056
|
-
/** How many links were tokenised. */
|
|
1057
547
|
rewritten: number;
|
|
1058
|
-
/** Trackable links not present in the campaign's link table. */
|
|
1059
548
|
untracked: string[];
|
|
1060
|
-
/**
|
|
1061
|
-
* Placeholders the template asked for that no slot supplied.
|
|
1062
|
-
*
|
|
1063
|
-
* Reported rather than left in place as literal `{{bandit.x}}`, which is
|
|
1064
|
-
* what a recipient would otherwise read in their inbox.
|
|
1065
|
-
*/
|
|
1066
549
|
missingSlots: string[];
|
|
1067
550
|
}
|
|
1068
|
-
/**
|
|
1069
|
-
* Substitute slot values, then tokenise every trackable link.
|
|
1070
|
-
*
|
|
1071
|
-
* Order matters: substitution happens first, so a slot whose value contains a
|
|
1072
|
-
* link gets that link tokenised too. Doing it the other way round would leave
|
|
1073
|
-
* links from bandit-selected content untracked - exactly the content whose
|
|
1074
|
-
* performance we are trying to measure.
|
|
1075
|
-
*/
|
|
1076
551
|
declare function renderEmail(templateHtml: string, options: RenderEmailOptions): RenderedEmail;
|
|
1077
|
-
/**
|
|
1078
|
-
* The slot names a template refers to.
|
|
1079
|
-
*
|
|
1080
|
-
* Used at authoring time to check a template against a campaign's slots before
|
|
1081
|
-
* anything is sent, rather than discovering the mismatch in an inbox.
|
|
1082
|
-
*/
|
|
1083
552
|
declare function extractSlotNames(templateHtml: string): string[];
|
|
1084
553
|
|
|
1085
|
-
/**
|
|
1086
|
-
* Offline rendering from a campaign plan.
|
|
1087
|
-
*
|
|
1088
|
-
* This is what makes a two-million-recipient send possible. The plan is
|
|
1089
|
-
* fetched once; every recipient after that is derived locally - arm selection,
|
|
1090
|
-
* token minting, slot substitution and link rewriting - with no network call
|
|
1091
|
-
* at all. The customer's send loop runs at whatever speed their own ESP client
|
|
1092
|
-
* manages, and we are not in it.
|
|
1093
|
-
*
|
|
1094
|
-
* Nothing here needs to reach us, which is also why the customer's template
|
|
1095
|
-
* and merge data never leave their infrastructure.
|
|
1096
|
-
*/
|
|
1097
554
|
interface PlanRenderResult extends RenderedEmail {
|
|
1098
|
-
/** Arm chosen per slot, positionally against the plan's slots. */
|
|
1099
555
|
combination: number[];
|
|
1100
|
-
/** The token minted for each link, positionally against the link table. */
|
|
1101
556
|
tokens: string[];
|
|
1102
557
|
}
|
|
1103
|
-
/**
|
|
1104
|
-
* Render one recipient's email from a plan.
|
|
1105
|
-
*
|
|
1106
|
-
* Derivation is deterministic in (salt, recipientId, slot), so re-rendering the
|
|
1107
|
-
* same recipient - a retry, a resumed batch, a preview - produces byte-identical
|
|
1108
|
-
* output and the same tokens. A send that crashes halfway can simply be run
|
|
1109
|
-
* again without double-counting anyone or issuing a second set of links.
|
|
1110
|
-
*/
|
|
1111
558
|
declare function renderFromPlan(plan: EmailPlan, recipientId: string, templateHtml: string, companySecret: string): Promise<PlanRenderResult>;
|
|
1112
|
-
/**
|
|
1113
|
-
* Whether a plan should be refreshed before continuing a send.
|
|
1114
|
-
*
|
|
1115
|
-
* A plan is one snapshot of the posterior, so serving a whole blast from it
|
|
1116
|
-
* makes the entire send a single learning epoch - the bandit cannot react to
|
|
1117
|
-
* anything it learns partway through. Refreshing every `refreshEvery`
|
|
1118
|
-
* recipients turns a 2M send into a few dozen epochs for a few dozen API
|
|
1119
|
-
* calls, which is the trade the customer gets to make rather than us.
|
|
1120
|
-
*/
|
|
1121
559
|
declare function shouldRefreshPlan(plan: EmailPlan, recipientsRenderedSincePlan: number, now?: number): boolean;
|
|
1122
|
-
/**
|
|
1123
|
-
* Post a plan-mode send's impressions so the bandit has a denominator.
|
|
1124
|
-
*
|
|
1125
|
-
* This is not optional bookkeeping. `plan` mode makes no per-recipient call,
|
|
1126
|
-
* so without this the only observations that ever reach the posterior are
|
|
1127
|
-
* clicks - every one of them a success - and every arm converges on
|
|
1128
|
-
* "everything works" while the dashboard shows excellent numbers measuring
|
|
1129
|
-
* nothing. The same omission was caught once already in the `batch` render
|
|
1130
|
-
* path; it is easier to make here precisely because nothing else round-trips.
|
|
1131
|
-
*
|
|
1132
|
-
* Aggregated per arm, so ten thousand recipients across a handful of arms is
|
|
1133
|
-
* one small request. `batchKey` makes it idempotent: a retried post must not
|
|
1134
|
-
* double the denominator and quietly halve every arm's apparent rate.
|
|
1135
|
-
*/
|
|
1136
560
|
declare function reportPlanImpressions(plan: EmailPlan, recipientIds: string[], options: {
|
|
1137
561
|
apiUrl: string;
|
|
1138
562
|
apiKey: string;
|