@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/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
- CONTEXTUAL_NEURAL = "contextual_neural"
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
- 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 Company, ContentType, type ContextualFeatures, type CreateApiKeyRequest, type CreateApiKeyResponse, type CreateExperimentRequest, type Experiment, type ExperimentStats, ExperimentStatus, type Funnel, type FunnelRewardRequest, FunnelStatus, type IdentifyMergeReason, type IdentifyUserRequest, type IdentifyUserResponse, type ResolvedIdentity, type Session, type TrackNamedEventRequest, type Treatment, type TreatmentMetadata, type TreatmentStats, type User, type VariantConfig, buildSubdomainUrl, calculateConfidenceInterval, extractSubdomain, generateId, generateSubdomain, getContentTypeCategory, isValidEmail, isValidSubdomain, normalizeTreatmentWeights, sanitizeMetadata, validateTreatmentWeights };
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 };