| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { AzulState, Rng } from "./engine.js"; |
| import { MCTS, STALL_ROUNDS, selectAction } from "./mcts.js"; |
| import { OnnxEvaluator, webgpuLikely } from "./net.js"; |
| import { describeAction } from "./report.js"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const BATCH_BY_BACKEND = { |
| wasm: { batch: 16, minBatch: 1 }, |
| webgpu: { batch: 64, minBatch: 8 }, |
| }; |
|
|
| let ort = null; |
| let evaluator = null; |
| |
| |
| |
| |
| |
| |
| let coach = null; |
| let mainSpec = null; |
| let searchConfig = {}; |
| |
| |
| |
| |
| |
| |
| let cancelGen = 0; |
| const rng = new Rng((Date.now() ^ 0x5eed) >>> 0); |
|
|
| |
| |
| |
| |
| |
| |
| |
| async function fetchWithProgress(url, id) { |
| const response = await fetch(url); |
| if (!response.ok) throw new Error(`model fetch failed: ${response.status} ${response.statusText}`); |
| const total = Number(response.headers.get("content-length")) || 0; |
| if (!response.body) return new Uint8Array(await response.arrayBuffer()); |
| const reader = response.body.getReader(); |
| const chunks = []; |
| let received = 0; |
| for (;;) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| chunks.push(value); |
| received += value.length; |
| self.postMessage({ type: "loading", id, received, total }); |
| } |
| const bytes = new Uint8Array(received); |
| let offset = 0; |
| for (const chunk of chunks) { |
| bytes.set(chunk, offset); |
| offset += chunk.length; |
| } |
| return bytes; |
| } |
|
|
| |
| async function loadRuntime(spec) { |
| const runtime = await import(spec.module); |
| |
| |
| runtime.env.wasm.numThreads = 1; |
| |
| |
| runtime.env.wasm.wasmPaths = { wasm: spec.wasm }; |
| runtime.env.logLevel = "error"; |
| return runtime; |
| } |
|
|
| async function init(msg) { |
| const bytes = await fetchWithProgress(msg.modelUrl, msg.id); |
| |
| |
| |
| |
| const tried = []; |
| |
| |
| |
| const wanted = msg.backends.filter((spec) => spec.ep !== "webgpu" || webgpuLikely()); |
| coach = null; |
| |
| |
| for (const spec of wanted) { |
| try { |
| ort = await loadRuntime(spec); |
| mainSpec = spec; |
| evaluator = await OnnxEvaluator.create(ort, bytes, { |
| backend: spec.name, |
| executionProviders: [spec.ep], |
| }); |
| |
| |
| const warm = AzulState.newGame(1, new Rng(1)); |
| await evaluator.evaluate(warm, warm.legalActions()); |
| break; |
| } catch (err) { |
| tried.push(`${spec.name}: ${String((err && err.message) || err)}`); |
| ort = null; |
| evaluator = null; |
| } |
| } |
| if (!evaluator) throw new Error("no usable onnxruntime backend — " + tried.join("; ")); |
|
|
| searchConfig = { ...(BATCH_BY_BACKEND[evaluator.backend] || { batch: 1, minBatch: 1 }) }; |
| return { |
| bytes: bytes.length, |
| backend: evaluator.backend, |
| batch: searchConfig.batch, |
| margin: evaluator.hasMargin, |
| outputs: evaluator.outputNames, |
| fallbacks: tried, |
| }; |
| } |
|
|
| |
| async function coachInit(msg) { |
| if (!msg.modelUrl) { |
| coach = null; |
| return { coach: false }; |
| } |
| if (!ort || !mainSpec) throw new Error("the opponent's net must load first"); |
| const bytes = await fetchWithProgress(msg.modelUrl, msg.id); |
| coach = await OnnxEvaluator.create(ort, bytes, { |
| backend: mainSpec.name, |
| executionProviders: [mainSpec.ep], |
| }); |
| const warm = AzulState.newGame(1, new Rng(1)); |
| await coach.evaluate(warm, warm.legalActions()); |
| return { coach: true, bytes: bytes.length }; |
| } |
|
|
| |
| const adviser = () => coach || evaluator; |
|
|
| |
| let lastRate = null; |
|
|
| function noteRate(result) { |
| if (result && result.sims > 32 && result.elapsedS > 0.2) { |
| lastRate = result.sims / result.elapsedS; |
| } |
| return result; |
| } |
|
|
| async function search(msg) { |
| const state = AzulState.fromSetup(msg.setup, new Rng(rng.next())); |
| const legal = state.legalActions(); |
| if (!legal.length) throw new Error("no legal actions in the position sent to the worker"); |
| if (legal.length === 1) { |
| return { action: legal[0], search: { sims: 0, elapsedS: 0, forced: true } }; |
| } |
|
|
| const mcts = new MCTS(evaluator, searchConfig, new Rng(rng.next())); |
| const gen = cancelGen; |
| const result = await mcts.search(state, { |
| timeLimitS: msg.budgetS, |
| shouldStop: () => cancelGen !== gen, |
| onProgress: ({ sims, elapsedS }) => { |
| self.postMessage({ type: "progress", id: msg.id, sims, elapsedS }); |
| }, |
| }); |
| |
| |
| const action = |
| state.roundIndex >= STALL_ROUNDS ? selectAction(result.policy, 1, mcts.rng) : result.best; |
| const top = [...result.visits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); |
| noteRate(result); |
| return { |
| action, |
| search: { |
| sims: result.sims, |
| elapsedS: result.elapsedS, |
| value: result.value, |
| nodes: mcts.nodesCreated, |
| forced: false, |
| top, |
| backend: evaluator.backend, |
| batch: searchConfig.batch, |
| rate: lastRate, |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function rate(msg) { |
| const state = AzulState.fromSetup(msg.setup, new Rng(rng.next())); |
| const action = msg.actionId; |
| const legal = state.legalActions(); |
| const base = { budgetS: msg.budgetS, legal: legal.length, sims: 0, elapsedS: 0 }; |
| if (legal.indexOf(action) === -1) { |
| return { coach: { ...base, unrated: true, reason: "that move is not legal" } }; |
| } |
| if (legal.length === 1) { |
| return { coach: { ...base, delta: 0, forced: true } }; |
| } |
|
|
| const mcts = new MCTS(adviser(), searchConfig, new Rng(rng.next())); |
| |
| |
| const gen = cancelGen; |
| const result = await mcts.search(state, { |
| timeLimitS: msg.budgetS, |
| shouldStop: () => cancelGen !== gen, |
| onProgress: ({ sims, elapsedS }) => { |
| self.postMessage({ type: "progress", id: msg.id, sims, elapsedS }); |
| }, |
| }); |
| base.sims = result.sims; |
| base.elapsedS = result.elapsedS; |
|
|
| const explored = mcts.rootChildren().filter((c) => c.visits && c.q !== null); |
| if (!explored.length) { |
| return { |
| coach: { ...base, unrated: true, reason: "the search had no time to explore this position" }, |
| }; |
| } |
| const best = explored.reduce((a, b) => (b.q > a.q ? b : a)); |
| const mine = explored.find((c) => c.action === action); |
| if (!mine) { |
| return { |
| coach: { ...base, unrated: true, reason: "the search never explored this move" }, |
| }; |
| } |
| return { |
| coach: { |
| ...base, |
| |
| |
| delta: Math.min(0, mine.q - best.q), |
| your_q: mine.q, |
| best_q: best.q, |
| visits: mine.visits, |
| best_visits: best.visits, |
| best_text: describeAction(state, best.action).text, |
| explored: explored.length, |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async function analyze(msg) { |
| const state = AzulState.fromSetup(msg.setup, new Rng(rng.next())); |
| const legal = state.legalActions(); |
| const base = { budgetS: msg.budgetS, legal: legal.length, sims: 0, elapsedS: 0 }; |
| if (legal.length <= 1) return { analysis: { ...base, forced: true, children: [] } }; |
|
|
| const mcts = new MCTS(adviser(), searchConfig, new Rng(rng.next())); |
| const gen = cancelGen; |
| const result = await mcts.search(state, { |
| timeLimitS: msg.budgetS, |
| shouldStop: () => cancelGen !== gen, |
| onProgress: ({ sims, elapsedS }) => { |
| self.postMessage({ type: "progress", id: msg.id, sims, elapsedS }); |
| }, |
| }); |
| const children = mcts |
| .rootChildren() |
| .filter((c) => c.visits && c.q !== null) |
| .map((c) => ({ action: c.action, q: c.q, visits: c.visits })); |
| let bestAction = null; |
| let bestText = null; |
| if (children.length) { |
| const best = children.reduce((a, b) => (b.q > a.q ? b : a)); |
| bestAction = best.action; |
| bestText = describeAction(state, best.action).text; |
| } |
| return { |
| analysis: { |
| ...base, |
| sims: result.sims, |
| elapsedS: result.elapsedS, |
| children, |
| best_action: bestAction, |
| best_text: bestText, |
| }, |
| }; |
| } |
|
|
| |
| async function policy(msg) { |
| const state = AzulState.fromSetup(msg.setup, new Rng(rng.next())); |
| const legal = state.legalActions(); |
| if (!legal.length) throw new Error("no legal actions in the position sent to the worker"); |
| const { priors, value } = await adviser().evaluate(state, legal); |
| let best = 0; |
| for (let i = 1; i < priors.length; i++) if (priors[i] > priors[best]) best = i; |
| return { action: legal[best], search: { sims: 0, elapsedS: 0, value, prior: priors[best], forced: false } }; |
| } |
|
|
| self.onmessage = async (event) => { |
| const msg = event.data; |
| if (msg.type === "cancel") { |
| cancelGen += 1; |
| return; |
| } |
| try { |
| let payload; |
| if (msg.type === "init") payload = await init(msg); |
| else if (msg.type === "coach") payload = await coachInit(msg); |
| else if (msg.type === "search") payload = await search(msg); |
| else if (msg.type === "policy") payload = await policy(msg); |
| else if (msg.type === "rate") payload = await rate(msg); |
| else if (msg.type === "analyze") payload = await analyze(msg); |
| else throw new Error(`unknown message type ${msg.type}`); |
| self.postMessage({ type: msg.type === "init" ? "ready" : "result", id: msg.id, ...payload }); |
| } catch (err) { |
| self.postMessage({ type: "error", id: msg.id, message: String((err && err.message) || err) }); |
| } |
| }; |
|
|