Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

4.2 kB Ruw bestand
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;
}