Drop7 Research
src/core/typescript/sampled-beam-solver.test.tsTypeScript352 lines · 9.1 KB
import assert from "node:assert/strict";
import test from "node:test";
import {
  BOARD_SIZE,
  EMPTY,
  MOVES_PER_LEVEL,
  boardFromRows,
  createGame,
  type Board,
  type Cell,
  type GameState,
} from "./engine.ts";
import {
  DEFAULT_SAMPLED_BEAM_POLICY_DEPTH,
  DEFAULT_SAMPLED_BEAM_POLICY_SCENARIOS,
  DEFAULT_SAMPLED_BEAM_WIDTH,
  MAX_SAMPLED_BEAM_DEPTH,
  MAX_SAMPLED_BEAM_POLICY_DEPTH,
  MAX_SAMPLED_BEAM_POLICY_SCENARIOS,
  MAX_SAMPLED_BEAM_SCENARIOS,
  MAX_SAMPLED_BEAM_WIDTH,
  MAX_SAMPLED_BEAM_WORK,
  evaluateSampledBeamMoves,
} from "./sampled-beam-solver.ts";

const E = EMPTY;
const row = (...cells: Cell[]) => cells;
const blank = () => row(E, E, E, E, E, E, E);

function position(
  board: Board,
  overrides: Partial<GameState> = {},
): GameState {
  return {
    board,
    nextDisc: 4,
    score: 0,
    level: 1,
    movesRemaining: MOVES_PER_LEVEL,
    movesPlayed: 0,
    gameOver: false,
    ...overrides,
  };
}

const fastEvaluator = (state: GameState) =>
  -state.board.reduce<number>(
    (occupied, cell) => occupied + Number(cell !== E),
    0,
  );

test("sampled beam evaluation is deterministic, fair, and memory-bounded", () => {
  const options = {
    scenarios: 6,
    depth: 5,
    beamWidth: 4,
    seed: 0xdecafbad,
    evaluator: fastEvaluator,
  };
  const first = evaluateSampledBeamMoves(createGame(() => 0.5), options);
  const second = evaluateSampledBeamMoves(createGame(() => 0.5), options);

  assert.deepEqual(first, second);
  assert.equal(first.complete, true);
  assert.equal(first.completedScenarios, options.scenarios);
  assert.equal(first.columns.length, BOARD_SIZE);
  assert.ok(
    first.columns.every((column) => column.scenarios === options.scenarios),
  );
  assert.ok(first.columns.every((column) => column.variance >= 0));
  assert.ok(first.work.simulatedMoves > BOARD_SIZE * options.scenarios);
  assert.equal(
    first.work.total,
    first.work.simulatedMoves + first.work.evaluatedStates,
  );
  assert.ok(first.work.policySearches > 0);
  assert.ok(first.work.deduplicatedStates > 0);
  assert.ok(first.work.prunedStates > 0);
  assert.ok(first.work.peakBeamSize <= options.beamWidth);
  assert.ok(first.work.peakCandidateStates <= options.beamWidth * BOARD_SIZE);
});

test("common scenarios give mirrored root moves equal statistics", () => {
  const result = evaluateSampledBeamMoves(createGame(() => 0.5), {
    scenarios: 5,
    depth: 4,
    beamWidth: 6,
    seed: 1234,
    evaluator: fastEvaluator,
  });
  const values = new Map(
    result.columns.map((evaluation) => [evaluation.column, evaluation]),
  );

  assert.deepEqual(values.get(0), { ...values.get(6), column: 0 });
  assert.deepEqual(values.get(1), { ...values.get(5), column: 1 });
  assert.deepEqual(values.get(2), { ...values.get(4), column: 2 });
});

test("mirrored positions receive mirrored sampled-beam evaluations", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, 2, E, E, E, E),
    row(4, E, 6, E, E, E, E),
    row(3, E, 6, 7, E, E, E),
  ]);
  const options = {
    scenarios: 6,
    depth: 5,
    beamWidth: 8,
    seed: 9876,
    evaluator: fastEvaluator,
  };
  const forward = evaluateSampledBeamMoves(position(board), options);
  const mirrored = evaluateSampledBeamMoves(
    position(mirrorBoard(board)),
    options,
  );
  const mirroredValues = new Map(
    mirrored.columns.map((evaluation) => [evaluation.column, evaluation]),
  );

  for (const evaluation of forward.columns) {
    const opposite = mirroredValues.get(BOARD_SIZE - 1 - evaluation.column);
    assert.ok(opposite);
    assert.equal(evaluation.mean, opposite.mean);
    assert.equal(evaluation.variance, opposite.variance);
  }
  assert.equal(
    forward.bestColumn,
    mirrored.bestColumn === null
      ? null
      : BOARD_SIZE - 1 - mirrored.bestColumn,
  );
});

test("the evaluator and scenario seed are independent of state.score", () => {
  const game = createGame(() => 0.5);
  const observedScores: number[] = [];
  const options = {
    scenarios: 4,
    depth: 4,
    beamWidth: 5,
    seed: 42,
    evaluator: (state: GameState) => {
      observedScores.push(state.score);
      return fastEvaluator(state);
    },
  };
  const baseline = evaluateSampledBeamMoves(game, options);
  const rescored = evaluateSampledBeamMoves(
    { ...game, score: 9_999_999 },
    options,
  );

  assert.deepEqual(rescored, baseline);
  assert.ok(observedScores.length > 0);
  assert.ok(observedScores.every((score) => score === 0));
});

test("depth one has predictable work and the default beam width", () => {
  const scenarios = 3;
  const result = evaluateSampledBeamMoves(createGame(() => 0.5), {
    scenarios,
    depth: 1,
    seed: 7,
    evaluator: () => 123,
  });

  assert.equal(result.beamWidth, DEFAULT_SAMPLED_BEAM_WIDTH);
  assert.equal(result.policyDepth, DEFAULT_SAMPLED_BEAM_POLICY_DEPTH);
  assert.equal(
    result.policyScenarios,
    DEFAULT_SAMPLED_BEAM_POLICY_SCENARIOS,
  );
  assert.equal(result.work.simulatedMoves, BOARD_SIZE * scenarios);
  assert.equal(result.work.evaluatedStates, BOARD_SIZE * scenarios);
  assert.ok(result.columns.every((column) => column.mean === 123));
  assert.ok(result.columns.every((column) => column.variance === 0));
});

test("seven scenarios stratify the next disc across all seven values", () => {
  const result = evaluateSampledBeamMoves(createGame(() => 0.5), {
    scenarios: BOARD_SIZE,
    depth: 1,
    beamWidth: 1,
    seed: 2026,
    evaluator: (state) => state.nextDisc,
  });

  assert.ok(
    result.columns.every((column) => Math.abs(column.mean - 4) < 1e-12),
  );
  assert.ok(
    result.columns.every(
      (column) => Math.abs(column.variance - 4) < 1e-12,
    ),
  );
});

test("continuation decisions average every configured policy scenario", () => {
  const shared = {
    scenarios: 1,
    depth: 2,
    policyDepth: 1,
    beamWidth: 1,
    seed: 17,
    evaluator: () => 0,
  };
  const one = evaluateSampledBeamMoves(createGame(() => 0.5), {
    ...shared,
    policyScenarios: 1,
  });
  const four = evaluateSampledBeamMoves(createGame(() => 0.5), {
    ...shared,
    policyScenarios: 4,
  });

  // Per outer root: root/continuation moves plus final evaluation are three
  // units. Each policy scenario evaluates seven moves at two units apiece.
  assert.equal(one.work.total, BOARD_SIZE * (3 + BOARD_SIZE * 2));
  assert.equal(four.work.total, BOARD_SIZE * (3 + BOARD_SIZE * 2 * 4));
  assert.equal(one.work.policySearches, BOARD_SIZE);
  assert.equal(four.work.policySearches, BOARD_SIZE);
});

test("a deterministic work limit stops only between fair scenario batches", () => {
  const maxWork = 2_000;
  const result = evaluateSampledBeamMoves(createGame(() => 0.5), {
    scenarios: 5,
    depth: 4,
    policyDepth: 2,
    policyScenarios: 1,
    beamWidth: 2,
    maxWork,
    seed: 99,
    evaluator: fastEvaluator,
  });

  assert.equal(result.complete, false);
  assert.ok(result.completedScenarios > 0);
  assert.ok(result.completedScenarios < result.scenarios);
  assert.equal(result.work.total, maxWork);
  assert.ok(
    result.columns.every(
      (column) => column.scenarios === result.completedScenarios,
    ),
  );
});

test("sampled beam inputs are bounded and validated", () => {
  const game = createGame(() => 0.5);
  assert.throws(
    () =>
      evaluateSampledBeamMoves(game, {
        scenarios: 0,
        depth: 1,
        seed: 0,
      }),
    /scenarios/,
  );
  assert.throws(
    () =>
      evaluateSampledBeamMoves(game, {
        scenarios: MAX_SAMPLED_BEAM_SCENARIOS + 1,
        depth: 1,
        seed: 0,
      }),
    /scenarios/,
  );
  assert.throws(
    () =>
      evaluateSampledBeamMoves(game, {
        scenarios: 1,
        depth: MAX_SAMPLED_BEAM_DEPTH + 1,
        seed: 0,
      }),
    /depth/,
  );
  assert.throws(
    () =>
      evaluateSampledBeamMoves(game, {
        scenarios: 1,
        depth: 1,
        policyDepth: MAX_SAMPLED_BEAM_POLICY_DEPTH + 1,
        seed: 0,
      }),
    /policyDepth/,
  );
  assert.throws(
    () =>
      evaluateSampledBeamMoves(game, {
        scenarios: 1,
        depth: 1,
        policyScenarios: MAX_SAMPLED_BEAM_POLICY_SCENARIOS + 1,
        seed: 0,
      }),
    /policyScenarios/,
  );
  assert.throws(
    () =>
      evaluateSampledBeamMoves(game, {
        scenarios: 1,
        depth: 1,
        beamWidth: MAX_SAMPLED_BEAM_WIDTH + 1,
        seed: 0,
      }),
    /beamWidth/,
  );
  assert.throws(
    () =>
      evaluateSampledBeamMoves(game, {
        scenarios: 1,
        depth: 1,
        maxWork: MAX_SAMPLED_BEAM_WORK + 1,
        seed: 0,
      }),
    /maxWork/,
  );
  assert.throws(
    () =>
      evaluateSampledBeamMoves(game, {
        scenarios: 1,
        depth: 1,
        seed: 0x1_0000_0000,
      }),
    /uint32/,
  );
  assert.throws(
    () =>
      evaluateSampledBeamMoves(game, {
        scenarios: 1,
        depth: 1,
        seed: 0,
        evaluator: () => Number.NaN,
      }),
    /finite/,
  );
});

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