Spreadsheet-UX op een echte relationele engine

main

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

6.2 kB Ruw bestand
import type { ControlDb } from '../control/db.js';
import { newId } from '../engine/ids.js';

/**
 * Reading and writing the landing page's evidence.
 *
 * Nothing here computes anything. What the reward *is* lives in `reward()`
 * below and in the window constants beside it, and that is deliberately the
 * only place it is defined: the moments are replayed from these rows on every
 * fit rather than accumulated in a column, so changing the definition of
 * durable use re-scores the whole history instead of corrupting it.
 */

/**
 * How long an account is watched before its impression is allowed to vote.
 *
 * A fixed window from *signup*, not from today, so an account from January
 * cannot out-earn one from June simply by having existed longer. It is also
 * what makes an impression mature: nothing is scored until its window has
 * closed, because an impression from last Tuesday is not a zero, it is a
 * question that has not been answered yet, and feeding it in as a zero would
 * mark down whatever the page is currently showing.
 */
export const OBSERVATION_WINDOW_DAYS = 28;

/**
 * Active days past this add nothing.
 *
 * The cap and the log together are what keep one enthusiast from deciding the
 * voice of the site. Against a few dozen observations an uncapped reward is a
 * single-sample experiment wearing a covariance matrix.
 */
export const ACTIVE_DAY_CAP = 20;

/** Durable use, on [0, 1]. */
export function reward(activeDays: number): number {
  const capped = Math.max(0, Math.min(activeDays, ACTIVE_DAY_CAP));
  return Math.log1p(capped) / Math.log1p(ACTIVE_DAY_CAP);
}

export interface ScoredImpression {
  id: string;
  items: string[];
  createdAt: string;
  converted: boolean;
  activeDays: number;
  reward: number;
}

interface ImpressionRow {
  id: string;
  items: string;
  created_at: string;
  account_id: string | null;
  active_days: number | null;
}

/** Serve a page. The row exists from here on; whether it counts is `markEngaged`. */
export function recordImpression(control: ControlDb, items: string[]): string {
  const id = newId('imp');
  control.sqlite
    .prepare('INSERT INTO voice_impression (id, items) VALUES (?, ?)')
    .run(id, JSON.stringify(items));
  return id;
}

/**
 * A person looked at it.
 *
 * Idempotent, and it never moves an existing timestamp — a reload posting the
 * beat twice must not restate when the page was seen.
 */
export function markEngaged(control: ControlDb, id: string): void {
  control.sqlite
    .prepare("UPDATE voice_impression SET engaged_at = datetime('now') WHERE id = ? AND engaged_at IS NULL")
    .run(id);
}

/**
 * Attach a signup to the page that produced it.
 *
 * Refuses to overwrite: an impression id is quoted back by the browser, so the
 * only thing stopping the same id being replayed against a second account is
 * this clause. Worst case someone donates their own signup to a page they liked
 * — which is a rounding error, and cheaper than a token exchange for it.
 */
export function attachAccount(control: ControlDb, id: string, accountId: string): void {
  control.sqlite
    .prepare('UPDATE voice_impression SET account_id = ? WHERE id = ? AND account_id IS NULL')
    .run(accountId, id);
}

/** This account did something today. Cheap, idempotent, once per account per day. */
export function recordActiveDay(control: ControlDb, accountId: string): void {
  control.sqlite
    .prepare("INSERT OR IGNORE INTO account_day (account_id, day) VALUES (?, date('now'))")
    .run(accountId);
}

/**
 * Every impression whose answer is in, oldest first.
 *
 * Oldest first because the bleed is applied per observation: replaying in the
 * order the evidence arrived is what makes the replay identical to an online
 * accumulator that had been running the whole time.
 *
 * `limit` takes the most recent N and still replays them oldest-first, which is
 * how the report shows a recent window beside the all-time one. A traffic
 * source changing under you reads as those two diverging.
 */
export function scoredImpressions(control: ControlDb, limit?: number): ScoredImpression[] {
  const cutoff = `-${OBSERVATION_WINDOW_DAYS} days`;
  const rows = control.sqlite
    .prepare<[string, string, number], ImpressionRow>(
      `SELECT i.id, i.items, i.created_at, i.account_id,
              (SELECT COUNT(*) FROM account_day d
                WHERE d.account_id = i.account_id
                  AND d.day >= date(a.created_at)
                  AND d.day <  date(a.created_at, ?)) AS active_days
         FROM voice_impression i
         LEFT JOIN account a ON a.id = i.account_id
        WHERE i.engaged_at IS NOT NULL
          -- Mature from the account's clock when it converted, from the page's
          -- when it did not: a signup two days ago has not had its 28 days yet
          -- even if the page it came from was served a month before that.
          AND COALESCE(a.created_at, i.created_at) <= datetime('now', ?)
        ORDER BY i.created_at DESC
        LIMIT ?`,
    )
    .all(`+${OBSERVATION_WINDOW_DAYS} days`, cutoff, limit && limit > 0 ? limit : -1);

  return rows
    .reverse()
    .map((row) => {
      const activeDays = row.account_id ? (row.active_days ?? 0) : 0;
      return {
        id: row.id,
        items: parseItems(row.items),
        createdAt: row.created_at,
        converted: row.account_id !== null,
        activeDays,
        reward: row.account_id ? reward(activeDays) : 0,
      };
    });
}

/** Impressions seen by a person but not yet old enough to say anything. */
export function pendingCount(control: ControlDb): number {
  const row = control.sqlite
    .prepare<[string], { n: number }>(
      `SELECT COUNT(*) AS n
         FROM voice_impression i
         LEFT JOIN account a ON a.id = i.account_id
        WHERE i.engaged_at IS NOT NULL
          AND COALESCE(a.created_at, i.created_at) > datetime('now', ?)`,
    )
    .get(`-${OBSERVATION_WINDOW_DAYS} days`);
  return row?.n ?? 0;
}

/**
 * A stored `items` array that will not parse is a corrupted row, not a reason
 * to take the landing page down. It drops out of the fit as an empty page.
 */
function parseItems(json: string): string[] {
  try {
    const parsed: unknown = JSON.parse(json);
    return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
  } catch {
    return [];
  }
}