Drop7 Research
src/core/typescript/rollout-solver.tsTypeScript511 lines · 13.9 KB
import {
  BOARD_SIZE,
  EMPTY,
  playMove,
  seededRandom,
  type Board,
  type DiscValue,
  type GameState,
} from "./engine.ts";
import {
  DEFAULT_HEURISTIC_PROFILE,
  HEURISTIC_GAME_OVER_UTILITY,
  evaluateHeuristic,
  type HeuristicProfileName,
} from "./heuristic.ts";

export type RolloutEvaluator = (state: GameState) => number;
/**
 * A seed-blind continuation policy. It receives only the state visible to a
 * player after the preceding simulated move, never the rollout seed or chance
 * tape. Returning null ends that sampled continuation with terminal utility.
 */
export type RolloutContinuationPolicy = (
  state: Readonly<GameState>,
) => number | null;

export interface RolloutSolverOptions {
  rollouts: number;
  /** Total simulated moves including the candidate root move. */
  horizon: number;
  /** Reveal samples per greedy policy candidate; defaults to one midpoint. */
  continuationSamples?: number;
  /** Unsigned 32-bit seed used for paired future-disc and reveal samples. */
  seed: number;
  heuristicProfile?: HeuristicProfileName;
  /** Overrides heuristicProfile. It should not depend on state.score. */
  evaluator?: RolloutEvaluator;
  /**
   * Optional observable-state policy for moves after the candidate root.
   * When omitted, the existing sampled greedy continuation is unchanged.
   */
  continuationPolicy?: RolloutContinuationPolicy;
  terminalUtility?: number;
  /**
   * Optional lower-confidence-bound penalty in standard deviations. A
   * positive value favors moves whose sampled continuations are consistently
   * good, which can reduce short-horizon survival failures.
   */
  riskAversion?: number;
  /** Latin-hypercube chance tapes; experimental and off by default. */
  stratifiedSamples?: boolean;
}

export interface RolloutColumnEvaluation {
  column: number;
  mean: number;
  /** Population variance across the sampled continuation utilities. */
  variance: number;
  rollouts: number;
}

export interface RolloutEvaluationResult {
  bestColumn: number | null;
  columns: readonly RolloutColumnEvaluation[];
  rollouts: number;
  horizon: number;
  continuationSamples: number;
  riskAversion: number;
  stratifiedSamples: boolean;
  seed: number;
  /** Simulated moves plus non-terminal heuristic evaluations. */
  work: number;
}

export const MAX_ROLLOUT_HORIZON = 40;
export const MAX_ROLLOUT_COUNT = 100_000;
export const MAX_CONTINUATION_SAMPLES = 8;

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 DISC_DOMAIN = 0x44495343;
const REVEAL_DOMAIN = 0x5245564c;
const POLICY_DOMAIN = 0x504f4c59;
const JITTER_DOMAIN = 0x4a495454;
const ROLLOUT_MULTIPLIER = 0x9e3779b9;
const PLY_MULTIPLIER = 0x85ebca6b;
const EVENT_MULTIPLIER = 0xc2b2ae35;

interface RunningStats {
  count: number;
  mean: number;
  squaredDeviation: number;
}

interface WorkCounter {
  value: number;
}

/**
 * Bounded-memory sparse-sampling planner for longer Drop7 horizons.
 *
 * Only seven online statistics are retained. Chance is sampled by calling the
 * ordinary engine transition with a deterministic reveal stream; the exact
 * reveal tree is never constructed. Every root column receives the same
 * future-disc sample for a given rollout and ply, reducing comparison noise.
 */
export function evaluateRolloutMoves(
  state: GameState,
  options: RolloutSolverOptions,
): RolloutEvaluationResult {
  const rollouts = boundedPositiveInteger(
    options.rollouts,
    "rollouts",
    MAX_ROLLOUT_COUNT,
  );
  const horizon = boundedPositiveInteger(
    options.horizon,
    "horizon",
    MAX_ROLLOUT_HORIZON,
  );
  const continuationSamples = boundedPositiveInteger(
    options.continuationSamples ?? 1,
    "continuationSamples",
    MAX_CONTINUATION_SAMPLES,
  );
  const seed = unsignedSeed(options.seed);
  const terminalUtility =
    options.terminalUtility ?? HEURISTIC_GAME_OVER_UTILITY;
  if (!Number.isFinite(terminalUtility)) {
    throw new Error("terminalUtility must be finite");
  }
  const riskAversion = options.riskAversion ?? 0;
  if (!Number.isFinite(riskAversion) || riskAversion < 0) {
    throw new Error("riskAversion must be a non-negative finite number");
  }
  const stratifiedSamples = options.stratifiedSamples ?? false;

  const profile = options.heuristicProfile ?? DEFAULT_HEURISTIC_PROFILE;
  const evaluator =
    options.evaluator ??
    ((position: GameState) => evaluateHeuristic(position, profile));
  const work: WorkCounter = { value: 0 };
  const rootStats: Array<{ column: number; stats: RunningStats }> = [];

  if (!state.gameOver) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      if (state.board[column] !== EMPTY) continue;
      const stats = createRunningStats();
      for (let rollout = 0; rollout < rollouts; rollout += 1) {
        const utility = sampleRootContinuation(
          state,
          column,
          rollout,
          rollouts,
          horizon,
          continuationSamples,
          seed,
          stratifiedSamples,
          evaluator,
          options.continuationPolicy,
          terminalUtility,
          work,
        );
        updateRunningStats(stats, utility);
      }
      rootStats.push({ column, stats });
    }
  }

  const columns = rootStats.map(({ column, stats }) => ({
    column,
    mean: stats.mean,
    variance:
      stats.count === 0 ? 0 : stats.squaredDeviation / stats.count,
    rollouts: stats.count,
  }));
  const bestColumn = chooseBestRootColumn(
    state.board,
    columns,
    riskAversion,
  );

  return {
    bestColumn,
    columns,
    rollouts,
    horizon,
    continuationSamples,
    riskAversion,
    stratifiedSamples,
    seed,
    work: work.value,
  };
}

function sampleRootContinuation(
  initial: GameState,
  rootColumn: number,
  rollout: number,
  rolloutCount: number,
  horizon: number,
  continuationSamples: number,
  seed: number,
  stratifiedSamples: boolean,
  evaluator: RolloutEvaluator,
  continuationPolicy: RolloutContinuationPolicy | undefined,
  terminalUtility: number,
  work: WorkCounter,
) {
  let total = 0;
  let state = initial;
  let column = rootColumn;

  for (let ply = 0; ply < horizon; ply += 1) {
    const move = playMove(
      state,
      column,
      stratifiedSamples
        ? stratifiedRevealRandom(seed, rollout, rolloutCount, ply)
        : seededRandom(revealSeed(seed, rollout, ply)),
      { captureAnimation: false },
    );
    work.value += 1;
    if (!move) return total + terminalUtility;

    total += move.scoreDelta;
    state = move.state;
    if (state.gameOver) return total + terminalUtility;

    // playMove samples its own next disc after resolving reveals. Replacing it
    // here domain-separates future discs from the number of reveals and gives
    // every candidate root column the same disc at this rollout and ply.
    state.nextDisc = rolloutDisc(
      seed,
      rollout,
      rolloutCount,
      ply + 1,
      stratifiedSamples,
    );
    if (ply + 1 === horizon) break;
    const nextColumn = continuationPolicy
      ? choosePolicyColumn(state, continuationPolicy)
      : chooseGreedyColumn(
          state,
          seed,
          rollout,
          ply + 1,
          continuationSamples,
          evaluator,
          terminalUtility,
          work,
        );
    if (nextColumn === null) return total + terminalUtility;
    column = nextColumn;
  }

  return total + evaluateLeaf(state, evaluator, terminalUtility, work);
}

function choosePolicyColumn(
  state: GameState,
  policy: RolloutContinuationPolicy,
) {
  const column = policy(state);
  if (column === null) return null;
  if (
    !Number.isSafeInteger(column) ||
    column < 0 ||
    column >= BOARD_SIZE ||
    state.board[column] !== EMPTY
  ) {
    throw new Error(
      `Rollout continuation policy returned illegal column ${String(column)}`,
    );
  }
  return column;
}

/** A deterministic, chance-blind one-step policy for continuation rollouts. */
function chooseGreedyColumn(
  state: GameState,
  seed: number,
  rollout: number,
  ply: number,
  continuationSamples: number,
  evaluator: RolloutEvaluator,
  terminalUtility: number,
  work: WorkCounter,
) {
  let bestColumn: number | null = null;
  let bestValue = Number.NEGATIVE_INFINITY;
  for (const column of columnOrderForBoard(state.board)) {
    if (state.board[column] !== EMPTY) continue;

    let value = 0;
    for (let sample = 0; sample < continuationSamples; sample += 1) {
      const reveal = continuationRevealSample(
        seed,
        rollout,
        ply,
        sample,
        continuationSamples,
      );
      // The policy probes use a separate random domain from the actual rollout
      // transition, so they estimate a move without peeking at its outcome.
      // A constant value within each probe also preserves mirror symmetry when
      // one wave reveals several covered discs in reverse scan order.
      const move = playMove(state, column, () => reveal, {
        captureAnimation: false,
      });
      work.value += 1;
      if (!move) {
        value += terminalUtility;
        continue;
      }
      value +=
        move.scoreDelta +
        evaluateLeaf(move.state, evaluator, terminalUtility, work);
    }
    value /= continuationSamples;
    if (value > bestValue) {
      bestValue = value;
      bestColumn = column;
    }
  }
  return bestColumn;
}

function continuationRevealSample(
  seed: number,
  rollout: number,
  ply: number,
  sample: number,
  sampleCount: number,
) {
  // Preserve the fixed midpoint sampling policy exactly at one sample.
  if (sampleCount === 1) return 0.5;
  const rotation = mixedSample(seed, rollout, ply, POLICY_DOMAIN);
  return ((sample + 0.5) / sampleCount + rotation) % 1;
}

function evaluateLeaf(
  state: GameState,
  evaluator: RolloutEvaluator,
  terminalUtility: number,
  work: WorkCounter,
) {
  if (state.gameOver) return terminalUtility;
  work.value += 1;
  const value = evaluator(state);
  if (!Number.isFinite(value)) {
    throw new Error("Rollout evaluator must return a finite number");
  }
  return value;
}

function chooseBestRootColumn(
  board: Board,
  columns: readonly RolloutColumnEvaluation[],
  riskAversion: number,
) {
  let bestColumn: number | null = null;
  let bestValue = Number.NEGATIVE_INFINITY;
  for (const column of columnOrderForBoard(board)) {
    const evaluation = columns.find((candidate) => candidate.column === column);
    const selectionValue = evaluation
      ? evaluation.mean - riskAversion * Math.sqrt(evaluation.variance)
      : Number.NEGATIVE_INFINITY;
    if (evaluation && selectionValue > bestValue) {
      bestValue = selectionValue;
      bestColumn = column;
    }
  }
  return bestColumn;
}

/**
 * Pick tie order in the board's canonical orientation. Distinct mirrored
 * positions therefore make mirrored tie choices; a perfectly symmetric board
 * necessarily retains the ordinary center-first order.
 */
function columnOrderForBoard(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 (forward < mirrored) return COLUMN_ORDER;
      if (forward > mirrored) return MIRRORED_COLUMN_ORDER;
    }
  }
  return COLUMN_ORDER;
}

function rolloutDisc(
  seed: number,
  rollout: number,
  rolloutCount: number,
  ply: number,
  stratified: boolean,
): DiscValue {
  const sample = stratified
    ? stratifiedSample(
        seed,
        rollout,
        rolloutCount,
        ply,
        0,
        DISC_DOMAIN,
      )
    : mixedSample(seed, rollout, ply, DISC_DOMAIN);
  return (Math.floor(sample * BOARD_SIZE) + 1) as DiscValue;
}

function revealSeed(seed: number, rollout: number, ply: number) {
  return mix32(
    seed ^
      Math.imul((rollout + 1) >>> 0, ROLLOUT_MULTIPLIER) ^
      Math.imul((ply + 1) >>> 0, PLY_MULTIPLIER) ^
      REVEAL_DOMAIN,
  );
}

function stratifiedRevealRandom(
  seed: number,
  rollout: number,
  rolloutCount: number,
  ply: number,
) {
  let event = 0;
  return () => {
    const sample = stratifiedSample(
      seed,
      rollout,
      rolloutCount,
      ply,
      event,
      REVEAL_DOMAIN,
    );
    event += 1;
    return sample;
  };
}

/** Latin-hypercube sample: every event uses every stratum exactly once. */
function stratifiedSample(
  seed: number,
  rollout: number,
  rolloutCount: number,
  ply: number,
  event: number,
  domain: number,
) {
  const eventSeed =
    seed ^
    Math.imul((ply + 1) >>> 0, PLY_MULTIPLIER) ^
    Math.imul((event + 1) >>> 0, EVENT_MULTIPLIER) ^
    domain;
  const rotation = mix32(eventSeed) % rolloutCount;
  const stratum = (rollout + rotation) % rolloutCount;
  const jitter = mix32(eventSeed ^ JITTER_DOMAIN) / 4_294_967_296;
  return (stratum + jitter) / rolloutCount;
}

function mixedSample(
  seed: number,
  rollout: number,
  ply: number,
  domain: number,
) {
  return (
    mix32(
      seed ^
        Math.imul((rollout + 1) >>> 0, ROLLOUT_MULTIPLIER) ^
        Math.imul((ply + 1) >>> 0, PLY_MULTIPLIER) ^
        domain,
    ) / 4_294_967_296
  );
}

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

function createRunningStats(): RunningStats {
  return { count: 0, mean: 0, squaredDeviation: 0 };
}

function updateRunningStats(stats: RunningStats, value: number) {
  stats.count += 1;
  const difference = value - stats.mean;
  stats.mean += difference / stats.count;
  stats.squaredDeviation += difference * (value - stats.mean);
}

function boundedPositiveInteger(value: number, name: string, maximum: number) {
  if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
    throw new Error(`${name} must be an integer from 1 to ${maximum}`);
  }
  return value;
}

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