Drop7 Research
approaches/heuristic-search/gray-throughput/tune.tsTypeScript586 lines · 16.7 KB
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";

import {
  MOVES_PER_LEVEL,
  createInitialBoard,
  playMove,
  seededRandom,
  type GameState,
} from "../../../src/core/typescript/engine.ts";
import {
  DEFAULT_GRAY_THROUGHPUT_WEIGHTS,
  evaluateGrayThroughputMoves,
  type GrayStateFeatures,
  type GrayThroughputWeights,
  type GrayTransitionFeatures,
} from "../../../src/core/typescript/gray-throughput-policy.ts";
import { headlessDisc } from "../../../src/core/typescript/headless.ts";

const TRAINING_SEED_START = 0x1d70_0000;
const CALIBRATION_SEED_START = 0x5d70_0000;
const ACTUAL_REVEAL_DOMAIN = 0x5245_564c;
const ACTUAL_MOVE_MULTIPLIER = 0x85eb_ca6b;
const DEFAULT_TUNER_SEED = 0x6772_7475;
const DEFAULT_POLICY_SEED = 0x6772_6179;
const CEM_RATE = 0.7;
const MINIMUM_DEVIATION = 0.08;
const MINIMUM_LOG_MULTIPLIER = -4;
const MAXIMUM_LOG_MULTIPLIER = 3;

const STATE_KEYS = Object.keys(
  DEFAULT_GRAY_THROUGHPUT_WEIGHTS.state,
) as (keyof GrayStateFeatures)[];
const TRANSITION_KEYS = Object.keys(
  DEFAULT_GRAY_THROUGHPUT_WEIGHTS.transition,
) as (keyof GrayTransitionFeatures)[];
const PARAMETER_COUNT = STATE_KEYS.length + TRANSITION_KEYS.length;

interface Arguments {
  generations: number;
  population: number;
  elites: number;
  games: number;
  finalGames: number;
  samples: number;
  maxMoves: number;
  tunerSeed: number;
  policySeed: number;
  output: string;
  calibrate: boolean;
}

interface GameResult {
  seed: number;
  score: number;
  moves: number;
  censored: boolean;
}

interface Summary {
  objective: number;
  meanScore: number;
  medianScore: number;
  meanMoves: number;
  censored: number;
  results: readonly GameResult[];
}

interface Candidate {
  vector: number[];
  summary: Summary;
}

interface Distribution {
  means: number[];
  deviations: number[];
}

function runGame(
  seed: number,
  weights: GrayThroughputWeights,
  samples: number,
  policySeed: number,
  maxMoves: number,
): GameResult {
  let state: GameState = {
    board: createInitialBoard(),
    nextDisc: headlessDisc(seed, 0),
    score: 0,
    level: 1,
    movesRemaining: MOVES_PER_LEVEL,
    movesPlayed: 0,
    gameOver: false,
  };
  while (!state.gameOver && state.movesPlayed < maxMoves) {
    const decision = evaluateGrayThroughputMoves(state, {
      samples,
      continuationSamples: 1,
      depth: 1,
      policySeed,
      weights,
    });
    if (decision.bestColumn === null) {
      throw new Error("gray tuner found no move for a live state");
    }
    const move = playMove(
      state,
      decision.bestColumn,
      seededRandom(
        mix32(
          seed ^
            Math.imul(state.movesPlayed + 1, ACTUAL_MOVE_MULTIPLIER) ^
            ACTUAL_REVEAL_DOMAIN,
        ),
      ),
      { captureAnimation: false },
    );
    if (!move) throw new Error(`illegal gray move ${decision.bestColumn}`);
    state = move.state.gameOver
      ? move.state
      : {
          ...move.state,
          nextDisc: headlessDisc(seed, move.state.movesPlayed),
        };
  }
  return {
    seed,
    score: state.score,
    moves: state.movesPlayed,
    censored: !state.gameOver,
  };
}

function evaluate(
  vector: readonly number[],
  seeds: readonly number[],
  options: Arguments,
) {
  const weights = vectorToWeights(vector);
  const results = seeds.map((seed) =>
    runGame(
      seed,
      weights,
      options.samples,
      options.policySeed,
      options.maxMoves,
    ),
  );
  return summarize(results, options.maxMoves);
}

function summarize(
  results: readonly GameResult[],
  maxMoves: number,
): Summary {
  const scores = results.map((result) => result.score).sort(numberOrder);
  const normalizedLogs = results.map(
    (result) => Math.log1p(result.score) / Math.log1p(1_000_000),
  );
  const normalizedMoves = results.map((result) => result.moves / maxMoves);
  return {
    // Log score makes one lucky chain unable to dominate a generation. Moves
    // and censoring keep the optimizer focused on long-run survival.
    objective:
      mean(normalizedLogs) * 0.65 +
      mean(normalizedMoves) * 0.25 +
      (results.filter((result) => result.censored).length / results.length) *
        0.1,
    meanScore: mean(scores),
    medianScore: median(scores),
    meanMoves: mean(results.map((result) => result.moves)),
    censored: results.filter((result) => result.censored).length,
    results,
  };
}

async function tune(options: Arguments) {
  const trainingSeeds = consecutiveSeeds(TRAINING_SEED_START, options.games);
  const finalTrainingSeeds = consecutiveSeeds(
    TRAINING_SEED_START,
    options.finalGames,
  );
  let distribution: Distribution = {
    means: Array<number>(PARAMETER_COUNT).fill(0),
    deviations: Array<number>(PARAMETER_COUNT).fill(0.8),
  };
  let champion: Candidate = {
    vector: Array<number>(PARAMETER_COUNT).fill(0),
    summary: evaluate(
      Array<number>(PARAMETER_COUNT).fill(0),
      trainingSeeds,
      options,
    ),
  };

  process.stdout.write(
    `gray program search · train ${seedRange(trainingSeeds)} · calibration ${formatSeed(CALIBRATION_SEED_START)}+ ${options.calibrate ? "enabled" : "untouched"}\n`,
  );
  process.stdout.write(`baseline · ${formatSummary(champion.summary)}\n`);

  for (let generation = 0; generation < options.generations; generation += 1) {
    const candidates = population(
      distribution,
      champion.vector,
      options.population,
      options.tunerSeed,
      generation,
    ).map((vector): Candidate => ({
      vector,
      summary: evaluate(vector, trainingSeeds, options),
    }));
    candidates.sort(compareCandidates);
    const elites = candidates.slice(0, options.elites);
    if (compareCandidates(elites[0], champion) < 0) champion = elites[0];
    distribution = updateDistribution(distribution, elites);
    process.stdout.write(
      `generation ${(generation + 1).toString().padStart(2)} · ${formatSummary(elites[0].summary)} · champion ${champion.summary.objective.toFixed(5)}\n`,
    );
    await checkpoint(options.output, options, generation + 1, distribution, champion);
  }

  const baselineVector = Array<number>(PARAMETER_COUNT).fill(0);
  const finalBaseline = evaluate(baselineVector, finalTrainingSeeds, options);
  const finalChampion = evaluate(champion.vector, finalTrainingSeeds, options);
  process.stdout.write(`training baseline · ${formatSummary(finalBaseline)}\n`);
  process.stdout.write(`training champion · ${formatSummary(finalChampion)}\n`);
  process.stdout.write(
    `${formatPaired(finalBaseline.results, finalChampion.results)}\n`,
  );

  let calibration:
    | { baseline: Summary; champion: Summary }
    | undefined;
  if (options.calibrate) {
    const calibrationSeeds = consecutiveSeeds(
      CALIBRATION_SEED_START,
      options.finalGames,
    );
    calibration = {
      baseline: evaluate(baselineVector, calibrationSeeds, options),
      champion: evaluate(champion.vector, calibrationSeeds, options),
    };
    process.stdout.write(
      `calibration baseline · ${formatSummary(calibration.baseline)}\n`,
    );
    process.stdout.write(
      `calibration champion · ${formatSummary(calibration.champion)}\n`,
    );
    process.stdout.write(
      `${formatPaired(calibration.baseline.results, calibration.champion.results)}\n`,
    );
  }

  await checkpoint(
    options.output,
    options,
    options.generations,
    distribution,
    champion,
    {
      training: {
        baseline: omitResults(finalBaseline),
        champion: omitResults(finalChampion),
      },
      calibration: calibration
        ? {
            baseline: omitResults(calibration.baseline),
            champion: omitResults(calibration.champion),
          }
        : undefined,
    },
  );
  process.stdout.write(`checkpoint ${resolve(options.output)}\n`);
}

function population(
  distribution: Distribution,
  champion: readonly number[],
  size: number,
  tunerSeed: number,
  generation: number,
) {
  const vectors = [[...champion]];
  const random = seededRandom(
    mix32(tunerSeed ^ Math.imul(generation + 1, 0x9e37_79b9)),
  );
  while (vectors.length < size) {
    vectors.push(
      distribution.means.map((average, index) =>
        clip(average + gaussian(random) * distribution.deviations[index]),
      ),
    );
  }
  return vectors;
}

function updateDistribution(
  previous: Distribution,
  elites: readonly Candidate[],
): Distribution {
  const eliteMeans = previous.means.map((_, index) =>
    mean(elites.map((elite) => elite.vector[index])),
  );
  const eliteDeviations = previous.deviations.map((_, index) => {
    const average = eliteMeans[index];
    return Math.sqrt(
      mean(
        elites.map((elite) => (elite.vector[index] - average) ** 2),
      ),
    );
  });
  return {
    means: previous.means.map((average, index) =>
      clip(average * (1 - CEM_RATE) + eliteMeans[index] * CEM_RATE),
    ),
    deviations: previous.deviations.map((deviation, index) =>
      Math.max(
        MINIMUM_DEVIATION,
        deviation * (1 - CEM_RATE) + eliteDeviations[index] * CEM_RATE,
      ),
    ),
  };
}

function vectorToWeights(vector: readonly number[]): GrayThroughputWeights {
  if (vector.length !== PARAMETER_COUNT) {
    throw new Error(`expected ${PARAMETER_COUNT} gray parameters`);
  }
  const state = { ...DEFAULT_GRAY_THROUGHPUT_WEIGHTS.state };
  const transition = { ...DEFAULT_GRAY_THROUGHPUT_WEIGHTS.transition };
  let index = 0;
  for (const key of STATE_KEYS) {
    state[key] *= Math.exp(vector[index]);
    index += 1;
  }
  for (const key of TRANSITION_KEYS) {
    transition[key] *= Math.exp(vector[index]);
    index += 1;
  }
  return {
    state,
    transition,
    continuationWeight: DEFAULT_GRAY_THROUGHPUT_WEIGHTS.continuationWeight,
  };
}

function compareCandidates(first: Candidate, second: Candidate) {
  return (
    second.summary.objective - first.summary.objective ||
    second.summary.meanMoves - first.summary.meanMoves ||
    second.summary.medianScore - first.summary.medianScore
  );
}

async function checkpoint(
  path: string,
  options: Arguments,
  generation: number,
  distribution: Distribution,
  champion: Candidate,
  evaluation?: unknown,
) {
  await writeFile(
    path,
    `${JSON.stringify(
      {
        version: 1,
        generation,
        options,
        parameters: [
          ...STATE_KEYS.map((key) => `state.${key}`),
          ...TRANSITION_KEYS.map((key) => `transition.${key}`),
        ],
        distribution,
        champion: {
          vector: champion.vector,
          weights: vectorToWeights(champion.vector),
          training: omitResults(champion.summary),
        },
        evaluation,
      },
      null,
      2,
    )}\n`,
  );
}

function formatPaired(
  baseline: readonly GameResult[],
  champion: readonly GameResult[],
) {
  const deltas = champion.map(
    (result, index) => result.score - baseline[index].score,
  );
  const interval = bootstrap(deltas, 10_000);
  return `paired ${signed(mean(deltas))} · median ${signed(median(deltas))} · W/T/L ${deltas.filter((value) => value > 0).length}/${deltas.filter((value) => value === 0).length}/${deltas.filter((value) => value < 0).length} · bootstrap95 [${signed(interval[0])}, ${signed(interval[1])}]`;
}

function bootstrap(values: readonly number[], samples: number) {
  const random = seededRandom(0x6772_626f);
  const means = Array<number>(samples);
  for (let sample = 0; sample < samples; sample += 1) {
    let total = 0;
    for (let index = 0; index < values.length; index += 1) {
      total += values[Math.floor(random() * values.length)];
    }
    means[sample] = total / values.length;
  }
  means.sort(numberOrder);
  return [
    means[Math.floor(samples * 0.025)],
    means[Math.floor(samples * 0.975)],
  ] as const;
}

function parseArguments(arguments_: readonly string[]): Arguments | null {
  const options: Arguments = {
    generations: 12,
    population: 32,
    elites: 8,
    games: 24,
    finalGames: 64,
    samples: 2,
    maxMoves: 1_000,
    tunerSeed: DEFAULT_TUNER_SEED,
    policySeed: DEFAULT_POLICY_SEED,
    output: "/tmp/drop7-gray-throughput.json",
    calibrate: false,
  };
  for (let index = 0; index < arguments_.length; index += 1) {
    const flag = arguments_[index];
    if (flag === "--help" || flag === "-h") return null;
    if (flag === "--calibrate") {
      options.calibrate = true;
      continue;
    }
    const text = arguments_[index + 1];
    if (text === undefined) throw new Error(`missing value after ${flag}`);
    index += 1;
    switch (flag) {
      case "--generations":
        options.generations = positiveInteger(text, flag, 1_000);
        break;
      case "--population":
        options.population = positiveInteger(text, flag, 1_000);
        break;
      case "--elites":
        options.elites = positiveInteger(text, flag, 999);
        break;
      case "--games":
        options.games = positiveInteger(text, flag, 10_000);
        break;
      case "--final-games":
        options.finalGames = positiveInteger(text, flag, 10_000);
        break;
      case "--samples":
        options.samples = positiveInteger(text, flag, 16);
        break;
      case "--max-moves":
        options.maxMoves = positiveInteger(text, flag, 10_000);
        break;
      case "--tuner-seed":
        options.tunerSeed = parseSeed(text, flag);
        break;
      case "--policy-seed":
        options.policySeed = parseSeed(text, flag);
        break;
      case "--output":
        options.output = text;
        break;
      default:
        throw new Error(`unknown option ${flag}`);
    }
  }
  if (options.elites >= options.population) {
    throw new Error("--elites must be smaller than --population");
  }
  return options;
}

function helpText() {
  return `Drop7 gray-throughput program search

Options:
  --generations <n>  CEM generations (default 12)
  --population <n>   candidates per generation (default 32)
  --elites <n>       elites per generation (default 8)
  --games <n>        0x1d70 training games (default 24)
  --final-games <n>  full evaluation games (default 64)
  --samples <n>      seed-blind reveal samples (default 2)
  --max-moves <n>    censoring cap (default 1000)
  --calibrate        evaluate final champion on 0x5d70 seeds
  --output <path>    checkpoint path
`;
}

function positiveInteger(text: string, flag: string, maximum: number) {
  const value = Number(text);
  if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
    throw new Error(`${flag} must be an integer from 1 to ${maximum}`);
  }
  return value;
}

function parseSeed(text: string, flag: string) {
  const value = Number(text);
  if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) {
    throw new Error(`${flag} must be a uint32`);
  }
  return value >>> 0;
}

function omitResults(summary: Summary) {
  return {
    objective: summary.objective,
    meanScore: summary.meanScore,
    medianScore: summary.medianScore,
    meanMoves: summary.meanMoves,
    censored: summary.censored,
  };
}

function clip(value: number) {
  return Math.max(
    MINIMUM_LOG_MULTIPLIER,
    Math.min(MAXIMUM_LOG_MULTIPLIER, value),
  );
}

function gaussian(random: () => number) {
  const first = Math.max(Number.EPSILON, random());
  return Math.sqrt(-2 * Math.log(first)) * Math.cos(2 * Math.PI * random());
}

function consecutiveSeeds(start: number, count: number) {
  if (start + count - 1 > 0xffff_ffff) throw new Error("seed range overflow");
  return Array.from({ length: count }, (_, offset) => start + offset);
}

function mean(values: readonly number[]) {
  return values.reduce((sum, value) => sum + value, 0) / values.length;
}

function median(values: readonly number[]) {
  const sorted = [...values].sort(numberOrder);
  const middle = Math.floor(sorted.length / 2);
  return sorted.length % 2 === 0
    ? (sorted[middle - 1] + sorted[middle]) / 2
    : sorted[middle];
}

function numberOrder(first: number, second: number) {
  return first - second;
}

function formatSummary(summary: Summary) {
  return `mean ${Math.round(summary.meanScore).toLocaleString()} · median ${Math.round(summary.medianScore).toLocaleString()} · moves ${summary.meanMoves.toFixed(1)} · censored ${summary.censored}/${summary.results.length} · objective ${summary.objective.toFixed(5)}`;
}

function formatSeed(seed: number) {
  return `0x${seed.toString(16).padStart(8, "0")}`;
}

function seedRange(seeds: readonly number[]) {
  return `${formatSeed(seeds[0])}..${formatSeed(seeds.at(-1)!)}`;
}

function signed(value: number) {
  const rounded = Math.round(value);
  return `${rounded >= 0 ? "+" : ""}${rounded.toLocaleString()}`;
}

function mix32(value: number) {
  let mixed = value >>> 0;
  mixed = Math.imul(mixed ^ (mixed >>> 16), 0x7feb_352d);
  mixed = Math.imul(mixed ^ (mixed >>> 15), 0x846c_a68b);
  return (mixed ^ (mixed >>> 16)) >>> 0;
}

async function main() {
  const options = parseArguments(process.argv.slice(2));
  if (options === null) process.stdout.write(helpText());
  else await tune(options);
}

void main();