Drop7 Research
approaches/heuristic-search/phase-horizon/main.tsTypeScript740 lines · 21.4 KB
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

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

/**
 * Training-only coordinate sweep for the phase-aware sparse horizon evaluator.
 *
 * The sweep is intentionally small: each weight family is halved or doubled
 * around one fixed center. The selected weights are locked before calibration
 * is allowed. The 0x7d70 validation and 0xd700 final ranges are hard-rejected.
 */

const TRAINING_SEED_START = 0x1d70_0000;
const CALIBRATION_SEED_START = 0x5d70_0000;
const VALIDATION_SEED_START = 0x7d70_0000;
const RESERVED_FINAL_SEED_START = 0xd700_0000;
const POLICY_SEED = 0xd707_5eed;
const REVEAL_DOMAIN = 0x5245_564c;
const DEFAULT_PILOT_GAMES = 2;
const DEFAULT_CONFIRM_GAMES = 16;
const DEFAULT_CALIBRATION_GAMES = 16;
const DEFAULT_MAX_MOVES = 500;
const DEFAULT_MAX_WORK = 1_000_000;
const DEFAULT_MAX_CACHE = 40_000;
const DEFAULT_TERMINAL_UTILITY = -1_000_000;
const REQUIRED_TRAINING_MEAN = 400_000;
const MATERIAL_IMPROVEMENT = 1.05;

interface Arguments {
  pilotGames: number;
  confirmGames: number;
  calibrationGames: number;
  maxMoves: number;
  maxWork: number;
  maxCacheEntries: number;
  terminalUtility: number;
  presetNames?: string[];
  outputPath?: string;
  pilotOnly: boolean;
  selfTest: boolean;
}

interface Scales {
  queue: number;
  altitude: number;
  clog: number;
  build: number;
  release: number;
}

interface Preset {
  name: string;
  scales: Scales;
  weights: PhaseHorizonWeights;
}

interface GameResult {
  seed: number;
  score: number;
  moves: number;
  censored: boolean;
  numberedCleared: number;
  coversRevealed: number;
  coversCracked: number;
  maxChain: number;
  meanDepth: number;
  workPerMove: number;
}

interface Summary {
  games: number;
  meanScore: number;
  medianScore: number;
  minimumScore: number;
  maximumScore: number;
  meanMoves: number;
  censoredGames: number;
  meanNumberedCleared: number;
  meanCoversRevealed: number;
  meanCoversCracked: number;
  meanMaxChain: number;
  meanDepth: number;
  meanWorkPerMove: number;
  results: readonly GameResult[];
}

interface CandidateResult {
  name: string;
  scales: Scales | null;
  weights: PhaseHorizonWeights | null;
  summary: Summary;
  pairedMeanScoreDelta: number;
  pairedMeanMoveDelta: number;
}

interface LabArtifact {
  format: "drop7-phase-horizon-sweep";
  version: 1;
  seedPolicy: {
    trainingSeedStart: number;
    calibrationSeedStart: number;
    validationSeedStart: number;
    reservedFinalSeedStart: number;
  };
  search: {
    depth: 3;
    samples: 5;
    policySeed: number;
    maxWork: number;
    maxCacheEntries: number;
    terminalUtility: number;
    maxMoves: number;
  };
  pilot: CandidateResult[];
  frozenChampion: string | null;
  confirmation: CandidateResult[] | null;
  qualified: boolean;
  calibration: CandidateResult[] | null;
  rejected: readonly string[];
}

const CENTER_SCALES: Scales = {
  queue: 1,
  altitude: 1,
  clog: 1,
  build: 1,
  release: 1,
};

const PRESETS: readonly Preset[] = [
  preset("center", {}),
  preset("queue-half", { queue: 0.5 }),
  preset("queue-double", { queue: 2 }),
  preset("altitude-half", { altitude: 0.5 }),
  preset("altitude-double", { altitude: 2 }),
  preset("clog-half", { clog: 0.5 }),
  preset("clog-double", { clog: 2 }),
  preset("build-half", { build: 0.5 }),
  preset("build-double", { build: 2 }),
  preset("release-half", { release: 0.5 }),
  preset("release-double", { release: 2 }),
  preset("release-quad", { release: 4 }),
  preset("release2-altitude-half", { release: 2, altitude: 0.5 }),
  preset("release2-build-half", { release: 2, build: 0.5 }),
  preset("release2-clog-double", { release: 2, clog: 2 }),
  preset("release2-safety-double", {
    release: 2,
    queue: 2,
    altitude: 2,
  }),
];

function preset(name: string, overrides: Partial<Scales>): Preset {
  const scales = { ...CENTER_SCALES, ...overrides };
  return { name, scales, weights: scaledWeights(scales) };
}

function scaledWeights(scales: Scales): PhaseHorizonWeights {
  const center = DEFAULT_PHASE_HORIZON_WEIGHTS;
  return {
    baselineScale: center.baselineScale,
    projectedOccupancyDebt:
      center.projectedOccupancyDebt * scales.queue,
    residualCoverDebt: center.residualCoverDebt * scales.queue,
    coverAltitudeDebt: center.coverAltitudeDebt * scales.altitude,
    imminentCoverAltitudeDebt:
      center.imminentCoverAltitudeDebt * scales.altitude,
    peakHeightRisk: center.peakHeightRisk * scales.altitude,
    lowCapLoad: center.lowCapLoad * scales.clog,
    adjacentLowCapLoad: center.adjacentLowCapLoad * scales.clog,
    directBuildInventory: center.directBuildInventory * scales.build,
    quietBuildOptions: center.quietBuildOptions * scales.build,
    quietDirectGain: center.quietDirectGain * scales.build,
    triggerReadiness: center.triggerReadiness * scales.release,
    releaseReadiness: center.releaseReadiness * scales.release,
  };
}

export async function runSweep(options: Arguments): Promise<LabArtifact> {
  const selected = selectPresets(options.presetNames);
  const cache = new Map<string, GameResult>();
  const trainingPilotSeeds = seedRange(
    TRAINING_SEED_START,
    options.pilotGames,
  );
  const baselinePilot = evaluateCandidate(
    "combined",
    null,
    null,
    trainingPilotSeeds,
    options,
    cache,
    "pilot",
  );
  const pilot = [
    baselinePilot,
    ...selected.map((candidate) =>
      evaluateCandidate(
        candidate.name,
        candidate.scales,
        candidate.weights,
        trainingPilotSeeds,
        options,
        cache,
        "pilot",
        baselinePilot.summary,
      ),
    ),
  ];
  pilot.sort(compareCandidate);
  printLeaderboard("training pilot", pilot);

  const bestPhase = pilot.find((candidate) => candidate.name !== "combined");
  const pilotMaterial =
    bestPhase !== undefined &&
    bestPhase.summary.meanScore >=
      baselinePilot.summary.meanScore * MATERIAL_IMPROVEMENT;
  const rejected = pilot
    .filter(
      (candidate) =>
        candidate.name !== "combined" && candidate.name !== bestPhase?.name,
    )
    .map((candidate) =>
      `${candidate.name}: pilot mean ${Math.round(candidate.summary.meanScore)} (${signed(candidate.pairedMeanScoreDelta)} vs combined)`,
    );

  if (!bestPhase || options.pilotOnly || !pilotMaterial) {
    const reason = !bestPhase
      ? "no phase candidate was evaluated"
      : options.pilotOnly
        ? "pilot-only mode"
        : `best pilot failed the ${Math.round((MATERIAL_IMPROVEMENT - 1) * 100)}% material-improvement gate`;
    process.stdout.write(`calibration skipped: ${reason}\n`);
    return artifact(options, pilot, null, null, false, null, rejected);
  }

  // Lock the preset name and exact weights before expanding the training
  // sample; confirmation and calibration cannot change candidate selection.
  const frozen = selected.find((candidate) => candidate.name === bestPhase.name)!;
  const confirmationSeeds = seedRange(
    TRAINING_SEED_START,
    options.confirmGames,
  );
  const baselineConfirmation = evaluateCandidate(
    "combined",
    null,
    null,
    confirmationSeeds,
    options,
    cache,
    "confirmation",
  );
  const championConfirmation = evaluateCandidate(
    frozen.name,
    frozen.scales,
    frozen.weights,
    confirmationSeeds,
    options,
    cache,
    "confirmation",
    baselineConfirmation.summary,
  );
  const confirmation = [baselineConfirmation, championConfirmation];
  printLeaderboard("training confirmation", confirmation);
  const qualified =
    options.confirmGames >= 16 &&
    championConfirmation.summary.meanScore >= REQUIRED_TRAINING_MEAN &&
    championConfirmation.summary.meanScore >=
      baselineConfirmation.summary.meanScore * MATERIAL_IMPROVEMENT;
  if (!qualified) {
    rejected.push(
      `${frozen.name}: confirmation mean ${Math.round(championConfirmation.summary.meanScore)} did not clear both ${REQUIRED_TRAINING_MEAN} and the material baseline gate`,
    );
    process.stdout.write(
      `calibration skipped: frozen ${frozen.name} did not qualify on ${options.confirmGames} training games\n`,
    );
    return artifact(
      options,
      pilot,
      frozen.name,
      confirmation,
      false,
      null,
      rejected,
    );
  }

  const calibrationSeeds = seedRange(
    CALIBRATION_SEED_START,
    options.calibrationGames,
  );
  const baselineCalibration = evaluateCandidate(
    "combined",
    null,
    null,
    calibrationSeeds,
    options,
    cache,
    "calibration",
  );
  const championCalibration = evaluateCandidate(
    frozen.name,
    frozen.scales,
    frozen.weights,
    calibrationSeeds,
    options,
    cache,
    "calibration",
    baselineCalibration.summary,
  );
  const calibration = [baselineCalibration, championCalibration];
  printLeaderboard("frozen calibration", calibration);
  return artifact(
    options,
    pilot,
    frozen.name,
    confirmation,
    true,
    calibration,
    rejected,
  );
}

function artifact(
  options: Arguments,
  pilot: CandidateResult[],
  frozenChampion: string | null,
  confirmation: CandidateResult[] | null,
  qualified: boolean,
  calibration: CandidateResult[] | null,
  rejected: string[],
): LabArtifact {
  return {
    format: "drop7-phase-horizon-sweep",
    version: 1,
    seedPolicy: {
      trainingSeedStart: TRAINING_SEED_START,
      calibrationSeedStart: CALIBRATION_SEED_START,
      validationSeedStart: VALIDATION_SEED_START,
      reservedFinalSeedStart: RESERVED_FINAL_SEED_START,
    },
    search: {
      depth: 3,
      samples: 5,
      policySeed: POLICY_SEED,
      maxWork: options.maxWork,
      maxCacheEntries: options.maxCacheEntries,
      terminalUtility: options.terminalUtility,
      maxMoves: options.maxMoves,
    },
    pilot,
    frozenChampion,
    confirmation,
    qualified,
    calibration,
    rejected,
  };
}

function evaluateCandidate(
  name: string,
  scales: Scales | null,
  weights: PhaseHorizonWeights | null,
  seeds: readonly number[],
  options: Arguments,
  cache: Map<string, GameResult>,
  stage: string,
  baseline?: Summary,
): CandidateResult {
  const results = seeds.map((seed, index) => {
    const key = `${name}:${seed}`;
    const cached = cache.get(key);
    if (cached) return cached;
    const result = runGame(seed, weights, options);
    cache.set(key, result);
    process.stderr.write(
      `${stage} ${name} ${index + 1}/${seeds.length} ${formatSeed(seed)} · ${result.score.toLocaleString("en-US")} · ${result.moves} moves${result.censored ? " capped" : ""}\n`,
    );
    return result;
  });
  const summary = summarize(results);
  const pairedMeanScoreDelta = baseline
    ? mean(
        results.map(
          (result, index) => result.score - baseline.results[index].score,
        ),
      )
    : 0;
  const pairedMeanMoveDelta = baseline
    ? mean(
        results.map(
          (result, index) => result.moves - baseline.results[index].moves,
        ),
      )
    : 0;
  return {
    name,
    scales,
    weights,
    summary,
    pairedMeanScoreDelta,
    pairedMeanMoveDelta,
  };
}

function runGame(
  seed: number,
  weights: PhaseHorizonWeights | null,
  options: Arguments,
): GameResult {
  assertAllowedSeed(seed);
  let state: GameState = {
    board: createInitialBoard(),
    nextDisc: headlessDisc(seed, 0),
    score: 0,
    level: 1,
    movesRemaining: MOVES_PER_LEVEL,
    movesPlayed: 0,
    gameOver: false,
  };
  const evaluator = weights
    ? (position: GameState) => evaluatePhaseHorizon(position, weights)
    : undefined;
  let numberedCleared = 0;
  let coversRevealed = 0;
  let coversCracked = 0;
  let maxChain = 0;
  let depth = 0;
  let work = 0;

  while (!state.gameOver && state.movesPlayed < options.maxMoves) {
    const evaluation = evaluateSparseExpectimaxMoves(state, {
      maxDepth: 3,
      chanceSamples: 5,
      maxWork: options.maxWork,
      maxCacheEntries: options.maxCacheEntries,
      seed: POLICY_SEED,
      terminalUtility: options.terminalUtility,
      heuristicProfile: "combined",
      ...(evaluator ? { evaluator } : {}),
    });
    if (evaluation.bestColumn === null) {
      throw new Error("Sparse phase lab found no move in a live game");
    }
    depth += evaluation.depth;
    work += evaluation.work;
    const revealSeed = mix32(
      seed ^
        Math.imul(state.movesPlayed + 1, 0x85eb_ca6b) ^
        REVEAL_DOMAIN,
    );
    const move = playMove(
      state,
      evaluation.bestColumn,
      seededRandom(revealSeed),
      { captureAnimation: true },
    );
    if (!move) throw new Error("Sparse phase lab selected an illegal move");
    for (const wave of move.waves) {
      numberedCleared += wave.cleared;
      coversRevealed += wave.revealed;
    }
    for (const frame of move.animation) {
      if (frame.kind !== "impact") continue;
      for (const index of frame.indexes) {
        if (frame.board[index] === CRACKED) coversCracked += 1;
      }
    }
    maxChain = Math.max(maxChain, move.waves.length);
    state = move.state.gameOver
      ? move.state
      : {
          ...move.state,
          nextDisc: headlessDisc(seed, move.state.movesPlayed),
        };
  }

  return {
    seed,
    score: state.score,
    moves: state.movesPlayed,
    censored: !state.gameOver,
    numberedCleared,
    coversRevealed,
    coversCracked,
    maxChain,
    meanDepth: state.movesPlayed === 0 ? 0 : depth / state.movesPlayed,
    workPerMove: state.movesPlayed === 0 ? 0 : work / state.movesPlayed,
  };
}

function summarize(results: readonly GameResult[]): Summary {
  const scores = results.map((result) => result.score).sort(numberOrder);
  return {
    games: results.length,
    meanScore: mean(scores),
    medianScore: percentile(scores, 0.5),
    minimumScore: scores[0],
    maximumScore: scores.at(-1)!,
    meanMoves: mean(results.map((result) => result.moves)),
    censoredGames: results.filter((result) => result.censored).length,
    meanNumberedCleared: mean(
      results.map((result) => result.numberedCleared),
    ),
    meanCoversRevealed: mean(
      results.map((result) => result.coversRevealed),
    ),
    meanCoversCracked: mean(results.map((result) => result.coversCracked)),
    meanMaxChain: mean(results.map((result) => result.maxChain)),
    meanDepth: mean(results.map((result) => result.meanDepth)),
    meanWorkPerMove: mean(results.map((result) => result.workPerMove)),
    results,
  };
}

function printLeaderboard(label: string, results: readonly CandidateResult[]) {
  process.stdout.write(`\n${label}\n`);
  for (const result of [...results].sort(compareCandidate)) {
    process.stdout.write(
      `${result.name.padEnd(16)} mean ${Math.round(result.summary.meanScore).toLocaleString("en-US")} · moves ${result.summary.meanMoves.toFixed(1)} · clears ${result.summary.meanNumberedCleared.toFixed(1)} · reveals ${result.summary.meanCoversRevealed.toFixed(1)} · chain ${result.summary.meanMaxChain.toFixed(2)} · ${signed(result.pairedMeanScoreDelta)} vs combined\n`,
    );
  }
}

function compareCandidate(first: CandidateResult, second: CandidateResult) {
  return (
    second.summary.meanScore - first.summary.meanScore ||
    second.summary.meanMoves - first.summary.meanMoves ||
    first.name.localeCompare(second.name)
  );
}

function selectPresets(names?: readonly string[]) {
  if (!names) return [...PRESETS];
  const selected = names.map((name) => {
    const candidate = PRESETS.find((item) => item.name === name);
    if (!candidate) {
      throw new Error(
        `Unknown preset ${name}; choose ${PRESETS.map((item) => item.name).join(", ")}`,
      );
    }
    return candidate;
  });
  return [...new Map(selected.map((item) => [item.name, item])).values()];
}

function seedRange(start: number, count: number) {
  const seeds = Array.from(
    { length: count },
    (_, index) => (start + index) >>> 0,
  );
  for (const seed of seeds) assertAllowedSeed(seed);
  return seeds;
}

function assertAllowedSeed(seed: number) {
  if (!Number.isSafeInteger(seed) || seed < 0 || seed > 0xffff_ffff) {
    throw new Error("phase horizon game seed must be a uint32");
  }
  const training = seed >= TRAINING_SEED_START && seed < CALIBRATION_SEED_START;
  const calibration =
    seed >= CALIBRATION_SEED_START && seed < VALIDATION_SEED_START;
  if (!training && !calibration) {
    throw new Error(
      `seed ${formatSeed(seed)} is outside training/calibration; validation and final ranges are forbidden`,
    );
  }
}

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

function percentile(sorted: readonly number[], fraction: number) {
  if (sorted.length === 1) return sorted[0];
  const position = fraction * (sorted.length - 1);
  const lower = Math.floor(position);
  const upper = Math.ceil(position);
  const mix = position - lower;
  return sorted[lower] * (1 - mix) + sorted[upper] * mix;
}

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

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 signed(value: number) {
  const rounded = Math.round(value).toLocaleString("en-US");
  return `${value >= 0 ? "+" : ""}${rounded}`;
}

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

export function parseArguments(arguments_: readonly string[]): Arguments {
  const options: Arguments = {
    pilotGames: DEFAULT_PILOT_GAMES,
    confirmGames: DEFAULT_CONFIRM_GAMES,
    calibrationGames: DEFAULT_CALIBRATION_GAMES,
    maxMoves: DEFAULT_MAX_MOVES,
    maxWork: DEFAULT_MAX_WORK,
    maxCacheEntries: DEFAULT_MAX_CACHE,
    terminalUtility: DEFAULT_TERMINAL_UTILITY,
    pilotOnly: false,
    selfTest: false,
  };
  const numeric = new Map<string, keyof Arguments>([
    ["--pilot-games", "pilotGames"],
    ["--confirm-games", "confirmGames"],
    ["--calibration-games", "calibrationGames"],
    ["--max-moves", "maxMoves"],
    ["--max-work", "maxWork"],
    ["--max-cache", "maxCacheEntries"],
    ["--terminal-utility", "terminalUtility"],
  ]);
  for (let index = 0; index < arguments_.length; index += 1) {
    const flag = arguments_[index];
    if (flag === "--pilot-only") {
      options.pilotOnly = true;
      continue;
    }
    if (flag === "--self-test") {
      options.selfTest = true;
      continue;
    }
    if (flag === "--presets") {
      options.presetNames = requiredValue(arguments_, ++index, flag)
        .split(",")
        .filter(Boolean);
      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 [
    "pilotGames",
    "confirmGames",
    "calibrationGames",
    "maxMoves",
    "maxWork",
    "maxCacheEntries",
  ] as const) {
    if (!Number.isSafeInteger(options[key]) || options[key] < 1) {
      throw new Error(`${key} must be a positive integer`);
    }
  }
  if (!Number.isFinite(options.terminalUtility)) {
    throw new Error("terminalUtility must be finite");
  }
  selectPresets(options.presetNames);
  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;
}

export function runSelfTest() {
  const parsed = parseArguments([
    "--pilot-games",
    "1",
    "--presets",
    "center,build-double",
  ]);
  if (parsed.pilotGames !== 1 || parsed.presetNames?.length !== 2) {
    throw new Error("phase horizon argument parser failed");
  }
  assertAllowedSeed(TRAINING_SEED_START);
  assertAllowedSeed(CALIBRATION_SEED_START);
  for (const seed of [VALIDATION_SEED_START, RESERVED_FINAL_SEED_START]) {
    let threw = false;
    try {
      assertAllowedSeed(seed);
    } catch {
      threw = true;
    }
    if (!threw) throw new Error("reserved seed guard failed");
  }
  if (PRESETS.some((candidate) => !Number.isFinite(candidate.weights.lowCapLoad))) {
    throw new Error("phase horizon preset contains invalid weights");
  }
  process.stdout.write("drop7 phase horizon lab self-test passed\n");
}

export async function runCli(arguments_: readonly string[]) {
  const options = parseArguments(arguments_);
  if (options.selfTest) {
    runSelfTest();
    return;
  }
  process.stdout.write(
    `phase horizon sparse d3/s5 · training ${formatSeed(TRAINING_SEED_START)}+ · calibration ${formatSeed(CALIBRATION_SEED_START)}+ only after qualification · validation ${formatSeed(VALIDATION_SEED_START)}+ untouched · final ${formatSeed(RESERVED_FINAL_SEED_START)}+ untouched\n`,
  );
  const result = await runSweep(options);
  if (options.outputPath) {
    await writeFile(
      options.outputPath,
      `${JSON.stringify(result, null, 2)}\n`,
      "utf8",
    );
    process.stdout.write(`report ${resolve(options.outputPath)}\n`);
  }
}

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