import { VOICE_PER_PAGE, VOICE_POOL } from '@wellformed/shared'; /** * Choosing which selling points a visitor sees. * * A softmax over the pool, drawn without replacement, reading weights that came * out of the covariance matrix — see `latentWeights`. The sampler itself stays * the simple thing it should be. * * Two properties matter more here than the softmax does. * * The temperature is not a schedule, it is a function of how much evidence * exists. With nothing observed the distribution is exactly uniform; it sharpens * only as the effective sample size grows. So "random" is not a phase to get * through before the real policy starts — it is what the policy *is* until the * data earns something sharper, and there is no moment where anyone has to * declare the experiment over. * * The floor is the other half. Every point keeps a share of the traffic * forever, so none can be killed by an unlucky first fortnight, and each goes on * collecting the evidence that could overturn its weight. Without it a bandit at * this sample size reliably locks onto noise. */ /** How sharp the softmax gets once the evidence is in. */ const KAPPA_MAX = 6; /** Effective observations at which the sampler is halfway to trusting itself. */ const KAPPA_HALF_LIFE = 60; /** Share of each draw handed back to the uniform distribution, always. */ const EXPLORE_FLOOR = 0.05; /** * How far below the best point a score may sit and still be exp()'d. * * Applied to the *gap* from the leader, never to the score itself. A softmax is * shift-invariant, so clamping absolute scores is not a safety net — once every * point is past the limit they all pin to the same value and the sampler quietly * collapses back to uniform, at exactly the point it has learned the most. * Clamping the gap keeps the ordering and only bounds the tail. */ const GAP_CLAMP = 8; export interface VoiceFitWeights { /** Effective sample size behind the weights, after bleeding. */ weight: number; /** Bled standard deviation of the reward — the unit the weights are read in. */ sdR: number; /** Point id → its weight. Missing means zero, i.e. unproven. */ weights: Record; } export function trustFrom(weight: number): number { return (KAPPA_MAX * weight) / (weight + KAPPA_HALF_LIFE); } /** * One page's worth of selling points. * * `rng` is injectable so the tests can assert the distribution rather than * assert that something random happened. */ export function drawPage(fit: VoiceFitWeights, rng: () => number = Math.random): string[] { // A reward with no spread yet — everyone scored zero, or nothing has matured — // makes every weight meaningless rather than large. Fall back to uniform. const sd = fit.sdR > 1e-9 ? fit.sdR : 0; const kappa = trustFrom(fit.weight); const remaining = VOICE_POOL.map((point) => point.id); const chosen: string[] = []; const wanted = Math.min(VOICE_PER_PAGE, remaining.length); for (let pick = 0; pick < wanted; pick++) { const scores = remaining.map((id) => (sd === 0 ? 0 : (kappa * (fit.weights[id] ?? 0)) / sd)); const highest = Math.max(...scores); let total = 0; const weighted = scores.map((score) => { const value = Math.exp(Math.max(-GAP_CLAMP, score - highest)); total += value; return value; }); // Mixed towards uniform per draw rather than once over the page, so the // floor holds for the fourth point as well as the first. const uniform = 1 / remaining.length; let roll = rng(); let index = remaining.length - 1; for (let i = 0; i < remaining.length; i++) { roll -= (1 - EXPLORE_FLOOR) * (weighted[i]! / total) + EXPLORE_FLOOR * uniform; if (roll <= 0) { index = i; break; } } chosen.push(remaining[index]!); remaining.splice(index, 1); } return chosen; } /** The indicator vector a page becomes once it is an observation. */ export function indicators(items: string[], order: string[]): Float64Array { const x = new Float64Array(order.length); const seen = new Set(items); for (let i = 0; i < order.length; i++) { if (seen.has(order[i]!)) x[i] = 1; } return x; }