Drop7 Research
approaches/oracle-curriculum/perfect-information-oracle/main.tsTypeScript559 lines · 16.7 KB
import { pathToFileURL } from "node:url";
import {
  CLEAR_BONUS,
  LEVEL_BONUS,
  MOVES_PER_LEVEL,
  createInitialBoard,
  legalColumns,
  playMove,
  seededRandom,
  type GameState,
  type MoveResult,
} from "../../../src/core/typescript/engine.ts";
import { evaluateHeuristic } from "../../../src/core/typescript/heuristic.ts";
import { headlessDisc } from "../../../src/core/typescript/headless.ts";

/**
 * UPPER-BOUND DIAGNOSTIC ONLY.
 *
 * This intentionally unfair oracle can see the headless game's future disc
 * and reveal streams. It is useful for testing whether long-horizon play can
 * reach a target score under this simulator, but it is not a deployable or
 * statistically fair Drop7 policy. Beam pruning also means it is not a proof
 * of the true perfect-information optimum.
 */

export interface OracleGameOptions {
  seed: number;
  depth: number;
  beamWidth: number;
  maxMoves: number;
}

export interface OracleGameResult {
  seed: number;
  score: number;
  moves: number;
  finalLevel: number;
  gameOver: boolean;
  censored: boolean;
  clears: number;
  maxChain: number;
  generatedStates: number;
  deduplicatedStates: number;
  peakCandidateStates: number;
  elapsedMs: number;
}

export interface OracleArguments {
  seeds: number[];
  depth: number;
  beamWidth: number;
  maxMoves: number;
  smoke: boolean;
}

interface BeamNode {
  state: GameState;
  firstColumn: number | null;
  dynamicKey: string;
  rank: number;
}

interface PlanResult {
  column: number | null;
  generatedStates: number;
  deduplicatedStates: number;
  peakCandidateStates: number;
}

interface OracleStep {
  move: MoveResult;
  state: GameState;
}

const DEFAULT_DEPTH = 12;
const DEFAULT_BEAM_WIDTH = 512;
const DEFAULT_MAX_MOVES = 500;
const DEFAULT_GAME_COUNT = 1;
const MAX_GAME_COUNT = 100_000;
const MAX_BEAM_WIDTH = 100_000;
const TERMINAL_PENALTY = -1_000_000_000;

// These constants reproduce the public simulator's per-move reveal stream so
// the diagnostic oracle can be compared against fair policies on paired games.
const REVEAL_DOMAIN = 0x5245564c;
const REVEAL_MOVE_MULTIPLIER = 0x85ebca6b;

const DIAGNOSTIC_BANNER =
  "Drop7 perfect-information oracle — UPPER-BOUND DIAGNOSTIC ONLY (not a fair policy)";

export function runOracleGame(options: OracleGameOptions): OracleGameResult {
  const startedAt = performance.now();
  const seed = normalizeSeed(options.seed);
  const depth = positiveInteger(options.depth, "depth");
  const beamWidth = positiveInteger(options.beamWidth, "beamWidth");
  const maxMoves = positiveInteger(options.maxMoves, "maxMoves");
  if (beamWidth > MAX_BEAM_WIDTH) {
    throw new Error(`beamWidth cannot exceed ${MAX_BEAM_WIDTH}`);
  }

  let state: GameState = {
    board: createInitialBoard(),
    nextDisc: headlessDisc(seed, 0),
    score: 0,
    level: 1,
    movesRemaining: MOVES_PER_LEVEL,
    movesPlayed: 0,
    gameOver: false,
  };
  let clears = 0;
  let maxChain = 0;
  let generatedStates = 0;
  let deduplicatedStates = 0;
  let peakCandidateStates = 0;

  while (!state.gameOver && state.movesPlayed < maxMoves) {
    // Receding horizon: deliberately commit only the best path's first move,
    // then rebuild the beam from the actual successor on the next turn.
    const plan = planOracleMove(state, seed, depth, beamWidth);
    if (plan.column === null) {
      throw new Error("Oracle found no legal move for a live Drop7 game");
    }
    generatedStates += plan.generatedStates;
    deduplicatedStates += plan.deduplicatedStates;
    peakCandidateStates = Math.max(
      peakCandidateStates,
      plan.peakCandidateStates,
    );

    const step = playOracleMove(state, plan.column, seed);
    if (!step) {
      throw new Error(`Oracle chose illegal column ${plan.column}`);
    }
    clears += clearCount(step.move);
    maxChain = Math.max(maxChain, step.move.waves.length);
    state = step.state;
  }

  return {
    seed,
    score: state.score,
    moves: state.movesPlayed,
    finalLevel: state.level,
    gameOver: state.gameOver,
    censored: !state.gameOver,
    clears,
    maxChain,
    generatedStates,
    deduplicatedStates,
    peakCandidateStates,
    elapsedMs: Math.max(0, performance.now() - startedAt),
  };
}

/** Find one move with an unfair, perfect-information receding-horizon beam. */
export function planOracleMove(
  root: GameState,
  seed: number,
  depth: number,
  beamWidth: number,
): PlanResult {
  const normalizedSeed = normalizeSeed(seed);
  positiveInteger(depth, "depth");
  positiveInteger(beamWidth, "beamWidth");
  if (beamWidth > MAX_BEAM_WIDTH) {
    throw new Error(`beamWidth cannot exceed ${MAX_BEAM_WIDTH}`);
  }

  const rootKey = dynamicStateKey(root);
  let beam: BeamNode[] = [
    {
      state: root,
      firstColumn: null,
      dynamicKey: rootKey,
      rank: rankState(root),
    },
  ];
  let generatedStates = 0;
  let deduplicatedStates = 0;
  let peakCandidateStates = 1;

  for (let ply = 0; ply < depth; ply += 1) {
    // At most seven successors per retained state are live at once. The map
    // also merges dynamically identical positions, keeping retained memory at
    // O(7 * beamWidth), independent of the full game-tree size.
    const candidates = new Map<string, BeamNode>();

    for (const node of beam) {
      if (node.state.gameOver) {
        insertCandidate(candidates, node);
        continue;
      }

      for (const column of legalColumns(node.state.board)) {
        const step = playOracleMove(node.state, column, normalizedSeed);
        if (!step) continue;
        generatedStates += 1;
        const dynamicKey = dynamicStateKey(step.state);
        const candidate: BeamNode = {
          state: step.state,
          firstColumn: node.firstColumn ?? column,
          dynamicKey,
          // Ranking is filled after deduplication, avoiding repeated feature
          // extraction for paths that converge on the same position.
          rank: 0,
        };
        if (candidates.has(dynamicKey)) deduplicatedStates += 1;
        insertCandidate(candidates, candidate);
      }
    }

    if (candidates.size === 0) break;
    peakCandidateStates = Math.max(peakCandidateStates, candidates.size);
    const ranked = [...candidates.values()];
    for (const node of ranked) node.rank = rankState(node.state);
    ranked.sort(compareBeamNodes);
    beam = ranked.slice(0, beamWidth);
  }

  beam.sort(compareBeamNodes);
  return {
    column: beam.find((node) => node.firstColumn !== null)?.firstColumn ?? null,
    generatedStates,
    deduplicatedStates,
    peakCandidateStates,
  };
}

/** Apply exactly the same future-disc/reveal mapping as headless mode. */
function playOracleMove(
  state: GameState,
  column: number,
  seed: number,
): OracleStep | null {
  const moveNumber = state.movesPlayed;
  const revealSeed = mix32(
    seed ^
      Math.imul((moveNumber + 1) >>> 0, REVEAL_MOVE_MULTIPLIER) ^
      REVEAL_DOMAIN,
  );
  // Every candidate gets a fresh copy of this move's stream. Otherwise the
  // order in which beam branches are expanded would change reveal values.
  const move = playMove(state, column, seededRandom(revealSeed), {
    captureAnimation: false,
  });
  if (!move) return null;

  const nextState = move.state.gameOver
    ? move.state
    : {
        ...move.state,
        nextDisc: headlessDisc(seed, move.state.movesPlayed),
      };
  return { move, state: nextState };
}

function insertCandidate(
  candidates: Map<string, BeamNode>,
  candidate: BeamNode,
) {
  const previous = candidates.get(candidate.dynamicKey);
  if (!previous || dominatesEquivalentState(candidate, previous)) {
    candidates.set(candidate.dynamicKey, candidate);
  }
}

/** For an identical future position, more accumulated score always dominates. */
function dominatesEquivalentState(candidate: BeamNode, previous: BeamNode) {
  if (candidate.state.score !== previous.state.score) {
    return candidate.state.score > previous.state.score;
  }
  return firstColumnOrder(candidate.firstColumn, previous.firstColumn) < 0;
}

function rankState(state: GameState) {
  return (
    state.score +
    evaluateHeuristic(state, "combined") +
    (state.gameOver ? TERMINAL_PENALTY : 0)
  );
}

function compareBeamNodes(first: BeamNode, second: BeamNode) {
  return (
    second.rank - first.rank ||
    second.state.score - first.state.score ||
    firstColumnOrder(first.firstColumn, second.firstColumn) ||
    first.dynamicKey.localeCompare(second.dynamicKey)
  );
}

function firstColumnOrder(first: number | null, second: number | null) {
  return (first ?? 7) - (second ?? 7);
}

/**
 * Score accounting recovers exact clears, including the rare possibility of
 * one clear before a level rise and another clear after the new covered row.
 */
function clearCount(move: MoveResult) {
  const wavePoints = move.waves.reduce((sum, wave) => sum + wave.points, 0);
  const clearPoints =
    move.scoreDelta - wavePoints - (move.levelAdvanced ? LEVEL_BONUS : 0);
  const clears = clearPoints / CLEAR_BONUS;
  if (!Number.isInteger(clears) || clears < 0) {
    throw new Error("Move score could not be decomposed into clear bonuses");
  }
  return clears;
}

/** Score is omitted so converged positions retain only their dominant path. */
function dynamicStateKey(state: GameState) {
  return `${state.board.join("")}|${state.nextDisc}|${state.level}|${state.movesRemaining}|${state.movesPlayed}|${state.gameOver ? 1 : 0}`;
}

export function runCli(arguments_: readonly string[]) {
  const parsed = parseArguments(arguments_);
  if (parsed === null) {
    process.stdout.write(helpText());
    return;
  }
  if (parsed.smoke) {
    runSmokeTest();
    return;
  }

  process.stdout.write(`${DIAGNOSTIC_BANNER}\n`);
  process.stdout.write(
    `depth ${parsed.depth} · beam ${formatInteger(parsed.beamWidth)} · cap ${formatInteger(parsed.maxMoves)} moves · ${parsed.seeds.length} seed(s)\n\n`,
  );

  const results: OracleGameResult[] = [];
  for (const seed of parsed.seeds) {
    const result = runOracleGame({
      seed,
      depth: parsed.depth,
      beamWidth: parsed.beamWidth,
      maxMoves: parsed.maxMoves,
    });
    results.push(result);
    process.stdout.write(`${formatGame(result)}\n`);
  }
  process.stdout.write(`\n${formatSummary(results)}\n`);
}

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

export function parseArguments(
  arguments_: readonly string[],
): OracleArguments | null {
  let seedStart = 1;
  let gameCount = DEFAULT_GAME_COUNT;
  let explicitSeeds: number[] | undefined;
  let depth = DEFAULT_DEPTH;
  let beamWidth = DEFAULT_BEAM_WIDTH;
  let maxMoves = DEFAULT_MAX_MOVES;
  let smoke = false;

  for (let index = 0; index < arguments_.length; index += 1) {
    const flag = arguments_[index];
    if (flag === "--help" || flag === "-h") return null;
    if (flag === "--smoke") {
      smoke = true;
      continue;
    }
    const value = arguments_[index + 1];
    if (value === undefined) throw new Error(`Missing value after ${flag}`);
    index += 1;

    switch (flag) {
      case "--seed":
        seedStart = parseInteger(value, flag);
        break;
      case "--seeds":
        explicitSeeds = parseSeeds(value);
        break;
      case "--games":
        gameCount = parsePositiveInteger(value, flag);
        if (gameCount > MAX_GAME_COUNT) {
          throw new Error(`--games cannot exceed ${MAX_GAME_COUNT}`);
        }
        break;
      case "--depth":
        depth = parsePositiveInteger(value, flag);
        break;
      case "--beam":
        beamWidth = parsePositiveInteger(value, flag);
        if (beamWidth > MAX_BEAM_WIDTH) {
          throw new Error(`--beam cannot exceed ${MAX_BEAM_WIDTH}`);
        }
        break;
      case "--max-moves":
      case "--maxMoves":
        maxMoves = parsePositiveInteger(value, flag);
        break;
      default:
        throw new Error(`Unknown option ${flag}`);
    }
  }

  const seeds = [
    ...new Set(
      explicitSeeds ??
        Array.from({ length: gameCount }, (_, offset) => seedStart + offset),
    ),
  ];
  for (const seed of seeds) normalizeSeed(seed);
  return { seeds, depth, beamWidth, maxMoves, smoke };
}

function parseSeeds(value: string) {
  const range = /^(?<start>-?\d+)\.\.(?<end>-?\d+)$/.exec(value);
  if (!range?.groups) {
    const seeds = value.split(",").map((seed) => parseInteger(seed, "--seeds"));
    if (seeds.length > MAX_GAME_COUNT) {
      throw new Error(`--seeds cannot contain more than ${MAX_GAME_COUNT} games`);
    }
    return seeds;
  }

  const start = parseInteger(range.groups.start, "--seeds");
  const end = parseInteger(range.groups.end, "--seeds");
  const length = Math.abs(end - start) + 1;
  if (length > MAX_GAME_COUNT) {
    throw new Error(`--seeds cannot contain more than ${MAX_GAME_COUNT} games`);
  }
  const direction = start <= end ? 1 : -1;
  return Array.from(
    { length },
    (_, offset) => start + offset * direction,
  );
}

function runSmokeTest() {
  const options: OracleGameOptions = {
    seed: 7,
    depth: 3,
    beamWidth: 16,
    maxMoves: 7,
  };
  const first = runOracleGame(options);
  const second = runOracleGame(options);
  const deterministicFields = (result: OracleGameResult) => ({
    score: result.score,
    moves: result.moves,
    finalLevel: result.finalLevel,
    gameOver: result.gameOver,
    clears: result.clears,
    maxChain: result.maxChain,
    generatedStates: result.generatedStates,
    deduplicatedStates: result.deduplicatedStates,
    peakCandidateStates: result.peakCandidateStates,
  });
  if (
    JSON.stringify(deterministicFields(first)) !==
    JSON.stringify(deterministicFields(second))
  ) {
    throw new Error("Oracle smoke test was not deterministic");
  }
  if (first.moves === 0 || first.score < 0) {
    throw new Error("Oracle smoke test did not produce a valid game prefix");
  }
  process.stdout.write(
    `${DIAGNOSTIC_BANNER}\nsmoke ok · seed ${first.seed} · ${formatInteger(first.score)} points · ${first.moves} moves\n`,
  );
}

function formatGame(result: OracleGameResult) {
  const status = result.censored ? "censored" : "game over";
  return [
    `seed ${result.seed}`,
    `${formatInteger(result.score)} points`,
    `${result.moves} moves`,
    `${result.clears} clears`,
    `max chain ${result.maxChain}`,
    status,
    `${(result.elapsedMs / 1_000).toFixed(2)}s`,
  ].join(" · ");
}

function formatSummary(results: readonly OracleGameResult[]) {
  const mean = (values: readonly number[]) =>
    values.reduce((sum, value) => sum + value, 0) / values.length;
  return [
    `mean ${formatInteger(mean(results.map((result) => result.score)))} points`,
    `${mean(results.map((result) => result.moves)).toFixed(1)} moves`,
    `${mean(results.map((result) => result.clears)).toFixed(2)} clears`,
    `mean max chain ${mean(results.map((result) => result.maxChain)).toFixed(2)}`,
    `${results.filter((result) => result.censored).length}/${results.length} censored`,
  ].join(" · ");
}

function helpText() {
  return `${DIAGNOSTIC_BANNER}

Usage:
  node --experimental-strip-types approaches/oracle-curriculum/perfect-information-oracle/main.ts [options]

Options:
  --seed <n>           First seed when using --games (default: 1)
  --seeds <list>       Comma list or inclusive range, e.g. 1,7,9 or 1..8
  --games <n>          Consecutive seeds to run (default: ${DEFAULT_GAME_COUNT})
  --depth <ply>        Perfect-information lookahead (default: ${DEFAULT_DEPTH})
  --beam <states>      States retained per ply (default: ${DEFAULT_BEAM_WIDTH})
  --max-moves <n>      Per-game move cap (default: ${DEFAULT_MAX_MOVES})
  --smoke              Run a small deterministic self-check
  --help, -h           Show this help

This oracle sees future random streams and is only an optimistic diagnostic.
It is not a fair policy, and beam pruning does not guarantee the true optimum.
`;
}

function parsePositiveInteger(value: string, flag: string) {
  const parsed = parseInteger(value, flag);
  if (parsed < 1) throw new Error(`${flag} must be at least 1`);
  return parsed;
}

function parseInteger(value: string, flag: string) {
  const parsed = Number(value);
  if (!Number.isSafeInteger(parsed)) {
    throw new Error(`${flag} must be an integer`);
  }
  return parsed;
}

function positiveInteger(value: number, label: string) {
  if (!Number.isSafeInteger(value) || value < 1) {
    throw new Error(`${label} must be a positive integer`);
  }
  return value;
}

function normalizeSeed(seed: number) {
  if (!Number.isSafeInteger(seed) || seed < 0 || seed > 0xffff_ffff) {
    throw new Error("Seeds must be uint32 integers");
  }
  return seed >>> 0;
}

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

function formatInteger(value: number) {
  return Math.round(value).toLocaleString("en-US");
}