import { VOICE_POOL } from '@wellformed/shared'; import type { ControlDb } from '../control/db.js'; import { DEFAULT_BLEED, Moments, latentWeights, rankFor } from './moments.js'; import { indicators, type VoiceFitWeights } from './select.js'; import { scoredImpressions } from './store.js'; /** * Turning the impression table into a weight per selling point. * * One parameter per line, which is a great many parameters against the signups * a year like this produces — and the covariance matrix is what makes that * affordable rather than hopeless. Lines that keep being shown together, and * whose pages keep doing similarly well, are related by the record, so evidence * about one is partly evidence about the other. The solve runs through the * low-rank latent space that relatedness defines, and its rank grows with the * evidence, so the model only ever has as many degrees of freedom as the data * can pay for. See `latentWeights`. * * The moments are replayed from the impression rows on every fit rather than * kept in a column. That is a deliberate trade and it is only correct at this * size: a few thousand rows is a millisecond, and what it buys is worth more * than the milliseconds. Changing the definition of durable use re-scores the * whole history instead of corrupting an accumulator built under the old one. * Adding a line to the pool changes no state at all. And there is no long-lived * float that can drift away from what the rows say. * * Replaying oldest-first with the same bleed gives numerically the same answer * an online accumulator would have reached, because it is the same updates in * the same order. Welford is still the primitive; it just runs on demand. */ export interface VoiceFit extends VoiceFitWeights { /** Mean durable use across the window — the number the weights explain. */ meanR: number; /** Mature, engaged impressions that went into this. */ observations: number; /** How many of those were signups. */ converted: number; /** Dimensions of the latent space the evidence has paid for so far. */ rank: number; /** Share of pages each point appeared on, under the same bleed. */ rate: Record; } export interface FitOptions { /** Fit over only the most recent N mature impressions. */ limit?: number; bleed?: number; /** Override the rank the evidence would otherwise buy. Tests and the report. */ rank?: number; } export function fitVoice(control: ControlDb, options: FitOptions = {}): VoiceFit { const order = VOICE_POOL.map((point) => point.id); const dim = order.length; const moments = new Moments(dim, options.bleed ?? DEFAULT_BLEED); const rows = scoredImpressions(control, options.limit); let converted = 0; for (const row of rows) { if (row.converted) converted++; // Points retired from the pool simply do not appear in the indicator // vector, so a deleted point stops influencing the fit without taking the // rest of its page's evidence with it. moments.push(indicators(row.items, order), row.reward); } const rank = options.rank ?? rankFor(moments.weight, dim); const solved = latentWeights(moments.covXX(), moments.covXR(), dim, rank); const rates = moments.rate(); const weights: Record = {}; const rate: Record = {}; for (let i = 0; i < dim; i++) { weights[order[i]!] = solved[i]!; rate[order[i]!] = rates[i]!; } return { weight: moments.weight, sdR: moments.sdR(), meanR: moments.meanR, weights, rate, rank, observations: rows.length, converted, }; } /** * The fit the live page samples from. * * Held for a few minutes because the input only changes when an impression * matures — which happens on a 28-day clock, not per request. Keyed weakly by * control database so tests that build several apps in one process do not hand * each other's numbers around. */ const CACHE_TTL_MS = 5 * 60_000; const cache = new WeakMap(); export function cachedFit(control: ControlDb): VoiceFit { const held = cache.get(control); const now = Date.now(); if (held && now - held.at < CACHE_TTL_MS) return held.fit; const fit = fitVoice(control); cache.set(control, { at: now, fit }); return fit; }