@chessceo/mcp 0.49.10 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analysis/auto.js +173 -182
- package/dist/analysis/cloud.js +182 -0
- package/dist/analysis/deep.js +39 -66
- package/dist/analysis/file_handle.js +43 -36
- package/dist/analysis/response.js +180 -142
- package/dist/index.js +28 -65
- package/dist/pgn/exporter.js +13 -11
- package/dist/pgn/mutations.js +18 -5
- package/dist/pgn/parser.js +14 -15
- package/dist/pgn/types.js +2 -0
- package/dist/tools.js +75 -101
- package/docs/engine-usage.md +38 -40
- package/docs/pgn-authoring.md +3 -3
- package/docs/summary-authoring.md +1 -1
- package/package.json +1 -1
package/dist/analysis/deep.js
CHANGED
|
@@ -1,22 +1,11 @@
|
|
|
1
|
-
// deep_analyse: async background job for
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// Concretely: the job fires an unawaited authedRequest to the backend
|
|
10
|
-
// with engines=["stockfish"] + long movetime; the backend's per-engine
|
|
11
|
-
// semaphore lets that hold only the SF slot for the duration. The
|
|
12
|
-
// MCP-side promise resolves when the long HTTP call returns (nginx
|
|
13
|
-
// proxy_read_timeout is bumped to 420s on /api/agent/ to cover 5-min
|
|
14
|
-
// movetime + engine bestmove grace).
|
|
15
|
-
//
|
|
16
|
-
// Extracted from index.ts in v0.44 as part of the file split.
|
|
17
|
-
import { authedRequest } from "../http.js";
|
|
18
|
-
import { analysisToStoredEval, convertCloudSnapshotResponse } from "./response.js";
|
|
19
|
-
import { analyseRouting, resolveFromNodeOrFen, storeEvalOnNode, } from "./file_handle.js";
|
|
1
|
+
// deep_analyse: async background job for ONE long think on ONE position
|
|
2
|
+
// (up to 5 min movetime), on any engine (default Stockfish). Same
|
|
3
|
+
// start / status / cancel shape as auto_evaluate. The long call holds only
|
|
4
|
+
// that engine's slot, so the other engines stay usable through
|
|
5
|
+
// cloud_analyse while it runs. nginx proxy_read_timeout on /api/agent/ is
|
|
6
|
+
// 420s to cover a 5-min think plus the bestmove grace.
|
|
7
|
+
import { analysePositions, convertPositionResult, parseEngines, resultToStoredEval } from "./response.js";
|
|
8
|
+
import { resolveFromNodeOrFen, storeEvals } from "./file_handle.js";
|
|
20
9
|
const deepJobs = new Map();
|
|
21
10
|
const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
|
|
22
11
|
function newDeepJobId() {
|
|
@@ -33,57 +22,43 @@ function reapExpiredDeepJobs() {
|
|
|
33
22
|
}
|
|
34
23
|
export async function deepAnalyseStart(args) {
|
|
35
24
|
reapExpiredDeepJobs();
|
|
25
|
+
const [engine] = parseEngines(args.engine, ["stockfish"]);
|
|
36
26
|
const resolved = await resolveFromNodeOrFen(args);
|
|
37
27
|
const fen = resolved.fen;
|
|
38
28
|
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 60_000;
|
|
39
|
-
// Default 2
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
const
|
|
29
|
+
// Default 2 for Stockfish (it loses strength at higher multipv), 8 for
|
|
30
|
+
// the NN engines (it costs them nothing).
|
|
31
|
+
const multipv = typeof args.multipv === "number" ? args.multipv : engine === "stockfish" ? 2 : 8;
|
|
32
|
+
const opts = { engines: [engine], movetime_ms: movetimeMs, multipv: { [engine]: multipv } };
|
|
33
|
+
if (typeof args.contempt === "number")
|
|
34
|
+
opts.contempt = args.contempt;
|
|
35
|
+
if (typeof args.rental === "string" && args.rental.trim())
|
|
36
|
+
opts.rentals = { [engine]: args.rental.trim() };
|
|
43
37
|
const jobId = newDeepJobId();
|
|
44
38
|
const job = {
|
|
45
39
|
id: jobId,
|
|
46
|
-
|
|
40
|
+
engine,
|
|
41
|
+
opts,
|
|
47
42
|
status: "running",
|
|
48
43
|
fileHandle: resolved.file,
|
|
49
44
|
fen,
|
|
50
45
|
movetimeMs,
|
|
51
|
-
multipv,
|
|
52
46
|
startedAt: Date.now(),
|
|
53
47
|
cancelController: new AbortController(),
|
|
54
48
|
};
|
|
55
49
|
deepJobs.set(jobId, job);
|
|
56
|
-
// Kick off the long HTTP call unawaited — resolves when the backend
|
|
57
|
-
// returns the SF snapshot. authedRequest is a plain fetch under the
|
|
58
|
-
// hood; abort signal flows via cancelController.
|
|
59
50
|
void runDeepJob(job).catch(err => {
|
|
60
51
|
job.status = "error";
|
|
61
52
|
job.error = err instanceof Error ? err.message : String(err);
|
|
62
53
|
job.finishedAt = Date.now();
|
|
63
54
|
});
|
|
64
|
-
return {
|
|
65
|
-
job_id: jobId,
|
|
66
|
-
status: "running",
|
|
67
|
-
movetime_ms: movetimeMs,
|
|
68
|
-
fen,
|
|
69
|
-
};
|
|
55
|
+
return { job_id: jobId, status: "running", engine, movetime_ms: movetimeMs, fen };
|
|
70
56
|
}
|
|
71
57
|
async function runDeepJob(job) {
|
|
72
|
-
const body = {
|
|
73
|
-
fen: job.fen,
|
|
74
|
-
movetime_ms: job.movetimeMs,
|
|
75
|
-
stockfish_multipv: job.multipv,
|
|
76
|
-
engines: ["stockfish"],
|
|
77
|
-
...job.routing,
|
|
78
|
-
};
|
|
79
58
|
let raw;
|
|
80
59
|
try {
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
// job so the caller stops polling; the backend still runs the
|
|
84
|
-
// engine to completion and the result is stored on the job
|
|
85
|
-
// record but flagged cancelled.
|
|
86
|
-
raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
|
|
60
|
+
// Cancel only marks the job; the backend runs the think to completion.
|
|
61
|
+
[raw] = await analysePositions([{ fen: job.fen }], job.opts);
|
|
87
62
|
}
|
|
88
63
|
catch (err) {
|
|
89
64
|
job.status = "error";
|
|
@@ -91,31 +66,26 @@ async function runDeepJob(job) {
|
|
|
91
66
|
job.finishedAt = Date.now();
|
|
92
67
|
return;
|
|
93
68
|
}
|
|
94
|
-
const converted =
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
job.finishedAt = Date.now();
|
|
104
|
-
// Same node-persistence as cloud_analyse: if the caller anchored on
|
|
105
|
-
// file_id+node_id, store the SF-only eval as the node's ceoEval so
|
|
106
|
-
// quote_engine_eval can cite it later. We build a StoredEval that has
|
|
107
|
-
// only the sf leg — no Lc0 was run.
|
|
108
|
-
if (job.fileHandle && sf) {
|
|
109
|
-
const ev = analysisToStoredEval({ stockfish: sf });
|
|
69
|
+
const converted = raw ? convertPositionResult(raw) : undefined;
|
|
70
|
+
const block = converted?.engines[job.engine];
|
|
71
|
+
job.result = block ?? null;
|
|
72
|
+
if (block?.error)
|
|
73
|
+
job.error = block.error;
|
|
74
|
+
// Merge into the node's ceoEval (other engines' stored reads are kept)
|
|
75
|
+
// and stamp transpositions, same as cloud_analyse.
|
|
76
|
+
if (job.fileHandle && converted && !job.cancelController.signal.aborted) {
|
|
77
|
+
const ev = resultToStoredEval(converted);
|
|
110
78
|
if (ev) {
|
|
111
79
|
try {
|
|
112
|
-
await
|
|
80
|
+
[job.storedOn] = await storeEvals(job.fileHandle.id, [{ fen: job.fen, ev }]);
|
|
113
81
|
}
|
|
114
|
-
catch {
|
|
115
|
-
|
|
82
|
+
catch (err) {
|
|
83
|
+
job.storeError = `analysis done but saving to the file failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
116
84
|
}
|
|
117
85
|
}
|
|
118
86
|
}
|
|
87
|
+
job.status = job.cancelController.signal.aborted ? "cancelled" : block && !block.error ? "done" : "error";
|
|
88
|
+
job.finishedAt = Date.now();
|
|
119
89
|
}
|
|
120
90
|
export function deepAnalyseStatus(args) {
|
|
121
91
|
reapExpiredDeepJobs();
|
|
@@ -134,8 +104,11 @@ export function deepAnalyseStatus(args) {
|
|
|
134
104
|
status: job.status,
|
|
135
105
|
movetime_ms: job.movetimeMs,
|
|
136
106
|
elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
|
|
107
|
+
engine: job.engine,
|
|
137
108
|
fen: job.fen,
|
|
138
109
|
result: job.result,
|
|
110
|
+
stored_on: job.storedOn,
|
|
111
|
+
store_error: job.storeError,
|
|
139
112
|
error: job.error,
|
|
140
113
|
started_at_ms: job.startedAt,
|
|
141
114
|
finished_at_ms: job.finishedAt,
|
|
@@ -120,48 +120,55 @@ export function getNodeByPath(root, path) {
|
|
|
120
120
|
}
|
|
121
121
|
return cur;
|
|
122
122
|
}
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// we silently drop the store rather than fail the analysis the LLM
|
|
129
|
-
// actually asked for. The eval is still returned in the response
|
|
130
|
-
// either way.
|
|
123
|
+
// Merge fresh evals into a file and save once. Each entry is stamped on
|
|
124
|
+
// every node that reaches its position (the frontend's 3-field FEN key:
|
|
125
|
+
// piece placement + side to move + castling), so a transposition is never
|
|
126
|
+
// analysed twice. Merging per engine means a Stockfish-only result keeps a
|
|
127
|
+
// stored human or Lc0 read.
|
|
131
128
|
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
129
|
+
// The file is re-read right before the write, and a version race (someone
|
|
130
|
+
// saved between our GET and PUT) is retried once on a fresh copy; the merge
|
|
131
|
+
// makes re-applying safe. Returns, per entry, the ids it was stamped on
|
|
132
|
+
// (empty if that position is not in the file). Throws if the save fails
|
|
133
|
+
// twice, so callers can report it rather than silently lose evals.
|
|
134
|
+
export async function storeEvals(fileId, entries) {
|
|
135
|
+
let lastErr;
|
|
136
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
137
|
+
const g = await fetchGame(fileId);
|
|
138
|
+
const parsed = parsePGN(g.pgnContent);
|
|
139
|
+
const fenIndex = buildFenIndex(parsed.root);
|
|
140
|
+
const idIndex = buildIdIndex(parsed.root);
|
|
141
|
+
let file = parsed;
|
|
142
|
+
const stamped = [];
|
|
143
|
+
for (const { fen, ev } of entries) {
|
|
144
|
+
const group = fenIndex.get(positionKey(fen)) ?? [];
|
|
145
|
+
const paths = group.map(n => resolveNodeId(idIndex, n.id));
|
|
146
|
+
const step = setCeoEvalMany(file, paths, ev);
|
|
147
|
+
file = step.file;
|
|
148
|
+
stamped.push(step.ids);
|
|
149
|
+
}
|
|
150
|
+
if (stamped.every(ids => ids.length === 0))
|
|
151
|
+
return stamped;
|
|
152
|
+
try {
|
|
153
|
+
await saveGame(fileId, exportPGN(file), g.version ?? 0);
|
|
154
|
+
return stamped;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
lastErr = err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
161
|
+
}
|
|
162
|
+
// Single-node wrapper: stamp `ev` on the handle's node and its transpositions.
|
|
163
|
+
// Returns the ids, the addressed node first; empty on failure (the analysis
|
|
164
|
+
// result is still returned to the caller either way).
|
|
134
165
|
export async function storeEvalOnNode(handle, ev) {
|
|
135
166
|
try {
|
|
136
167
|
const anchor = getNodeByPath(handle.parsedFile.root, handle.nodePath);
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
const group = fenIndex.get(key) ?? [anchor];
|
|
140
|
-
// Resolve every transposed node back to its path. cloneOnPath
|
|
141
|
-
// rebuilds the spine so we need paths, not references — the
|
|
142
|
-
// id index was built against the original tree and every id in
|
|
143
|
-
// `group` exists there.
|
|
144
|
-
const idIndex = handle.idIndex ?? buildIdIndex(handle.parsedFile.root);
|
|
145
|
-
const paths = group.map(n => resolveNodeId(idIndex, n.id));
|
|
146
|
-
const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
|
|
147
|
-
const newPgn = exportPGN(newFile);
|
|
148
|
-
await saveGame(handle.id, newPgn, handle.version);
|
|
149
|
-
// Ensure the primary node (the one the LLM addressed) comes first.
|
|
150
|
-
const anchorId = anchor.id;
|
|
151
|
-
return [anchorId, ...ids.filter(x => x !== anchorId)];
|
|
168
|
+
const [ids] = await storeEvals(handle.id, [{ fen: anchor.fen, ev }]);
|
|
169
|
+
return [anchor.id, ...(ids ?? []).filter(x => x !== anchor.id)];
|
|
152
170
|
}
|
|
153
171
|
catch {
|
|
154
172
|
return [];
|
|
155
173
|
}
|
|
156
174
|
}
|
|
157
|
-
// Routing fields for POST /api/agent/cloud-engines/analyse: which rental to
|
|
158
|
-
// run on and which engine legs to run. Omitted fields let the backend pick.
|
|
159
|
-
export function analyseRouting(args) {
|
|
160
|
-
const out = {};
|
|
161
|
-
if (typeof args.contract_id === "string" && args.contract_id.trim()) {
|
|
162
|
-
out.contract_id = args.contract_id.trim();
|
|
163
|
-
}
|
|
164
|
-
if (Array.isArray(args.engines))
|
|
165
|
-
out.engines = args.engines;
|
|
166
|
-
return out;
|
|
167
|
-
}
|
|
@@ -1,18 +1,96 @@
|
|
|
1
|
-
// Shared engine
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Extracted from src/index.ts in v0.44 as part of the file split.
|
|
1
|
+
// Shared engine helpers for cloud_analyse, auto_evaluate, deep_analyse and
|
|
2
|
+
// the compact eval attached to position tools. The backend returns one
|
|
3
|
+
// result per position with a block per engine (stockfish, lc0, human), PVs
|
|
4
|
+
// in UCI and WDL in per mille. This module calls it in chunks, converts PVs
|
|
5
|
+
// to SAN and WDL to percent, and turns a result into the stored ceoEval.
|
|
7
6
|
import { Chess } from "chess.js";
|
|
8
7
|
import { authedRequest } from "../http.js";
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
export const ENGINES = ["stockfish", "lc0", "human"];
|
|
9
|
+
// Engine name → key in the stored ceoEval / [%ceo-eval] escape.
|
|
10
|
+
export const STORED_KEY = { stockfish: "sf", lc0: "lc0", human: "human" };
|
|
11
|
+
// Backend limits (handlers/agent.go): positions per call, and positions ×
|
|
12
|
+
// movetime per call. analysePositions chunks to stay inside both.
|
|
13
|
+
export const MAX_POSITIONS_PER_CALL = 10;
|
|
14
|
+
const MAX_CALL_BUDGET_MS = 150_000;
|
|
15
|
+
export const DEFAULT_MOVETIME_MS = 2000;
|
|
16
|
+
// Validate an `engines` argument. Unknown names throw (better than silently
|
|
17
|
+
// running fewer engines than the caller asked for).
|
|
18
|
+
export function parseEngines(v, fallback) {
|
|
19
|
+
if (v === undefined || v === null)
|
|
20
|
+
return fallback;
|
|
21
|
+
const list = Array.isArray(v) ? v : [v];
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const e of list) {
|
|
24
|
+
const name = String(e).trim().toLowerCase();
|
|
25
|
+
if (!ENGINES.includes(name)) {
|
|
26
|
+
throw new Error(`unknown engine '${e}' (allowed: stockfish, lc0, human)`);
|
|
27
|
+
}
|
|
28
|
+
if (!out.includes(name))
|
|
29
|
+
out.push(name);
|
|
30
|
+
}
|
|
31
|
+
if (out.length === 0)
|
|
32
|
+
throw new Error("engines must list at least one of: stockfish, lc0, human");
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
// Pull the optional per-engine knobs (multipv, contempt, rentals) out of tool
|
|
36
|
+
// args. Shared by every analysis tool so they all accept the same shape.
|
|
37
|
+
export function analyseOptionsFromArgs(args, engines, movetimeMs) {
|
|
38
|
+
const opts = { engines, movetime_ms: movetimeMs };
|
|
39
|
+
const pickMap = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
40
|
+
const mpv = pickMap(args.multipv);
|
|
41
|
+
if (mpv) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const e of ENGINES)
|
|
44
|
+
if (typeof mpv[e] === "number")
|
|
45
|
+
out[e] = mpv[e];
|
|
46
|
+
opts.multipv = out;
|
|
47
|
+
}
|
|
48
|
+
if (typeof args.contempt === "number")
|
|
49
|
+
opts.contempt = args.contempt;
|
|
50
|
+
const rentals = pickMap(args.rentals);
|
|
51
|
+
if (rentals) {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const e of ENGINES)
|
|
54
|
+
if (typeof rentals[e] === "string" && rentals[e].trim())
|
|
55
|
+
out[e] = rentals[e].trim();
|
|
56
|
+
opts.rentals = out;
|
|
57
|
+
}
|
|
58
|
+
return opts;
|
|
59
|
+
}
|
|
60
|
+
// Run positions through the backend, chunked to its per-call limits. Results
|
|
61
|
+
// come back in input order, raw (UCI PVs, per-mille WDL).
|
|
62
|
+
export async function analysePositions(positions, opts) {
|
|
63
|
+
const movetime = opts.movetime_ms ?? DEFAULT_MOVETIME_MS;
|
|
64
|
+
const perCall = Math.max(1, Math.min(MAX_POSITIONS_PER_CALL, Math.floor(MAX_CALL_BUDGET_MS / movetime)));
|
|
65
|
+
// Every position gets an id; results are matched back by id, not order.
|
|
66
|
+
const withIds = positions.map((p, i) => ({ id: p.id ?? `p${i + 1}`, fen: p.fen }));
|
|
67
|
+
const byId = new Map();
|
|
68
|
+
for (let i = 0; i < withIds.length; i += perCall) {
|
|
69
|
+
const body = {
|
|
70
|
+
positions: withIds.slice(i, i + perCall),
|
|
71
|
+
engines: opts.engines,
|
|
72
|
+
movetime_ms: movetime,
|
|
73
|
+
};
|
|
74
|
+
if (opts.multipv && Object.keys(opts.multipv).length > 0)
|
|
75
|
+
body.multipv = opts.multipv;
|
|
76
|
+
if (typeof opts.contempt === "number")
|
|
77
|
+
body.contempt = opts.contempt;
|
|
78
|
+
if (opts.rentals && Object.keys(opts.rentals).length > 0)
|
|
79
|
+
body.rentals = opts.rentals;
|
|
80
|
+
const raw = (await authedRequest("POST", "/api/agent/cloud-engines/analyse", body));
|
|
81
|
+
if (!raw || !Array.isArray(raw.positions))
|
|
82
|
+
throw new Error("unexpected response from the analyse endpoint");
|
|
83
|
+
for (const r of raw.positions)
|
|
84
|
+
if (r && typeof r.id === "string")
|
|
85
|
+
byId.set(r.id, r);
|
|
86
|
+
}
|
|
87
|
+
// A position the backend didn't answer comes back with no engines, so
|
|
88
|
+
// callers report it as failed rather than shifting other results.
|
|
89
|
+
return withIds.map(p => byId.get(p.id) ?? { id: p.id, fen: p.fen, engines: {} });
|
|
90
|
+
}
|
|
91
|
+
// Convert a UCI move sequence into SAN by walking it on chess.js from the
|
|
92
|
+
// given FEN. LLMs reason far better in SAN. If a move fails to parse
|
|
93
|
+
// (truncated PV), stop cleanly rather than throw.
|
|
16
94
|
export function uciLineToSAN(startFen, uciMoves) {
|
|
17
95
|
const board = new Chess(startFen);
|
|
18
96
|
const out = [];
|
|
@@ -51,145 +129,105 @@ export function uciMoveToSAN(startFen, uci) {
|
|
|
51
129
|
return uci;
|
|
52
130
|
}
|
|
53
131
|
}
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
export
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const stored = analysisToStoredEval(converted);
|
|
63
|
-
return storedEvalToCompact(stored, converted);
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
// Trim every PV in a converted cloud-analyse response to `maxPlies`
|
|
70
|
-
// and mark each trimmed line with `pv_truncated: true` so the LLM
|
|
71
|
-
// sees what happened. Applied ONLY to cloud_analyse (short synchronous
|
|
72
|
-
// snapshot); deep_analyse is the explicit "give me the deep line"
|
|
73
|
-
// tool and keeps its full PV.
|
|
74
|
-
export function capPvsInResponse(converted, maxPlies) {
|
|
75
|
-
if (!converted || typeof converted !== "object")
|
|
76
|
-
return;
|
|
77
|
-
const r = converted;
|
|
78
|
-
for (const eng of [r.stockfish, r.lc0]) {
|
|
79
|
-
if (!eng || !Array.isArray(eng.lines))
|
|
132
|
+
// In place: PVs and bestMove to SAN, WDL from per mille to percent, and (if
|
|
133
|
+
// pvMaxPlies is set) PVs capped with `pv_truncated` on trimmed lines. PVs
|
|
134
|
+
// past ~6 plies are speculative, and long ones get pasted into add_line as
|
|
135
|
+
// if they were prep; the caller re-analyses the end position to see more.
|
|
136
|
+
export function convertPositionResult(r, pvMaxPlies) {
|
|
137
|
+
for (const e of ENGINES) {
|
|
138
|
+
const block = r.engines?.[e];
|
|
139
|
+
if (!block)
|
|
80
140
|
continue;
|
|
81
|
-
for (const line of
|
|
82
|
-
if (Array.isArray(line.pv)
|
|
83
|
-
line.pv = line.pv
|
|
84
|
-
line.
|
|
141
|
+
for (const line of block.lines ?? []) {
|
|
142
|
+
if (Array.isArray(line.pv)) {
|
|
143
|
+
line.pv = uciLineToSAN(r.fen, line.pv);
|
|
144
|
+
if (pvMaxPlies && line.pv.length > pvMaxPlies) {
|
|
145
|
+
line.pv = line.pv.slice(0, pvMaxPlies);
|
|
146
|
+
line.pv_truncated = true;
|
|
147
|
+
}
|
|
85
148
|
}
|
|
149
|
+
if (line.wdl)
|
|
150
|
+
line.wdl = wdlPercent(line.wdl);
|
|
86
151
|
}
|
|
152
|
+
if (typeof block.bestMove === "string")
|
|
153
|
+
block.bestMove = uciMoveToSAN(r.fen, block.bestMove);
|
|
87
154
|
}
|
|
88
|
-
|
|
155
|
+
return r;
|
|
89
156
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (typeof eng.bestMove === "string")
|
|
104
|
-
eng.bestMove = uciMoveToSAN(startFen, eng.bestMove);
|
|
157
|
+
// Per mille → whole percent summing to 100 (largest remainder), so a
|
|
158
|
+
// stored W/D/L never reads 101%.
|
|
159
|
+
function wdlPercent(m) {
|
|
160
|
+
const total = m.w + m.d + m.l || 1;
|
|
161
|
+
const exact = [m.w, m.d, m.l].map(v => (v * 100) / total);
|
|
162
|
+
const floor = exact.map(Math.floor);
|
|
163
|
+
let left = 100 - floor.reduce((a, b) => a + b, 0);
|
|
164
|
+
const order = exact.map((v, i) => [v - floor[i], i]).sort((a, b) => b[0] - a[0]);
|
|
165
|
+
for (const [, i] of order) {
|
|
166
|
+
if (left <= 0)
|
|
167
|
+
break;
|
|
168
|
+
floor[i]++;
|
|
169
|
+
left--;
|
|
105
170
|
}
|
|
106
|
-
return
|
|
171
|
+
return { w: floor[0], d: floor[1], l: floor[2] };
|
|
107
172
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// based on side-to-move, cloud_snapshot passes through). Pure
|
|
114
|
-
// pass-through here — a previous sign-flip on black-to-move was
|
|
115
|
-
// wrong and silently inverted every Black-to-move stored eval.
|
|
116
|
-
const engineEval = (block) => {
|
|
117
|
-
const line = block?.lines?.[0];
|
|
118
|
-
if (!line)
|
|
119
|
-
return undefined;
|
|
120
|
-
const depth = line.depth ?? block?.depth;
|
|
121
|
-
if (typeof line.mate === "number")
|
|
122
|
-
return { mate: line.mate, depth };
|
|
123
|
-
if (typeof line.scoreCp === "number")
|
|
124
|
-
return { cp: line.scoreCp, depth };
|
|
125
|
-
return undefined;
|
|
126
|
-
};
|
|
127
|
-
const sf = engineEval(r.stockfish);
|
|
128
|
-
const lc0 = engineEval(r.lc0);
|
|
129
|
-
if (!sf && !lc0)
|
|
130
|
-
return null;
|
|
173
|
+
// Stored eval from a CONVERTED result (WDL already in percent). Stockfish
|
|
174
|
+
// stores cp/mate; lc0 and human store WDL, falling back to cp/mate if an
|
|
175
|
+
// engine sent no WDL. Engines that errored or returned no line are left out,
|
|
176
|
+
// so a merge keeps whatever was stored for them before.
|
|
177
|
+
export function resultToStoredEval(r) {
|
|
131
178
|
const ev = {};
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (abs < 60)
|
|
151
|
-
return effective > 0 ? "$14" : "$15";
|
|
152
|
-
if (abs < 130)
|
|
153
|
-
return effective > 0 ? "$16" : "$17";
|
|
154
|
-
return effective > 0 ? "$18" : "$19";
|
|
155
|
-
}
|
|
156
|
-
// Adapter for the compact eval attached to live query responses. Same
|
|
157
|
-
// derivation logic; different output shape (needs the .nag + summary
|
|
158
|
-
// used by get_position_stats / prep_snapshot).
|
|
159
|
-
export function storedEvalToCompact(ev, analysis) {
|
|
160
|
-
if (!ev)
|
|
161
|
-
return null;
|
|
162
|
-
const a = analysis;
|
|
163
|
-
const compact = { nag: ev.nag ?? null };
|
|
164
|
-
if (ev.sf) {
|
|
165
|
-
compact.stockfish = {
|
|
166
|
-
cp: ev.sf.cp,
|
|
167
|
-
mate: ev.sf.mate,
|
|
168
|
-
bestMove: a.stockfish?.bestMove,
|
|
169
|
-
pv: a.stockfish?.lines?.[0]?.pv,
|
|
170
|
-
};
|
|
179
|
+
for (const e of ENGINES) {
|
|
180
|
+
const block = r.engines?.[e];
|
|
181
|
+
const line = block?.lines?.[0];
|
|
182
|
+
if (!block || block.error || !line)
|
|
183
|
+
continue;
|
|
184
|
+
const depth = line.depth ?? block.depth;
|
|
185
|
+
let stored;
|
|
186
|
+
if (e !== "stockfish" && line.wdl) {
|
|
187
|
+
stored = { w: line.wdl.w, d: line.wdl.d, l: line.wdl.l, depth };
|
|
188
|
+
}
|
|
189
|
+
else if (typeof line.mate === "number") {
|
|
190
|
+
stored = { mate: line.mate, depth };
|
|
191
|
+
}
|
|
192
|
+
else if (typeof line.scoreCp === "number") {
|
|
193
|
+
stored = { cp: line.scoreCp, depth };
|
|
194
|
+
}
|
|
195
|
+
if (stored)
|
|
196
|
+
ev[STORED_KEY[e]] = stored;
|
|
171
197
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
198
|
+
return ev.sf || ev.lc0 || ev.human ? ev : null;
|
|
199
|
+
}
|
|
200
|
+
// Engines that produced no usable line for this position, with the reason.
|
|
201
|
+
export function engineFailures(r, engines) {
|
|
202
|
+
const out = {};
|
|
203
|
+
for (const e of engines) {
|
|
204
|
+
const block = r.engines?.[e];
|
|
205
|
+
if (!block)
|
|
206
|
+
out[e] = "no result";
|
|
207
|
+
else if (block.error)
|
|
208
|
+
out[e] = block.error;
|
|
209
|
+
else if (!block.lines || block.lines.length === 0)
|
|
210
|
+
out[e] = "no lines";
|
|
179
211
|
}
|
|
180
|
-
return
|
|
212
|
+
return out;
|
|
181
213
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
214
|
+
export async function fetchCompactEval(fen) {
|
|
215
|
+
try {
|
|
216
|
+
const [raw] = await analysePositions([{ fen }], {
|
|
217
|
+
engines: ["stockfish"],
|
|
218
|
+
movetime_ms: 1500,
|
|
219
|
+
multipv: { stockfish: 1 },
|
|
220
|
+
});
|
|
221
|
+
if (!raw)
|
|
222
|
+
return null;
|
|
223
|
+
const r = convertPositionResult(raw, 6);
|
|
224
|
+
const sf = r.engines.stockfish;
|
|
225
|
+
const line = sf?.lines?.[0];
|
|
226
|
+
if (!sf || sf.error || !line)
|
|
227
|
+
return null;
|
|
228
|
+
return { stockfish: { cp: line.scoreCp, mate: line.mate, bestMove: sf.bestMove, pv: line.pv } };
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return null;
|
|
193
232
|
}
|
|
194
|
-
return merged;
|
|
195
233
|
}
|