Drop7 Research
approaches/heuristic-search/open-loop/main.tsTypeScript289 lines · 9.5 KB
import {
  MOVES_PER_LEVEL,
  createInitialBoard,
  playMove,
  seededRandom,
  type GameState,
} from "../../../src/core/typescript/engine.ts";
import { evaluateHeuristic } from "../../../src/core/typescript/heuristic.ts";
import { headlessDisc } from "../../../src/core/typescript/headless.ts";
import { evaluateRecursivePotential } from "../../../src/core/typescript/recursive-potential.ts";
import {
  MAX_OPEN_LOOP_TIME_MS,
  evaluateRobustOpenLoopBeam,
} from "../../../src/core/typescript/robust-open-loop-beam.ts";
import { evaluateTunnelingState } from "../../../src/core/typescript/tunneling-heuristic.ts";

type Profile = "combined" | "recursive" | "tunneling";

interface LabOptions {
  profile: Profile;
  seed: number;
  plannerSeed: number;
  scenarios: number;
  depth: number;
  beamWidth: number;
  riskAversion: number;
  maxWork: number;
  timeLimitMs: number;
  recursiveScale: number;
  tunnelingScale: number;
  maxMoves: number;
}

interface GameResult {
  seed: number;
  score: number;
  moves: number;
  maxChain: number;
  reveals: number;
  work: number;
  meanDepth: number;
  incomplete: number;
  gameOver: boolean;
  elapsedMs: number;
}

const REVEAL_DOMAIN = 0x5245564c;

function evaluator(profile: Profile, options: LabOptions) {
  if (profile === "recursive") {
    return (state: GameState) =>
      evaluateRecursivePotential(state, options.recursiveScale);
  }
  if (profile === "tunneling") {
    return (state: GameState) =>
      evaluateTunnelingState(state, options.tunnelingScale);
  }
  return (state: GameState) => evaluateHeuristic(state, "combined");
}

function runGame(options: LabOptions): GameResult {
  const startedAt = performance.now();
  let state: GameState = {
    board: createInitialBoard(),
    nextDisc: headlessDisc(options.seed, 0),
    score: 0,
    level: 1,
    movesRemaining: MOVES_PER_LEVEL,
    movesPlayed: 0,
    gameOver: false,
  };
  const evaluate = evaluator(options.profile, options);
  let maxChain = 0;
  let reveals = 0;
  let work = 0;
  let completedDepthTotal = 0;
  let incomplete = 0;

  while (!state.gameOver && state.movesPlayed < options.maxMoves) {
    const result = evaluateRobustOpenLoopBeam(state, {
      scenarios: options.scenarios,
      depth: options.depth,
      beamWidth: options.beamWidth,
      riskAversion: options.riskAversion,
      maxWork: options.maxWork,
      timeLimitMs: options.timeLimitMs,
      seed: options.plannerSeed,
      evaluator: evaluate,
    });
    if (result.bestColumn === null) {
      throw new Error("Open-loop planner returned no move for a live game");
    }
    work += result.work.total;
    completedDepthTotal += result.completedDepth;
    if (!result.complete) incomplete += 1;

    const move = playMove(
      state,
      result.bestColumn,
      seededRandom(
        mix32(
          options.seed ^
            Math.imul(state.movesPlayed + 1, 0x85ebca6b) ^
            REVEAL_DOMAIN,
        ),
      ),
      { captureAnimation: false },
    );
    if (!move) throw new Error(`Illegal plan move ${result.bestColumn}`);
    maxChain = Math.max(maxChain, move.waves.length);
    for (const wave of move.waves) reveals += wave.revealed;
    state = move.state.gameOver
      ? move.state
      : {
          ...move.state,
          nextDisc: headlessDisc(options.seed, move.state.movesPlayed),
        };
  }

  return {
    seed: options.seed,
    score: state.score,
    moves: state.movesPlayed,
    maxChain,
    reveals,
    work,
    meanDepth:
      state.movesPlayed === 0 ? 0 : completedDepthTotal / state.movesPlayed,
    incomplete,
    gameOver: state.gameOver,
    elapsedMs: Math.max(0, performance.now() - startedAt),
  };
}

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

function integer(name: string, fallback: number, minimum = 1) {
  const index = process.argv.indexOf(name);
  if (index < 0) return fallback;
  const value = Number(process.argv[index + 1]);
  if (!Number.isSafeInteger(value) || value < minimum) {
    throw new Error(`${name} must be an integer >= ${minimum}`);
  }
  return value;
}

function finite(name: string, fallback: number) {
  const index = process.argv.indexOf(name);
  if (index < 0) return fallback;
  const value = Number(process.argv[index + 1]);
  if (!Number.isFinite(value) || value < 0) {
    throw new Error(`${name} must be a non-negative finite number`);
  }
  return value;
}

function uint32(name: string, fallback: number) {
  const value = integer(name, fallback, 0);
  if (value > 0xffff_ffff) throw new Error(`${name} must be a uint32`);
  return value >>> 0;
}

const seedStart = uint32("--seed", 0x1d70_0000);
const games = integer("--games", 4);
if (seedStart + games - 1 > 0xffff_ffff) {
  throw new Error("seed range exceeds uint32");
}
const profileText = process.argv.includes("--profiles")
  ? process.argv[process.argv.indexOf("--profiles") + 1]
  : "combined,recursive,tunneling";
const profiles = [...new Set(profileText.split(","))] as Profile[];
if (
  profiles.length === 0 ||
  profiles.some(
    (profile) =>
      profile !== "combined" &&
      profile !== "recursive" &&
      profile !== "tunneling",
  )
) {
  throw new Error("--profiles accepts combined, recursive, and tunneling");
}
const shared = {
  plannerSeed: uint32("--planner-seed", 0xd707_5eed),
  scenarios: integer("--scenarios", 16),
  depth: integer("--depth", 4),
  beamWidth: integer("--beam-width", 32),
  riskAversion: finite("--risk-aversion", 0),
  maxWork: integer("--max-work", 2_000_000),
  timeLimitMs: integer("--time-limit-ms", MAX_OPEN_LOOP_TIME_MS),
  recursiveScale: finite("--recursive-scale", 1),
  tunnelingScale: finite("--tunneling-scale", 0.25),
  maxMoves: integer("--max-moves", 500),
};
const details = process.argv.includes("--details");
const resultsByProfile = new Map<Profile, readonly GameResult[]>();

for (const profile of profiles) {
  const results = Array.from({ length: games }, (_, offset) =>
    runGame({
      ...shared,
      profile,
      seed: seedStart + offset,
    }),
  );
  const scores = results.map((result) => result.score).sort((a, b) => a - b);
  const mean = (values: readonly number[]) =>
    values.reduce((sum, value) => sum + value, 0) / values.length;
  const totalMoves = results.reduce((sum, result) => sum + result.moves, 0);
  const elapsedMs = results.reduce(
    (sum, result) => sum + result.elapsedMs,
    0,
  );
  resultsByProfile.set(profile, results);
  process.stdout.write(
    `${profile.padEnd(10)} mean ${Math.round(mean(scores)).toLocaleString()} · median ${scores[Math.floor(scores.length / 2)].toLocaleString()} · moves ${mean(results.map((result) => result.moves)).toFixed(1)} · chain ${mean(results.map((result) => result.maxChain)).toFixed(2)} · reveals ${mean(results.map((result) => result.reveals)).toFixed(1)} · depth ${mean(results.map((result) => result.meanDepth)).toFixed(2)} · work/move ${Math.round(results.reduce((sum, result) => sum + result.work, 0) / Math.max(1, totalMoves)).toLocaleString()} · runtime ${(elapsedMs / 1_000).toFixed(1)}s · incomplete ${results.reduce((sum, result) => sum + result.incomplete, 0)}/${totalMoves} · d=${shared.depth} s=${shared.scenarios} w=${shared.beamWidth} risk=${shared.riskAversion} maxWork=${shared.maxWork}\n`,
  );
  if (details) {
    process.stdout.write(`details ${JSON.stringify({ profile, results })}\n`);
  }
}

const baseline = resultsByProfile.get("combined");
if (baseline) {
  for (const profile of profiles) {
    if (profile === "combined") continue;
    const candidate = resultsByProfile.get(profile);
    if (!candidate) continue;
    const deltas = baseline.map(
      (result, index) => candidate[index].score - result.score,
    );
    const wins = deltas.filter((delta) => delta > 0).length;
    const ties = deltas.filter((delta) => delta === 0).length;
    const losses = deltas.length - wins - ties;
    const interval = bootstrapMeanInterval(deltas, 10_000);
    process.stdout.write(
      `${profile.padEnd(10)} paired ${signed(average(deltas), 0)} · median ${signed(median(deltas), 0)} · W/T/L ${wins}/${ties}/${losses} · bootstrap95 [${signed(interval[0], 0)}, ${signed(interval[1], 0)}]\n`,
    );
  }
}

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

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

function bootstrapMeanInterval(
  values: readonly number[],
  samples: number,
): readonly [number, number] {
  let randomState = 0x0b07_5eed;
  const random = () => {
    randomState =
      (Math.imul(randomState, 1_664_525) + 1_013_904_223) >>> 0;
    return randomState / 4_294_967_296;
  };
  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((first, second) => first - second);
  return [
    means[Math.floor(samples * 0.025)],
    means[Math.floor(samples * 0.975)],
  ];
}

function signed(value: number, digits: number) {
  return `${value >= 0 ? "+" : ""}${value.toLocaleString(undefined, {
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
  })}`;
}