/** * Variant pinning (plan v1.1 §3.3). * * ε-greedy variant selection runs PER REQUEST on the server. A page with a * hero island and a CTA island therefore makes two selections, and without * pinning they are independent draws: the hero can come from arm A and the * button from arm B. That page is not a mixed experience, it is a broken one — * and worse, the impression it generates attributes a blend of two variants to * whichever id happened to be reported, which quietly poisons the very signal * the bandit is learning from. * * So: the FIRST resolution in a page view wins, and every subsequent call * pins it via `variant=`. One page view, one variant, one honest attribution. * * The sync `get()`/`pin()` pair alone is not enough, because sibling islands * mount in the SAME React commit: both read `get()` before either stream has * resolved, both see undefined, and the server draws twice — exactly the * failure described above, reintroduced by a race. `reserve()` closes it: the * first caller per pin key becomes the LEADER and opens its stream unpinned; * every later caller becomes a FOLLOWER and receives a promise that resolves * with the leader's variant once its stream reports one. A leader that fails * before learning a variant aborts the reservation so someone else can lead, * and followers carry a timeout so a hung leader degrades to today's * unpinned behaviour instead of blocking the page. * * Scoped per PIN KEY, not globally: two campaigns on one page are genuinely * separate experiments and must not pin each other. The key is the campaign id * when the island is campaign-addressed, and the SURFACE id when the island is * surface-only — sibling surface islands resolve through the same deployment * and would otherwise each draw independently, the exact mixed page pinning * exists to prevent. (Two different keys that happen to resolve to the same * campaign server-side are treated as separate experiments; addressing is the * caller's statement of intent.) */ import type { ContentSource } from "./wire.js"; import type { CopyField } from "./state.js"; /** * How long a follower waits for the leader's stream before giving up and * requesting unpinned. * * This must cover the SERVER'S stream budget, not a hopeful median: the edge * caps a stream at 30 seconds (context.WithTimeout in * mosaic-edge-serve/internal/handler/stream.go), and the variant id arrives in * the terminal `done` event — so a slow generation can legally take the whole * budget before any follower learns the draw. An earlier 3s value expired * inside that window, sent followers unpinned mid-generation, and painted the * mixed page pinning exists to prevent. Waiting is cheap: a follower holds * like any client mount — canvas reserved, its ordinary hold budget spanning * the wait — and if that budget runs out first its fallback types in, with * any later copy landing as the single permitted rewrite (useIsland's mount * hold). 30s server budget + 1s margin for transport. */ export declare const RESERVATION_TIMEOUT_MS = 31000; export interface LeaderReservation { kind: "leader"; /** * The stream resolved: pin `variantId` and release every follower with it. * Idempotent; calls after `resolve` or `abort` are ignored. */ resolve: (variantId: string) => void; /** * The leader failed (or unmounted) before learning a variant. Followers * currently waiting are released unpinned, and the NEXT `reserve()` call * for this pin key leads. Idempotent. */ abort: () => void; } export interface FollowerReservation { kind: "follower"; /** * Resolves with the pinned variant, or `undefined` when the leader aborted, * the wait timed out, or the leader finished without a variant — in which * case the follower should request unpinned, exactly as it would have * before reservations existed. Never rejects. */ variantId: Promise; } export type VariantReservation = LeaderReservation | FollowerReservation; /** One field as the visitor saw it: its text and where that text came from. */ export interface ShownCopy { text: string; source: ContentSource; } /** * What one copy slot showed in this page view, kept so a later mount of the * same slot shows exactly that again instead of holding and replacing it. * Renderers read and write it through `VariantRegistry.slot`. */ export interface SlotMemory { /** When the slot first held blank on screen (`Date.now()`), or null. */ heldSince: number | null; /** Per field, the copy the visitor saw. Null until something showed. */ shown: Partial> | null; /** The variant and campaign the shown copy belongs to, when known. */ variantId: string | null; campaignId: string | null; /** The provenance the impression reports for the shown copy. */ contentSource: ContentSource | null; /** The slot's impression has been recorded in this page view. */ impressionRecorded: boolean; } export declare class VariantRegistry { private readonly pinned; private readonly pending; private readonly shownFallbacks; private readonly slots; private currentView; private readonly newViewListeners; /** * Run `listener` whenever a page view ends and a new one begins (a client * navigation, `enterView` with a different view, or `reset` after use). * Not for the page's first view: state a server render left for it stays. * Returns the unsubscribe. */ onNewView(listener: () => void): () => void; /** * The explicit page view the registry is scoped to, or null for the * implicit view (the one `reset()` starts). */ get viewId(): string | null; /** * Scope the registry to page view `viewId`. A different id than the * current one starts a new page view: pins, reservations and slot * memories of the old view are dropped (as `reset()` does) before * anything is pinned for the new one. The same id again changes nothing, * so calling it on every render is safe. Returns true when it started a * new view. */ enterView(viewId: string): boolean; /** * The memory of one copy slot in this page view, created empty on first * use. `key` is the renderer's slot identity. Cleared with the view. */ slot(key: string): SlotMemory; /** * Record that a slot's fallback has been shown in this page view (the hold * ran out, or a server reservation revealed it). It is final for the rest * of the page view: a later mount of the same slot — a re-render, a * suspended boundary resolving — shows it again instead of holding, and * no later stream replaces it. `key` is the renderer's slot identity. */ markFallbackShown(key: string): void; /** Has this slot's fallback already been shown in this page view? */ fallbackShown(key: string): boolean; /** * The variant to request for this pin key — the campaign id, or the surface * id for surface-only islands — if one is already pinned. Undefined means * "let the server choose" — which it should, exactly once. */ get(key: string): string | undefined; /** * Record a resolved variant. First writer wins. * * Later calls are ignored rather than overwriting, because a second * resolution arriving at all means one island raced ahead of the pin — and * in that case the right repair is to keep the value already reported on the * impression, not to adopt the newer one and disagree with our own * analytics. */ pin(key: string, variantId: string): string; /** * Claim, or wait for, this page view's variant for a pin key (campaign id, * or surface id for surface-only islands). * * The first caller (per key, per page view) gets a LEADER reservation * and must eventually call `resolve` or `abort` on it. Everyone else gets a * FOLLOWER whose promise resolves with the leader's variant, capped at * `timeoutMs`. A key that is already pinned returns a follower whose * promise is already resolved — awaiting it costs one microtask. */ reserve(key: string, timeoutMs?: number): VariantReservation; /** * New page view. Call on client-side navigation, or scope the registry to * explicit views with `enterView` (the provider does this for you when it * is given a `serverContext` or a `viewKey`). */ reset(): void; } /** * The registry for the current page view. * * Module-scoped on purpose: pinning must span every SparkleTree component on * the page regardless of which provider or tree rendered it, and a * per-provider registry would let two providers each pin a different arm of * the same campaign. */ export declare const pageVariants: VariantRegistry; //# sourceMappingURL=variant.d.ts.map