diff --git a/.eslintrc.js b/.eslintrc.js index 74936633..cada89f0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -107,7 +107,7 @@ module.exports = { }, overrides: [ { - files: ["src/test.tsx", "*/__tests__/*"], + files: ["src/test.tsx", "**/__tests__/*"], // since test files are not part of tsconfig.json, // parserOptions.project must be unset parserOptions: { diff --git a/src/ScoreEstimator.ts b/src/ScoreEstimator.ts index 507042be..1f410e5f 100644 --- a/src/ScoreEstimator.ts +++ b/src/ScoreEstimator.ts @@ -23,24 +23,16 @@ import { GobanCore } from "./GobanCore"; import { GoEngine, PlayerScore, GoEngineRules } from "./GoEngine"; import { JGOFNumericPlayerColor } from "./JGOF"; import { _ } from "./translate"; +import { estimateScoreWasm } from "./local_estimators/wasm_estimator"; -declare const CLIENT: boolean; +export { init_score_estimator, estimateScoreWasm } from "./local_estimators/wasm_estimator"; +export { estimateScoreVoronoi } from "./local_estimators/voronoi"; -/* The OGSScoreEstimator method is a wasm compiled C program that - * does simple random playouts. On the client, the OGSScoreEstimator script - * is loaded in an async fashion, so at some point that global variable - * becomes not null and can be used. - */ - -/* In addition to the OGSScoreEstimator method, we have a RemoteScoring system +/* In addition to the local estimators, we have a RemoteScoring system * which needs to be initialized by either the client or the server if we want * remote scoring enabled. */ -declare let OGSScoreEstimator: any; -let OGSScoreEstimator_initialized = false; -let OGSScoreEstimatorModule: any; - export interface ScoreEstimateRequest { player_to_move: "black" | "white"; width: number; @@ -67,59 +59,25 @@ export function set_remote_scorer( remote_scorer = scorer; } -let init_promise: Promise; - -export function init_score_estimator(): Promise { - if (!CLIENT) { - throw new Error("Only initialize WASM library on the client side"); - } - - if (OGSScoreEstimator_initialized) { - return Promise.resolve(true); - } - - if (init_promise) { - return init_promise; - } - - try { - if ( - !OGSScoreEstimatorModule && - (("OGSScoreEstimator" in window) as any) && - ((window as any)["OGSScoreEstimator"] as any) - ) { - OGSScoreEstimatorModule = (window as any)["OGSScoreEstimator"] as any; - } - } catch (e) { - console.error(e); - } - - if (OGSScoreEstimatorModule) { - OGSScoreEstimatorModule = OGSScoreEstimatorModule(); - OGSScoreEstimator_initialized = true; - return Promise.resolve(true); - } - - const script: HTMLScriptElement = document.getElementById( - "ogs_score_estimator_script", - ) as HTMLScriptElement; - if (script) { - let resolve: (tf: boolean) => void; - init_promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - }); - - script.onload = () => { - OGSScoreEstimatorModule = OGSScoreEstimator; - OGSScoreEstimatorModule = OGSScoreEstimatorModule(); - OGSScoreEstimator_initialized = true; - resolve(true); - }; - - return init_promise; - } else { - return Promise.reject("score estimator not available"); - } +/** + * The interface that local estimators should follow. + * + * @param board representation of the board with any dead stones already + * removed (black = 1, empty = 0, white = -1) + * @param color_to_move the player whose turn it is + * @param trials number of playouts. Not applicable to all estimators, but + * higher generally means higher accuracy and higher compute cost + * @param tolerance (0.0-1.0) confidence required to mark an intersection not neutral. + */ +type LocalEstimator = ( + board: number[][], + color_to_move: "black" | "white", + trials: number, + tolerance: number, +) => GoMath.NumberMatrix; +let local_scorer = estimateScoreWasm; +export function set_local_scorer(scorer: LocalEstimator) { + local_scorer = scorer; } interface SEPoint { @@ -296,13 +254,13 @@ export class ScoreEstimator { public estimateScore(trials: number, tolerance: number): Promise { if (!this.prefer_remote || this.height > 19 || this.width > 19) { - return this.estimateScoreWASM(trials, tolerance); + return this.estimateScoreLocal(trials, tolerance); } if (remote_scorer) { return this.estimateScoreRemote(); } else { - return this.estimateScoreWASM(trials, tolerance); + return this.estimateScoreLocal(trials, tolerance); } } @@ -361,11 +319,7 @@ export class ScoreEstimator { /* Somewhat deprecated in-browser score estimator that utilizes our WASM compiled * OGSScoreEstimatorModule */ - private estimateScoreWASM(trials: number, tolerance: number): Promise { - if (!OGSScoreEstimator_initialized) { - throw new Error("Score estimator not intialized yet, uptime = " + performance.now()); - } - + private estimateScoreLocal(trials: number, tolerance: number): Promise { if (!trials) { trials = 1000; } @@ -373,61 +327,21 @@ export class ScoreEstimator { tolerance = 0.25; } - /* Call our score estimator code to do the estimation. We do this assignment here - * because it's likely that the module isn't done loading on the client - * when the top of this script (where score estimator is first assigned) is - * executing. (it's loaded async) - */ - const nbytes = 4 * this.engine.width * this.engine.height; - const ptr = OGSScoreEstimatorModule._malloc(nbytes); - const ints = new Int32Array(OGSScoreEstimatorModule.HEAP32.buffer, ptr, nbytes); - let i = 0; + const board = GoMath.makeMatrix(this.width, this.height); for (let y = 0; y < this.height; ++y) { for (let x = 0; x < this.width; ++x) { - ints[i] = this.board[y][x] === 2 ? -1 : this.board[y][x]; + board[y][x] = this.board[y][x] === 2 ? -1 : this.board[y][x]; if (this.removal[y][x]) { - ints[i] = 0; + board[y][x] = 0; } - ++i; - } - } - const _estimate = OGSScoreEstimatorModule.cwrap("estimate", "number", [ - "number", - "number", - "number", - "number", - "number", - "number", - ]); - const estimate = _estimate as ( - w: number, - h: number, - p: number, - c: number, - tr: number, - to: number, - ) => number; - const estimated_score = estimate( - this.width, - this.height, - ptr, - this.engine.colorToMove() === "black" ? 1 : -1, - trials, - tolerance, - ); - - const ownership = GoMath.makeMatrix(this.width, this.height, 0); - i = 0; - for (let y = 0; y < this.height; ++y) { - for (let x = 0; x < this.width; ++x) { - ownership[y][x] = ints[i]; - ++i; } } + const ownership = local_scorer(board, this.engine.colorToMove(), trials, tolerance); + + const estimated_score = sum_board(ownership); const adjusted = adjust_estimate(this.engine, this.board, ownership, estimated_score); - OGSScoreEstimatorModule._free(ptr); this.updateEstimate(adjusted.score, adjusted.ownership); return Promise.resolve(); } @@ -764,6 +678,17 @@ function get_dimensions(board: Array>) { return { width: board[0].length, height: board.length }; } +function sum_board(board: GoMath.NumberMatrix) { + const { width, height } = get_dimensions(board); + let sum = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + sum += board[y][x]; + } + } + return sum; +} + /** * SE Group and GoStoneGroup have a slightly different interface. */ diff --git a/src/__tests__/ScoreEstimator.test.ts b/src/__tests__/ScoreEstimator.test.ts index ccb55f17..20d4493f 100644 --- a/src/__tests__/ScoreEstimator.test.ts +++ b/src/__tests__/ScoreEstimator.test.ts @@ -1,5 +1,11 @@ import { GoEngine } from "../GoEngine"; -import { ScoreEstimator, adjust_estimate, set_remote_scorer } from "../ScoreEstimator"; +import { + ScoreEstimator, + adjust_estimate, + set_local_scorer, + set_remote_scorer, +} from "../ScoreEstimator"; +import { estimateScoreVoronoi } from "../local_estimators/voronoi"; describe("adjust_estimate", () => { const BOARD = [ @@ -63,6 +69,8 @@ describe("ScoreEstimator", () => { set_remote_scorer(async () => { return { ownership: OWNERSHIP, score: -7.5 }; }); + + set_local_scorer(estimateScoreVoronoi); }); afterEach(() => { @@ -97,7 +105,7 @@ describe("ScoreEstimator", () => { }); test("amount and winner", async () => { - const se = new ScoreEstimator(undefined, engine, 10, 0.5, prefer_remote); + const se = new ScoreEstimator(undefined, engine, trials, tolerance, false); await se.when_ready; @@ -106,4 +114,213 @@ describe("ScoreEstimator", () => { expect(se.winner).toBe("White"); expect(se.amount).toBe(0.5); }); + + test("local", async () => { + const se = new ScoreEstimator(undefined, engine, 10, 0.5, false); + + await se.when_ready; + + expect(se.ownership).toEqual([ + [1, 0, 0, -1], + [1, 0, 0, -1], + ]); + }); + + test("local 9x9 unfinished", async () => { + const moves = [ + [4, 4], + [2, 4], + [6, 4], + [3, 6], + [2, 2], + [3, 3], + [5, 2], + [3, 2], + [4, 1], + [3, 1], + ]; + const engine = new GoEngine({ komi: KOMI, width: 9, height: 9, rules: "chinese" }); + for (const [x, y] of moves) { + engine.place(x, y); + } + const se = new ScoreEstimator(undefined, engine, 10, 0.5, false); + + expect(se.ownership).toEqual([ + [1, 0, -1, -1, 1, 1, 1, 1, 1], + [1, 1, 0, -1, 1, 1, 1, 1, 1], + [1, 1, 1, -1, 0, 1, 1, 1, 1], + [0, 0, 0, -1, 0, 1, 1, 1, 1], + [-1, -1, -1, 0, 1, 1, 1, 1, 1], + [-1, -1, -1, -1, 1, 1, 1, 1, 1], + [-1, -1, -1, -1, -1, -1, 1, 1, 1], + [-1, -1, -1, -1, -1, -1, 1, 1, 1], + [-1, -1, -1, -1, -1, -1, 1, 1, 1], + ]); + }); + + test("score()", async () => { + const se = new ScoreEstimator(undefined, engine, 10, 0.5, false); + await se.when_ready; + + se.score(); + + expect(se.white).toEqual({ + handicap: 0, + komi: 0.5, + prisoners: 0, + scoring_positions: "dadb", + stones: 0, + territory: 0, + total: 0.5, + }); + expect(se.black).toEqual({ + handicap: 0, + komi: 0, + prisoners: 0, + scoring_positions: "aaab", + stones: 0, + territory: 0, + total: 0, + }); + }); + + test("score() chinese", async () => { + const engine = new GoEngine({ komi: KOMI, width: 4, height: 2, rules: "chinese" }); + engine.place(1, 0); + engine.place(2, 0); + engine.place(1, 1); + engine.place(2, 1); + + const se = new ScoreEstimator(undefined, engine, 10, 0.5, false); + await se.when_ready; + + se.score(); + + expect(se.white).toEqual({ + handicap: 0, + komi: 0.5, + prisoners: 0, + scoring_positions: "dadbcacb", + stones: 2, + territory: 0, + total: 2.5, + }); + expect(se.black).toEqual({ + handicap: 0, + komi: 0, + prisoners: 0, + scoring_positions: "aaabbabb", + stones: 2, + territory: 0, + total: 2, + }); + }); + + test("don't score territory in seki (japanese)", async () => { + // . x o . + // x x . o + + const engine = new GoEngine({ komi: KOMI, width: 4, height: 2, rules: "japanese" }); + engine.place(1, 0); + engine.place(2, 0); + engine.place(1, 1); + engine.place(3, 1); + engine.place(0, 1); + + const se = new ScoreEstimator(undefined, engine, 10, 0.5, false); + await se.when_ready; + + se.score(); + + expect(se.white).toEqual({ + handicap: 0, + komi: 0.5, + prisoners: 0, + scoring_positions: "", + stones: 0, + territory: 0, + total: 0.5, + }); + expect(se.black).toEqual({ + handicap: 0, + komi: 0, + prisoners: 0, + scoring_positions: "", + stones: 0, + territory: 0, + total: 0, + }); + }); + + test("score() with removed stones", async () => { + const se = new ScoreEstimator(undefined, engine, 10, 0.5, false); + se.toggleMetaGroupRemoval(1, 0); + se.toggleMetaGroupRemoval(2, 0); + await se.when_ready; + + se.score(); + + expect(se.white).toEqual({ + handicap: 0, + komi: 0.5, + prisoners: 2, + scoring_positions: "", + stones: 0, + territory: 0, + total: 2.5, + }); + expect(se.black).toEqual({ + handicap: 0, + komi: 0, + prisoners: 2, + scoring_positions: "", + stones: 0, + territory: 0, + total: 2, + }); + }); + + test("getStoneRemovalString()", async () => { + const se = new ScoreEstimator(undefined, engine, 10, 0.5, false); + se.toggleMetaGroupRemoval(1, 0); + se.toggleMetaGroupRemoval(2, 0); + await se.when_ready; + + expect(se.getStoneRemovalString()).toBe("babbcacb"); + + se.clearRemoved(); + + expect(se.getStoneRemovalString()).toBe(""); + }); + + test("goban callback", async () => { + const fake_goban = { + updateScoreEstimation: jest.fn(), + setForRemoval: jest.fn(), + }; + + const se = new ScoreEstimator(fake_goban as any, engine, 10, 0.5, false); + await se.when_ready; + + expect(fake_goban.updateScoreEstimation).toBeCalled(); + + se.setRemoved(1, 0, 1); + expect(fake_goban.setForRemoval).toBeCalledWith(1, 0, 1); + }); + + test("getProbablyDead", async () => { + const markBoardAllBlack = () => [ + [1, 1, 1, 1], + [1, 1, 1, 1], + ]; + set_local_scorer(markBoardAllBlack); + + const se = new ScoreEstimator(undefined, engine, 10, 0.5, false); + await se.when_ready; + + // Note (bpj): I think this might be a bug + // This is marking all stones dead, but the black stones should still be alive. + expect(se.getProbablyDead()).toBe("babbcacb"); + // expect(se.getProbablyDead()).toBe("cacb"); + }); }); diff --git a/src/local_estimators/__tests__/voronoi.test.ts b/src/local_estimators/__tests__/voronoi.test.ts new file mode 100644 index 00000000..930f1457 --- /dev/null +++ b/src/local_estimators/__tests__/voronoi.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright (C) Online-Go.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { estimateScoreVoronoi } from "../voronoi"; + +test("one color only scores board for that color", () => { + const board = [ + [0, 0, 0], + [0, 1, 0], + [0, 0, 0], + ]; + + expect(estimateScoreVoronoi(board)).toEqual([ + [1, 1, 1], + [1, 1, 1], + [1, 1, 1], + ]); +}); + +test("border is one stone wide", () => { + const board = [ + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0], + [0, 0, 0, -1, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + ]; + + expect(estimateScoreVoronoi(board)).toEqual([ + [1, 1, 1, 1, 1, 0], + [1, 1, 1, 1, 0, -1], + [1, 1, 1, 0, -1, -1], + [1, 1, 0, -1, -1, -1], + [1, 0, -1, -1, -1, -1], + [0, -1, -1, -1, -1, -1], + ]); +}); diff --git a/src/local_estimators/voronoi.ts b/src/local_estimators/voronoi.ts new file mode 100644 index 00000000..cd0d2b7a --- /dev/null +++ b/src/local_estimators/voronoi.ts @@ -0,0 +1,88 @@ +/* + * Copyright (C) Online-Go.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { dup } from "../GoUtil"; + +/** + * This estimator simply marks territory for whichever color has a + * closer stone (Manhattan distance). See discussion at + * https://forums.online-go.com/t/weak-score-estimator-and-japanese-rules/41041/70 + */ +export function estimateScoreVoronoi(board: number[][]) { + const { width, height } = get_dims(board); + const ownership: number[][] = dup(board); + let points = getPoints(board, (pt) => pt !== 0); + while (points.length) { + const unvisited = points + .flatMap((pt) => getNeighbors(width, height, pt)) + .filter((pt) => ownership[pt.y][pt.x] === 0); + unvisited + .map((pt) => ({ x: pt.x, y: pt.y, color: getOwningColor(ownership, pt) })) + .forEach(({ x, y, color }) => { + ownership[y][x] = color; + }); + points = unvisited.filter(({ x, y }) => ownership[y][x] !== 0); + } + return ownership; +} + +function getOwningColor(board: number[][], pt: Coordinate): -1 | 0 | 1 { + const { width, height } = get_dims(board); + const neighbors = getNeighbors(width, height, pt); + const non_neutral_neighbors = neighbors.filter((pt) => board[pt.y][pt.x] !== 0); + if (non_neutral_neighbors.every((pt) => board[pt.y][pt.x] === 1)) { + return 1; + } + if (non_neutral_neighbors.every((pt) => board[pt.y][pt.x] === -1)) { + return -1; + } + return 0; +} + +type Coordinate = { x: number; y: number }; +function getPoints(board: number[][], f: (pt: number) => boolean): Coordinate[] { + const { width, height } = get_dims(board); + const points: Coordinate[] = []; + for (let y = 0; y < height; ++y) { + for (let x = 0; x < width; ++x) { + if (f(board[y][x])) { + points.push({ x, y }); + } + } + } + return points; +} +function getNeighbors(width: number, height: number, { x, y }: Coordinate): Coordinate[] { + const neighbors: Coordinate[] = []; + if (x > 0) { + neighbors.push({ x: x - 1, y }); + } + if (x < width - 1) { + neighbors.push({ x: x + 1, y }); + } + if (y > 0) { + neighbors.push({ x, y: y - 1 }); + } + if (y < height - 1) { + neighbors.push({ x, y: y + 1 }); + } + + return neighbors; +} + +function get_dims(board: unknown[][]) { + return { width: board[0].length, height: board.length }; +} diff --git a/src/local_estimators/wasm_estimator.ts b/src/local_estimators/wasm_estimator.ts new file mode 100644 index 00000000..b4f2da23 --- /dev/null +++ b/src/local_estimators/wasm_estimator.ts @@ -0,0 +1,138 @@ +/* + * Copyright (C) Online-Go.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* The OGSScoreEstimator method is a wasm compiled C program that + * does simple random playouts. On the client, the OGSScoreEstimator script + * is loaded in an async fashion, so at some point that global variable + * becomes not null and can be used. + */ + +import * as GoMath from "../GoMath"; + +declare const CLIENT: boolean; + +declare let OGSScoreEstimator: any; +let OGSScoreEstimator_initialized = false; +let OGSScoreEstimatorModule: any; + +let init_promise: Promise; + +export function init_score_estimator(): Promise { + if (!CLIENT) { + throw new Error("Only initialize WASM library on the client side"); + } + + if (OGSScoreEstimator_initialized) { + return Promise.resolve(true); + } + + if (init_promise) { + return init_promise; + } + + try { + if ( + !OGSScoreEstimatorModule && + (("OGSScoreEstimator" in window) as any) && + ((window as any)["OGSScoreEstimator"] as any) + ) { + OGSScoreEstimatorModule = (window as any)["OGSScoreEstimator"] as any; + } + } catch (e) { + console.error(e); + } + + if (OGSScoreEstimatorModule) { + OGSScoreEstimatorModule = OGSScoreEstimatorModule(); + OGSScoreEstimator_initialized = true; + return Promise.resolve(true); + } + + const script: HTMLScriptElement = document.getElementById( + "ogs_score_estimator_script", + ) as HTMLScriptElement; + if (script) { + let resolve: (tf: boolean) => void; + init_promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + }); + + script.onload = () => { + OGSScoreEstimatorModule = OGSScoreEstimator; + OGSScoreEstimatorModule = OGSScoreEstimatorModule(); + OGSScoreEstimator_initialized = true; + resolve(true); + }; + + return init_promise; + } else { + return Promise.reject("score estimator not available"); + } +} + +export function estimateScoreWasm( + board: number[][], + color_to_move: "black" | "white", + trials: number, + tolerance: number, +) { + if (!OGSScoreEstimator_initialized) { + throw new Error("Score estimator not intialized yet, uptime = " + performance.now()); + } + + const width = board[0].length; + const height = board.length; + const nbytes = 4 * width * height; + const ptr = OGSScoreEstimatorModule._malloc(nbytes); + const ints = new Int32Array(OGSScoreEstimatorModule.HEAP32.buffer, ptr, nbytes); + let i = 0; + for (let y = 0; y < height; ++y) { + for (let x = 0; x < width; ++x) { + ints[i] = board[y][x]; + ++i; + } + } + const _estimate = OGSScoreEstimatorModule.cwrap("estimate", "number", [ + "number", + "number", + "number", + "number", + "number", + "number", + ]); + const estimate = _estimate as ( + w: number, + h: number, + p: number, + c: number, + tr: number, + to: number, + ) => number; + estimate(width, height, ptr, color_to_move === "black" ? 1 : -1, trials, tolerance); + + const ownership = GoMath.makeMatrix(width, height, 0); + i = 0; + for (let y = 0; y < height; ++y) { + for (let x = 0; x < width; ++x) { + ownership[y][x] = ints[i]; + ++i; + } + } + + OGSScoreEstimatorModule._free(ptr); + + return ownership; +}