@runbandit/sdk 1.0.0 → 2.0.1
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/README.md +3 -1
- package/dist/index.d.ts +814 -5
- package/dist/index.js +844 -30
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
package/dist/index.d.ts
CHANGED
|
@@ -50,10 +50,12 @@ interface Experiment {
|
|
|
50
50
|
name: string;
|
|
51
51
|
description?: string;
|
|
52
52
|
algorithm: BanditAlgorithm;
|
|
53
|
+
forgetting?: ForgettingConfig;
|
|
53
54
|
status: ExperimentStatus;
|
|
54
55
|
contentType: ContentType;
|
|
55
56
|
treatments: Treatment[];
|
|
56
57
|
context?: Record<string, any>;
|
|
58
|
+
bundleId?: string;
|
|
57
59
|
image?: string;
|
|
58
60
|
emoji?: string;
|
|
59
61
|
assignmentTtl: number;
|
|
@@ -61,7 +63,18 @@ interface Experiment {
|
|
|
61
63
|
createdAt: Date;
|
|
62
64
|
updatedAt: Date;
|
|
63
65
|
endAt?: Date;
|
|
66
|
+
placement?: Placement;
|
|
64
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
|
+
declare const PLACEMENTS: readonly ["hero_headline", "hero_subhead", "cta_primary", "banner_text", "badge_text"];
|
|
77
|
+
type Placement = (typeof PLACEMENTS)[number];
|
|
65
78
|
interface Treatment {
|
|
66
79
|
id: string;
|
|
67
80
|
experimentId: string;
|
|
@@ -74,12 +87,47 @@ interface Treatment {
|
|
|
74
87
|
}
|
|
75
88
|
interface AlgorithmResult {
|
|
76
89
|
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
|
+
*/
|
|
77
103
|
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
|
+
probabilityBest?: number;
|
|
78
123
|
}
|
|
79
124
|
interface BanditResult {
|
|
80
125
|
assignmentId: string;
|
|
81
126
|
treatmentId: string;
|
|
127
|
+
/** @deprecated see `AlgorithmResult.confidence`. */
|
|
82
128
|
confidence: number;
|
|
129
|
+
/** See `AlgorithmResult.probabilityBest`. */
|
|
130
|
+
probabilityBest?: number;
|
|
83
131
|
metadata?: Record<string, any>;
|
|
84
132
|
}
|
|
85
133
|
interface ContextualFeatures {
|
|
@@ -106,13 +154,88 @@ interface VariantConfig {
|
|
|
106
154
|
content: string;
|
|
107
155
|
metadata?: TreatmentMetadata & Record<string, any>;
|
|
108
156
|
}
|
|
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
|
+
*/
|
|
109
164
|
declare enum BanditAlgorithm {
|
|
110
|
-
EPSILON_GREEDY = "epsilon_greedy",
|
|
111
|
-
UCB1 = "ucb1",
|
|
112
165
|
THOMPSON_SAMPLING = "thompson_sampling",
|
|
113
|
-
CONTEXTUAL_LINEAR = "contextual_linear"
|
|
114
|
-
|
|
166
|
+
CONTEXTUAL_LINEAR = "contextual_linear"
|
|
167
|
+
}
|
|
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
|
+
declare enum Posterior {
|
|
180
|
+
BETA = "beta",
|
|
181
|
+
NORMAL_GAMMA = "normal_gamma"
|
|
115
182
|
}
|
|
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
|
+
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
|
+
declare enum ForgettingMode {
|
|
207
|
+
NONE = "none",
|
|
208
|
+
DISCOUNTED = "discounted",
|
|
209
|
+
SLIDING_WINDOW = "sliding_window"
|
|
210
|
+
}
|
|
211
|
+
interface ForgettingConfig {
|
|
212
|
+
mode: ForgettingMode;
|
|
213
|
+
/** Half-life in milliseconds. DISCOUNTED only. */
|
|
214
|
+
halfLifeMs?: number;
|
|
215
|
+
/** Window length in milliseconds. SLIDING_WINDOW only. */
|
|
216
|
+
windowMs?: number;
|
|
217
|
+
}
|
|
218
|
+
/** Default half-life for DISCOUNTED: one week. */
|
|
219
|
+
declare const DEFAULT_HALF_LIFE_MS: number;
|
|
220
|
+
/** Default window for SLIDING_WINDOW: two weeks. */
|
|
221
|
+
declare const DEFAULT_WINDOW_MS: number;
|
|
222
|
+
/** The stationary default applied when an experiment has no forgetting config. */
|
|
223
|
+
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
|
+
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
|
+
declare function normalizeAlgorithm(algorithm: string | null | undefined): BanditAlgorithm;
|
|
116
239
|
declare enum ContentType {
|
|
117
240
|
PLAIN_TEXT = "plain_text",
|
|
118
241
|
MARKDOWN = "markdown",
|
|
@@ -162,6 +285,8 @@ interface AssignmentRequest {
|
|
|
162
285
|
userId: string;
|
|
163
286
|
context?: ContextualFeatures;
|
|
164
287
|
sessionId?: string;
|
|
288
|
+
/** Browser page the assignment was requested from (origin + pathname). */
|
|
289
|
+
pageUrl?: string;
|
|
165
290
|
}
|
|
166
291
|
interface TrackNamedEventRequest {
|
|
167
292
|
name: string;
|
|
@@ -226,6 +351,72 @@ interface ResolvedIdentity {
|
|
|
226
351
|
anonymousIds: string[];
|
|
227
352
|
isIdentified: boolean;
|
|
228
353
|
}
|
|
354
|
+
type BundleMode = 'factored' | 'joint';
|
|
355
|
+
interface Bundle {
|
|
356
|
+
id: string;
|
|
357
|
+
name: string;
|
|
358
|
+
description?: string;
|
|
359
|
+
status: ExperimentStatus;
|
|
360
|
+
mode: BundleMode;
|
|
361
|
+
funnelId?: string;
|
|
362
|
+
assignmentTtl: number;
|
|
363
|
+
conversionWindowMs: number;
|
|
364
|
+
companyId: string;
|
|
365
|
+
createdAt: Date;
|
|
366
|
+
updatedAt: Date;
|
|
367
|
+
}
|
|
368
|
+
interface BundleAssignmentRequest {
|
|
369
|
+
bundleId: string;
|
|
370
|
+
userId: string;
|
|
371
|
+
context?: ContextualFeatures;
|
|
372
|
+
sessionId?: string;
|
|
373
|
+
pageUrl?: string;
|
|
374
|
+
}
|
|
375
|
+
interface BundleSlotAssignment {
|
|
376
|
+
assignmentId: string;
|
|
377
|
+
experimentId: string;
|
|
378
|
+
treatmentId: string;
|
|
379
|
+
config: VariantConfig | Record<string, unknown>;
|
|
380
|
+
}
|
|
381
|
+
interface BundleAssignmentResponse {
|
|
382
|
+
bundleAssignmentId: string;
|
|
383
|
+
slots: Record<string, BundleSlotAssignment>;
|
|
384
|
+
}
|
|
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
|
+
interface SnapshotTreatment {
|
|
393
|
+
id: string;
|
|
394
|
+
name?: string;
|
|
395
|
+
config: unknown;
|
|
396
|
+
/** Probability the next visitor is assigned this arm. Sums to ~1 per experiment. */
|
|
397
|
+
pct: number;
|
|
398
|
+
}
|
|
399
|
+
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
|
+
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
|
+
assignmentTtl: number;
|
|
413
|
+
treatments: SnapshotTreatment[];
|
|
414
|
+
}
|
|
415
|
+
interface CompanySnapshot {
|
|
416
|
+
ttl: number;
|
|
417
|
+
generatedAt: number;
|
|
418
|
+
experiments: Record<string, SnapshotExperiment>;
|
|
419
|
+
}
|
|
229
420
|
|
|
230
421
|
declare function generateId(): string;
|
|
231
422
|
declare function validateTreatmentWeights(treatments: Treatment[]): boolean;
|
|
@@ -265,6 +456,369 @@ declare function extractSubdomain(hostname: string, baseDomain: string): string
|
|
|
265
456
|
*/
|
|
266
457
|
declare function buildSubdomainUrl(subdomain: string, baseDomain: string, path?: string, protocol?: string): string;
|
|
267
458
|
|
|
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
|
+
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
|
+
interface SigningKey {
|
|
503
|
+
version: number;
|
|
504
|
+
secret: string;
|
|
505
|
+
}
|
|
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
|
+
interface EmailTokenPayload {
|
|
512
|
+
/** Campaign id. */
|
|
513
|
+
c: string;
|
|
514
|
+
/** Pseudonymous recipient - see `hashRecipient`. Never a raw identifier. */
|
|
515
|
+
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
|
+
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
|
+
l: number;
|
|
541
|
+
/** Signing key version. Set by `mintEmailToken`; do not pass it in. */
|
|
542
|
+
v?: number;
|
|
543
|
+
}
|
|
544
|
+
type VerifiedToken = {
|
|
545
|
+
valid: true;
|
|
546
|
+
payload: Required<EmailTokenPayload>;
|
|
547
|
+
} | {
|
|
548
|
+
valid: false;
|
|
549
|
+
reason: 'malformed' | 'unknown_key' | 'bad_signature';
|
|
550
|
+
};
|
|
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
|
+
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
|
+
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
|
+
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
|
+
declare function appendEmailToken(url: string, token: string): string;
|
|
602
|
+
|
|
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
|
+
interface PlanArm {
|
|
631
|
+
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
|
+
armIndex: number;
|
|
640
|
+
}
|
|
641
|
+
interface PlanSlot {
|
|
642
|
+
experimentId: string;
|
|
643
|
+
/** Slot name, for substituting into the template. */
|
|
644
|
+
name: string;
|
|
645
|
+
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
|
+
probabilities: number[];
|
|
655
|
+
/** Arm content, positionally aligned with `arms`. */
|
|
656
|
+
configs: unknown[];
|
|
657
|
+
}
|
|
658
|
+
interface EmailPlan {
|
|
659
|
+
campaignId: string;
|
|
660
|
+
slots: PlanSlot[];
|
|
661
|
+
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
|
+
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
|
+
signingKey: {
|
|
678
|
+
version: number;
|
|
679
|
+
secret: string;
|
|
680
|
+
};
|
|
681
|
+
/** Re-request after this many recipients to pick up what the send has learned. */
|
|
682
|
+
refreshEvery: number;
|
|
683
|
+
/** When this plan stops being usable. */
|
|
684
|
+
expiresAt: number;
|
|
685
|
+
}
|
|
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
|
+
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
|
+
declare function pickArm(probabilities: number[], uniform: number): number;
|
|
709
|
+
/** The arm each slot assigns this recipient, positionally by slot. */
|
|
710
|
+
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
|
+
declare function impressionManifest(plan: EmailPlan, recipientIds: string[]): Promise<Array<{
|
|
718
|
+
experimentId: string;
|
|
719
|
+
treatmentId: string;
|
|
720
|
+
count: number;
|
|
721
|
+
}>>;
|
|
722
|
+
|
|
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
|
+
interface SlotBinding {
|
|
756
|
+
$slot: string;
|
|
757
|
+
}
|
|
758
|
+
type Bound<T> = T | SlotBinding;
|
|
759
|
+
declare function isSlotBinding(value: unknown): value is SlotBinding;
|
|
760
|
+
interface HeadingBlock {
|
|
761
|
+
type: 'heading';
|
|
762
|
+
text: Bound<string>;
|
|
763
|
+
level?: 1 | 2 | 3;
|
|
764
|
+
align?: 'left' | 'center' | 'right';
|
|
765
|
+
color?: Bound<string>;
|
|
766
|
+
}
|
|
767
|
+
interface TextBlock {
|
|
768
|
+
type: 'text';
|
|
769
|
+
text: Bound<string>;
|
|
770
|
+
align?: 'left' | 'center' | 'right';
|
|
771
|
+
color?: Bound<string>;
|
|
772
|
+
}
|
|
773
|
+
interface ButtonBlock {
|
|
774
|
+
type: 'button';
|
|
775
|
+
text: Bound<string>;
|
|
776
|
+
href: Bound<string>;
|
|
777
|
+
backgroundColor?: Bound<string>;
|
|
778
|
+
textColor?: Bound<string>;
|
|
779
|
+
align?: 'left' | 'center' | 'right';
|
|
780
|
+
}
|
|
781
|
+
interface ImageBlock {
|
|
782
|
+
type: 'image';
|
|
783
|
+
src: Bound<string>;
|
|
784
|
+
alt: Bound<string>;
|
|
785
|
+
href?: Bound<string>;
|
|
786
|
+
width?: number;
|
|
787
|
+
}
|
|
788
|
+
interface DividerBlock {
|
|
789
|
+
type: 'divider';
|
|
790
|
+
color?: string;
|
|
791
|
+
}
|
|
792
|
+
interface SpacerBlock {
|
|
793
|
+
type: 'spacer';
|
|
794
|
+
height?: number;
|
|
795
|
+
}
|
|
796
|
+
type EmailBlock = HeadingBlock | TextBlock | ButtonBlock | ImageBlock | DividerBlock | SpacerBlock;
|
|
797
|
+
interface EmailDocument {
|
|
798
|
+
/** Subject line - a slot like any other, and usually the highest-leverage one. */
|
|
799
|
+
subject: Bound<string>;
|
|
800
|
+
preheader?: Bound<string>;
|
|
801
|
+
blocks: EmailBlock[];
|
|
802
|
+
backgroundColor?: string;
|
|
803
|
+
contentWidth?: number;
|
|
804
|
+
}
|
|
805
|
+
/** Every slot name the document refers to, in document order, de-duplicated. */
|
|
806
|
+
declare function extractDocumentSlots(document: EmailDocument): string[];
|
|
807
|
+
interface RenderedDocument {
|
|
808
|
+
subject: string;
|
|
809
|
+
html: string;
|
|
810
|
+
/** Slots the document referenced that no value was supplied for. */
|
|
811
|
+
missingSlots: string[];
|
|
812
|
+
}
|
|
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
|
+
declare function renderEmailDocument(document: EmailDocument, slots: Record<string, string>): RenderedDocument;
|
|
821
|
+
|
|
268
822
|
interface BanditsClientConfig {
|
|
269
823
|
apiUrl: string;
|
|
270
824
|
apiKey: string;
|
|
@@ -326,11 +880,266 @@ declare class BanditsClient {
|
|
|
326
880
|
* @param value - The payout value (e.g., purchase amount in dollars)
|
|
327
881
|
*/
|
|
328
882
|
trackFunnelReward(funnelId: string, userId: string, value: number): Promise<void>;
|
|
883
|
+
getBundle(bundleId: string, userId: string, context?: ContextualFeatures): Promise<BundleAssignmentResponse>;
|
|
884
|
+
trackBundleReward(bundleId: string, userId: string, value: number): Promise<void>;
|
|
329
885
|
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
|
+
private normalizeApiUrl;
|
|
330
891
|
private getOrCreateSessionId;
|
|
331
892
|
private makeRequest;
|
|
332
893
|
private startBatchFlush;
|
|
333
894
|
destroy(): void;
|
|
334
895
|
}
|
|
335
896
|
|
|
336
|
-
|
|
897
|
+
interface SyncAssignment {
|
|
898
|
+
clientAssignmentId: string;
|
|
899
|
+
experimentId: string;
|
|
900
|
+
treatmentId: string;
|
|
901
|
+
config: unknown;
|
|
902
|
+
}
|
|
903
|
+
interface SnapshotClientConfig {
|
|
904
|
+
apiUrl: string;
|
|
905
|
+
publishableKey: string;
|
|
906
|
+
/** Defaults to window.localStorage. Injectable for tests and SSR. */
|
|
907
|
+
storage?: Storage;
|
|
908
|
+
/** Injectable clock. Defaults to Date.now. */
|
|
909
|
+
now?: () => number;
|
|
910
|
+
/** Session lifetime in ms. Must match the BanditsClient using the same storage. */
|
|
911
|
+
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
|
+
inlineSnapshot?: CompanySnapshot;
|
|
918
|
+
fetchImpl?: typeof fetch;
|
|
919
|
+
}
|
|
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
|
+
declare function pickTreatment(treatments: SnapshotTreatment[], draw: number): SnapshotTreatment | null;
|
|
929
|
+
declare class SnapshotClient {
|
|
930
|
+
private config;
|
|
931
|
+
private storage;
|
|
932
|
+
private now;
|
|
933
|
+
private fetchImpl;
|
|
934
|
+
private snapshot;
|
|
935
|
+
private queue;
|
|
936
|
+
private sessionId;
|
|
937
|
+
private inFlight;
|
|
938
|
+
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
|
+
getAssignmentSync(experimentId: string, userId: string): SyncAssignment | null;
|
|
950
|
+
/** Fetch a fresh snapshot and cache it. Safe to call on every pageview. */
|
|
951
|
+
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
|
+
flush(): Promise<void>;
|
|
961
|
+
private doFlush;
|
|
962
|
+
/** The session id these reports are attributed under. */
|
|
963
|
+
getSessionId(): string;
|
|
964
|
+
pendingReportCount(): number;
|
|
965
|
+
/** The cached snapshot, or null when absent or past its ttl. */
|
|
966
|
+
private freshSnapshot;
|
|
967
|
+
private readSnapshot;
|
|
968
|
+
private persistSnapshot;
|
|
969
|
+
private readQueue;
|
|
970
|
+
/** Stored decisions, pruned of expired entries. */
|
|
971
|
+
private readDecisions;
|
|
972
|
+
private writeDecisions;
|
|
973
|
+
private persistQueue;
|
|
974
|
+
}
|
|
975
|
+
|
|
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
|
+
declare const SESSION_ID_KEY = "bandits_session_id";
|
|
985
|
+
declare const SESSION_CREATED_AT_KEY = "bandits_session_created_at";
|
|
986
|
+
/** 24 hours, in milliseconds. */
|
|
987
|
+
declare const DEFAULT_SESSION_TTL_MS = 86400000;
|
|
988
|
+
interface SessionOptions {
|
|
989
|
+
/** Where the session lives. Null (no browser storage) means ephemeral. */
|
|
990
|
+
storage?: Storage | null;
|
|
991
|
+
/** Injectable clock, in milliseconds. */
|
|
992
|
+
now?: () => number;
|
|
993
|
+
ttlMs?: number;
|
|
994
|
+
}
|
|
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
|
+
declare function getOrCreateSessionId(options?: SessionOptions): string;
|
|
1000
|
+
|
|
1001
|
+
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
|
+
linkTable: string[];
|
|
1008
|
+
/** One token per link table entry, positionally aligned. */
|
|
1009
|
+
tokens: string[];
|
|
1010
|
+
}
|
|
1011
|
+
interface RewriteResult {
|
|
1012
|
+
html: string;
|
|
1013
|
+
/** How many hrefs were tokenised. */
|
|
1014
|
+
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
|
+
untracked: string[];
|
|
1024
|
+
}
|
|
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
|
+
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
|
+
declare function rewriteEmailLinks(html: string, options: RewriteOptions): RewriteResult;
|
|
1045
|
+
|
|
1046
|
+
interface RenderEmailOptions {
|
|
1047
|
+
/** Chosen content per slot, keyed by slot name, from /api/email/render. */
|
|
1048
|
+
slots: Record<string, string>;
|
|
1049
|
+
/** The campaign's ordered destination URLs. */
|
|
1050
|
+
linkTable: string[];
|
|
1051
|
+
/** One token per link table entry, minted for this recipient. */
|
|
1052
|
+
tokens: string[];
|
|
1053
|
+
}
|
|
1054
|
+
interface RenderedEmail {
|
|
1055
|
+
html: string;
|
|
1056
|
+
/** How many links were tokenised. */
|
|
1057
|
+
rewritten: number;
|
|
1058
|
+
/** Trackable links not present in the campaign's link table. */
|
|
1059
|
+
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
|
+
missingSlots: string[];
|
|
1067
|
+
}
|
|
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
|
+
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
|
+
declare function extractSlotNames(templateHtml: string): string[];
|
|
1084
|
+
|
|
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
|
+
interface PlanRenderResult extends RenderedEmail {
|
|
1098
|
+
/** Arm chosen per slot, positionally against the plan's slots. */
|
|
1099
|
+
combination: number[];
|
|
1100
|
+
/** The token minted for each link, positionally against the link table. */
|
|
1101
|
+
tokens: string[];
|
|
1102
|
+
}
|
|
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
|
+
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
|
+
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
|
+
declare function reportPlanImpressions(plan: EmailPlan, recipientIds: string[], options: {
|
|
1137
|
+
apiUrl: string;
|
|
1138
|
+
apiKey: string;
|
|
1139
|
+
batchKey?: string;
|
|
1140
|
+
}): Promise<{
|
|
1141
|
+
recorded: boolean;
|
|
1142
|
+
alreadyRecorded: boolean;
|
|
1143
|
+
}>;
|
|
1144
|
+
|
|
1145
|
+
export { type APIResponse, type Account, type AlgorithmResult, type ApiKey, type Assignment, type AssignmentRequest, BanditAlgorithm, BanditsClient as BanditClient, type BanditConfig, type BanditResult, BanditsClient, type BanditsClientConfig, type Bound, type Bundle, type BundleAssignmentRequest, type BundleAssignmentResponse, type BundleMode, type BundleSlotAssignment, type ButtonBlock, type Company, type CompanySnapshot, ContentType, type ContextualFeatures, type CreateApiKeyRequest, type CreateApiKeyResponse, type CreateExperimentRequest, DEFAULT_HALF_LIFE_MS, DEFAULT_SESSION_TTL_MS, DEFAULT_WINDOW_MS, type DividerBlock, EMAIL_TOKEN_PARAM, type EmailBlock, type EmailDocument, type EmailPlan, type EmailTokenPayload, type Experiment, type ExperimentStats, ExperimentStatus, type ForgettingConfig, ForgettingMode, type Funnel, type FunnelRewardRequest, FunnelStatus, type HeadingBlock, type IdentifyMergeReason, type IdentifyUserRequest, type IdentifyUserResponse, type ImageBlock, PLACEMENTS, type Placement, type PlanArm, type PlanRenderResult, type PlanSlot, Posterior, type RenderEmailOptions, type RenderedDocument, type RenderedEmail, type ResolvedIdentity, type RewriteOptions, type RewriteResult, SESSION_CREATED_AT_KEY, SESSION_ID_KEY, STATIONARY, type Session, type SessionOptions, type SigningKey, type SlotBinding, SnapshotClient, type SnapshotClientConfig, type SnapshotExperiment, type SnapshotTreatment, type SpacerBlock, type SyncAssignment, type TextBlock, type TrackNamedEventRequest, type Treatment, type TreatmentMetadata, type TreatmentStats, type User, type VariantConfig, type VerifiedToken, appendEmailToken, buildSubdomainUrl, calculateConfidenceInterval, extractDocumentSlots, extractEmailLinks, extractSlotNames, extractSubdomain, generateId, generateSubdomain, getContentTypeCategory, getOrCreateSessionId, hashRecipient, impressionManifest, isSlotBinding, isValidEmail, isValidSubdomain, mintEmailToken, normalizeAlgorithm, normalizePosterior, normalizeTreatmentWeights, pickArm, pickTreatment, renderEmail, renderEmailDocument, renderFromPlan, reportPlanImpressions, resolveForgetting, rewriteEmailLinks, sampleCombination, sanitizeMetadata, shouldRefreshPlan, uniformFor, validateTreatmentWeights, verifyEmailToken };
|