Drop7 Research
approaches/baselines-diagnostics/native-parity/main.tsTypeScript185 lines · 5.7 KB
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";

import {
  MOVES_PER_LEVEL,
  createInitialBoard,
  playMove,
  seededRandom,
  type GameState,
  type MoveResult,
} from "../../../src/core/typescript/engine.ts";
import { headlessDisc } from "../../../src/core/typescript/headless.ts";

const DEFAULT_NATIVE = "build/native-suite";
const DEFAULT_SEEDS = [0, 1, 0x1d70_0000, 0xdead_beef, 0xffff_ffff];
const REVEAL_DOMAIN = 0x5245_564c;
const TRACE_DOMAIN = 0x5452_4143;

interface TraceRecord {
  seed: number;
  move: number;
  disc: number;
  column: number;
  scoreDelta: number;
  score: number;
  level: number;
  movesRemaining: number;
  gameOver: boolean;
  clearedBoard: boolean;
  levelAdvanced: boolean;
  waves: MoveResult["waves"];
  board: string;
}

function trace(seed: number, maxMoves: number) {
  let state: GameState = {
    board: createInitialBoard(),
    nextDisc: headlessDisc(seed, 0),
    score: 0,
    level: 1,
    movesRemaining: MOVES_PER_LEVEL,
    movesPlayed: 0,
    gameOver: false,
  };
  const actionRandom = seededRandom(mix32(seed ^ TRACE_DOMAIN));
  const records: TraceRecord[] = [];
  while (!state.gameOver && state.movesPlayed < maxMoves) {
    const legal = Array.from({ length: 7 }, (_, column) => column).filter(
      (column) => state.board[column] === 0,
    );
    const column = legal[Math.floor(actionRandom() * legal.length)];
    if (column === undefined) throw new Error("Live state had no legal move");
    const disc = state.nextDisc;
    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(`Trace policy chose illegal column ${column}`);
    state = move.state.gameOver
      ? move.state
      : {
          ...move.state,
          nextDisc: headlessDisc(seed, move.state.movesPlayed),
        };
    records.push({
      seed,
      move: state.movesPlayed,
      disc,
      column,
      scoreDelta: move.scoreDelta,
      score: state.score,
      level: state.level,
      movesRemaining: state.movesRemaining,
      gameOver: state.gameOver,
      clearedBoard: move.clearedBoard,
      levelAdvanced: move.levelAdvanced,
      waves: move.waves,
      board: state.board.join(""),
    });
  }
  return records.map((record) => JSON.stringify(record));
}

function compareTrace(nativePath: string, seed: number, maxMoves: number) {
  const result = spawnSync(
    nativePath,
    ["--trace", "--seed", String(seed), "--moves", String(maxMoves)],
    { encoding: "utf8" },
  );
  if (result.error) throw result.error;
  if (result.status !== 0) {
    throw new Error(
      `Native trace failed for ${formatSeed(seed)}:\n${result.stderr.trim()}`,
    );
  }
  const actual = result.stdout.trim().length === 0
    ? []
    : result.stdout.trimEnd().split("\n");
  const expected = trace(seed, maxMoves);
  const lines = Math.max(actual.length, expected.length);
  for (let index = 0; index < lines; index += 1) {
    if (actual[index] !== expected[index]) {
      throw new Error(
        [
          `Trace mismatch for ${formatSeed(seed)} at line ${index + 1}`,
          `TS:     ${expected[index] ?? "<missing>"}`,
          `Native: ${actual[index] ?? "<missing>"}`,
        ].join("\n"),
      );
    }
  }
  return actual.length;
}

function parseArguments(arguments_: readonly string[]) {
  const value = (flag: string) => {
    const index = arguments_.indexOf(flag);
    return index < 0 ? undefined : arguments_[index + 1];
  };
  const nativePath = value("--native") ?? DEFAULT_NATIVE;
  const maxMoves = Number(value("--moves") ?? 500);
  if (!Number.isSafeInteger(maxMoves) || maxMoves < 1) {
    throw new Error("--moves must be a positive integer");
  }
  const rawSeeds = value("--seeds");
  const seedCount = Number(value("--seed-count") ?? 0);
  const seedStart = Number(value("--seed-start") ?? 0x2d70_0000);
  if (!Number.isSafeInteger(seedCount) || seedCount < 0) {
    throw new Error("--seed-count must be a nonnegative integer");
  }
  if (!Number.isSafeInteger(seedStart) || seedStart < 0 || seedStart > 0xffff_ffff) {
    throw new Error("--seed-start must be a uint32");
  }
  const seeds = rawSeeds !== undefined
    ? rawSeeds.split(",").map((raw) => {
        const parsed = Number(raw);
        if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 0xffff_ffff) {
          throw new Error(`Invalid uint32 seed: ${raw}`);
        }
        return parsed >>> 0;
      })
    : seedCount > 0
      ? Array.from(
          { length: seedCount },
          (_, offset) => (seedStart + offset) >>> 0,
        )
      : DEFAULT_SEEDS;
  if (seeds.length === 0) throw new Error("At least one seed is required");
  return { nativePath, maxMoves, seeds };
}

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 formatSeed(seed: number) {
  return `0x${seed.toString(16).padStart(8, "0")}`;
}

export function runCli(arguments_: readonly string[]) {
  const { nativePath, maxMoves, seeds } = parseArguments(arguments_);
  let totalMoves = 0;
  for (const seed of seeds) {
    const moves = compareTrace(nativePath, seed, maxMoves);
    totalMoves += moves;
    process.stderr.write(`parity ${formatSeed(seed)}: ${moves} moves\n`);
  }
  process.stdout.write(
    `PARITY ${JSON.stringify({ seeds: seeds.length, moves: totalMoves, exact: true })}\n`,
  );
}

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