Upload folder using huggingface_hub
Browse files- browser/entry.js +310 -26
- browser/manifest.json +33 -3
browser/entry.js
CHANGED
|
@@ -3607,6 +3607,44 @@ function moveToIndex(fromName, toName, promoLetter) {
|
|
| 3607 |
// algorithm that isn't finalized yet would mean re-porting it again
|
| 3608 |
// later. Default behavior here (plain PUCT) matches Python's default
|
| 3609 |
// config exactly; nothing is being weakened, just left for later.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3610 |
class MCTSConfig {
|
| 3611 |
constructor({ nSimulations = 800, timeBudgetMs = null, // anytime: stop on whichever hits first
|
| 3612 |
cPuct = 1.5, batchSize = 1, // >1 enables virtual-loss leaf batching
|
|
@@ -3647,6 +3685,19 @@ function wdlToScore(wdlProbs) {
|
|
| 3647 |
function isDraw(chess) {
|
| 3648 |
return chess.isGameOver() && !chess.isCheckmate();
|
| 3649 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3650 |
class MCTS {
|
| 3651 |
/**
|
| 3652 |
* @param {{evaluateBatch(chessInstances): Promise<{policyLogits: Float32Array[], wdlLogits: Float32Array[]}>}} evaluator
|
|
@@ -3655,41 +3706,72 @@ class MCTS {
|
|
| 3655 |
constructor(evaluator, config) {
|
| 3656 |
this.evaluator = evaluator;
|
| 3657 |
this.config = config || new MCTSConfig();
|
|
|
|
|
|
|
| 3658 |
}
|
| 3659 |
/**
|
| 3660 |
* Run search from `chess`'s current position. Returns the winning move's
|
| 3661 |
* SAN string (the most-visited root child). Mutates `chess` internally
|
| 3662 |
* via move()/undo() during simulation but always leaves it at the
|
| 3663 |
* original position when it returns.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3664 |
*/
|
| 3665 |
-
async search(chess) {
|
| 3666 |
const cfg = this.config;
|
|
|
|
| 3667 |
const root = new Node(1.0);
|
| 3668 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3669 |
let sim = 0;
|
| 3670 |
if (cfg.batchSize <= 1) {
|
| 3671 |
while (sim < cfg.nSimulations) {
|
| 3672 |
-
if (
|
| 3673 |
break;
|
| 3674 |
}
|
| 3675 |
await this._simulate(root, chess);
|
| 3676 |
sim++;
|
|
|
|
|
|
|
| 3677 |
}
|
| 3678 |
}
|
| 3679 |
else {
|
| 3680 |
// Bootstrap root expansion exactly once, matching serial search.
|
| 3681 |
-
if (sim < cfg.nSimulations && (
|
| 3682 |
const value = await this._expand(root, chess);
|
| 3683 |
this._backup([root], value);
|
| 3684 |
sim++;
|
|
|
|
| 3685 |
}
|
| 3686 |
while (sim < cfg.nSimulations) {
|
| 3687 |
-
if (
|
| 3688 |
break;
|
| 3689 |
}
|
| 3690 |
-
const
|
| 3691 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3692 |
sim += completed;
|
|
|
|
|
|
|
| 3693 |
if (completed === 0)
|
| 3694 |
break;
|
| 3695 |
}
|
|
@@ -3716,6 +3798,21 @@ class MCTS {
|
|
| 3716 |
}
|
| 3717 |
return bestEdge.san;
|
| 3718 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3719 |
// -- internals -----------------------------------------------------
|
| 3720 |
async _simulate(root, chess) {
|
| 3721 |
let node = root;
|
|
@@ -3741,12 +3838,13 @@ class MCTS {
|
|
| 3741 |
chess.undo();
|
| 3742 |
}
|
| 3743 |
}
|
| 3744 |
-
async _runBatchedRound(root, chess, roundBudget, startTime) {
|
|
|
|
| 3745 |
const pending = new Map();
|
| 3746 |
const order = [];
|
| 3747 |
let completed = 0;
|
| 3748 |
for (let i = 0; i < roundBudget; i++) {
|
| 3749 |
-
if (
|
| 3750 |
break;
|
| 3751 |
}
|
| 3752 |
const result = this._selectLeafVL(root, chess);
|
|
@@ -3772,6 +3870,9 @@ class MCTS {
|
|
| 3772 |
completed += this._finishFreshEntry(entries[i], policyLogits[i], wdlLogits[i]);
|
| 3773 |
}
|
| 3774 |
}
|
|
|
|
|
|
|
|
|
|
| 3775 |
return completed;
|
| 3776 |
}
|
| 3777 |
_selectLeafVL(root, chess) {
|
|
@@ -3949,18 +4050,40 @@ const OPENING_BOOK = Object.freeze({ "1nbqkbnr/1ppppppp/8/8/8/1P2P3/1BPP1PPP/rN1
|
|
| 3949 |
//
|
| 3950 |
// Contract (rules technical appendix, schema chess-gpt-package-v1):
|
| 3951 |
// export async function loadPackage({ artifacts, config, ort })
|
| 3952 |
-
// -> { newGame({ random }) -> { chooseMove({ history, legalMoves }) -> SAN,
|
| 3953 |
// dispose() },
|
| 3954 |
// dispose() }
|
| 3955 |
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3956 |
// Because we only ever return a member of `legalMoves`, this can never lose
|
| 3957 |
// on an illegal/invalid move -- if anything above goes wrong for a specific
|
| 3958 |
// position, we fall back to legalMoves[0] rather than throw.
|
| 3959 |
-
//
|
| 3960 |
-
//
|
| 3961 |
-
//
|
| 3962 |
-
// fast test iteration)
|
|
|
|
|
|
|
| 3963 |
const DEFAULT_TIME_BUDGET_MS = 9200;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3964 |
const MAX_SIMULATIONS = 100000; // effectively unbounded; time budget governs
|
| 3965 |
// Virtual-loss leaf batching (chessdb-4ru): a batchSize sweep (1/2/4/8/
|
| 3966 |
// 16/32) against the real model via onnxruntime-node
|
|
@@ -3981,9 +4104,153 @@ function emitEvent(onEvent, event) {
|
|
| 3981 |
// Diagnostics must never change engine behavior.
|
| 3982 |
}
|
| 3983 |
}
|
| 3984 |
-
function
|
| 3985 |
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
| 3986 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3987 |
export function normalizeBookFen(fen) {
|
| 3988 |
return fen.split(" ").slice(0, 4).join(" ");
|
| 3989 |
}
|
|
@@ -4064,13 +4331,25 @@ export async function loadPackage({ artifacts, ort, config }) {
|
|
| 4064 |
async newGame( /* { random } */) {
|
| 4065 |
const mcts = new MCTS(evaluator, new MCTSConfig({ nSimulations, timeBudgetMs, batchSize: MCTS_BATCH_SIZE }));
|
| 4066 |
return {
|
| 4067 |
-
async chooseMove({ history, legalMoves }) {
|
| 4068 |
if (!legalMoves || legalMoves.length === 0) {
|
| 4069 |
throw new Error("chooseMove called with no legalMoves");
|
| 4070 |
}
|
| 4071 |
-
const startedAt =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4072 |
try {
|
| 4073 |
-
|
| 4074 |
for (const san of history) {
|
| 4075 |
chess.move(san);
|
| 4076 |
}
|
|
@@ -4080,6 +4359,7 @@ export async function loadPackage({ artifacts, ort, config }) {
|
|
| 4080 |
history: [...history],
|
| 4081 |
fen: chess.fen(),
|
| 4082 |
legalMoves: [...legalMoves],
|
|
|
|
| 4083 |
});
|
| 4084 |
const bookMove = history.length <= 9
|
| 4085 |
? bookMoveSan(chess, OPENING_BOOK, legalMoves)
|
|
@@ -4089,12 +4369,16 @@ export async function loadPackage({ artifacts, ort, config }) {
|
|
| 4089 |
type: "book_hit",
|
| 4090 |
ply: history.length,
|
| 4091 |
move: bookMove,
|
| 4092 |
-
elapsedMs:
|
| 4093 |
});
|
| 4094 |
-
return bookMove;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4095 |
}
|
| 4096 |
-
const chosen = await mcts.search(chess);
|
| 4097 |
-
const elapsedMs =
|
| 4098 |
// mcts.search() draws its move from chess.js's own legal-move
|
| 4099 |
// list at the current position, which should always match the
|
| 4100 |
// runner-provided legalMoves -- but if the runner's SAN
|
|
@@ -4110,7 +4394,7 @@ export async function loadPackage({ artifacts, ort, config }) {
|
|
| 4110 |
move: fallback,
|
| 4111 |
elapsedMs,
|
| 4112 |
});
|
| 4113 |
-
return fallback;
|
| 4114 |
}
|
| 4115 |
emitEvent(onEvent, {
|
| 4116 |
type: "mcts_move",
|
|
@@ -4119,7 +4403,7 @@ export async function loadPackage({ artifacts, ort, config }) {
|
|
| 4119 |
elapsedMs,
|
| 4120 |
simCount: mcts.lastSimCount,
|
| 4121 |
});
|
| 4122 |
-
return chosen;
|
| 4123 |
}
|
| 4124 |
catch (error) {
|
| 4125 |
const fallback = legalMoves[0];
|
|
@@ -4128,9 +4412,9 @@ export async function loadPackage({ artifacts, ort, config }) {
|
|
| 4128 |
reason: "exception",
|
| 4129 |
error: String(error instanceof Error ? error.message : error),
|
| 4130 |
move: fallback,
|
| 4131 |
-
elapsedMs:
|
| 4132 |
});
|
| 4133 |
-
return fallback;
|
| 4134 |
}
|
| 4135 |
},
|
| 4136 |
async dispose() { },
|
|
|
|
| 3607 |
// algorithm that isn't finalized yet would mean re-porting it again
|
| 3608 |
// later. Default behavior here (plain PUCT) matches Python's default
|
| 3609 |
// config exactly; nothing is being weakened, just left for later.
|
| 3610 |
+
const ADAPTIVE_BATCH_SAFETY = 1.25;
|
| 3611 |
+
const ADAPTIVE_BATCH_GUARD_MS = 2;
|
| 3612 |
+
const ADAPTIVE_BATCH_EMA_ALPHA = 0.25;
|
| 3613 |
+
function clockMs() {
|
| 3614 |
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
| 3615 |
+
return performance.now();
|
| 3616 |
+
}
|
| 3617 |
+
return Date.now();
|
| 3618 |
+
}
|
| 3619 |
+
/**
|
| 3620 |
+
* Choose the largest halving batch that conservatively fits the remaining
|
| 3621 |
+
* budget. `referenceBatchMs` is an observed duration for
|
| 3622 |
+
* `referenceBatchSize`; lower sizes use a linear estimate plus safety margin.
|
| 3623 |
+
* A null reference preserves the configured batch for cold-start behavior.
|
| 3624 |
+
*/
|
| 3625 |
+
function adaptiveBatchSizeForBudget(maxBatchSize, remainingMs, referenceBatchMs, referenceBatchSize = maxBatchSize) {
|
| 3626 |
+
if (!Number.isInteger(maxBatchSize) || maxBatchSize <= 0)
|
| 3627 |
+
return 0;
|
| 3628 |
+
if (remainingMs === Infinity)
|
| 3629 |
+
return maxBatchSize;
|
| 3630 |
+
if (!Number.isFinite(remainingMs) || remainingMs <= 0)
|
| 3631 |
+
return 0;
|
| 3632 |
+
if (!Number.isFinite(referenceBatchMs)
|
| 3633 |
+
|| referenceBatchMs <= 0
|
| 3634 |
+
|| !Number.isInteger(referenceBatchSize)
|
| 3635 |
+
|| referenceBatchSize <= 0) {
|
| 3636 |
+
return maxBatchSize;
|
| 3637 |
+
}
|
| 3638 |
+
for (let candidate = maxBatchSize; candidate >= 1; candidate = Math.floor(candidate / 2)) {
|
| 3639 |
+
const estimatedMs = referenceBatchMs * (candidate / referenceBatchSize);
|
| 3640 |
+
const requiredMs = estimatedMs * ADAPTIVE_BATCH_SAFETY + ADAPTIVE_BATCH_GUARD_MS;
|
| 3641 |
+
if (remainingMs >= requiredMs)
|
| 3642 |
+
return candidate;
|
| 3643 |
+
if (candidate === 1)
|
| 3644 |
+
break;
|
| 3645 |
+
}
|
| 3646 |
+
return 0;
|
| 3647 |
+
}
|
| 3648 |
class MCTSConfig {
|
| 3649 |
constructor({ nSimulations = 800, timeBudgetMs = null, // anytime: stop on whichever hits first
|
| 3650 |
cPuct = 1.5, batchSize = 1, // >1 enables virtual-loss leaf batching
|
|
|
|
| 3685 |
function isDraw(chess) {
|
| 3686 |
return chess.isGameOver() && !chess.isCheckmate();
|
| 3687 |
}
|
| 3688 |
+
function rootSnapshot(root, simCount) {
|
| 3689 |
+
return {
|
| 3690 |
+
simCount,
|
| 3691 |
+
candidates: Array.from(root.children.values(), (edge) => ({
|
| 3692 |
+
san: edge.san,
|
| 3693 |
+
from: edge.from,
|
| 3694 |
+
to: edge.to,
|
| 3695 |
+
promotion: edge.promotion,
|
| 3696 |
+
visits: edge.node.visitCount,
|
| 3697 |
+
prior: edge.node.prior,
|
| 3698 |
+
})),
|
| 3699 |
+
};
|
| 3700 |
+
}
|
| 3701 |
class MCTS {
|
| 3702 |
/**
|
| 3703 |
* @param {{evaluateBatch(chessInstances): Promise<{policyLogits: Float32Array[], wdlLogits: Float32Array[]}>}} evaluator
|
|
|
|
| 3706 |
constructor(evaluator, config) {
|
| 3707 |
this.evaluator = evaluator;
|
| 3708 |
this.config = config || new MCTSConfig();
|
| 3709 |
+
this.batchDurationMs = null;
|
| 3710 |
+
this.batchDurationSamples = 0;
|
| 3711 |
}
|
| 3712 |
/**
|
| 3713 |
* Run search from `chess`'s current position. Returns the winning move's
|
| 3714 |
* SAN string (the most-visited root child). Mutates `chess` internally
|
| 3715 |
* via move()/undo() during simulation but always leaves it at the
|
| 3716 |
* original position when it returns.
|
| 3717 |
+
*
|
| 3718 |
+
* `timeBudgetMs` (optional, second-argument object) overrides
|
| 3719 |
+
* `this.config.timeBudgetMs` for this one call only -- lets a caller that
|
| 3720 |
+
* creates one long-lived MCTS instance per game (entry.js does, so root
|
| 3721 |
+
* statistics reset per move but the instance itself persists) still honor
|
| 3722 |
+
* a per-move budget that can change move to move (e.g. a tournament
|
| 3723 |
+
* runner's per-move time limit).
|
| 3724 |
*/
|
| 3725 |
+
async search(chess, { timeBudgetMs, onRootUpdate } = {}) {
|
| 3726 |
const cfg = this.config;
|
| 3727 |
+
const effectiveTimeBudgetMs = timeBudgetMs !== undefined ? timeBudgetMs : cfg.timeBudgetMs;
|
| 3728 |
const root = new Node(1.0);
|
| 3729 |
+
const notifyRoot = (simCount) => {
|
| 3730 |
+
if (typeof onRootUpdate !== "function" || root.children.size === 0)
|
| 3731 |
+
return;
|
| 3732 |
+
try {
|
| 3733 |
+
onRootUpdate(rootSnapshot(root, simCount));
|
| 3734 |
+
}
|
| 3735 |
+
catch {
|
| 3736 |
+
// Optional diagnostics must never change search behavior.
|
| 3737 |
+
}
|
| 3738 |
+
};
|
| 3739 |
+
const startTime = clockMs();
|
| 3740 |
let sim = 0;
|
| 3741 |
if (cfg.batchSize <= 1) {
|
| 3742 |
while (sim < cfg.nSimulations) {
|
| 3743 |
+
if (effectiveTimeBudgetMs !== null && clockMs() - startTime >= effectiveTimeBudgetMs) {
|
| 3744 |
break;
|
| 3745 |
}
|
| 3746 |
await this._simulate(root, chess);
|
| 3747 |
sim++;
|
| 3748 |
+
if (sim === 1 || sim % 16 === 0)
|
| 3749 |
+
notifyRoot(sim);
|
| 3750 |
}
|
| 3751 |
}
|
| 3752 |
else {
|
| 3753 |
// Bootstrap root expansion exactly once, matching serial search.
|
| 3754 |
+
if (sim < cfg.nSimulations && (effectiveTimeBudgetMs === null || clockMs() - startTime < effectiveTimeBudgetMs)) {
|
| 3755 |
const value = await this._expand(root, chess);
|
| 3756 |
this._backup([root], value);
|
| 3757 |
sim++;
|
| 3758 |
+
notifyRoot(sim);
|
| 3759 |
}
|
| 3760 |
while (sim < cfg.nSimulations) {
|
| 3761 |
+
if (effectiveTimeBudgetMs !== null && clockMs() - startTime >= effectiveTimeBudgetMs) {
|
| 3762 |
break;
|
| 3763 |
}
|
| 3764 |
+
const maxRoundBudget = Math.min(cfg.batchSize, cfg.nSimulations - sim);
|
| 3765 |
+
const remainingMs = effectiveTimeBudgetMs === null
|
| 3766 |
+
? Infinity
|
| 3767 |
+
: effectiveTimeBudgetMs - (clockMs() - startTime);
|
| 3768 |
+
const roundBudget = this._chooseAdaptiveBatchSize(remainingMs, maxRoundBudget);
|
| 3769 |
+
if (roundBudget === 0)
|
| 3770 |
+
break;
|
| 3771 |
+
const completed = await this._runBatchedRound(root, chess, roundBudget, startTime, effectiveTimeBudgetMs);
|
| 3772 |
sim += completed;
|
| 3773 |
+
if (completed > 0)
|
| 3774 |
+
notifyRoot(sim);
|
| 3775 |
if (completed === 0)
|
| 3776 |
break;
|
| 3777 |
}
|
|
|
|
| 3798 |
}
|
| 3799 |
return bestEdge.san;
|
| 3800 |
}
|
| 3801 |
+
_recordDefaultBatchTiming(elapsedMs) {
|
| 3802 |
+
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0)
|
| 3803 |
+
return;
|
| 3804 |
+
if (this.batchDurationMs === null) {
|
| 3805 |
+
this.batchDurationMs = elapsedMs;
|
| 3806 |
+
}
|
| 3807 |
+
else {
|
| 3808 |
+
this.batchDurationMs = ((1 - ADAPTIVE_BATCH_EMA_ALPHA) * this.batchDurationMs
|
| 3809 |
+
+ ADAPTIVE_BATCH_EMA_ALPHA * elapsedMs);
|
| 3810 |
+
}
|
| 3811 |
+
this.batchDurationSamples++;
|
| 3812 |
+
}
|
| 3813 |
+
_chooseAdaptiveBatchSize(remainingMs, maxBatchSize = this.config.batchSize) {
|
| 3814 |
+
return adaptiveBatchSizeForBudget(maxBatchSize, remainingMs, this.batchDurationMs, this.config.batchSize);
|
| 3815 |
+
}
|
| 3816 |
// -- internals -----------------------------------------------------
|
| 3817 |
async _simulate(root, chess) {
|
| 3818 |
let node = root;
|
|
|
|
| 3838 |
chess.undo();
|
| 3839 |
}
|
| 3840 |
}
|
| 3841 |
+
async _runBatchedRound(root, chess, roundBudget, startTime, timeBudgetMs) {
|
| 3842 |
+
const roundStart = clockMs();
|
| 3843 |
const pending = new Map();
|
| 3844 |
const order = [];
|
| 3845 |
let completed = 0;
|
| 3846 |
for (let i = 0; i < roundBudget; i++) {
|
| 3847 |
+
if (timeBudgetMs !== null && clockMs() - startTime >= timeBudgetMs) {
|
| 3848 |
break;
|
| 3849 |
}
|
| 3850 |
const result = this._selectLeafVL(root, chess);
|
|
|
|
| 3870 |
completed += this._finishFreshEntry(entries[i], policyLogits[i], wdlLogits[i]);
|
| 3871 |
}
|
| 3872 |
}
|
| 3873 |
+
if (order.length === this.config.batchSize) {
|
| 3874 |
+
this._recordDefaultBatchTiming(clockMs() - roundStart);
|
| 3875 |
+
}
|
| 3876 |
return completed;
|
| 3877 |
}
|
| 3878 |
_selectLeafVL(root, chess) {
|
|
|
|
| 4050 |
//
|
| 4051 |
// Contract (rules technical appendix, schema chess-gpt-package-v1):
|
| 4052 |
// export async function loadPackage({ artifacts, config, ort })
|
| 4053 |
+
// -> { newGame({ random }) -> { chooseMove({ history, legalMoves, moveTimeLimitMs }) -> SAN,
|
| 4054 |
// dispose() },
|
| 4055 |
// dispose() }
|
| 4056 |
//
|
| 4057 |
+
// moveTimeLimitMs (added to the rules 2026-07-31): the runner's wall-clock
|
| 4058 |
+
// budget in ms for this move. A submission MAY use it and MAY ignore it;
|
| 4059 |
+
// this one uses it to cap MCTS's per-move search so we stay under it (the
|
| 4060 |
+
// runner allows a 1.25x grace period before terminating the worker as a
|
| 4061 |
+
// loss, but that grace is not extra thinking time and must not be relied
|
| 4062 |
+
// on -- see MOVE_TIME_LIMIT_MARGIN below). Packages published before this
|
| 4063 |
+
// revision remain valid: the field is additive, and chooseMove falls back
|
| 4064 |
+
// to the fixed DEFAULT_TIME_BUDGET_MS/config.timeBudgetMs budget below
|
| 4065 |
+
// when it's absent (also how local tests/benchmarks that don't pass it
|
| 4066 |
+
// keep working unchanged).
|
| 4067 |
+
//
|
| 4068 |
// Because we only ever return a member of `legalMoves`, this can never lose
|
| 4069 |
// on an illegal/invalid move -- if anything above goes wrong for a specific
|
| 4070 |
// position, we fall back to legalMoves[0] rather than throw.
|
| 4071 |
+
// Fallback budget when the runner doesn't supply moveTimeLimitMs on
|
| 4072 |
+
// chooseMove (older runner, or a local test/benchmark harness that omits
|
| 4073 |
+
// it -- see test_smoke.mjs). Also overridable via loadPackage's `config`
|
| 4074 |
+
// (e.g. { timeBudgetMs: 500 } for fast test iteration); config.timeBudgetMs
|
| 4075 |
+
// wins over this default but NOT over a real per-move moveTimeLimitMs,
|
| 4076 |
+
// which is the live constraint whenever the runner provides one.
|
| 4077 |
const DEFAULT_TIME_BUDGET_MS = 9200;
|
| 4078 |
+
// Fraction of the runner's per-move moveTimeLimitMs actually budgeted to
|
| 4079 |
+
// MCTS search, leaving margin for encoding/dispatch overhead around each
|
| 4080 |
+
// simulation's model call before the runner's own clock runs out. Matches
|
| 4081 |
+
// the historical 9.2s-of-10s margin (DEFAULT_TIME_BUDGET_MS above, from
|
| 4082 |
+
// when the budget was a fixed ~10s/move) so this doesn't change behavior
|
| 4083 |
+
// for the moveTimeLimitMs=10000 case. Deliberately stays inside
|
| 4084 |
+
// moveTimeLimitMs itself, not the runner's 1.25x grace period -- that
|
| 4085 |
+
// grace is for worker-teardown latency, not additional thinking time.
|
| 4086 |
+
const MOVE_TIME_LIMIT_MARGIN = 0.92;
|
| 4087 |
const MAX_SIMULATIONS = 100000; // effectively unbounded; time budget governs
|
| 4088 |
// Virtual-loss leaf batching (chessdb-4ru): a batchSize sweep (1/2/4/8/
|
| 4089 |
// 16/32) against the real model via onnxruntime-node
|
|
|
|
| 4104 |
// Diagnostics must never change engine behavior.
|
| 4105 |
}
|
| 4106 |
}
|
| 4107 |
+
function entryClockMs() {
|
| 4108 |
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
| 4109 |
}
|
| 4110 |
+
const MAX_CANDIDATES = 4;
|
| 4111 |
+
const MIN_REFRESH_MS = 150;
|
| 4112 |
+
const MAX_COMMANDS_PER_MOVE = 56;
|
| 4113 |
+
const ORDINARY_COMMAND_LIMIT = MAX_COMMANDS_PER_MOVE - 1;
|
| 4114 |
+
const CANDIDATE_FADE_MS = 900;
|
| 4115 |
+
const FINAL_FADE_MS = 1500;
|
| 4116 |
+
function selectCandidateArrows(snapshot, legalMoves) {
|
| 4117 |
+
const legal = new Set(Array.isArray(legalMoves) ? legalMoves : []);
|
| 4118 |
+
const candidates = Array.isArray(snapshot?.candidates) ? snapshot.candidates : [];
|
| 4119 |
+
const ranked = candidates
|
| 4120 |
+
.filter((candidate) => candidate && legal.has(candidate.san))
|
| 4121 |
+
.sort((a, b) => {
|
| 4122 |
+
const aVisits = Number.isFinite(a.visits) ? a.visits : 0;
|
| 4123 |
+
const bVisits = Number.isFinite(b.visits) ? b.visits : 0;
|
| 4124 |
+
const aHasVisits = aVisits > 0 ? 1 : 0;
|
| 4125 |
+
const bHasVisits = bVisits > 0 ? 1 : 0;
|
| 4126 |
+
return bHasVisits - aHasVisits
|
| 4127 |
+
|| bVisits - aVisits
|
| 4128 |
+
|| (Number.isFinite(b.prior) ? b.prior : 0) - (Number.isFinite(a.prior) ? a.prior : 0)
|
| 4129 |
+
|| String(a.san).localeCompare(String(b.san));
|
| 4130 |
+
});
|
| 4131 |
+
const selected = [];
|
| 4132 |
+
const keys = new Set();
|
| 4133 |
+
for (const candidate of ranked) {
|
| 4134 |
+
if (typeof candidate.from !== "string" || typeof candidate.to !== "string")
|
| 4135 |
+
continue;
|
| 4136 |
+
const key = `${candidate.from}-${candidate.to}`;
|
| 4137 |
+
if (keys.has(key))
|
| 4138 |
+
continue;
|
| 4139 |
+
keys.add(key);
|
| 4140 |
+
selected.push({
|
| 4141 |
+
san: candidate.san,
|
| 4142 |
+
from: candidate.from,
|
| 4143 |
+
to: candidate.to,
|
| 4144 |
+
});
|
| 4145 |
+
if (selected.length === MAX_CANDIDATES)
|
| 4146 |
+
break;
|
| 4147 |
+
}
|
| 4148 |
+
return selected.map((candidate, rank) => ({
|
| 4149 |
+
...candidate,
|
| 4150 |
+
intensity: selected.length > 1 ? 1 - 0.6 * rank / (selected.length - 1) : 1,
|
| 4151 |
+
}));
|
| 4152 |
+
}
|
| 4153 |
+
function createThinkingDisplay(thinking) {
|
| 4154 |
+
const emit = typeof thinking?.emit === "function" ? thinking.emit.bind(thinking) : null;
|
| 4155 |
+
const displayed = new Map();
|
| 4156 |
+
let ordinaryCommands = 0;
|
| 4157 |
+
let lastUpdateAt = -Infinity;
|
| 4158 |
+
const safeEmit = (command) => {
|
| 4159 |
+
if (!emit)
|
| 4160 |
+
return;
|
| 4161 |
+
try {
|
| 4162 |
+
emit(command);
|
| 4163 |
+
}
|
| 4164 |
+
catch {
|
| 4165 |
+
// Display diagnostics must never change move selection.
|
| 4166 |
+
}
|
| 4167 |
+
};
|
| 4168 |
+
return {
|
| 4169 |
+
enabled: Boolean(emit),
|
| 4170 |
+
start() {
|
| 4171 |
+
displayed.clear();
|
| 4172 |
+
ordinaryCommands = 1;
|
| 4173 |
+
lastUpdateAt = -Infinity;
|
| 4174 |
+
safeEmit({ type: "clearAll" });
|
| 4175 |
+
},
|
| 4176 |
+
update(snapshot, legalMoves) {
|
| 4177 |
+
const now = entryClockMs();
|
| 4178 |
+
if (now - lastUpdateAt < MIN_REFRESH_MS)
|
| 4179 |
+
return;
|
| 4180 |
+
lastUpdateAt = now;
|
| 4181 |
+
const selected = selectCandidateArrows(snapshot, legalMoves);
|
| 4182 |
+
const next = new Map(selected.map((arrow) => [
|
| 4183 |
+
`${arrow.from}-${arrow.to}`,
|
| 4184 |
+
arrow,
|
| 4185 |
+
]));
|
| 4186 |
+
const diff = [];
|
| 4187 |
+
for (const [key, arrow] of displayed) {
|
| 4188 |
+
const replacement = next.get(key);
|
| 4189 |
+
if (!replacement) {
|
| 4190 |
+
diff.push({ type: "clearArrow", from: arrow.from, to: arrow.to, fadeMs: CANDIDATE_FADE_MS });
|
| 4191 |
+
}
|
| 4192 |
+
else if (replacement.intensity !== arrow.intensity) {
|
| 4193 |
+
diff.push({
|
| 4194 |
+
type: "drawArrow",
|
| 4195 |
+
from: replacement.from,
|
| 4196 |
+
to: replacement.to,
|
| 4197 |
+
intensity: replacement.intensity,
|
| 4198 |
+
fadeMs: CANDIDATE_FADE_MS,
|
| 4199 |
+
});
|
| 4200 |
+
}
|
| 4201 |
+
}
|
| 4202 |
+
for (const [key, arrow] of next) {
|
| 4203 |
+
if (!displayed.has(key)) {
|
| 4204 |
+
diff.push({
|
| 4205 |
+
type: "drawArrow",
|
| 4206 |
+
from: arrow.from,
|
| 4207 |
+
to: arrow.to,
|
| 4208 |
+
intensity: arrow.intensity,
|
| 4209 |
+
fadeMs: CANDIDATE_FADE_MS,
|
| 4210 |
+
});
|
| 4211 |
+
}
|
| 4212 |
+
}
|
| 4213 |
+
if (ordinaryCommands + diff.length > ORDINARY_COMMAND_LIMIT)
|
| 4214 |
+
return;
|
| 4215 |
+
for (const command of diff) {
|
| 4216 |
+
safeEmit(command);
|
| 4217 |
+
ordinaryCommands++;
|
| 4218 |
+
}
|
| 4219 |
+
displayed.clear();
|
| 4220 |
+
for (const [key, arrow] of next)
|
| 4221 |
+
displayed.set(key, arrow);
|
| 4222 |
+
},
|
| 4223 |
+
commit(move, position) {
|
| 4224 |
+
const san = typeof move === "string" ? move : move?.san;
|
| 4225 |
+
if (ordinaryCommands + displayed.size <= ORDINARY_COMMAND_LIMIT) {
|
| 4226 |
+
for (const arrow of displayed.values()) {
|
| 4227 |
+
safeEmit({
|
| 4228 |
+
type: "clearArrow",
|
| 4229 |
+
from: arrow.from,
|
| 4230 |
+
to: arrow.to,
|
| 4231 |
+
fadeMs: CANDIDATE_FADE_MS,
|
| 4232 |
+
});
|
| 4233 |
+
ordinaryCommands++;
|
| 4234 |
+
}
|
| 4235 |
+
displayed.clear();
|
| 4236 |
+
}
|
| 4237 |
+
const verbose = position && typeof position.moves === "function"
|
| 4238 |
+
? position.moves({ verbose: true }).find((candidate) => candidate.san === san)
|
| 4239 |
+
: null;
|
| 4240 |
+
if (verbose) {
|
| 4241 |
+
safeEmit({
|
| 4242 |
+
type: "drawArrow",
|
| 4243 |
+
from: verbose.from,
|
| 4244 |
+
to: verbose.to,
|
| 4245 |
+
intensity: 1,
|
| 4246 |
+
fadeMs: FINAL_FADE_MS,
|
| 4247 |
+
});
|
| 4248 |
+
}
|
| 4249 |
+
return san;
|
| 4250 |
+
},
|
| 4251 |
+
};
|
| 4252 |
+
}
|
| 4253 |
+
export { selectCandidateArrows };
|
| 4254 |
export function normalizeBookFen(fen) {
|
| 4255 |
return fen.split(" ").slice(0, 4).join(" ");
|
| 4256 |
}
|
|
|
|
| 4331 |
async newGame( /* { random } */) {
|
| 4332 |
const mcts = new MCTS(evaluator, new MCTSConfig({ nSimulations, timeBudgetMs, batchSize: MCTS_BATCH_SIZE }));
|
| 4333 |
return {
|
| 4334 |
+
async chooseMove({ history, legalMoves, moveTimeLimitMs, thinking }) {
|
| 4335 |
if (!legalMoves || legalMoves.length === 0) {
|
| 4336 |
throw new Error("chooseMove called with no legalMoves");
|
| 4337 |
}
|
| 4338 |
+
const startedAt = entryClockMs();
|
| 4339 |
+
const display = createThinkingDisplay(thinking);
|
| 4340 |
+
display.start();
|
| 4341 |
+
// moveTimeLimitMs is the runner's live per-move budget when
|
| 4342 |
+
// provided; margin it down so search itself stops with room to
|
| 4343 |
+
// encode/return before the runner's own clock (and definitely
|
| 4344 |
+
// before the 1.25x grace teardown) expires. Falls back to the
|
| 4345 |
+
// fixed timeBudgetMs (config override or DEFAULT_TIME_BUDGET_MS)
|
| 4346 |
+
// when the runner doesn't supply it.
|
| 4347 |
+
const searchTimeBudgetMs = Number.isFinite(moveTimeLimitMs) && moveTimeLimitMs > 0
|
| 4348 |
+
? moveTimeLimitMs * MOVE_TIME_LIMIT_MARGIN
|
| 4349 |
+
: timeBudgetMs;
|
| 4350 |
+
let chess;
|
| 4351 |
try {
|
| 4352 |
+
chess = new Chess();
|
| 4353 |
for (const san of history) {
|
| 4354 |
chess.move(san);
|
| 4355 |
}
|
|
|
|
| 4359 |
history: [...history],
|
| 4360 |
fen: chess.fen(),
|
| 4361 |
legalMoves: [...legalMoves],
|
| 4362 |
+
searchTimeBudgetMs,
|
| 4363 |
});
|
| 4364 |
const bookMove = history.length <= 9
|
| 4365 |
? bookMoveSan(chess, OPENING_BOOK, legalMoves)
|
|
|
|
| 4369 |
type: "book_hit",
|
| 4370 |
ply: history.length,
|
| 4371 |
move: bookMove,
|
| 4372 |
+
elapsedMs: entryClockMs() - startedAt,
|
| 4373 |
});
|
| 4374 |
+
return display.commit(bookMove, chess);
|
| 4375 |
+
}
|
| 4376 |
+
const searchOptions = { timeBudgetMs: searchTimeBudgetMs };
|
| 4377 |
+
if (display.enabled) {
|
| 4378 |
+
searchOptions.onRootUpdate = (snapshot) => display.update(snapshot, legalMoves);
|
| 4379 |
}
|
| 4380 |
+
const chosen = await mcts.search(chess, searchOptions);
|
| 4381 |
+
const elapsedMs = entryClockMs() - startedAt;
|
| 4382 |
// mcts.search() draws its move from chess.js's own legal-move
|
| 4383 |
// list at the current position, which should always match the
|
| 4384 |
// runner-provided legalMoves -- but if the runner's SAN
|
|
|
|
| 4394 |
move: fallback,
|
| 4395 |
elapsedMs,
|
| 4396 |
});
|
| 4397 |
+
return display.commit(fallback, chess);
|
| 4398 |
}
|
| 4399 |
emitEvent(onEvent, {
|
| 4400 |
type: "mcts_move",
|
|
|
|
| 4403 |
elapsedMs,
|
| 4404 |
simCount: mcts.lastSimCount,
|
| 4405 |
});
|
| 4406 |
+
return display.commit(chosen, chess);
|
| 4407 |
}
|
| 4408 |
catch (error) {
|
| 4409 |
const fallback = legalMoves[0];
|
|
|
|
| 4412 |
reason: "exception",
|
| 4413 |
error: String(error instanceof Error ? error.message : error),
|
| 4414 |
move: fallback,
|
| 4415 |
+
elapsedMs: entryClockMs() - startedAt,
|
| 4416 |
});
|
| 4417 |
+
return display.commit(fallback, chess);
|
| 4418 |
}
|
| 4419 |
},
|
| 4420 |
async dispose() { },
|
browser/manifest.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
| 3 |
"name": "minichess-gpt-student-v1",
|
| 4 |
"entrypoint": {
|
| 5 |
"path": "entry.js",
|
| 6 |
-
"bytes":
|
| 7 |
-
"sha256": "
|
| 8 |
},
|
| 9 |
"artifacts": {
|
| 10 |
"model": {
|
|
@@ -13,5 +13,35 @@
|
|
| 13 |
"sha256": "aad1bed67c2f3517d30f914bafe9d57f6c7f84194150daa5322f7081f3a1df0f"
|
| 14 |
}
|
| 15 |
},
|
| 16 |
-
"config": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
|
|
|
| 3 |
"name": "minichess-gpt-student-v1",
|
| 4 |
"entrypoint": {
|
| 5 |
"path": "entry.js",
|
| 6 |
+
"bytes": 1017367,
|
| 7 |
+
"sha256": "ff7875c9f06f6eda265dde2cd2f86811b4a449079c0c1e8fdc1a3d845bc73e8a"
|
| 8 |
},
|
| 9 |
"artifacts": {
|
| 10 |
"model": {
|
|
|
|
| 13 |
"sha256": "aad1bed67c2f3517d30f914bafe9d57f6c7f84194150daa5322f7081f3a1df0f"
|
| 14 |
}
|
| 15 |
},
|
| 16 |
+
"config": {
|
| 17 |
+
"release": {
|
| 18 |
+
"variant": "student-v1",
|
| 19 |
+
"modelSha256": "aad1bed67c2f3517d30f914bafe9d57f6c7f84194150daa5322f7081f3a1df0f",
|
| 20 |
+
"modelBytes": 5551177,
|
| 21 |
+
"sourceCommit": "cdedb20d8348148d9c02ec5ba6fe0d01958da81c",
|
| 22 |
+
"modelPath": "runs/student_distillation/student-v1/student.onnx",
|
| 23 |
+
"modelSource": "registry",
|
| 24 |
+
"modelContract": {
|
| 25 |
+
"validated": true,
|
| 26 |
+
"publishable": true,
|
| 27 |
+
"inputName": "tokens",
|
| 28 |
+
"inputType": "int64",
|
| 29 |
+
"sequenceLength": 69,
|
| 30 |
+
"outputShapes": {
|
| 31 |
+
"policy": [
|
| 32 |
+
1,
|
| 33 |
+
4272
|
| 34 |
+
],
|
| 35 |
+
"wdl": [
|
| 36 |
+
1,
|
| 37 |
+
3
|
| 38 |
+
],
|
| 39 |
+
"uncertainty": [
|
| 40 |
+
1,
|
| 41 |
+
1
|
| 42 |
+
]
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
}
|