Drop7 Research
src/core/typescript/risk-sensitive-planner.test.tsTypeScript208 lines · 5.8 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 {
  MAX_RISK_CACHE_ENTRIES,
  MAX_RISK_CHANCE_SAMPLES,
  MAX_RISK_CONTINUATION_DEPTH,
  MAX_RISK_SCENARIOS,
  MAX_RISK_WORK,
  evaluateRiskSensitiveMoves,
  summarizeRiskDistribution,
} from "./risk-sensitive-planner.ts";

const E = EMPTY;
const row = (...cells: Cell[]) => cells;
const blank = () => row(E, E, E, E, E, E, E);
const fastEvaluator = (state: GameState) =>
  -state.board.reduce<number>(
    (occupied, cell) => occupied + Number(cell !== EMPTY),
    0,
  );

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 deterministicOptions = {
  scenarios: 7,
  continuationDepth: 1,
  chanceSamples: 3,
  tailFraction: 0.25,
  riskWeight: 0.5,
  seed: 0xdecafbad,
  maxWork: 100_000,
  maxCacheEntries: 32,
  evaluator: fastEvaluator,
};

test("risk planner is deterministic and obeys work and memory bounds", () => {
  const state = createGame(() => 0.5);
  const first = evaluateRiskSensitiveMoves(state, deterministicOptions);
  const second = evaluateRiskSensitiveMoves(state, deterministicOptions);

  assert.deepEqual(second, first);
  assert.equal(first.complete, true);
  assert.equal(first.completedScenarios, deterministicOptions.scenarios);
  assert.ok(first.work <= deterministicOptions.maxWork);
  assert.ok(first.cacheEntries <= deterministicOptions.maxCacheEntries);
  assert.ok(
    first.peakUtilityValues <= BOARD_SIZE * deterministicOptions.scenarios,
  );
  assert.ok(
    first.columns.every(
      (column) => column.scenarios === first.completedScenarios,
    ),
  );
});

test("planner chance and evaluator are blind to accumulated game score", () => {
  const state = createGame(() => 0.5);
  const observedScores: number[] = [];
  const options = {
    ...deterministicOptions,
    evaluator: (leaf: GameState) => {
      observedScores.push(leaf.score);
      return fastEvaluator(leaf);
    },
  };
  const baseline = evaluateRiskSensitiveMoves(state, options);
  const rescored = evaluateRiskSensitiveMoves(
    { ...state, score: 8_765_432 },
    options,
  );

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

test("asymmetric positions and their reflections receive mirrored decisions", () => {
  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 forward = evaluateRiskSensitiveMoves(
    position(board),
    deterministicOptions,
  );
  const reverse = evaluateRiskSensitiveMoves(
    position(mirrorBoard(board)),
    deterministicOptions,
  );
  const reverseByColumn = new Map(
    reverse.columns.map((column) => [column.column, column]),
  );

  for (const column of forward.columns) {
    const opposite = reverseByColumn.get(BOARD_SIZE - 1 - column.column);
    assert.ok(opposite);
    assert.equal(opposite.mean, column.mean);
    assert.equal(opposite.lowerQuantile, column.lowerQuantile);
    assert.equal(opposite.cvar, column.cvar);
    assert.equal(opposite.selectionValue, column.selectionValue);
  }
  assert.equal(
    reverse.bestColumn,
    forward.bestColumn === null
      ? null
      : BOARD_SIZE - 1 - forward.bestColumn,
  );
});

test("CVaR is the exact fractional mean of the requested lower tail", () => {
  const summary = summarizeRiskDistribution([0, 100, 100, 100], 0.375, 0.5);
  assert.equal(summary.mean, 75);
  assert.equal(summary.lowerQuantile, 100);
  // The lower 1.5 observations contain 0 plus half of the first 100.
  assert.ok(Math.abs(summary.cvar - 100 / 3) < 1e-12);
  assert.ok(
    Math.abs(
      summary.selectionValue - (summary.mean + 0.5 * (summary.cvar - summary.mean)),
    ) < 1e-12,
  );
});

test("a partial scenario never creates an unpaired root comparison", () => {
  const state = createGame(() => 0.5);
  const result = evaluateRiskSensitiveMoves(state, {
    ...deterministicOptions,
    scenarios: 20,
    continuationDepth: 2,
    maxWork: 311,
  });

  assert.equal(result.complete, false);
  assert.equal(result.stopReason, "work");
  assert.equal(result.work, 311);
  assert.ok(result.completedScenarios < result.requestedScenarios);
  assert.ok(
    result.columns.every(
      (column) => column.scenarios === result.completedScenarios,
    ),
  );
});

test("configuration bounds and terminal states are handled explicitly", () => {
  const state = createGame(() => 0.5);
  const valid = {
    scenarios: 1,
    continuationDepth: 0,
    chanceSamples: 1,
    seed: 0,
  };
  for (const options of [
    { ...valid, scenarios: MAX_RISK_SCENARIOS + 1 },
    { ...valid, continuationDepth: MAX_RISK_CONTINUATION_DEPTH + 1 },
    { ...valid, chanceSamples: MAX_RISK_CHANCE_SAMPLES + 1 },
    { ...valid, maxWork: MAX_RISK_WORK + 1 },
    { ...valid, maxCacheEntries: MAX_RISK_CACHE_ENTRIES + 1 },
    { ...valid, tailFraction: 0 },
    { ...valid, riskWeight: 3 },
    { ...valid, seed: -1 },
  ]) {
    assert.throws(() => evaluateRiskSensitiveMoves(state, options));
  }

  const terminal = evaluateRiskSensitiveMoves(
    { ...state, gameOver: true },
    valid,
  );
  assert.equal(terminal.bestColumn, null);
  assert.deepEqual(terminal.columns, []);
  assert.equal(terminal.work, 0);
});

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