Drop7 Research
approaches/baselines-diagnostics/trajectory-throughput/main.tsTypeScript1,419 lines · 46.6 KB
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

import {
  BOARD_SIZE,
  CRACKED,
  EMPTY,
  MOVES_PER_LEVEL,
  SOLID,
  contiguousLineLength,
  createInitialBoard,
  placeDisc,
  playMove,
  seededRandom,
  type Board,
  type GameState,
  type MoveResult,
} from "../../../src/core/typescript/engine.ts";
import {
  extractHeuristicFeatures,
  type HeuristicFeatures,
} from "../../../src/core/typescript/heuristic.ts";
import { headlessDisc } from "../../../src/core/typescript/headless.ts";
import { extractRecursivePotentialFeatures } from "../../../src/core/typescript/recursive-potential.ts";
import { evaluateMoves } from "../../../src/core/typescript/solver.ts";
import { planOracleMove } from "../../oracle-curriculum/perfect-information-oracle/main.ts";

/**
 * Read-only trajectory diagnostic for comparing long-lived Drop7 policies.
 *
 * The perfect-information oracle is an upper-bound diagnostic, not a fair
 * deployable policy. Every row is matched by environment seed and completed
 * five-move window before policy differences are compared. Training and
 * calibration/probe seeds are deliberately used; validation and reserved
 * final-evaluation seeds remain untouched.
 */

const DEFAULT_TRAINING_SEED_START = 0x2d70_0000;
const DEFAULT_CALIBRATION_SEED_START = 0x5d70_0000;
const DEFAULT_VALIDATION_SEED_START = 0x7d70_0000;
const DEFAULT_TRAINING_GAMES = 4;
const DEFAULT_CALIBRATION_GAMES = 4;
const DEFAULT_MAX_MOVES = 100;
const DEFAULT_ORACLE_DEPTH = 4;
const DEFAULT_ORACLE_BEAM = 128;
const DEFAULT_BASELINE_DEPTH = 4;
const DEFAULT_BASELINE_WORK = 20_000;
const DEFAULT_WINDOW = MOVES_PER_LEVEL;

const ACTUAL_REVEAL_DOMAIN = 0x5245_564c;
const ACTUAL_MOVE_MULTIPLIER = 0x85eb_ca6b;
const PROBE_REVEAL_DOMAIN = 0x4451_5256;
const COLUMN_ORDER = [3, 2, 4, 1, 5, 0, 6] as const;
const MIRRORED_COLUMN_ORDER = [3, 4, 2, 5, 1, 6, 0] as const;
const FEATURE_SIZE = 170;

type PolicyName = "oracle" | "combined" | "dqn";

interface Arguments {
  checkpointPath?: string;
  outputPath?: string;
  trainingSeedStart?: number;
  calibrationSeedStart?: number;
  trainingGames: number;
  calibrationGames: number;
  maxMoves: number;
  oracleDepth: number;
  oracleBeam: number;
  baselineDepth: number;
  baselineWork: number;
  window: number;
  selfTest: boolean;
}

interface NetworkSnapshot {
  inputSize: number;
  hiddenOne: number;
  hiddenTwo: number;
  weightsOne: number[];
  biasesOne: number[];
  weightsTwo: number[];
  biasesTwo: number[];
  weightsThree: number[];
  biasThree: number;
}

interface DqnArtifact {
  format: "drop7-observable-double-dqn";
  version: 1;
  algorithm: "double-dqn";
  observableOnly: true;
  trainingSeedStart: number;
  validationSeedStart: number;
  reservedFinalSeedStart: number;
  options: {
    trainingGames: number;
    policySamples: number;
    policySeed: number;
  };
  network: NetworkSnapshot;
}

interface PolicyContext {
  artifact?: DqnArtifact;
  baselineDepth: number;
  baselineWork: number;
  oracleDepth: number;
  oracleBeam: number;
}

interface ChoiceDiagnostic {
  column: number | null;
  completedDepth?: number;
  searchWork?: number;
  generatedStates?: number;
  qMargin?: number;
}

interface BoardMetrics {
  occupied: number;
  solid: number;
  cracked: number;
  numbered: number;
  maxHeight: number;
  meanHeight: number;
  lowCapCount: number;
  lowCapLoad: number;
  adjacentLowCapLoad: number;
  directPotential: number;
  latentPotential: number;
  recursivePotential: number;
  recursiveBonus: number;
  crackedExposure: number;
  solidExposure: number;
  adjacentOnes: number;
  tripleTwos: number;
  deadLowNumbers: number;
}

interface WindowThroughput {
  coversCracked: number;
  coversRevealed: number;
  numberedCleared: number;
  score: number;
  chainWaves: number;
  maxChain: number;
  boardClears: number;
  quietMoves: number;
  multiWaveMoves: number;
  quietDirectGain: number;
  quietLatentGain: number;
  quietRecursiveGain: number;
  triggerPotentialSpent: number;
}

interface WindowRecord extends BoardMetrics, WindowThroughput {
  seed: number;
  seedSet: "training" | "calibration";
  policy: PolicyName;
  startMove: number;
  endMove: number;
  directDelta: number;
  latentDelta: number;
  recursiveDelta: number;
  lowCapLoadDelta: number;
  meanSearchDepth: number | null;
  meanSearchWork: number | null;
  meanGeneratedStates: number | null;
  meanQMargin: number | null;
}

interface GameRecord {
  seed: number;
  seedSet: "training" | "calibration";
  policy: PolicyName;
  score: number;
  moves: number;
  censored: boolean;
  windows: WindowRecord[];
}

interface Report {
  warning: string;
  configuration: {
    trainingSeeds: readonly number[];
    calibrationSeeds: readonly number[];
    maxMoves: number;
    window: number;
    oracle: { depth: number; beamWidth: number };
    combined: { maxDepth: number; maxWork: number };
    dqn: null | {
      checkpoint: string;
      policySamples: number;
      policySeed: number;
    };
  };
  games: GameRecord[];
  matchedWindows: WindowRecord[];
  separation: Record<string, SignalComparison[]>;
}

interface SignalSpec {
  name: string;
  kind: "state" | "throughput" | "delta";
  read: (record: WindowRecord) => number;
}

interface SignalComparison {
  signal: string;
  kind: SignalSpec["kind"];
  samples: number;
  seeds: number;
  oracleMean: number;
  otherMean: number;
  pairedDelta: number;
  pairedEffect: number | null;
}

const SIGNALS: readonly SignalSpec[] = [
  signal("occupied", "state"),
  signal("solid", "state"),
  signal("cracked", "state"),
  signal("numbered", "state"),
  signal("maxHeight", "state"),
  signal("meanHeight", "state"),
  signal("lowCapCount", "state"),
  signal("lowCapLoad", "state"),
  signal("adjacentLowCapLoad", "state"),
  signal("directPotential", "state"),
  signal("latentPotential", "state"),
  signal("recursivePotential", "state"),
  signal("recursiveBonus", "state"),
  signal("crackedExposure", "state"),
  signal("solidExposure", "state"),
  signal("adjacentOnes", "state"),
  signal("tripleTwos", "state"),
  signal("deadLowNumbers", "state"),
  signal("coversCracked", "throughput"),
  signal("coversRevealed", "throughput"),
  signal("numberedCleared", "throughput"),
  signal("score", "throughput"),
  signal("chainWaves", "throughput"),
  signal("maxChain", "throughput"),
  signal("quietMoves", "throughput"),
  signal("multiWaveMoves", "throughput"),
  signal("quietDirectGain", "throughput"),
  signal("quietLatentGain", "throughput"),
  signal("quietRecursiveGain", "throughput"),
  signal("triggerPotentialSpent", "throughput"),
  signal("directDelta", "delta"),
  signal("latentDelta", "delta"),
  signal("recursiveDelta", "delta"),
  signal("lowCapLoadDelta", "delta"),
] as const;

function signal(
  name: keyof WindowRecord & string,
  kind: SignalSpec["kind"],
): SignalSpec {
  return {
    name,
    kind,
    read: (record) => record[name] as number,
  };
}

export async function buildTrajectoryReport(options: Arguments): Promise<Report> {
  const artifact = options.checkpointPath
    ? await readDqnArtifact(options.checkpointPath)
    : undefined;
  const trainingSeedStart = normalizeSeed(
    options.trainingSeedStart ??
      artifact?.trainingSeedStart ??
      DEFAULT_TRAINING_SEED_START,
  );
  const calibrationSeedStart = normalizeSeed(
    options.calibrationSeedStart ??
      (artifact
        ? artifact.trainingSeedStart + artifact.options.trainingGames
        : DEFAULT_CALIBRATION_SEED_START),
  );
  const validationSeedStart =
    artifact?.validationSeedStart ?? DEFAULT_VALIDATION_SEED_START;
  const trainingSeeds = seedRange(trainingSeedStart, options.trainingGames);
  const calibrationSeeds = seedRange(
    calibrationSeedStart,
    options.calibrationGames,
  );
  assertDiagnosticSeedRange(trainingSeeds, validationSeedStart, "training");
  assertDiagnosticSeedRange(
    calibrationSeeds,
    validationSeedStart,
    "calibration",
  );

  const context: PolicyContext = {
    artifact,
    baselineDepth: options.baselineDepth,
    baselineWork: options.baselineWork,
    oracleDepth: options.oracleDepth,
    oracleBeam: options.oracleBeam,
  };
  const policies: PolicyName[] = ["oracle", "combined"];
  if (artifact) policies.push("dqn");
  const games: GameRecord[] = [];

  for (const [seedSet, seeds] of [
    ["training", trainingSeeds],
    ["calibration", calibrationSeeds],
  ] as const) {
    for (const seed of seeds) {
      for (const policy of policies) {
        const game = runTrajectoryGame(
          seed,
          seedSet,
          policy,
          context,
          options.maxMoves,
          options.window,
        );
        games.push(game);
        process.stderr.write(
          `${seedSet} ${formatSeed(seed)} ${policy.padEnd(8)} · ${game.score.toLocaleString("en-US")} · ${game.moves} moves${game.censored ? " capped" : ""}\n`,
        );
      }
    }
  }

  const matchedWindows = matchWindows(games, policies);
  const separation: Record<string, SignalComparison[]> = {};
  for (const other of policies.filter((policy) => policy !== "oracle")) {
    separation[`oracle-vs-${other}`] = compareSignals(
      matchedWindows,
      "oracle",
      other,
    );
  }

  return {
    warning:
      "The oracle sees future discs/reveals and is only an upper-bound diagnostic; differences are associative, not causal feature proofs.",
    configuration: {
      trainingSeeds,
      calibrationSeeds,
      maxMoves: options.maxMoves,
      window: options.window,
      oracle: { depth: options.oracleDepth, beamWidth: options.oracleBeam },
      combined: {
        maxDepth: options.baselineDepth,
        maxWork: options.baselineWork,
      },
      dqn: artifact
        ? {
            checkpoint: resolve(options.checkpointPath!),
            policySamples: artifact.options.policySamples,
            policySeed: artifact.options.policySeed,
          }
        : null,
    },
    games,
    matchedWindows,
    separation,
  };
}

function runTrajectoryGame(
  seed: number,
  seedSet: GameRecord["seedSet"],
  policy: PolicyName,
  context: PolicyContext,
  maxMoves: number,
  windowSize: number,
): GameRecord {
  let state = initialState(seed);
  let windowStartState = state;
  let throughput = emptyThroughput();
  let searchDepth = 0;
  let searchDepthSamples = 0;
  let searchWork = 0;
  let searchWorkSamples = 0;
  let generatedStates = 0;
  let generatedStateSamples = 0;
  let qMargin = 0;
  let qMarginSamples = 0;
  const windows: WindowRecord[] = [];

  while (!state.gameOver && state.movesPlayed < maxMoves) {
    const choice = chooseMove(policy, state, seed, context);
    if (choice.column === null) {
      throw new Error(`${policy} found no legal move in a live game`);
    }
    const moved = playActualMove(state, choice.column, seed);
    if (!moved) throw new Error(`${policy} chose illegal column ${choice.column}`);
    accumulateMove(throughput, state, moved);
    if (choice.completedDepth !== undefined) {
      searchDepth += choice.completedDepth;
      searchDepthSamples += 1;
    }
    if (choice.searchWork !== undefined) {
      searchWork += choice.searchWork;
      searchWorkSamples += 1;
    }
    if (choice.generatedStates !== undefined) {
      generatedStates += choice.generatedStates;
      generatedStateSamples += 1;
    }
    if (choice.qMargin !== undefined && Number.isFinite(choice.qMargin)) {
      qMargin += choice.qMargin;
      qMarginSamples += 1;
    }
    state = moved.state;

    if (state.movesPlayed % windowSize === 0) {
      const before = boardMetrics(windowStartState);
      const after = boardMetrics(state);
      windows.push({
        seed,
        seedSet,
        policy,
        startMove: state.movesPlayed - windowSize,
        endMove: state.movesPlayed,
        ...after,
        ...throughput,
        directDelta: after.directPotential - before.directPotential,
        latentDelta: after.latentPotential - before.latentPotential,
        recursiveDelta: after.recursivePotential - before.recursivePotential,
        lowCapLoadDelta: after.lowCapLoad - before.lowCapLoad,
        meanSearchDepth:
          searchDepthSamples === 0 ? null : searchDepth / searchDepthSamples,
        meanSearchWork:
          searchWorkSamples === 0 ? null : searchWork / searchWorkSamples,
        meanGeneratedStates:
          generatedStateSamples === 0
            ? null
            : generatedStates / generatedStateSamples,
        meanQMargin: qMarginSamples === 0 ? null : qMargin / qMarginSamples,
      });
      windowStartState = state;
      throughput = emptyThroughput();
      searchDepth = 0;
      searchDepthSamples = 0;
      searchWork = 0;
      searchWorkSamples = 0;
      generatedStates = 0;
      generatedStateSamples = 0;
      qMargin = 0;
      qMarginSamples = 0;
    }
  }

  return {
    seed,
    seedSet,
    policy,
    score: state.score,
    moves: state.movesPlayed,
    censored: !state.gameOver,
    windows,
  };
}

function chooseMove(
  policy: PolicyName,
  state: GameState,
  seed: number,
  context: PolicyContext,
): ChoiceDiagnostic {
  if (policy === "oracle") {
    const plan = planOracleMove(
      state,
      seed,
      context.oracleDepth,
      context.oracleBeam,
    );
    return {
      column: plan.column,
      generatedStates: plan.generatedStates,
    };
  }
  if (policy === "combined") {
    const evaluation = evaluateMoves(state, {
      maxDepth: context.baselineDepth,
      maxWork: context.baselineWork,
      timeLimitMs: Number.POSITIVE_INFINITY,
      heuristicProfile: "combined",
    });
    return {
      column: evaluation.bestColumn,
      completedDepth: evaluation.depth,
      searchWork: evaluation.work,
    };
  }
  if (!context.artifact) throw new Error("DQN checkpoint is unavailable");
  return chooseDqnMove(state, context.artifact);
}

function initialState(seed: number): GameState {
  return {
    board: createInitialBoard(),
    nextDisc: headlessDisc(seed, 0),
    score: 0,
    level: 1,
    movesRemaining: MOVES_PER_LEVEL,
    movesPlayed: 0,
    gameOver: false,
  };
}

function playActualMove(state: GameState, column: number, seed: number) {
  const revealSeed = mix32(
    seed ^
      Math.imul(state.movesPlayed + 1, ACTUAL_MOVE_MULTIPLIER) ^
      ACTUAL_REVEAL_DOMAIN,
  );
  const move = playMove(state, column, seededRandom(revealSeed), {
    captureAnimation: true,
  });
  if (!move) return null;
  const nextState = move.state.gameOver
    ? move.state
    : {
        ...move.state,
        nextDisc: headlessDisc(seed, move.state.movesPlayed),
      };
  return { ...move, state: nextState };
}

function emptyThroughput(): WindowThroughput {
  return {
    coversCracked: 0,
    coversRevealed: 0,
    numberedCleared: 0,
    score: 0,
    chainWaves: 0,
    maxChain: 0,
    boardClears: 0,
    quietMoves: 0,
    multiWaveMoves: 0,
    quietDirectGain: 0,
    quietLatentGain: 0,
    quietRecursiveGain: 0,
    triggerPotentialSpent: 0,
  };
}

function accumulateMove(
  target: WindowThroughput,
  beforeState: GameState,
  move: MoveResult,
) {
  for (const wave of move.waves) {
    target.coversRevealed += wave.revealed;
    target.numberedCleared += wave.cleared;
  }
  for (const frame of move.animation) {
    if (frame.kind !== "impact") continue;
    for (const index of frame.indexes) {
      if (frame.board[index] === CRACKED) target.coversCracked += 1;
    }
  }
  target.score += move.scoreDelta;
  target.chainWaves += move.waves.length;
  target.maxChain = Math.max(target.maxChain, move.waves.length);
  if (move.clearedBoard) target.boardClears += 1;
  const before = boardMetrics(beforeState);
  const after = boardMetrics(move.state);
  const directDelta = after.directPotential - before.directPotential;
  const latentDelta = after.latentPotential - before.latentPotential;
  const recursiveDelta = after.recursivePotential - before.recursivePotential;
  if (move.waves.length === 0) {
    target.quietMoves += 1;
    target.quietDirectGain += directDelta;
    target.quietLatentGain += latentDelta;
    target.quietRecursiveGain += recursiveDelta;
  } else {
    if (move.waves.length >= 2) target.multiWaveMoves += 1;
    target.triggerPotentialSpent += Math.max(
      0,
      -(directDelta + latentDelta + recursiveDelta),
    );
  }
}

function boardMetrics(state: GameState): BoardMetrics {
  const heuristic = extractHeuristicFeatures(state);
  const recursive = extractRecursivePotentialFeatures(state);
  const heights = columnHeights(state.board);
  const capValues = Array<number>(BOARD_SIZE).fill(0);
  let lowCapCount = 0;
  let lowCapLoad = 0;
  let adjacentLowCapLoad = 0;
  for (let column = 0; column < BOARD_SIZE; column += 1) {
    if (heights[column] === 0) continue;
    const topRow = BOARD_SIZE - heights[column];
    const cap = state.board[topRow * BOARD_SIZE + column];
    if (cap === 1 || cap === 2) {
      capValues[column] = cap;
      lowCapCount += 1;
      lowCapLoad += heights[column] ** 2 * (cap === 1 ? 1.5 : 1);
    }
    if (column > 0 && capValues[column - 1] > 0 && capValues[column] > 0) {
      adjacentLowCapLoad += Math.min(heights[column - 1], heights[column]) ** 2;
    }
  }
  return {
    occupied: heuristic.solidCells + heuristic.crackedCells + heuristic.numberedCells,
    solid: heuristic.solidCells,
    cracked: heuristic.crackedCells,
    numbered: heuristic.numberedCells,
    maxHeight: Math.max(...heights),
    meanHeight: mean(heights),
    lowCapCount,
    lowCapLoad,
    adjacentLowCapLoad,
    directPotential: heuristic.directPotential,
    latentPotential: heuristic.latentChainPotential,
    recursivePotential: recursive.deepChainEnergy + recursive.deepCoverExposure,
    recursiveBonus:
      recursive.deepChainEnergy * 480 + recursive.deepCoverExposure * 160,
    crackedExposure: heuristic.crackedExposure,
    solidExposure: heuristic.solidExposure,
    adjacentOnes: heuristic.adjacentOnes,
    tripleTwos: heuristic.tripleTwos,
    deadLowNumbers: heuristic.deadLowNumbers,
  };
}

function matchWindows(
  games: readonly GameRecord[],
  policies: readonly PolicyName[],
) {
  const grouped = new Map<string, Map<PolicyName, WindowRecord>>();
  for (const game of games) {
    for (const window of game.windows) {
      const key = `${window.seed}:${window.endMove}`;
      let group = grouped.get(key);
      if (!group) {
        group = new Map();
        grouped.set(key, group);
      }
      group.set(window.policy, window);
    }
  }
  const result: WindowRecord[] = [];
  for (const group of grouped.values()) {
    if (!policies.every((policy) => group.has(policy))) continue;
    for (const policy of policies) result.push(group.get(policy)!);
  }
  return result.sort(
    (first, second) =>
      first.endMove - second.endMove ||
      first.seed - second.seed ||
      first.policy.localeCompare(second.policy),
  );
}

function compareSignals(
  records: readonly WindowRecord[],
  firstPolicy: PolicyName,
  secondPolicy: PolicyName,
) {
  const first = new Map<string, WindowRecord>();
  const second = new Map<string, WindowRecord>();
  for (const record of records) {
    const key = `${record.seed}:${record.endMove}`;
    if (record.policy === firstPolicy) first.set(key, record);
    if (record.policy === secondPolicy) second.set(key, record);
  }
  if (first.size === 0 || second.size === 0) return [];
  return SIGNALS.map((spec): SignalComparison => {
    const firstValues: number[] = [];
    const secondValues: number[] = [];
    const deltas: number[] = [];
    const deltasBySeed = new Map<number, number[]>();
    for (const [key, firstRecord] of first) {
      const secondRecord = second.get(key);
      if (!secondRecord) continue;
      const firstValue = spec.read(firstRecord);
      const secondValue = spec.read(secondRecord);
      firstValues.push(firstValue);
      secondValues.push(secondValue);
      const delta = firstValue - secondValue;
      deltas.push(delta);
      const seedDeltas = deltasBySeed.get(firstRecord.seed) ?? [];
      seedDeltas.push(delta);
      deltasBySeed.set(firstRecord.seed, seedDeltas);
    }
    const clusteredDeltas = [...deltasBySeed.values()].map(mean);
    return {
      signal: spec.name,
      kind: spec.kind,
      samples: deltas.length,
      seeds: deltasBySeed.size,
      oracleMean: mean(firstValues),
      otherMean: mean(secondValues),
      pairedDelta: mean(deltas),
      pairedEffect: standardizedPairedEffect(clusteredDeltas),
    };
  }).sort(
    (first, second) =>
      Math.abs(second.pairedEffect ?? 0) - Math.abs(first.pairedEffect ?? 0) ||
      first.signal.localeCompare(second.signal),
  );
}

/** Exact inference path for the v1 observable Double-DQN checkpoint. */
function chooseDqnMove(
  state: GameState,
  artifact: DqnArtifact,
): ChoiceDiagnostic {
  if (state.gameOver) return { column: null };
  const observable = canonicalObservable(state);
  const legal = columnOrder(observable.mirrored).filter(
    (column) => state.board[column] === EMPTY,
  );
  if (legal.length === 0) return { column: null };
  const ranked = legal.map((column) => ({
    column,
    value: networkValue(
      artifact.network,
      actionInput(
        state,
        column,
        artifact.options.policySamples,
        artifact.options.policySeed,
      ),
    ),
  }));
  ranked.sort((first, second) => second.value - first.value);
  return {
    column: ranked[0].column,
    qMargin:
      ranked.length === 1
        ? Number.POSITIVE_INFINITY
        : ranked[0].value - ranked[1].value,
  };
}

function networkValue(snapshot: NetworkSnapshot, input: Float64Array) {
  const hiddenOne = new Float64Array(snapshot.hiddenOne);
  for (let output = 0; output < snapshot.hiddenOne; output += 1) {
    let sum = snapshot.biasesOne[output];
    const offset = output * snapshot.inputSize;
    for (let inputIndex = 0; inputIndex < snapshot.inputSize; inputIndex += 1) {
      sum += snapshot.weightsOne[offset + inputIndex] * input[inputIndex];
    }
    hiddenOne[output] = Math.max(0, sum);
  }
  const hiddenTwo = new Float64Array(snapshot.hiddenTwo);
  for (let output = 0; output < snapshot.hiddenTwo; output += 1) {
    let sum = snapshot.biasesTwo[output];
    const offset = output * snapshot.hiddenOne;
    for (let inputIndex = 0; inputIndex < snapshot.hiddenOne; inputIndex += 1) {
      sum += snapshot.weightsTwo[offset + inputIndex] * hiddenOne[inputIndex];
    }
    hiddenTwo[output] = Math.max(0, sum);
  }
  let value = snapshot.biasThree;
  for (let index = 0; index < snapshot.hiddenTwo; index += 1) {
    value += snapshot.weightsThree[index] * hiddenTwo[index];
  }
  return value;
}

function actionInput(
  state: GameState,
  column: number,
  samples: number,
  policySeed: number,
) {
  const observable = canonicalObservable(state);
  const canonicalColumn = observable.mirrored ? BOARD_SIZE - 1 - column : column;
  const board = observable.mirrored ? mirrorBoard(state.board) : state.board;
  const values: number[] = [];

  for (const cell of board) values.push(cell >= 1 && cell <= 7 ? cell / 7 : 0);
  for (const cell of board) {
    values.push(cell === SOLID ? 1 : cell === CRACKED ? 0.5 : 0);
  }
  for (let disc = 1; disc <= 7; disc += 1) {
    values.push(state.nextDisc === disc ? 1 : 0);
  }
  values.push(state.movesRemaining / MOVES_PER_LEVEL);
  values.push(Math.min(state.level, 100) / 100);
  values.push(Math.min(state.movesPlayed, 500) / 500);
  appendHeuristic(values, extractHeuristicFeatures(state));
  const heights = columnHeights(board);
  for (const height of heights) values.push(height / BOARD_SIZE);

  for (let candidate = 0; candidate < BOARD_SIZE; candidate += 1) {
    values.push(candidate === canonicalColumn ? 1 : 0);
  }
  const actualHeights = columnHeights(state.board);
  const landingHeight = actualHeights[column] + 1;
  const leftHeight = column === 0 ? landingHeight : actualHeights[column - 1];
  const rightHeight =
    column === BOARD_SIZE - 1 ? landingHeight : actualHeights[column + 1];
  const canonicalLeftHeight = observable.mirrored ? rightHeight : leftHeight;
  const canonicalRightHeight = observable.mirrored ? leftHeight : rightHeight;
  let coveredInColumn = 0;
  let lowInColumn = 0;
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    const cell = state.board[row * BOARD_SIZE + column];
    if (cell === SOLID || cell === CRACKED) coveredInColumn += 1;
    if (cell === 1 || cell === 2) lowInColumn += 1;
  }
  const placed = placeDisc(state.board, column, state.nextDisc);
  const landingRow = BOARD_SIZE - landingHeight;
  const horizontalLength = placed
    ? contiguousLineLength(placed, landingRow, column, "row")
    : 0;
  values.push(landingHeight / BOARD_SIZE);
  values.push((landingHeight / BOARD_SIZE) ** 2);
  values.push(Math.abs(canonicalColumn - 3) / 3);
  values.push((landingHeight - canonicalLeftHeight) / BOARD_SIZE);
  values.push((landingHeight - canonicalRightHeight) / BOARD_SIZE);
  values.push(coveredInColumn / BOARD_SIZE);
  values.push(lowInColumn / BOARD_SIZE);
  values.push((state.nextDisc - landingHeight) / BOARD_SIZE);
  values.push((state.nextDisc - horizontalLength) / BOARD_SIZE);
  values.push(
    ...expectedCandidateFeatures(
      state,
      column,
      samples,
      policySeed,
      observable.hash,
    ),
  );
  if (values.length !== FEATURE_SIZE) {
    throw new Error(`DQN feature size drifted: expected ${FEATURE_SIZE}, got ${values.length}`);
  }
  return Float64Array.from(values);
}

function expectedCandidateFeatures(
  state: GameState,
  column: number,
  samples: number,
  policySeed: number,
  observableHash: number,
) {
  const result = Array<number>(25).fill(0);
  for (let sample = 0; sample < samples; sample += 1) {
    const reveal = stratifiedProbe(observableHash, policySeed, sample, samples);
    const move = playMove(state, column, () => reveal, {
      captureAnimation: false,
    });
    if (!move) continue;
    let cleared = 0;
    let revealed = 0;
    for (const wave of move.waves) {
      cleared += wave.cleared;
      revealed += wave.revealed;
    }
    const scale = 1 / samples;
    result[0] += (Math.min(move.scoreDelta, 100_000) / 100_000) * scale;
    result[1] += (cleared / (BOARD_SIZE * BOARD_SIZE)) * scale;
    result[2] += (revealed / 14) * scale;
    result[3] += (Math.min(move.waves.length, 10) / 10) * scale;
    result[4] += (move.state.gameOver ? 1 : 0) * scale;
    result[5] += (move.clearedBoard ? 1 : 0) * scale;
    result[6] += (move.levelAdvanced ? 1 : 0) * scale;
    const heuristic: number[] = [];
    appendHeuristic(heuristic, extractHeuristicFeatures(move.state));
    for (let index = 0; index < heuristic.length; index += 1) {
      result[7 + index] += heuristic[index] * scale;
    }
    const heights = columnHeights(move.state.board);
    result[21] += (Math.max(...heights) / BOARD_SIZE) * scale;
    result[22] += (mean(heights) / BOARD_SIZE) * scale;
    result[23] +=
      (roughness(heights) / (BOARD_SIZE * (BOARD_SIZE - 1))) * scale;
    result[24] +=
      (heights.filter((height) => height < BOARD_SIZE).length / BOARD_SIZE) *
      scale;
  }
  return result;
}

function appendHeuristic(target: number[], feature: HeuristicFeatures) {
  target.push(
    feature.openColumns / 7,
    feature.heightLoad / 1_400,
    feature.solidCells / 49,
    feature.crackedCells / 49,
    feature.numberedCells / 49,
    feature.highLowNumbers / 20,
    feature.legacyNearMatches / 49,
    feature.directPotential / 49,
    feature.latentChainPotential / 49,
    feature.crackedExposure / 49,
    feature.solidExposure / 49,
    feature.adjacentOnes / 42,
    feature.tripleTwos / 14,
    feature.deadLowNumbers / 20,
  );
}

function canonicalObservable(state: GameState) {
  const mirrored = mirroredBoardIsSmaller(state.board);
  let hash = 0x811c_9dc5;
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const sourceColumn = mirrored ? BOARD_SIZE - 1 - column : column;
      hash ^= state.board[row * BOARD_SIZE + sourceColumn] + 1;
      hash = Math.imul(hash, 0x0100_0193);
    }
  }
  hash ^= state.nextDisc;
  hash = Math.imul(hash, 0x0100_0193);
  hash ^= state.movesRemaining;
  return { hash: mix32(hash), mirrored };
}

function stratifiedProbe(
  observableHash: number,
  policySeed: number,
  sample: number,
  samples: number,
) {
  const offset = mix32(observableHash ^ policySeed ^ PROBE_REVEAL_DOMAIN) % 7;
  const stratum = Math.floor(((sample + 0.5) * 7) / samples);
  const disc = ((offset + stratum) % 7) + 1;
  return (disc - 0.5) / 7;
}

async function readDqnArtifact(path: string): Promise<DqnArtifact> {
  const value: unknown = JSON.parse(await readFile(path, "utf8"));
  if (!isRecord(value)) throw new Error("DQN checkpoint must be an object");
  if (
    value.format !== "drop7-observable-double-dqn" ||
    value.version !== 1 ||
    value.algorithm !== "double-dqn" ||
    value.observableOnly !== true
  ) {
    throw new Error("Unsupported or non-observable DQN checkpoint");
  }
  const options = value.options;
  const network = value.network;
  if (!isRecord(options) || !isRecord(network)) {
    throw new Error("DQN checkpoint is missing options or network");
  }
  const snapshot: NetworkSnapshot = {
    inputSize: finiteInteger(network.inputSize, "network.inputSize"),
    hiddenOne: finiteInteger(network.hiddenOne, "network.hiddenOne"),
    hiddenTwo: finiteInteger(network.hiddenTwo, "network.hiddenTwo"),
    weightsOne: finiteArray(network.weightsOne, "network.weightsOne"),
    biasesOne: finiteArray(network.biasesOne, "network.biasesOne"),
    weightsTwo: finiteArray(network.weightsTwo, "network.weightsTwo"),
    biasesTwo: finiteArray(network.biasesTwo, "network.biasesTwo"),
    weightsThree: finiteArray(network.weightsThree, "network.weightsThree"),
    biasThree: finiteNumber(network.biasThree, "network.biasThree"),
  };
  if (snapshot.inputSize !== FEATURE_SIZE) {
    throw new Error(`Expected ${FEATURE_SIZE} DQN inputs, got ${snapshot.inputSize}`);
  }
  assertLength(snapshot.weightsOne, snapshot.inputSize * snapshot.hiddenOne, "weightsOne");
  assertLength(snapshot.biasesOne, snapshot.hiddenOne, "biasesOne");
  assertLength(snapshot.weightsTwo, snapshot.hiddenOne * snapshot.hiddenTwo, "weightsTwo");
  assertLength(snapshot.biasesTwo, snapshot.hiddenTwo, "biasesTwo");
  assertLength(snapshot.weightsThree, snapshot.hiddenTwo, "weightsThree");
  return {
    format: value.format,
    version: value.version,
    algorithm: value.algorithm,
    observableOnly: value.observableOnly,
    trainingSeedStart: normalizeSeed(value.trainingSeedStart),
    validationSeedStart: normalizeSeed(value.validationSeedStart),
    reservedFinalSeedStart: normalizeSeed(value.reservedFinalSeedStart),
    options: {
      trainingGames: finiteInteger(options.trainingGames, "options.trainingGames"),
      policySamples: finiteInteger(options.policySamples, "options.policySamples"),
      policySeed: normalizeSeed(options.policySeed),
    },
    network: snapshot,
  };
}

function assertLength(values: readonly number[], expected: number, name: string) {
  if (values.length !== expected) {
    throw new Error(`${name} needs ${expected} values, got ${values.length}`);
  }
}

function finiteArray(value: unknown, name: string) {
  if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
  return value.map((entry, index) => finiteNumber(entry, `${name}[${index}]`));
}

function finiteInteger(value: unknown, name: string) {
  const number = finiteNumber(value, name);
  if (!Number.isSafeInteger(number) || number < 1) {
    throw new Error(`${name} must be a positive integer`);
  }
  return number;
}

function finiteNumber(value: unknown, name: string) {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new Error(`${name} must be finite`);
  }
  return value;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function columnHeights(board: Board) {
  const heights = Array<number>(BOARD_SIZE).fill(0);
  for (let column = 0; column < BOARD_SIZE; column += 1) {
    for (let row = 0; row < BOARD_SIZE; row += 1) {
      if (board[row * BOARD_SIZE + column] !== EMPTY) heights[column] += 1;
    }
  }
  return heights;
}

function roughness(heights: readonly number[]) {
  let result = 0;
  for (let column = 1; column < heights.length; column += 1) {
    result += Math.abs(heights[column] - heights[column - 1]);
  }
  return result;
}

function mirroredBoardIsSmaller(board: Board) {
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    const offset = row * BOARD_SIZE;
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const forward = board[offset + column];
      const mirrored = board[offset + BOARD_SIZE - 1 - column];
      if (mirrored < forward) return true;
      if (mirrored > forward) return false;
    }
  }
  return false;
}

function mirrorBoard(board: Board): Board {
  const mirrored = board.slice();
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      mirrored[row * BOARD_SIZE + column] =
        board[row * BOARD_SIZE + BOARD_SIZE - 1 - column];
    }
  }
  return mirrored;
}

function columnOrder(mirrored: boolean) {
  return mirrored ? MIRRORED_COLUMN_ORDER : COLUMN_ORDER;
}

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

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

function meanNonNull(values: readonly (number | null)[]) {
  return mean(values.filter((value): value is number => value !== null));
}

function standardizedPairedEffect(deltas: readonly number[]) {
  if (deltas.length < 2) return 0;
  const average = mean(deltas);
  const variance =
    deltas.reduce((sum, value) => sum + (value - average) ** 2, 0) /
    (deltas.length - 1);
  const deviation = Math.sqrt(variance);
  if (deviation <= Number.EPSILON) {
    // The raw delta still communicates a constant separation, but a
    // standardized effect is undefined when seed-to-seed variance is zero.
    return average === 0 ? 0 : null;
  }
  return average / deviation;
}

function seedRange(start: number, count: number) {
  return Array.from({ length: count }, (_, index) => (start + index) >>> 0);
}

function assertDiagnosticSeedRange(
  seeds: readonly number[],
  validationSeedStart: number,
  label: string,
) {
  if (seeds.some((seed) => seed >= validationSeedStart)) {
    throw new Error(`${label} seed range overlaps validation/final seeds`);
  }
}

function normalizeSeed(value: unknown) {
  if (
    typeof value !== "number" ||
    !Number.isSafeInteger(value) ||
    value < 0 ||
    value > 0xffff_ffff
  ) {
    throw new Error("seed must be a uint32 integer");
  }
  return value >>> 0;
}

export function parseArguments(arguments_: readonly string[]): Arguments {
  const options: Arguments = {
    trainingGames: DEFAULT_TRAINING_GAMES,
    calibrationGames: DEFAULT_CALIBRATION_GAMES,
    maxMoves: DEFAULT_MAX_MOVES,
    oracleDepth: DEFAULT_ORACLE_DEPTH,
    oracleBeam: DEFAULT_ORACLE_BEAM,
    baselineDepth: DEFAULT_BASELINE_DEPTH,
    baselineWork: DEFAULT_BASELINE_WORK,
    window: DEFAULT_WINDOW,
    selfTest: false,
  };
  const numeric = new Map<string, keyof Arguments>([
    ["--training-start", "trainingSeedStart"],
    ["--calibration-start", "calibrationSeedStart"],
    ["--training-games", "trainingGames"],
    ["--calibration-games", "calibrationGames"],
    ["--max-moves", "maxMoves"],
    ["--oracle-depth", "oracleDepth"],
    ["--oracle-beam", "oracleBeam"],
    ["--baseline-depth", "baselineDepth"],
    ["--baseline-work", "baselineWork"],
    ["--window", "window"],
  ]);
  for (let index = 0; index < arguments_.length; index += 1) {
    const flag = arguments_[index];
    if (flag === "--self-test") {
      options.selfTest = true;
      continue;
    }
    if (flag === "--checkpoint") {
      options.checkpointPath = requiredValue(arguments_, ++index, flag);
      continue;
    }
    if (flag === "--output") {
      options.outputPath = requiredValue(arguments_, ++index, flag);
      continue;
    }
    const key = numeric.get(flag);
    if (!key) throw new Error(`Unknown argument: ${flag}`);
    (options as unknown as Record<string, number>)[key] = Number(
      requiredValue(arguments_, ++index, flag),
    );
  }
  for (const key of [
    "trainingGames",
    "calibrationGames",
    "maxMoves",
    "oracleDepth",
    "oracleBeam",
    "baselineDepth",
    "baselineWork",
    "window",
  ] as const) {
    if (!Number.isSafeInteger(options[key]) || options[key] < 1) {
      throw new Error(`${key} must be a positive integer`);
    }
  }
  if (options.maxMoves < options.window) {
    throw new Error("maxMoves must include at least one complete window");
  }
  if (options.trainingSeedStart !== undefined) {
    options.trainingSeedStart = normalizeSeed(options.trainingSeedStart);
  }
  if (options.calibrationSeedStart !== undefined) {
    options.calibrationSeedStart = normalizeSeed(options.calibrationSeedStart);
  }
  return options;
}

function requiredValue(arguments_: readonly string[], index: number, flag: string) {
  const value = arguments_[index];
  if (value === undefined) throw new Error(`${flag} needs a value`);
  return value;
}

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

function reportText(report: Report) {
  const policies = [...new Set(report.games.map((game) => game.policy))];
  const combinedWindows = report.games
    .filter((game) => game.policy === "combined")
    .flatMap((game) => game.windows);
  const oracleWindows = report.games
    .filter((game) => game.policy === "oracle")
    .flatMap((game) => game.windows);
  const lines = [
    "# Drop7 matched trajectory throughput",
    "",
    `> ${report.warning}`,
    "",
    `Seeds: training ${seedRangeText(report.configuration.trainingSeeds)}; calibration ${seedRangeText(report.configuration.calibrationSeeds)}. Window: ${report.configuration.window} moves. Cap: ${report.configuration.maxMoves} moves.`,
    `Oracle: depth ${report.configuration.oracle.depth}, beam ${report.configuration.oracle.beamWidth}. Combined: depth ${report.configuration.combined.maxDepth}, deterministic work ${report.configuration.combined.maxWork.toLocaleString("en-US")}. DQN: ${report.configuration.dqn ? report.configuration.dqn.checkpoint : "not loaded"}.`,
    `Search telemetry: combined completed mean depth ${formatNumber(meanNonNull(combinedWindows.map((window) => window.meanSearchDepth)), 2)} at ${formatNumber(meanNonNull(combinedWindows.map((window) => window.meanSearchWork)), 0)} work/move; oracle generated ${formatNumber(meanNonNull(oracleWindows.map((window) => window.meanGeneratedStates)), 0)} states/move.`,
    "",
    "## Outcomes",
    "",
    "| Policy | Games | Mean score | Mean moves | Capped |",
    "| --- | ---: | ---: | ---: | ---: |",
  ];
  for (const policy of policies) {
    const games = report.games.filter((game) => game.policy === policy);
    lines.push(
      `| ${policy} | ${games.length} | ${formatNumber(mean(games.map((game) => game.score)), 0)} | ${formatNumber(mean(games.map((game) => game.moves)), 1)} | ${games.filter((game) => game.censored).length} |`,
    );
  }

  lines.push(
    "",
    "## Matched five-move throughput",
    "",
    "Only seed/window pairs completed by every listed policy are included.",
    "",
    "| End move | N | Policy | Cracks | Reveals | Numbered cleared | Score | Chain waves | Multiwave moves | Quiet potential Δ |",
    "| ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
  );
  for (const aggregate of aggregateMatched(report.matchedWindows, policies)) {
    lines.push(
      `| ${aggregate.endMove} | ${aggregate.count} | ${aggregate.policy} | ${formatNumber(aggregate.coversCracked, 2)} | ${formatNumber(aggregate.coversRevealed, 2)} | ${formatNumber(aggregate.numberedCleared, 2)} | ${formatNumber(aggregate.score, 0)} | ${formatNumber(aggregate.chainWaves, 2)} | ${formatNumber(aggregate.multiWaveMoves, 2)} | ${formatSigned(aggregate.quietDirectGain + aggregate.quietLatentGain + aggregate.quietRecursiveGain, 3)} |`,
    );
  }

  lines.push(
    "",
    "## Matched board state",
    "",
    "Low-cap load is height² for a topmost 2 and 1.5×height² for a topmost 1; adjacent low-cap load adds the squared shared height of neighboring low caps.",
    "",
    "| End move | N | Policy | Occupied | Solid | Cracked | Numbered | Max h | Low caps | Low-cap load | Direct | Latent | Recursive |",
    "| ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
  );
  for (const aggregate of aggregateMatched(report.matchedWindows, policies)) {
    lines.push(
      `| ${aggregate.endMove} | ${aggregate.count} | ${aggregate.policy} | ${formatNumber(aggregate.occupied, 2)} | ${formatNumber(aggregate.solid, 2)} | ${formatNumber(aggregate.cracked, 2)} | ${formatNumber(aggregate.numbered, 2)} | ${formatNumber(aggregate.maxHeight, 2)} | ${formatNumber(aggregate.lowCapCount, 2)} | ${formatNumber(aggregate.lowCapLoad, 2)} | ${formatNumber(aggregate.directPotential, 3)} | ${formatNumber(aggregate.latentPotential, 3)} | ${formatNumber(aggregate.recursivePotential, 3)} |`,
    );
  }

  for (const [label, comparisons] of Object.entries(report.separation)) {
    lines.push(
      "",
      `## Strongest paired signals: ${label}`,
      "",
      "Effect is the mean oracle-minus-other delta divided by the standard deviation of per-seed mean deltas, so repeated windows do not pretend to be independent games. It is a ranking aid, not a causal estimate.",
      "",
      "| Signal | Kind | Windows | Seeds | Oracle | Other | Delta | Effect |",
      "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |",
    );
    for (const comparison of comparisons.slice(0, 14)) {
      lines.push(
        `| ${comparison.signal} | ${comparison.kind} | ${comparison.samples} | ${comparison.seeds} | ${formatNumber(comparison.oracleMean, 3)} | ${formatNumber(comparison.otherMean, 3)} | ${formatSigned(comparison.pairedDelta, 3)} | ${formatOptionalSigned(comparison.pairedEffect, 2)} |`,
      );
    }
  }

  lines.push(
    "",
    "## DQN-v2 feature/reward hypotheses",
    "",
    "- Represent potential as both stock and action delta: direct, latent, recursive propagation, and cracked/solid exposure. Reward positive potential delta on quiet build moves; separately reward realized clears, cracks, reveals, and chain depth so spending stored potential is not punished.",
    "- Add explicit low-cap count/load, adjacent low-cap load, adjacent-one, locked-two, maximum-height, and cover-altitude channels. Feed both the current state and deterministic candidate post-move deltas.",
    "- Normalize every dense shaping term and clip it below the value of survival. Keep a strong terminal penalty and use multi-step returns so delayed build-and-release sequences receive credit without requiring the reward function to guess the final chain exactly.",
    "- Treat the oracle as curriculum/diagnostic data only: it sees hidden futures. Any promoted reward or feature needs a fresh fair-policy ablation on untouched validation seeds.",
    "",
  );
  return lines.join("\n");
}

type AggregateRecord = Pick<WindowRecord, "policy" | "endMove"> &
  BoardMetrics &
  WindowThroughput & { count: number };

function aggregateMatched(
  records: readonly WindowRecord[],
  policies: readonly PolicyName[],
) {
  const groups = new Map<string, WindowRecord[]>();
  for (const record of records) {
    const key = `${record.endMove}:${record.policy}`;
    const group = groups.get(key) ?? [];
    group.push(record);
    groups.set(key, group);
  }
  const result: AggregateRecord[] = [];
  for (const [key, group] of groups) {
    const [endMoveText, policy] = key.split(":") as [string, PolicyName];
    const numeric = {} as Record<keyof (BoardMetrics & WindowThroughput), number>;
    for (const name of [
      "occupied",
      "solid",
      "cracked",
      "numbered",
      "maxHeight",
      "meanHeight",
      "lowCapCount",
      "lowCapLoad",
      "adjacentLowCapLoad",
      "directPotential",
      "latentPotential",
      "recursivePotential",
      "recursiveBonus",
      "crackedExposure",
      "solidExposure",
      "adjacentOnes",
      "tripleTwos",
      "deadLowNumbers",
      "coversCracked",
      "coversRevealed",
      "numberedCleared",
      "score",
      "chainWaves",
      "maxChain",
      "boardClears",
      "quietMoves",
      "multiWaveMoves",
      "quietDirectGain",
      "quietLatentGain",
      "quietRecursiveGain",
      "triggerPotentialSpent",
    ] as const) {
      numeric[name] = mean(group.map((record) => record[name]));
    }
    result.push({
      policy,
      endMove: Number(endMoveText),
      count: group.length,
      ...numeric,
    });
  }
  return result.sort(
    (first, second) =>
      first.endMove - second.endMove ||
      policies.indexOf(first.policy) - policies.indexOf(second.policy),
  );
}

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

function formatNumber(value: number, digits: number) {
  if (!Number.isFinite(value)) return value > 0 ? "∞" : value < 0 ? "−∞" : "n/a";
  return value.toLocaleString("en-US", {
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
  });
}

function formatSigned(value: number, digits: number) {
  return `${value >= 0 ? "+" : ""}${formatNumber(value, digits)}`;
}

function formatOptionalSigned(value: number | null, digits: number) {
  return value === null ? "n/a" : formatSigned(value, digits);
}

export function runSelfTest() {
  const state = initialState(0x2d70_0000);
  const metrics = boardMetrics(state);
  if (
    metrics.occupied !== 7 ||
    metrics.solid !== 7 ||
    metrics.cracked !== 0 ||
    metrics.maxHeight !== 1
  ) {
    throw new Error("Initial board metrics are incorrect");
  }
  const move = playActualMove(state, 3, 0x2d70_0000);
  if (!move) throw new Error("Self-test move was illegal");
  const throughput = emptyThroughput();
  accumulateMove(throughput, state, move);
  if (Object.values(throughput).some((value) => !Number.isFinite(value))) {
    throw new Error("Throughput contains a non-finite value");
  }
  const parsed = parseArguments(["--training-games", "2", "--window", "5"]);
  if (parsed.trainingGames !== 2 || parsed.window !== 5) {
    throw new Error("Argument parser failed");
  }
  process.stdout.write("drop7 trajectory throughput self-test passed\n");
}

export async function runCli(arguments_: readonly string[]) {
  const options = parseArguments(arguments_);
  if (options.selfTest) {
    runSelfTest();
    return;
  }
  const report = await buildTrajectoryReport(options);
  const text = reportText(report);
  process.stdout.write(`${text}\n`);
  if (options.outputPath) {
    await writeFile(
      options.outputPath,
      `${JSON.stringify(report, null, 2)}\n`,
      "utf8",
    );
  }
}

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