Drop7 Research
src/bench/policies.tsTypeScript259 lines · 8.8 KB
import type {
  Board,
  DiscValue,
  GameState,
} from "../core/typescript/engine.ts";
import { evaluateMoves } from "../core/typescript/solver.ts";
import { evaluateGrayThroughputMoves } from "../core/typescript/gray-throughput-policy.ts";
import { evaluateRolloutMoves } from "../core/typescript/rollout-solver.ts";
import { evaluateMctsMoves } from "../core/typescript/mcts-solver.ts";
import { evaluateSparseExpectimaxMoves } from "../core/typescript/sparse-expectimax.ts";
import { evaluateRiskSensitiveMoves } from "../core/typescript/risk-sensitive-planner.ts";
import { evaluateRobustOpenLoopBeam } from "../core/typescript/robust-open-loop-beam.ts";
import { nativeDecide } from "./native-policy.ts";

/**
 * The Drop7 Policy Protocol (D7P) TypeScript interface, mirroring the text
 * protocol in docs/d7p-protocol.md. A policy is a deterministic function of
 * the public state: the visible board, the visible next disc, the moves until
 * the next rise, and the terminal flag. It never receives the round id, the
 * generator seed, future discs, latent values, or the score.
 */
export interface PublicState {
  board: Board;
  nextDisc: DiscValue;
  movesRemaining: number;
  gameOver: boolean;
}

export interface BenchPolicy {
  /** Stable kebab-case identifier used in leaderboard data and the D7P wire protocol. */
  id: string;
  name: string;
  family: string;
  description: string;
  /** Repository-backed page explaining the exact strategy family/configuration. */
  researchPath: `/approaches/${string}`;
  /**
   * True when the decision depends only on the legal public state. Policies
   * that additionally read level or move number (which the research contract
   * excludes from the deployable interface) are flagged and shown separately.
   */
  publicInformation: boolean;
  /** Excluded from the default suite when true (runtime), opt in explicitly. */
  slow?: boolean;
  chooseColumn(state: GameState): number | null;
}

/**
 * Strict policies receive a sanitized state whose score, level, and move
 * counter are fixed constants, so they cannot read non-public fields even
 * accidentally. The board, next disc, rise clock, and terminal flag are the
 * only inputs that vary.
 */
function publicOnly(state: GameState): GameState {
  return {
    board: state.board,
    nextDisc: state.nextDisc,
    score: 0,
    level: 1,
    movesRemaining: state.movesRemaining,
    movesPlayed: 0,
    gameOver: state.gameOver,
  };
}

/** Solver-local seed derived from the policy id; never a game/round seed. */
function policySeed(id: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < id.length; index += 1) {
    hash = Math.imul(hash ^ id.charCodeAt(index), 0x01000193);
  }
  return hash >>> 0;
}

function define(policy: BenchPolicy): BenchPolicy {
  return policy;
}

export const BENCH_POLICIES: readonly BenchPolicy[] = [
  define({
    id: "greedy",
    name: "Greedy 1-ply",
    family: "heuristic-search",
    description:
      "Exact chance average over the immediate move, scored by the combined hand evaluator. The fast baseline.",
    researchPath: "/approaches/fair-expectimax/fair-policy",
    publicInformation: true,
    chooseColumn: (state) =>
      evaluateMoves(publicOnly(state), { maxDepth: 1, maxWork: 100_000 })
        .bestColumn,
  }),
  define({
    id: "expectimax-d2",
    name: "Expectimax D2",
    family: "fair-expectimax",
    description:
      "Full-width expectimax, two completed plies, combined leaf, fixed work bound.",
    researchPath: "/approaches/fair-expectimax/reference",
    publicInformation: true,
    chooseColumn: (state) =>
      evaluateMoves(publicOnly(state), { maxDepth: 2, maxWork: 100_000 })
        .bestColumn,
  }),
  define({
    id: "expectimax-d3",
    name: "TypeScript Expectimax D3",
    family: "fair-expectimax",
    description:
      "Full-width expectimax, three completed plies. The affordable end of the reference line.",
    researchPath: "/approaches/fair-expectimax/reference",
    publicInformation: true,
    slow: true,
    chooseColumn: (state) =>
      evaluateMoves(publicOnly(state), { maxDepth: 3, maxWork: 400_000 })
        .bestColumn,
  }),
  define({
    id: "expectimax-d4",
    name: "TypeScript Expectimax D4",
    family: "fair-expectimax",
    description:
      "Completed full-width TypeScript depth 4 with exact engine outcomes and the combined leaf. A playground analogue of the native research reference, not a source-identical port.",
    researchPath: "/approaches/fair-expectimax/reference",
    publicInformation: true,
    slow: true,
    chooseColumn: (state) =>
      evaluateMoves(publicOnly(state), { maxDepth: 4, maxWork: 3_200_000 })
        .bestColumn,
  }),
  define({
    id: "gray-throughput",
    name: "Gray throughput",
    family: "heuristic-search",
    description:
      "Rule policy prioritizing cracks, reveals, cover altitude, and occupancy flow.",
    researchPath: "/approaches/heuristic-search/gray-throughput",
    publicInformation: false,
    chooseColumn: (state) => evaluateGrayThroughputMoves(state).bestColumn,
  }),
  define({
    id: "rollout-h8",
    name: "Rollout H8",
    family: "heuristic-search",
    description:
      "Eight paired rollouts per root column over an eight-move horizon with a greedy continuation.",
    researchPath: "/approaches/heuristic-search/rollout",
    publicInformation: true,
    chooseColumn: (state) =>
      evaluateRolloutMoves(publicOnly(state), {
        rollouts: 8,
        horizon: 8,
        seed: policySeed("rollout-h8"),
      }).bestColumn,
  }),
  define({
    id: "mcts",
    name: "MCTS",
    family: "tree-search",
    description:
      "Chance-sampled Monte Carlo tree search, 400 simulations, 16-move horizon, heuristic leaf.",
    researchPath: "/approaches/tree-search/mcts",
    publicInformation: true,
    chooseColumn: (state) =>
      evaluateMctsMoves(publicOnly(state), {
        simulations: 400,
        horizon: 16,
        seed: policySeed("mcts"),
      }).bestColumn,
  }),
  define({
    id: "sparse-d2",
    name: "Sparse expectimax D2",
    family: "heuristic-search",
    description:
      "Iterative-deepening expectimax with five stratified chance samples per branch, two plies.",
    researchPath: "/approaches/heuristic-search/sparse-expectimax",
    publicInformation: true,
    chooseColumn: (state) =>
      evaluateSparseExpectimaxMoves(publicOnly(state), {
        maxDepth: 2,
        chanceSamples: 5,
        seed: policySeed("sparse-d2"),
      }).bestColumn,
  }),
  define({
    id: "risk-d2",
    name: "Risk-sensitive D2",
    family: "heuristic-search",
    description:
      "CVaR-weighted root over a two-ply expectimax continuation; trades mean for a safer lower tail.",
    researchPath: "/approaches/heuristic-search/risk-sensitive",
    publicInformation: true,
    chooseColumn: (state) =>
      evaluateRiskSensitiveMoves(publicOnly(state), {
        scenarios: 8,
        continuationDepth: 2,
        chanceSamples: 3,
        riskWeight: 0.5,
        maxWork: 150_000,
        seed: policySeed("risk-d2"),
      }).bestColumn,
  }),
  define({
    id: "open-loop-beam",
    name: "Open-loop beam",
    family: "heuristic-search",
    description:
      "Replanning open-loop prefix beam over twelve shared scenarios, bounded by logical work (the wall-clock cap is a loose safety net).",
    researchPath: "/approaches/heuristic-search/open-loop",
    publicInformation: true,
    chooseColumn: (state) =>
      evaluateRobustOpenLoopBeam(publicOnly(state), {
        scenarios: 12,
        depth: 3,
        beamWidth: 8,
        maxWork: 150_000,
        timeLimitMs: 60_000,
        seed: policySeed("open-loop-beam"),
      }).bestColumn,
  }),
  define({
    id: "native-fair-d4-s7",
    name: "Native fair D4, 7 strata",
    family: "lifetime-objective",
    description:
      "The research search itself: the bit-exact fast engine's completed full-width depth 4 with seven chance strata and the frozen fair leaf, through a one-shot native binary (build it with approaches/lifetime-objective/leaf-evolution/build.sh decide).",
    researchPath: "/approaches/lifetime-objective/leaf-evolution",
    publicInformation: true,
    slow: true,
    chooseColumn: (state) => nativeDecide(publicOnly(state), { chanceSamples: 7 }),
  }),
];

export const DEFAULT_POLICY_IDS = BENCH_POLICIES.filter(
  (policy) => !policy.slow,
).map((policy) => policy.id);

/** Public-information policies seeded into each human competition by default. */
export const COMPETITION_POLICY_IDS = [
  "expectimax-d4",
  "expectimax-d3",
  "expectimax-d2",
  "greedy",
  "open-loop-beam",
  "risk-d2",
  "sparse-d2",
  "rollout-h8",
  "mcts",
] as const;

export function getPolicy(id: string): BenchPolicy {
  const policy = BENCH_POLICIES.find((candidate) => candidate.id === id);
  if (!policy) {
    throw new Error(
      `Unknown policy "${id}". Available: ${BENCH_POLICIES.map((p) => p.id).join(", ")}`,
    );
  }
  return policy;
}