Drop7 Research
approaches/ntuple-rl/phase-blend/main.tsTypeScript421 lines · 13.8 KB
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";

import {
  BOARD_SIZE,
  MOVES_PER_LEVEL,
  createInitialBoard,
  playMove,
  seededRandom,
  type Board,
  type GameState,
} from "../../../src/core/typescript/engine.ts";
import { headlessDisc } from "../../../src/core/typescript/headless.ts";
import {
  DEFAULT_PHASE_HORIZON_WEIGHTS,
  evaluatePhaseHorizon,
} from "../../../src/core/typescript/phase-horizon-evaluator.ts";
import { evaluateSparseExpectimaxMoves } from "../../../src/core/typescript/sparse-expectimax.ts";

const DEFAULT_CHECKPOINT =
  "/tmp/drop7-ntuple-chance-hierarchical-l1-500k.bin";
const SCALE_SEED_START = 0x2e70_0000;
const TRAINING_SEED_START = 0x5d70_0000;
const PROBE_SEED_START = 0x4d70_0000;
const REVEAL_DOMAIN = 0x5245_564c;
const POLICY_DOMAIN = 0x504f_4c49;
const SEARCH_DOMAIN = 0xd707_5eed;
const COEFFICIENTS = [0, 0.25, 0.5, 1] as const;
const ACTIVE_FEATURES = 92;
const SHARED_TABLES = 17;
const ABSOLUTE_TABLES = 92;
const RISE_PHASES = 5;
const PATTERNS = 10_000;

interface Arguments {
  checkpoint: string;
  trainingGames: number;
  probeGames: number;
  scaleGames: number;
  maxMoves: number;
  depth: number;
  chanceSamples: number;
  maxWork: number;
}

interface GameResult {
  seed: number;
  score: number;
  moves: number;
  work: number;
  incomplete: number;
}

interface Summary {
  games: number;
  meanScore: number;
  meanMoves: number;
  minimumScore: number;
  maximumScore: number;
  meanWorkPerMove: number;
  incompleteDecisions: number;
  results: readonly GameResult[];
}

class HierarchicalNtuple {
  readonly shared: Float32Array;
  readonly absolute: Float32Array;

  constructor(path: string) {
    const bytes = readFileSync(path);
    const magic = bytes.subarray(0, 8).toString("latin1");
    if (magic !== "D7NTUH1\0") {
      throw new Error(`Expected a hierarchical n-tuple checkpoint, got ${magic}`);
    }
    const sharedEntries = bytes.readUInt32LE(8);
    const absoluteEntries = bytes.readUInt32LE(12);
    const expectedShared = SHARED_TABLES * RISE_PHASES * PATTERNS;
    const expectedAbsolute = ABSOLUTE_TABLES * RISE_PHASES * PATTERNS;
    if (sharedEntries !== expectedShared || absoluteEntries !== expectedAbsolute) {
      throw new Error("Unexpected n-tuple checkpoint dimensions");
    }
    const expectedBytes = 16 + (sharedEntries + absoluteEntries) * 4;
    if (bytes.byteLength !== expectedBytes) {
      throw new Error(`Expected ${expectedBytes} checkpoint bytes, got ${bytes.byteLength}`);
    }
    this.shared = copyFloats(bytes, 16, sharedEntries);
    this.absolute = copyFloats(bytes, 16 + sharedEntries * 4, absoluteEntries);
  }

  value(state: GameState) {
    if (state.gameOver) return 0;
    const board = canonicalBoard(state.board);
    let value = 0;
    let placement = 0;
    for (let row = 0; row < BOARD_SIZE; row += 1) {
      for (let start = 0; start <= BOARD_SIZE - 4; start += 1) {
        const pattern = patternCode(
          board[row * BOARD_SIZE + start],
          board[row * BOARD_SIZE + start + 1],
          board[row * BOARD_SIZE + start + 2],
          board[row * BOARD_SIZE + start + 3],
        );
        value += this.shared[index(start, state.movesRemaining, pattern)];
        value += this.absolute[index(placement, state.movesRemaining, pattern)];
        placement += 1;
      }
    }
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      for (let start = 0; start <= BOARD_SIZE - 4; start += 1) {
        const pattern = patternCode(
          board[start * BOARD_SIZE + column],
          board[(start + 1) * BOARD_SIZE + column],
          board[(start + 2) * BOARD_SIZE + column],
          board[(start + 3) * BOARD_SIZE + column],
        );
        value += this.shared[index(4 + start, state.movesRemaining, pattern)];
        value += this.absolute[index(placement, state.movesRemaining, pattern)];
        placement += 1;
      }
    }
    for (let row = 0; row < BOARD_SIZE - 1; row += 1) {
      for (let column = 0; column < BOARD_SIZE - 1; column += 1) {
        const pattern = patternCode(
          board[row * BOARD_SIZE + column],
          board[row * BOARD_SIZE + column + 1],
          board[(row + 1) * BOARD_SIZE + column],
          board[(row + 1) * BOARD_SIZE + column + 1],
        );
        const sharedTable = 8 + (row % 3) * 3 + (column % 3);
        value += this.shared[index(sharedTable, state.movesRemaining, pattern)];
        value += this.absolute[index(placement, state.movesRemaining, pattern)];
        placement += 1;
      }
    }
    if (placement !== ACTIVE_FEATURES) throw new Error("Bad tuple placement count");
    return value;
  }
}

const PHASE_SAFETY_WEIGHTS = {
  ...DEFAULT_PHASE_HORIZON_WEIGHTS,
  projectedOccupancyDebt:
    DEFAULT_PHASE_HORIZON_WEIGHTS.projectedOccupancyDebt * 2,
  residualCoverDebt: DEFAULT_PHASE_HORIZON_WEIGHTS.residualCoverDebt * 2,
  coverAltitudeDebt: DEFAULT_PHASE_HORIZON_WEIGHTS.coverAltitudeDebt * 2,
  imminentCoverAltitudeDebt:
    DEFAULT_PHASE_HORIZON_WEIGHTS.imminentCoverAltitudeDebt * 2,
  peakHeightRisk: DEFAULT_PHASE_HORIZON_WEIGHTS.peakHeightRisk * 2,
  triggerReadiness: DEFAULT_PHASE_HORIZON_WEIGHTS.triggerReadiness * 2,
  releaseReadiness: DEFAULT_PHASE_HORIZON_WEIGHTS.releaseReadiness * 2,
};

function runGame(
  seed: number,
  evaluator: (state: GameState) => number,
  options: Arguments,
): GameResult {
  let state: GameState = {
    board: createInitialBoard(),
    nextDisc: headlessDisc(seed, 0),
    score: 0,
    level: 1,
    movesRemaining: MOVES_PER_LEVEL,
    movesPlayed: 0,
    gameOver: false,
  };
  let work = 0;
  let incomplete = 0;
  while (!state.gameOver && state.movesPlayed < options.maxMoves) {
    const decision = evaluateSparseExpectimaxMoves(state, {
      maxDepth: options.depth,
      chanceSamples: options.chanceSamples,
      maxWork: options.maxWork,
      seed: mix32(SEARCH_DOMAIN ^ observableHash(state)),
      terminalUtility: -1_000_000,
      evaluator,
    });
    if (decision.bestColumn === null) throw new Error("Sparse search found no move");
    work += decision.work;
    if (!decision.complete) incomplete += 1;
    const revealSeed = mix32(
      seed ^
        Math.imul((state.movesPlayed + 1) >>> 0, 0x85eb_ca6b) ^
        REVEAL_DOMAIN,
    );
    const move = playMove(
      state,
      decision.bestColumn,
      seededRandom(revealSeed),
      { captureAnimation: false },
    );
    if (!move) throw new Error("Sparse search selected an illegal move");
    state = move.state.gameOver
      ? move.state
      : { ...move.state, nextDisc: headlessDisc(seed, move.state.movesPlayed) };
  }
  return { seed, score: state.score, moves: state.movesPlayed, work, incomplete };
}

function evaluateSeeds(
  seedStart: number,
  games: number,
  evaluator: (state: GameState) => number,
  options: Arguments,
) {
  const results = Array.from({ length: games }, (_, offset) =>
    runGame((seedStart + offset) >>> 0, evaluator, options),
  );
  return summarize(results);
}

function summarize(results: readonly GameResult[]): Summary {
  const scores = results.map((result) => result.score).sort(numberOrder);
  const moves = results.reduce((sum, result) => sum + result.moves, 0);
  return {
    games: results.length,
    meanScore: mean(scores),
    meanMoves: moves / results.length,
    minimumScore: scores[0],
    maximumScore: scores.at(-1)!,
    meanWorkPerMove:
      results.reduce((sum, result) => sum + result.work, 0) / moves,
    incompleteDecisions: results.reduce(
      (sum, result) => sum + result.incomplete,
      0,
    ),
    results,
  };
}

function calibrationStates(games: number, maxMoves: number) {
  const states: GameState[] = [];
  for (let offset = 0; offset < games; offset += 1) {
    const seed = (SCALE_SEED_START + offset) >>> 0;
    const policyRandom = seededRandom(mix32(seed ^ POLICY_DOMAIN));
    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 < Math.min(80, maxMoves)) {
      states.push(state);
      const legal = Array.from({ length: BOARD_SIZE }, (_, column) => column).filter(
        (column) => state.board[column] === 0,
      );
      const column = legal[Math.floor(policyRandom() * legal.length)];
      const revealSeed = mix32(
        seed ^
          Math.imul((state.movesPlayed + 1) >>> 0, 0x85eb_ca6b) ^
          REVEAL_DOMAIN,
      );
      const move = playMove(state, column, seededRandom(revealSeed), {
        captureAnimation: false,
      });
      if (!move) throw new Error("Calibration policy selected an illegal move");
      state = move.state.gameOver
        ? move.state
        : { ...move.state, nextDisc: headlessDisc(seed, move.state.movesPlayed) };
    }
  }
  return states;
}

function dispersionScale(model: HierarchicalNtuple, states: readonly GameState[]) {
  const phase = states.map((state) => evaluatePhaseHorizon(state, PHASE_SAFETY_WEIGHTS));
  const learned = states.map((state) => model.value(state));
  const phaseDispersion = standardDeviation(phase);
  const learnedDispersion = standardDeviation(learned);
  if (!(phaseDispersion > 0) || !(learnedDispersion > 0)) {
    throw new Error("Could not calibrate nonzero evaluator dispersion");
  }
  return { phaseDispersion, learnedDispersion, scale: phaseDispersion / learnedDispersion };
}

function canonicalBoard(board: Board) {
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const forward = board[row * BOARD_SIZE + column];
      const reflected = board[row * BOARD_SIZE + BOARD_SIZE - 1 - column];
      if (reflected < forward) return mirrorBoard(board);
      if (reflected > forward) return board;
    }
  }
  return board;
}

function mirrorBoard(board: Board) {
  return Array.from({ length: board.length }, (_, target) => {
    const row = Math.floor(target / BOARD_SIZE);
    const column = target % BOARD_SIZE;
    return board[row * BOARD_SIZE + BOARD_SIZE - 1 - column];
  });
}

function patternCode(first: number, second: number, third: number, fourth: number) {
  return ((first * 10 + second) * 10 + third) * 10 + fourth;
}

function index(table: number, movesRemaining: number, pattern: number) {
  return (table * RISE_PHASES + movesRemaining - 1) * PATTERNS + pattern;
}

function copyFloats(bytes: Buffer, offset: number, entries: number) {
  const result = new Float32Array(entries);
  for (let index = 0; index < entries; index += 1) {
    result[index] = bytes.readFloatLE(offset + index * 4);
  }
  return result;
}

function observableHash(state: GameState) {
  let hash = 0x811c_9dc5;
  for (const cell of canonicalBoard(state.board)) {
    hash ^= cell + 1;
    hash = Math.imul(hash, 0x0100_0193);
  }
  hash ^= state.nextDisc;
  hash = Math.imul(hash, 0x0100_0193);
  hash ^= state.movesRemaining;
  hash = Math.imul(hash, 0x0100_0193);
  hash ^= state.level;
  return mix32(hash);
}

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

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

function standardDeviation(values: readonly number[]) {
  const center = mean(values);
  return Math.sqrt(mean(values.map((value) => (value - center) ** 2)));
}

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

function parseArguments(arguments_: readonly string[]): Arguments {
  const value = (flag: string) => {
    const position = arguments_.indexOf(flag);
    return position < 0 ? undefined : arguments_[position + 1];
  };
  const integer = (flag: string, fallback: number) => {
    const parsed = Number(value(flag) ?? fallback);
    if (!Number.isSafeInteger(parsed) || parsed < 1) {
      throw new Error(`${flag} must be a positive integer`);
    }
    return parsed;
  };
  return {
    checkpoint: value("--checkpoint") ?? DEFAULT_CHECKPOINT,
    trainingGames: integer("--training-games", 8),
    probeGames: integer("--probe-games", 64),
    scaleGames: integer("--scale-games", 24),
    maxMoves: integer("--max-moves", 500),
    depth: integer("--depth", 2),
    chanceSamples: integer("--chance-samples", 3),
    maxWork: integer("--max-work", 100_000),
  };
}

export function runCli(arguments_: readonly string[]) {
  const options = parseArguments(arguments_);
  const model = new HierarchicalNtuple(options.checkpoint);
  const calibration = dispersionScale(
    model,
    calibrationStates(options.scaleGames, options.maxMoves),
  );
  process.stdout.write(`CALIBRATION ${JSON.stringify(calibration)}\n`);
  const candidates = COEFFICIENTS.map((coefficient) => {
    const evaluator = (state: GameState) =>
      evaluatePhaseHorizon(state, PHASE_SAFETY_WEIGHTS) +
      coefficient * calibration.scale * model.value(state);
    const summary = evaluateSeeds(
      TRAINING_SEED_START,
      options.trainingGames,
      evaluator,
      options,
    );
    process.stdout.write(
      `TRAINING ${JSON.stringify({ coefficient, ...summary })}\n`,
    );
    return { coefficient, evaluator, summary };
  });
  candidates.sort(
    (first, second) =>
      second.summary.meanScore - first.summary.meanScore ||
      first.coefficient - second.coefficient,
  );
  const frozen = candidates[0];
  process.stdout.write(
    `FROZEN ${JSON.stringify({ coefficient: frozen.coefficient, training: frozen.summary })}\n`,
  );
  const probe = evaluateSeeds(
    PROBE_SEED_START,
    options.probeGames,
    frozen.evaluator,
    options,
  );
  process.stdout.write(
    `RESULT ${JSON.stringify({ coefficient: frozen.coefficient, calibration, training: frozen.summary, probe })}\n`,
  );
}

if (
  process.argv[1] &&
  import.meta.url === pathToFileURL(process.argv[1]).href
) {
  runCli(process.argv.slice(2));
}