Spreadsheet-UX op een echte relationele engine

main

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

4.1 kB Ruw bestand
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<string, number>;
}

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;
}