/* The policy+value net, as seen from JavaScript. * * A port of ludometer/train/net.py's `NetEvaluator`: encode the position, run the * exported ONNX graph, softmax the logits **over the legal actions only** (the * same thing as masking the rest to -inf, and cheaper), and hand back priors * aligned with the caller's `legal` list plus a value in [-1, 1] for the player * to move. * * The graph is ../model/model.onnx, exported by ludometer/export/onnx_export.py * and checked against torch on 100 real positions before it is written, so the * only thing that can go wrong on this side is the input vector — which is what * the engine fixtures pin down. * * onnxruntime-web is passed in rather than imported, so this module stays usable * from a worker, from node, and from a test with a stub evaluator. * * Two things beyond a single forward pass live here, both measured rather than * assumed (see NOTES_FOR_REMI.md): * * - **Batching.** One position at a time is the worst case for every backend: * on this laptop the WASM runtime does 2.1 k positions/s at batch 1 and * 4.2 k at batch 32, and the WebGPU runtime does 250/s at batch 1 (a GPU * dispatch costs ~4 ms whatever is in it) but 16 k/s at batch 64. So * `evaluateBatch` is the primitive and `evaluate` is the batch-of-one * special case; the search feeds it through virtual loss. * * - **The margin head.** Older exports have two outputs (`policy`, `value`); * a later one may add `margin`, a prediction of the score gap. Which * outputs exist is read off the session, never assumed, so the same player * code runs both. */ import { ENCODED_SIZE } from "./engine.js"; /** The name of the optional third output: a signed score-gap prediction. */ export const MARGIN_OUTPUT = "margin"; export class OnnxEvaluator { constructor(session, ort, backend = "wasm") { this.session = session; this.ort = ort; this.backend = backend; this.buffer = new Float32Array(ENCODED_SIZE); this.batchBuffer = null; // grown on demand by evaluateBatch this.calls = 0; this.positions = 0; // Feature-detected, never assumed: a run3-era graph has policy+value, a // later one may also have margin. `outputNames` is what the runtime loaded. const names = session.outputNames || []; this.hasMargin = names.indexOf(MARGIN_OUTPUT) !== -1; this.outputNames = Array.from(names); } /** * @param {object} ort the onnxruntime-web module namespace * @param {string|ArrayBuffer|Uint8Array} model URL or raw bytes of model.onnx * @param {object} opts {executionProviders, backend} */ static async create(ort, model, opts = {}) { const backend = opts.backend || "wasm"; const session = await ort.InferenceSession.create(model, { executionProviders: opts.executionProviders || [backend], graphOptimizationLevel: "all", }); return new OnnxEvaluator(session, ort, backend); } /** `(state, legal) -> {priors, value, margin}`; priors are aligned with `legal`. */ async evaluate(state, legal) { state.encode(this.buffer); const input = new this.ort.Tensor("float32", this.buffer, [1, ENCODED_SIZE]); const out = await this.session.run({ obs: input }); this.calls += 1; this.positions += 1; return this._read(out, 0, legal); } /** * Evaluate `n` positions in one graph run. * * `states[i]` is scored against `legals[i]`; the result array is aligned with * both. This is the primitive the batched search calls — one dispatch for the * whole leaf set instead of one per leaf. */ async evaluateBatch(states, legals) { const n = states.length; if (n === 0) return []; if (n === 1) return [await this.evaluate(states[0], legals[0])]; if (!this.batchBuffer || this.batchBuffer.length < n * ENCODED_SIZE) { this.batchBuffer = new Float32Array(n * ENCODED_SIZE); } // A fresh view per run: ort keeps a reference to the tensor's data, so the // sub-arrays must cover exactly the rows this run submits. const data = this.batchBuffer.subarray(0, n * ENCODED_SIZE); for (let i = 0; i < n; i++) { states[i].encode(data.subarray(i * ENCODED_SIZE, (i + 1) * ENCODED_SIZE)); } const input = new this.ort.Tensor("float32", data, [n, ENCODED_SIZE]); const out = await this.session.run({ obs: input }); this.calls += 1; this.positions += n; const results = new Array(n); for (let i = 0; i < n; i++) results[i] = this._read(out, i, legals[i]); return results; } /** Row `row` of a (possibly batched) output, softmaxed over `legal`. */ _read(out, row, legal) { const value = out.value.data[row]; const margin = this.hasMargin ? out[MARGIN_OUTPUT].data[row] : null; const n = legal.length; if (n === 0) return { priors: new Float32Array(0), value, margin }; const logits = out.policy.data; // policy is [batch, ACTION_SPACE]; the last dim is the row stride. const dims = out.policy.dims; const stride = dims && dims.length ? dims[dims.length - 1] : logits.length; const base = row * stride; const priors = new Float32Array(n); let max = -Infinity; for (let i = 0; i < n; i++) { const v = logits[base + legal[i]]; priors[i] = v; if (v > max) max = v; } let sum = 0; for (let i = 0; i < n; i++) { const e = Math.exp(priors[i] - max); priors[i] = e; sum += e; } for (let i = 0; i < n; i++) priors[i] /= sum; return { priors, value, margin }; } } /** Uniform priors, zero value — lets the search run with no net at all (tests). */ export class UniformEvaluator { constructor() { this.calls = 0; this.positions = 0; this.hasMargin = false; this.backend = "none"; } async evaluate(_state, legal) { this.calls += 1; this.positions += 1; const n = legal.length; return { priors: new Float32Array(n).fill(n ? 1 / n : 0), value: 0, margin: null }; } async evaluateBatch(states, legals) { this.calls += 1; this.positions += states.length; return legals.map((legal) => ({ priors: new Float32Array(legal.length).fill(legal.length ? 1 / legal.length : 0), value: 0, margin: null, })); } } /* ------------------------------------------------------------------- backends */ /** * What the two vendored onnxruntime-web builds are, and when each one is used. * * The default build is the pure-WASM one the player has always shipped. The * `jspi` build adds onnxruntime's native **WebGPU** execution provider; it is * only downloaded when the browser can actually use it, so a browser without * WebGPU pays nothing for its existence. * * Two things have to be true for the WebGPU build to load at all: * - `navigator.gpu` — the browser has WebGPU; * - `WebAssembly.Suspending` — the browser has JSPI (stack switching), which * is how onnxruntime's WebGPU EP suspends the WASM call while the GPU * works. The alternative build (asyncify) needs no JSPI but is 2.5 MB * larger over the wire, which is not worth it for a fallback path. * * Anything else — Safari today, Firefox today, an old Chrome, a machine with no * GPU adapter — takes the WASM path, which is exactly what shipped before. */ export const BACKENDS = { wasm: { module: "../vendor/onnxruntime-web/ort.wasm.bundle.min.mjs", wasm: "../vendor/onnxruntime-web/ort-wasm-simd-threaded.wasm", ep: "wasm", }, webgpu: { module: "../vendor/onnxruntime-web/ort.jspi.bundle.min.mjs", wasm: "../vendor/onnxruntime-web/ort-wasm-simd-threaded.jspi.wasm", ep: "webgpu", }, }; /** * Is the WebGPU path worth trying in this environment? * * Deliberately cheap and synchronous — `requestAdapter()` is the real test and * it happens when the session is created, where a failure falls back anyway. */ export function webgpuLikely(scope = globalThis) { return !!( scope.navigator && scope.navigator.gpu && typeof scope.WebAssembly !== "undefined" && typeof scope.WebAssembly.Suspending === "function" ); }