Drop7 Research
src/core/typescript/learned-evaluator.test.tsTypeScript446 lines · 12.5 KB
import assert from "node:assert/strict";
import test from "node:test";
import {
  EMPTY,
  MOVES_PER_LEVEL,
  SOLID,
  boardFromRows,
  type Board,
  type Cell,
} from "./engine.ts";
import { evaluateHeuristic } from "./heuristic.ts";
import {
  COLUMN_HEIGHT_TOKEN_OFFSET,
  LEARNED_EVALUATOR_ACCUMULATOR_SIZE,
  LEARNED_EVALUATOR_ACTIVE_TOKEN_COUNT,
  LEARNED_EVALUATOR_CELL_KINDS,
  LEARNED_EVALUATOR_HIDDEN_SIZE,
  LEARNED_POLICY_ACCUMULATOR_SIZE,
  LEARNED_POLICY_ACTIVE_TOKEN_COUNT,
  LEARNED_POLICY_HIDDEN_SIZE,
  MOVES_REMAINING_TOKEN_OFFSET,
  NEXT_DISC_TOKEN_OFFSET,
  compileLearnedEvaluatorWeights,
  compileLearnedPolicyWeights,
  createRandomLearnedEvaluatorWeights,
  createZeroLearnedEvaluatorWeights,
  createZeroLearnedPolicyWeights,
  evaluateLearnedPolicy,
  evaluateLearnedPosition,
  extractLearnedEvaluatorTokens,
  extractLearnedPolicyTokens,
  type SerializedLearnedPolicyWeights,
  type SerializedLearnedEvaluatorWeights,
} from "./learned-evaluator.ts";

const E = EMPTY;
const row = (...cells: Cell[]) => cells;
const blank = () => row(E, E, E, E, E, E, E);
const empty = () => boardFromRows(Array.from({ length: 7 }, blank));

test("token extraction includes empty squares and both context families", () => {
  const { tokenIds, mirrored } = extractLearnedEvaluatorTokens({
    board: empty(),
    movesRemaining: 3,
  });

  assert.equal(mirrored, false);
  assert.equal(tokenIds.length, LEARNED_EVALUATOR_ACTIVE_TOKEN_COUNT);
  assert.equal(tokenIds[0], 0);
  assert.equal(tokenIds[1], LEARNED_EVALUATOR_CELL_KINDS);
  assert.equal(
    tokenIds[48],
    48 * LEARNED_EVALUATOR_CELL_KINDS,
    "the empty token remains active for the final square",
  );
  assert.equal(tokenIds[49], MOVES_REMAINING_TOKEN_OFFSET + 3);
  for (let column = 0; column < 7; column += 1) {
    assert.equal(
      tokenIds[50 + column],
      COLUMN_HEIGHT_TOKEN_OFFSET + column * 8,
    );
  }
});

test("policy tokens add the visible next disc to the canonical position", () => {
  const first = extractLearnedPolicyTokens({
    board: empty(),
    movesRemaining: 3,
    nextDisc: 1,
  });
  const fifth = extractLearnedPolicyTokens({
    board: empty(),
    movesRemaining: 3,
    nextDisc: 5,
  });

  assert.equal(first.tokenIds.length, LEARNED_POLICY_ACTIVE_TOKEN_COUNT);
  assert.deepEqual(
    first.tokenIds.slice(0, -1),
    fifth.tokenIds.slice(0, -1),
  );
  assert.equal(first.tokenIds.at(-1), NEXT_DISC_TOKEN_OFFSET);
  assert.equal(fifth.tokenIds.at(-1), NEXT_DISC_TOKEN_OFFSET + 4);
});

test("the Float32 network matches a hand-computed golden vector", () => {
  const artifact = mutableZeroWeights();
  const positionToken = 0;
  const movesToken = MOVES_REMAINING_TOKEN_OFFSET + MOVES_PER_LEVEL;
  const firstColumnHeightToken = COLUMN_HEIGHT_TOKEN_OFFSET;

  artifact.accumulatorBias[0] = 1;
  artifact.accumulatorBias[3] = -2;
  artifact.embedding[
    positionToken * LEARNED_EVALUATOR_ACCUMULATOR_SIZE
  ] = 2;
  artifact.embedding[
    movesToken * LEARNED_EVALUATOR_ACCUMULATOR_SIZE + 1
  ] = 4;
  artifact.embedding[
    firstColumnHeightToken * LEARNED_EVALUATOR_ACCUMULATOR_SIZE + 2
  ] = 5;

  artifact.hiddenBias[0] = 1;
  artifact.hiddenWeights[0] = 2;
  artifact.hiddenWeights[1] = -1;
  artifact.hiddenWeights[2] = 1;
  artifact.hiddenWeights[3] = 100;
  artifact.hiddenBias[1] = -10;
  artifact.hiddenWeights[LEARNED_EVALUATOR_ACCUMULATOR_SIZE + 2] = 1;
  artifact.outputWeights[0] = 2;
  artifact.outputWeights[1] = 100;
  artifact.outputBias = 3;

  // accumulator = ReLU([3, 4, 5, -2]); hidden = ReLU([8, -5]);
  // output = 3 + 8 * 2 = 19.
  assert.equal(
    evaluateLearnedPosition(
      { board: empty(), movesRemaining: MOVES_PER_LEVEL },
      compileLearnedEvaluatorWeights(artifact),
    ),
    19,
  );
});

test("moves remaining and column heights independently reach inference", () => {
  const artifact = mutableZeroWeights();
  for (let moves = 0; moves <= MOVES_PER_LEVEL; moves += 1) {
    artifact.embedding[
      (MOVES_REMAINING_TOKEN_OFFSET + moves) *
        LEARNED_EVALUATOR_ACCUMULATOR_SIZE
    ] = moves;
  }
  for (let column = 0; column < 7; column += 1) {
    for (let height = 0; height <= 7; height += 1) {
      artifact.embedding[
        (COLUMN_HEIGHT_TOKEN_OFFSET + column * 8 + height) *
          LEARNED_EVALUATOR_ACCUMULATOR_SIZE
      ] = height;
    }
  }
  artifact.hiddenWeights[0] = 1;
  artifact.outputWeights[0] = 1;
  const model = compileLearnedEvaluatorWeights(artifact);

  const occupied = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, E, E, E, E, 2),
    row(1, E, 3, E, E, E, SOLID),
  ]);

  assert.equal(
    evaluateLearnedPosition({ board: empty(), movesRemaining: 1 }, model),
    1,
  );
  assert.equal(
    evaluateLearnedPosition({ board: occupied, movesRemaining: 3 }, model),
    7,
    "three context moves plus four occupied cells",
  );
});

test("the direct policy routes features to logits and masks full columns", () => {
  const artifact = mutableZeroPolicyWeights();
  const nextDiscToken = NEXT_DISC_TOKEN_OFFSET + 3;
  artifact.accumulatorBias[0] = 1;
  artifact.embedding[
    nextDiscToken * LEARNED_POLICY_ACCUMULATOR_SIZE
  ] = 2;
  artifact.hiddenWeights[0] = 3;
  artifact.outputWeights[5 * LEARNED_POLICY_HIDDEN_SIZE] = 2;
  const model = compileLearnedPolicyWeights(artifact);

  const evaluation = evaluateLearnedPolicy(
    { board: empty(), movesRemaining: 5, nextDisc: 4 },
    model,
  );
  assert.equal(evaluation.logits[5], 18);
  assert.equal(evaluation.bestColumn, 5);

  const blocked: Cell[] = [...empty()];
  blocked[5] = SOLID;
  assert.equal(
    evaluateLearnedPolicy(
      { board: blocked, movesRemaining: 5, nextDisc: 4 },
      model,
    ).bestColumn,
    3,
    "the maximum-logit column is ignored when its top cell is occupied",
  );
});

test("direct-policy logits and choices reflect exactly with the board", () => {
  const board: Cell[] = [...empty()];
  board[42] = 1;
  const reflected = mirrorBoard(board);
  const artifact = mutableZeroPolicyWeights();
  for (let column = 0; column < 7; column += 1) {
    artifact.outputBias[column] = column;
  }
  const model = compileLearnedPolicyWeights(artifact);
  const forward = evaluateLearnedPolicy(
    { board, movesRemaining: 2, nextDisc: 7 },
    model,
  );
  const mirror = evaluateLearnedPolicy(
    { board: reflected, movesRemaining: 2, nextDisc: 7 },
    model,
  );

  assert.deepEqual([...forward.logits], [...mirror.logits].reverse());
  assert.equal(forward.bestColumn, 0);
  assert.equal(mirror.bestColumn, 6);
});

test("residual artifacts preserve the combined baseline and enforce safety bounds", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    row(E, E, E, 4, E, E, E),
    row(E, E, 2, SOLID, E, E, E),
    row(E, 3, 5, SOLID, E, E, E),
    row(1, SOLID, SOLID, SOLID, SOLID, SOLID, SOLID),
  ]);
  const state = {
    board,
    movesRemaining: 2,
    nextDisc: 3 as const,
    score: 91_000,
    level: 4,
    movesPlayed: 17,
    gameOver: false,
  };
  const expectedBaseline = evaluateHeuristic(state, "combined");
  const artifact = mutableZeroWeights();
  artifact.baseline = "combined";
  artifact.residualMinimum = -25;
  artifact.residualMaximum = 10;
  artifact.outputBias = -100;

  assert.equal(
    evaluateLearnedPosition(state, compileLearnedEvaluatorWeights(artifact)),
    expectedBaseline - 25,
  );

  artifact.outputBias = 100;
  assert.equal(
    evaluateLearnedPosition(state, compileLearnedEvaluatorWeights(artifact)),
    expectedBaseline + 10,
  );
});

test("horizontal mirrors have identical tokens and exact model values", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    row(E, E, E, 7, E, E, E),
    row(E, 4, E, 6, E, E, E),
    row(E, 2, E, SOLID, E, 5, E),
    row(1, 3, E, 2, E, 6, E),
    row(4, 5, 7, 3, E, 1, 2),
  ]);
  const reflected = mirrorBoard(board);
  const left = extractLearnedEvaluatorTokens({
    board,
    movesRemaining: 2,
  });
  const right = extractLearnedEvaluatorTokens({
    board: reflected,
    movesRemaining: 2,
  });

  assert.notEqual(left.mirrored, right.mirrored);
  assert.deepEqual(left.tokenIds, right.tokenIds);

  const artifact = {
    ...createRandomLearnedEvaluatorWeights(0x7a11_ce55),
    baseline: "combined" as const,
  };
  const model = compileLearnedEvaluatorWeights(
    JSON.parse(JSON.stringify(artifact)),
  );
  const forwardValue = evaluateLearnedPosition(
    { board, movesRemaining: 2 },
    model,
  );
  const reflectedValue = evaluateLearnedPosition(
    { board: reflected, movesRemaining: 2 },
    model,
  );
  assert.equal(forwardValue, reflectedValue);
});

test("serialized artifacts and evaluator positions are validated", () => {
  const valid = createZeroLearnedEvaluatorWeights();
  assert.doesNotThrow(() => compileLearnedEvaluatorWeights(valid));

  assert.throws(
    () => compileLearnedEvaluatorWeights({ ...valid, version: 2 }),
    /version must be 1/,
  );
  assert.throws(
    () =>
      compileLearnedEvaluatorWeights({
        ...valid,
        hiddenWeights: valid.hiddenWeights.slice(1),
      }),
    /hiddenWeights must contain 128 values/,
  );
  const nonFinite = [...valid.outputWeights];
  nonFinite[LEARNED_EVALUATOR_HIDDEN_SIZE - 1] = Number.NaN;
  assert.throws(
    () =>
      compileLearnedEvaluatorWeights({
        ...valid,
        outputWeights: nonFinite,
      }),
    /outputWeights\[7\] must be a finite Float32/,
  );
  assert.throws(
    () =>
      compileLearnedEvaluatorWeights({
        ...valid,
        accumulatorBias: new Float32Array(
          LEARNED_EVALUATOR_ACCUMULATOR_SIZE,
        ),
      }),
    /accumulatorBias must be a JSON array/,
  );
  assert.throws(
    () => compileLearnedEvaluatorWeights({ ...valid, baseline: "legacy" }),
    /baseline must be combined/,
  );
  assert.throws(
    () =>
      compileLearnedEvaluatorWeights({
        ...valid,
        residualMinimum: 5,
        residualMaximum: -5,
      }),
    /residualMinimum cannot exceed residualMaximum/,
  );

  assert.throws(
    () =>
      extractLearnedEvaluatorTokens({
        board: empty(),
        movesRemaining: MOVES_PER_LEVEL + 1,
      }),
    /movesRemaining must be an integer from 0 through 5/,
  );
  const invalidBoard = [...empty()];
  invalidBoard[12] = 10 as Cell;
  assert.throws(
    () =>
      extractLearnedEvaluatorTokens({
        board: invalidBoard,
        movesRemaining: 1,
      }),
    /board cell 12 must be an integer from 0 through 9/,
  );

  const validPolicy = createZeroLearnedPolicyWeights();
  assert.doesNotThrow(() => compileLearnedPolicyWeights(validPolicy));
  assert.throws(
    () =>
      compileLearnedPolicyWeights({
        ...validPolicy,
        outputBias: validPolicy.outputBias.slice(1),
      }),
    /policy outputBias must contain 7 values/,
  );
  assert.throws(
    () =>
      extractLearnedPolicyTokens({
        board: empty(),
        movesRemaining: 2,
        nextDisc: 0 as 1,
      }),
    /nextDisc must be an integer from 1 through 7/,
  );
});

interface MutableSerializedWeights {
  format: SerializedLearnedEvaluatorWeights["format"];
  version: SerializedLearnedEvaluatorWeights["version"];
  baseline?: "combined";
  residualMinimum?: number;
  residualMaximum?: number;
  embedding: number[];
  accumulatorBias: number[];
  hiddenWeights: number[];
  hiddenBias: number[];
  outputWeights: number[];
  outputBias: number;
}

function mutableZeroWeights(): MutableSerializedWeights {
  const weights = createZeroLearnedEvaluatorWeights();
  return {
    ...weights,
    embedding: [...weights.embedding],
    accumulatorBias: [...weights.accumulatorBias],
    hiddenWeights: [...weights.hiddenWeights],
    hiddenBias: [...weights.hiddenBias],
    outputWeights: [...weights.outputWeights],
  };
}

interface MutableSerializedPolicyWeights {
  format: SerializedLearnedPolicyWeights["format"];
  version: SerializedLearnedPolicyWeights["version"];
  embedding: number[];
  accumulatorBias: number[];
  hiddenWeights: number[];
  hiddenBias: number[];
  outputWeights: number[];
  outputBias: number[];
}

function mutableZeroPolicyWeights(): MutableSerializedPolicyWeights {
  const weights = createZeroLearnedPolicyWeights();
  return {
    ...weights,
    embedding: [...weights.embedding],
    accumulatorBias: [...weights.accumulatorBias],
    hiddenWeights: [...weights.hiddenWeights],
    hiddenBias: [...weights.hiddenBias],
    outputWeights: [...weights.outputWeights],
    outputBias: [...weights.outputBias],
  };
}

function mirrorBoard(board: Board): Board {
  const mirrored: Cell[] = [];
  for (let rowIndex = 0; rowIndex < 7; rowIndex += 1) {
    const start = rowIndex * 7;
    mirrored.push(...board.slice(start, start + 7).reverse());
  }
  return mirrored;
}