Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<string, number>;
}
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<string, number> = {};
const rate: Record<string, number> = {};
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<ControlDb, { at: number; fit: VoiceFit }>();
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;
}