@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/auto.js
CHANGED
|
@@ -1,49 +1,35 @@
|
|
|
1
1
|
// auto_evaluate: async background job that walks a prep-file subtree and
|
|
2
|
-
// stores
|
|
3
|
-
//
|
|
4
|
-
// lifecycle (start, run, status, cancel) is one self-contained unit.
|
|
5
|
-
//
|
|
6
|
-
// The naive walk-and-await approach held one HTTP request open for the
|
|
7
|
-
// full duration of the walk (200 nodes × ~1.5s serialized on the
|
|
8
|
-
// per-combo engine semaphore = ~5 min). MCP hosts vary in their
|
|
9
|
-
// tolerance for that. Switched to a background-job model:
|
|
2
|
+
// stores engine evals on every node. The job lifecycle (start, run, status,
|
|
3
|
+
// cancel) is one self-contained unit.
|
|
10
4
|
//
|
|
11
5
|
// 1. `auto_evaluate` collects targets, spawns an unawaited worker,
|
|
12
6
|
// returns `{ job_id, target_count }` immediately.
|
|
13
|
-
// 2. `auto_evaluate_status(job_id)` returns live progress
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// partial progress up to the last checkpoint is preserved.
|
|
7
|
+
// 2. `auto_evaluate_status(job_id)` returns live progress per engine.
|
|
8
|
+
// 3. `auto_evaluate_cancel(job_id)` stops the job; everything analysed
|
|
9
|
+
// up to the last saved batch stays in the file.
|
|
17
10
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
11
|
+
// Each node gets only the requested engines it is missing, so re-running
|
|
12
|
+
// with another engine (e.g. adding `human` after a Stockfish pass) analyses
|
|
13
|
+
// just that engine. Transpositions are analysed once and the eval is stamped
|
|
14
|
+
// on every node that reaches the position. Results are saved after every
|
|
15
|
+
// backend batch (up to 10 positions), merged per engine.
|
|
22
16
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
17
|
+
// The MCP server is long-lived (chessceo-mcp.service), so in-memory job
|
|
18
|
+
// state survives across HTTP requests. On restart jobs disappear; re-running
|
|
19
|
+
// skips what is already stored.
|
|
20
|
+
import { fetchGame } from "../http.js";
|
|
21
|
+
import { analysePositions, convertPositionResult, ENGINES, MAX_POSITIONS_PER_CALL, parseEngines, resultToStoredEval, STORED_KEY, } from "./response.js";
|
|
22
|
+
import { storeEvals } from "./file_handle.js";
|
|
28
23
|
import { parsePGN } from "../pgn/parser.js";
|
|
29
24
|
import { buildIdIndex, positionKey, resolveNodeId, ROOT_ID } from "../pgn/paths.js";
|
|
30
25
|
const evalJobs = new Map();
|
|
31
26
|
// GC finished jobs after this long so status polling remains useful
|
|
32
27
|
// for a while but the map doesn't grow unbounded across long uptimes.
|
|
33
28
|
const EVAL_JOB_TTL_MS = 15 * 60 * 1000;
|
|
34
|
-
// Checkpoint interval — save progress every N successfully-evaluated
|
|
35
|
-
// nodes so a mid-run kill leaves the tree partially populated. Small
|
|
36
|
-
// enough that <15s of work is at risk per checkpoint on a slow combo,
|
|
37
|
-
// large enough that the save overhead stays a small fraction of the
|
|
38
|
-
// per-node cost.
|
|
39
|
-
const SAVE_EVERY_N = 8;
|
|
40
29
|
function newEvalJobId() {
|
|
41
|
-
// 12 hex chars, low collision (same 32-bit width as node ids ×1.5).
|
|
42
30
|
const rand = Math.random().toString(16).slice(2, 8);
|
|
43
31
|
return `evj_${Date.now().toString(16)}${rand}`;
|
|
44
32
|
}
|
|
45
|
-
// Sweep expired jobs on every start/status call — cheap, doesn't need
|
|
46
|
-
// a background timer, keeps the map bounded to active + recent jobs.
|
|
47
33
|
function reapExpiredEvalJobs() {
|
|
48
34
|
const now = Date.now();
|
|
49
35
|
for (const [k, j] of evalJobs) {
|
|
@@ -52,8 +38,6 @@ function reapExpiredEvalJobs() {
|
|
|
52
38
|
}
|
|
53
39
|
}
|
|
54
40
|
}
|
|
55
|
-
// Path->node helper duplicated here so auto.ts doesn't depend on index.ts.
|
|
56
|
-
// Same body as index.ts:getNodeByPath.
|
|
57
41
|
function getNodeByPath(root, path) {
|
|
58
42
|
let cur = root;
|
|
59
43
|
for (const idx of path) {
|
|
@@ -63,187 +47,189 @@ function getNodeByPath(root, path) {
|
|
|
63
47
|
}
|
|
64
48
|
return cur;
|
|
65
49
|
}
|
|
66
|
-
export async function autoEvaluate(args
|
|
50
|
+
export async function autoEvaluate(args) {
|
|
67
51
|
reapExpiredEvalJobs();
|
|
68
52
|
const id = String(args.id);
|
|
69
53
|
const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
|
|
70
54
|
? String(args.node_id)
|
|
71
55
|
: ROOT_ID;
|
|
72
56
|
const onlyMissing = args.only_missing !== false; // default true
|
|
73
|
-
const
|
|
57
|
+
const engines = parseEngines(args.engines, ["stockfish"]);
|
|
58
|
+
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 2000;
|
|
59
|
+
const baseOpts = { engines, movetime_ms: movetimeMs };
|
|
60
|
+
if (typeof args.contempt === "number")
|
|
61
|
+
baseOpts.contempt = args.contempt;
|
|
62
|
+
if (args.rentals && typeof args.rentals === "object")
|
|
63
|
+
baseOpts.rentals = args.rentals;
|
|
74
64
|
const g = await fetchGame(id);
|
|
75
65
|
const file = parsePGN(g.pgnContent);
|
|
76
66
|
const idIndex = buildIdIndex(file.root);
|
|
77
67
|
const startPath = resolveNodeId(idIndex, startNodeId);
|
|
78
|
-
const
|
|
68
|
+
const startNode = getNodeByPath(file.root, startPath);
|
|
69
|
+
// Every node in the subtree (the root itself has no move to evaluate
|
|
70
|
+
// when the walk starts there). With only_missing, a node needs just the
|
|
71
|
+
// requested engines it has no stored eval for. One target per position:
|
|
72
|
+
// transpositions are analysed once and the eval is stamped on every twin
|
|
73
|
+
// at save time, and a twin's stored engines count for the position.
|
|
74
|
+
const byKey = new Map();
|
|
75
|
+
// Stored evals per position, merged across twins, and whether some twin
|
|
76
|
+
// lacks an engine another twin already has (then we copy, not re-run).
|
|
77
|
+
const known = new Map();
|
|
78
|
+
let skippedTranspositions = 0;
|
|
79
|
+
let alreadyComplete = 0;
|
|
79
80
|
const walk = (node, isStartAndRoot) => {
|
|
80
81
|
if (!isStartAndRoot) {
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
const key = positionKey(node.fen);
|
|
83
|
+
const have = (e) => Boolean(node.ceoEval?.[STORED_KEY[e]]);
|
|
84
|
+
const missing = onlyMissing ? engines.filter(e => !have(e)) : [...engines];
|
|
85
|
+
const prev = byKey.get(key);
|
|
86
|
+
if (prev) {
|
|
87
|
+
skippedTranspositions++;
|
|
88
|
+
// Only engines missing on every twin need running.
|
|
89
|
+
prev.engines = prev.engines.filter(e => missing.includes(e));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
byKey.set(key, { nodeId: node.id, fen: node.fen, engines: missing });
|
|
93
|
+
}
|
|
94
|
+
const k = known.get(key) ?? { fen: node.fen, ev: {}, nodes: 0, has: {} };
|
|
95
|
+
k.nodes++;
|
|
96
|
+
for (const e of engines) {
|
|
97
|
+
const mine = node.ceoEval?.[STORED_KEY[e]];
|
|
98
|
+
if (!mine)
|
|
99
|
+
continue;
|
|
100
|
+
k.has[e] = (k.has[e] ?? 0) + 1;
|
|
101
|
+
if (!k.ev[STORED_KEY[e]])
|
|
102
|
+
k.ev[STORED_KEY[e]] = mine;
|
|
83
103
|
}
|
|
104
|
+
known.set(key, k);
|
|
84
105
|
}
|
|
85
106
|
for (const child of node.children)
|
|
86
107
|
walk(child, false);
|
|
87
108
|
};
|
|
88
|
-
const startNode = getNodeByPath(file.root, startPath);
|
|
89
|
-
// If the caller anchored at the root, skip evaluating the root itself
|
|
90
|
-
// (no move); otherwise the anchor node IS a real move and gets evaluated.
|
|
91
109
|
walk(startNode, startNode.id === ROOT_ID);
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (seen.has(key)) {
|
|
106
|
-
skippedTranspositions++;
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
seen.add(key);
|
|
110
|
-
deduped.push(t);
|
|
110
|
+
// Twins that lack an engine another twin has: copy it over now (merge,
|
|
111
|
+
// no engine time).
|
|
112
|
+
const copies = onlyMissing
|
|
113
|
+
? [...known.values()]
|
|
114
|
+
.filter(k => engines.some(e => (k.has[e] ?? 0) > 0 && (k.has[e] ?? 0) < k.nodes))
|
|
115
|
+
.map(k => ({ fen: k.fen, ev: k.ev }))
|
|
116
|
+
: [];
|
|
117
|
+
if (copies.length > 0)
|
|
118
|
+
await storeEvals(id, copies);
|
|
119
|
+
const targets = [...byKey.values()].filter(t => {
|
|
120
|
+
if (t.engines.length === 0) {
|
|
121
|
+
alreadyComplete++;
|
|
122
|
+
return false;
|
|
111
123
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
// Nothing to do → return a done job synthetically so the caller doesn't
|
|
116
|
-
// need to special-case the empty response.
|
|
117
|
-
if (targets.length === 0) {
|
|
118
|
-
const jobId = newEvalJobId();
|
|
119
|
-
evalJobs.set(jobId, {
|
|
120
|
-
id: jobId,
|
|
121
|
-
fileId: id,
|
|
122
|
-
status: "done",
|
|
123
|
-
targetCount: 0,
|
|
124
|
-
evaluated: 0,
|
|
125
|
-
errored: 0,
|
|
126
|
-
failedNodeIds: [],
|
|
127
|
-
finalVersion: g.version,
|
|
128
|
-
startedAt: Date.now(),
|
|
129
|
-
finishedAt: Date.now(),
|
|
130
|
-
cancelled: false,
|
|
131
|
-
});
|
|
132
|
-
return { job_id: jobId, target_count: 0, status: "done", version: g.version };
|
|
133
|
-
}
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
134
126
|
const jobId = newEvalJobId();
|
|
135
127
|
const job = {
|
|
136
128
|
id: jobId,
|
|
137
129
|
fileId: id,
|
|
138
|
-
status: "running",
|
|
130
|
+
status: targets.length === 0 ? "done" : "running",
|
|
131
|
+
engines,
|
|
139
132
|
targetCount: targets.length,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
133
|
+
processed: 0,
|
|
134
|
+
evaluated: {},
|
|
135
|
+
failed: {},
|
|
143
136
|
startedAt: Date.now(),
|
|
144
137
|
cancelled: false,
|
|
145
138
|
};
|
|
146
|
-
|
|
147
|
-
// Unawaited — runs concurrently with the tool response. Any thrown
|
|
148
|
-
// error gets recorded on the job so the LLM's status poll surfaces
|
|
149
|
-
// it instead of the process seeing an unhandled rejection.
|
|
150
|
-
void runEvalJob(job, id, targets, movetimeMs, applyBatchMutations, analyseRouting(args)).catch(err => {
|
|
151
|
-
job.status = "error";
|
|
152
|
-
job.error = err instanceof Error ? err.message : String(err);
|
|
139
|
+
if (targets.length === 0)
|
|
153
140
|
job.finishedAt = Date.now();
|
|
154
|
-
|
|
141
|
+
evalJobs.set(jobId, job);
|
|
142
|
+
if (targets.length > 0) {
|
|
143
|
+
void runEvalJob(job, targets, baseOpts).catch(err => {
|
|
144
|
+
job.status = "error";
|
|
145
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
146
|
+
job.finishedAt = Date.now();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// Engines run in parallel per position, positions one after another.
|
|
150
|
+
const estimatedSeconds = Math.round((targets.length * movetimeMs) / 1000);
|
|
155
151
|
return {
|
|
156
152
|
job_id: jobId,
|
|
153
|
+
status: job.status,
|
|
154
|
+
engines,
|
|
157
155
|
target_count: targets.length,
|
|
158
|
-
|
|
159
|
-
// pick up the eval via auto-propagation. Zero when there are none.
|
|
156
|
+
already_complete: alreadyComplete,
|
|
160
157
|
skipped_transpositions: skippedTranspositions,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// on the per-combo semaphore means walltime ≈ target_count × movetime.
|
|
164
|
-
estimated_seconds: Math.round((targets.length * movetimeMs) / 1000),
|
|
158
|
+
copied_to_transpositions: copies.length,
|
|
159
|
+
estimated_seconds: estimatedSeconds,
|
|
165
160
|
};
|
|
166
161
|
}
|
|
167
|
-
// Worker
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const pending = [];
|
|
173
|
-
const flush = async () => {
|
|
174
|
-
if (pending.length === 0)
|
|
175
|
-
return;
|
|
176
|
-
// No expected_version — auto_evaluate treats concurrent edits by
|
|
177
|
-
// the LLM as last-write-wins on the ceoEval field specifically.
|
|
178
|
-
// Safe because set_ceo_eval is idempotent per node and other
|
|
179
|
-
// mutations (add_move / set_comment / etc.) don't touch ceoEval.
|
|
180
|
-
const saved = await applyBatchMutations({
|
|
181
|
-
id: fileId,
|
|
182
|
-
mutations: pending,
|
|
183
|
-
});
|
|
184
|
-
const sr = saved;
|
|
185
|
-
if (typeof sr.version === "number")
|
|
186
|
-
job.finalVersion = sr.version;
|
|
187
|
-
pending.length = 0;
|
|
188
|
-
};
|
|
189
|
-
// Consecutive-failure abort. If N cloud_analyse calls in a row error,
|
|
190
|
-
// the engine is almost certainly dead (vanished contract, network to
|
|
191
|
-
// VastAI down) and burning through the rest of the tree just wastes
|
|
192
|
-
// time. Bail with an explicit reason so a targeted retry is possible.
|
|
193
|
-
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
194
|
-
let consecutiveFailures = 0;
|
|
195
|
-
let aborted = false;
|
|
162
|
+
// Worker: groups targets by which engines they need (so a node that only
|
|
163
|
+
// lacks `human` doesn't re-run Stockfish), sends batches of up to 10
|
|
164
|
+
// positions, and saves each batch's evals right away.
|
|
165
|
+
async function runEvalJob(job, targets, baseOpts) {
|
|
166
|
+
const groups = new Map();
|
|
196
167
|
for (const t of targets) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
168
|
+
const sig = t.engines.join(",");
|
|
169
|
+
groups.set(sig, [...(groups.get(sig) ?? []), t]);
|
|
170
|
+
}
|
|
171
|
+
// Two failed batches in a row means the engine or rental is gone; stop
|
|
172
|
+
// with a reason instead of burning through the rest of the tree.
|
|
173
|
+
const MAX_CONSECUTIVE_FAILURES = 2;
|
|
174
|
+
let consecutiveFailures = 0;
|
|
175
|
+
const markFailed = (t, engine) => {
|
|
176
|
+
(job.failed[engine] ??= []).push(t.nodeId);
|
|
177
|
+
};
|
|
178
|
+
outer: for (const groupTargets of groups.values()) {
|
|
179
|
+
const engines = groupTargets[0].engines;
|
|
180
|
+
for (let i = 0; i < groupTargets.length; i += MAX_POSITIONS_PER_CALL) {
|
|
181
|
+
if (job.cancelled)
|
|
182
|
+
break outer;
|
|
183
|
+
const batch = groupTargets.slice(i, i + MAX_POSITIONS_PER_CALL);
|
|
184
|
+
let results;
|
|
185
|
+
try {
|
|
186
|
+
results = await analysePositions(batch.map(t => ({ id: t.nodeId, fen: t.fen })), { ...baseOpts, engines });
|
|
205
187
|
consecutiveFailures = 0;
|
|
206
188
|
}
|
|
207
|
-
|
|
208
|
-
job.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
consecutiveFailures++;
|
|
219
|
-
}
|
|
220
|
-
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
221
|
-
aborted = true;
|
|
222
|
-
job.abortedReason = `aborted after ${MAX_CONSECUTIVE_FAILURES} consecutive cloud_analyse failures — check that the cloud combo is still running (list_cloud_engines)`;
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
if (pending.length >= SAVE_EVERY_N) {
|
|
226
|
-
try {
|
|
227
|
-
await flush();
|
|
189
|
+
catch (err) {
|
|
190
|
+
job.lastError = err instanceof Error ? err.message : String(err);
|
|
191
|
+
for (const t of batch)
|
|
192
|
+
for (const e of engines)
|
|
193
|
+
markFailed(t, e);
|
|
194
|
+
job.processed += batch.length;
|
|
195
|
+
if (++consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
196
|
+
job.abortedReason = `stopped after ${MAX_CONSECUTIVE_FAILURES} failed batches in a row: ${job.lastError}. Check list_cloud_engines.`;
|
|
197
|
+
break outer;
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
228
200
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
201
|
+
const entries = [];
|
|
202
|
+
batch.forEach((t, idx) => {
|
|
203
|
+
const r = results[idx];
|
|
204
|
+
const ev = r ? resultToStoredEval(convertPositionResult(r)) : null;
|
|
205
|
+
for (const e of engines) {
|
|
206
|
+
if (ev?.[STORED_KEY[e]])
|
|
207
|
+
job.evaluated[e] = (job.evaluated[e] ?? 0) + 1;
|
|
208
|
+
else {
|
|
209
|
+
markFailed(t, e);
|
|
210
|
+
const msg = r?.engines?.[e]?.error;
|
|
211
|
+
if (msg)
|
|
212
|
+
job.lastError = `${e}: ${msg}`;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (ev)
|
|
216
|
+
entries.push({ fen: t.fen, ev });
|
|
217
|
+
});
|
|
218
|
+
job.processed += batch.length;
|
|
219
|
+
if (entries.length > 0) {
|
|
220
|
+
try {
|
|
221
|
+
await storeEvals(job.fileId, entries);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// The analysis is lost for this batch; record and keep going.
|
|
225
|
+
job.lastError = `save failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
226
|
+
for (const t of batch)
|
|
227
|
+
for (const e of engines)
|
|
228
|
+
markFailed(t, e);
|
|
229
|
+
}
|
|
233
230
|
}
|
|
234
231
|
}
|
|
235
232
|
}
|
|
236
|
-
// Final flush regardless of cancellation — durably persist whatever
|
|
237
|
-
// work was completed before the user asked to stop.
|
|
238
|
-
try {
|
|
239
|
-
await flush();
|
|
240
|
-
}
|
|
241
|
-
catch (err) {
|
|
242
|
-
job.error = err instanceof Error ? err.message : String(err);
|
|
243
|
-
job.status = "error";
|
|
244
|
-
job.finishedAt = Date.now();
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
233
|
job.status = job.cancelled ? "cancelled" : "done";
|
|
248
234
|
job.finishedAt = Date.now();
|
|
249
235
|
}
|
|
@@ -256,21 +242,27 @@ export function autoEvaluateStatus(args) {
|
|
|
256
242
|
if (!job) {
|
|
257
243
|
return {
|
|
258
244
|
status: "not_found",
|
|
259
|
-
note: "Job unknown — either expired (kept ~15 min after completion), never existed, or the MCP process restarted since it was created. Re-run auto_evaluate
|
|
245
|
+
note: "Job unknown — either expired (kept ~15 min after completion), never existed, or the MCP process restarted since it was created. Re-run auto_evaluate; the `only_missing` default skips engines already stored in the prep file.",
|
|
260
246
|
};
|
|
261
247
|
}
|
|
248
|
+
const failedCount = {};
|
|
249
|
+
for (const e of ENGINES)
|
|
250
|
+
if (job.failed[e]?.length)
|
|
251
|
+
failedCount[e] = job.failed[e].length;
|
|
262
252
|
return {
|
|
263
253
|
job_id: job.id,
|
|
264
254
|
status: job.status,
|
|
255
|
+
engines: job.engines,
|
|
265
256
|
target_count: job.targetCount,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
257
|
+
processed: job.processed,
|
|
258
|
+
remaining: Math.max(0, job.targetCount - job.processed),
|
|
259
|
+
evaluated: job.evaluated, // positions stored, per engine
|
|
260
|
+
failed: failedCount, // positions without a result, per engine
|
|
261
|
+
failed_node_ids: job.failed, // per engine, for a targeted retry with node_id
|
|
262
|
+
last_error: job.lastError,
|
|
263
|
+
aborted_reason: job.abortedReason,
|
|
271
264
|
done: job.status !== "running",
|
|
272
265
|
error: job.error,
|
|
273
|
-
version: job.finalVersion,
|
|
274
266
|
started_at_ms: job.startedAt,
|
|
275
267
|
finished_at_ms: job.finishedAt,
|
|
276
268
|
};
|
|
@@ -286,7 +278,6 @@ export function autoEvaluateCancel(args) {
|
|
|
286
278
|
return { status: job.status, note: "Job already finished; nothing to cancel." };
|
|
287
279
|
}
|
|
288
280
|
job.cancelled = true;
|
|
289
|
-
// status
|
|
290
|
-
|
|
291
|
-
return { status: "cancelling", evaluated_so_far: job.evaluated };
|
|
281
|
+
// status turns "cancelled" once the batch in flight is saved.
|
|
282
|
+
return { status: "cancelling", processed_so_far: job.processed };
|
|
292
283
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// cloud_analyse and ensure_engines: the synchronous analysis tool and the
|
|
2
|
+
// rental planner. cloud_analyse runs up to 10 positions on any engines the
|
|
3
|
+
// caller has running (stockfish, lc0, human), each engine on whichever
|
|
4
|
+
// rental provides it, and stores the evals on matching nodes when a file is
|
|
5
|
+
// given. ensure_engines tells the caller what is running, what it would
|
|
6
|
+
// need to start, and what a job would cost, without starting anything.
|
|
7
|
+
import { Chess } from "chess.js";
|
|
8
|
+
import { authedRequest, fetchGame } from "../http.js";
|
|
9
|
+
import { parsePGN } from "../pgn/parser.js";
|
|
10
|
+
import { buildIdIndex, resolveNodeId } from "../pgn/paths.js";
|
|
11
|
+
import { analyseOptionsFromArgs, analysePositions, convertPositionResult, DEFAULT_MOVETIME_MS, engineFailures, MAX_POSITIONS_PER_CALL, parseEngines, resultToStoredEval, } from "./response.js";
|
|
12
|
+
import { getNodeByPath, resolveFenFromArgs, storeEvals } from "./file_handle.js";
|
|
13
|
+
function stringList(v) {
|
|
14
|
+
if (!Array.isArray(v))
|
|
15
|
+
return [];
|
|
16
|
+
return v.map(x => String(x).trim()).filter(x => x.length > 0);
|
|
17
|
+
}
|
|
18
|
+
// Collect the positions to analyse from the tool args. Accepts lists
|
|
19
|
+
// (`node_ids` with `file_id`, `fens`, `lines`) and the single-position
|
|
20
|
+
// inputs (`node_id`, `fen`, `moves`), in any combination.
|
|
21
|
+
async function collectPositions(args) {
|
|
22
|
+
const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
|
|
23
|
+
const nodeIds = stringList(args.node_ids);
|
|
24
|
+
if (typeof args.node_id === "string" && args.node_id.trim())
|
|
25
|
+
nodeIds.push(args.node_id.trim());
|
|
26
|
+
const positions = [];
|
|
27
|
+
if (nodeIds.length > 0) {
|
|
28
|
+
if (!fileId)
|
|
29
|
+
throw new Error("node_id / node_ids need file_id");
|
|
30
|
+
const g = await fetchGame(fileId);
|
|
31
|
+
const file = parsePGN(g.pgnContent);
|
|
32
|
+
const idIndex = buildIdIndex(file.root);
|
|
33
|
+
for (const nid of nodeIds) {
|
|
34
|
+
const node = getNodeByPath(file.root, resolveNodeId(idIndex, nid));
|
|
35
|
+
positions.push({ id: nid, node_id: nid, fen: node.fen });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const checkFen = (fen) => {
|
|
39
|
+
try {
|
|
40
|
+
return new Chess(fen).fen();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`invalid FEN: ${fen}`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
for (const fen of stringList(args.fens)) {
|
|
47
|
+
positions.push({ id: `p${positions.length + 1}`, fen: checkFen(fen) });
|
|
48
|
+
}
|
|
49
|
+
// `lines`: SAN move sequences, each from `fen` (or the start position).
|
|
50
|
+
for (const line of stringList(args.lines)) {
|
|
51
|
+
positions.push({ id: `p${positions.length + 1}`, fen: resolveFenFromArgs({ fen: args.fen, moves: line }) });
|
|
52
|
+
}
|
|
53
|
+
if (positions.length === 0 && (typeof args.fen === "string" || typeof args.moves === "string")) {
|
|
54
|
+
positions.push({ id: "p1", fen: resolveFenFromArgs(args) });
|
|
55
|
+
}
|
|
56
|
+
if (positions.length === 0) {
|
|
57
|
+
throw new Error("nothing to analyse: pass fens, lines, fen/moves, or file_id with node_ids");
|
|
58
|
+
}
|
|
59
|
+
return { positions, fileId };
|
|
60
|
+
}
|
|
61
|
+
export async function cloudAnalyse(args, evalScale) {
|
|
62
|
+
const engines = parseEngines(args.engines, ["stockfish"]);
|
|
63
|
+
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : DEFAULT_MOVETIME_MS;
|
|
64
|
+
// PVs past ~6 plies are speculative and get pasted into add_line as if
|
|
65
|
+
// they were prep. Raise only to verify a forcing line.
|
|
66
|
+
const pvMaxPlies = typeof args.pv_max_plies === "number" && args.pv_max_plies > 0
|
|
67
|
+
? Math.min(args.pv_max_plies, 40)
|
|
68
|
+
: 6;
|
|
69
|
+
const { positions, fileId } = await collectPositions(args);
|
|
70
|
+
if (positions.length > MAX_POSITIONS_PER_CALL) {
|
|
71
|
+
throw new Error(`${positions.length} positions; cloud_analyse takes up to ${MAX_POSITIONS_PER_CALL} per call. ` +
|
|
72
|
+
"Split the list, or use auto_evaluate for a whole file or subtree.");
|
|
73
|
+
}
|
|
74
|
+
const results = await analysePositions(positions.map(p => ({ id: p.id, fen: p.fen })), analyseOptionsFromArgs(args, engines, movetimeMs));
|
|
75
|
+
const out = positions.map((p, i) => {
|
|
76
|
+
const r = results[i] ? convertPositionResult(results[i], pvMaxPlies) : { fen: p.fen, engines: {} };
|
|
77
|
+
const failed = engineFailures(r, engines);
|
|
78
|
+
return {
|
|
79
|
+
id: p.id,
|
|
80
|
+
...(p.node_id ? { node_id: p.node_id } : {}),
|
|
81
|
+
fen: p.fen,
|
|
82
|
+
engines: r.engines,
|
|
83
|
+
...(Object.keys(failed).length > 0 ? { failed } : {}),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
// Inside a file: merge each eval into every node that reaches the
|
|
87
|
+
// position (node-addressed or matched by FEN), in one save. This is what
|
|
88
|
+
// lets quote_engine_eval cite a measurement later.
|
|
89
|
+
let storeError;
|
|
90
|
+
if (fileId) {
|
|
91
|
+
const entries = [];
|
|
92
|
+
out.forEach((o, idx) => {
|
|
93
|
+
const r = results[idx];
|
|
94
|
+
const ev = r ? resultToStoredEval(r) : null;
|
|
95
|
+
if (ev)
|
|
96
|
+
entries.push({ fen: positions[idx].fen, ev, idx });
|
|
97
|
+
});
|
|
98
|
+
if (entries.length > 0) {
|
|
99
|
+
try {
|
|
100
|
+
const stamped = await storeEvals(fileId, entries);
|
|
101
|
+
entries.forEach((e, k) => {
|
|
102
|
+
if (stamped[k]?.length)
|
|
103
|
+
out[e.idx].stored_on = stamped[k];
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
storeError = `analysis done but saving to the file failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
engines,
|
|
113
|
+
movetime_ms: movetimeMs,
|
|
114
|
+
positions: out,
|
|
115
|
+
...(storeError ? { store_error: storeError } : {}),
|
|
116
|
+
notes: "scoreCp/mate are White's point of view. wdl is win/draw/loss percent, White's point of view.",
|
|
117
|
+
eval_scale: evalScale,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Read-only rental plan: for each requested engine, the running rental(s)
|
|
121
|
+
// that provide it, or the cheapest available SKU to start. With `positions`,
|
|
122
|
+
// adds a time and cost estimate for analysing that many positions.
|
|
123
|
+
export async function ensureEngines(args) {
|
|
124
|
+
const engines = parseEngines(args.engines, ["stockfish"]);
|
|
125
|
+
const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : DEFAULT_MOVETIME_MS;
|
|
126
|
+
const positions = typeof args.positions === "number" && args.positions > 0 ? Math.floor(args.positions) : 0;
|
|
127
|
+
const [rentalsRaw, optionsRaw] = await Promise.all([
|
|
128
|
+
authedRequest("GET", "/api/agent/cloud-engines"),
|
|
129
|
+
authedRequest("GET", "/api/agent/cloud-engines/options"),
|
|
130
|
+
]);
|
|
131
|
+
const rentals = (Array.isArray(rentalsRaw) ? rentalsRaw : []);
|
|
132
|
+
const options = (optionsRaw?.options ?? []);
|
|
133
|
+
// Engines run in parallel per position, so wall time is per position,
|
|
134
|
+
// not per engine.
|
|
135
|
+
const minutes = positions > 0 ? (positions * movetimeMs) / 60000 : 0;
|
|
136
|
+
const plan = {};
|
|
137
|
+
const toStart = [];
|
|
138
|
+
let estCost = 0;
|
|
139
|
+
for (const e of engines) {
|
|
140
|
+
const running = rentals.filter(r => (r.engines ?? []).includes(e));
|
|
141
|
+
if (running.length > 0) {
|
|
142
|
+
plan[e] = {
|
|
143
|
+
status: running.length === 1 ? "running" : "several running (pass rentals." + e + " to choose)",
|
|
144
|
+
rentals: running.map(r => ({ contract_id: r.contractId, name: r.displayName || r.instanceType, cost_per_minute: r.costPerMinute, state: r.instanceStatus || "running" })),
|
|
145
|
+
};
|
|
146
|
+
estCost += (running[0].costPerMinute ?? 0) * minutes;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const skus = options
|
|
150
|
+
.filter(o => o.engine === e && o.available !== false)
|
|
151
|
+
.sort((a, b) => (a.costPerHour ?? Infinity) - (b.costPerHour ?? Infinity));
|
|
152
|
+
if (skus.length === 0) {
|
|
153
|
+
plan[e] = { status: "not available on this account right now" };
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
toStart.push(e);
|
|
157
|
+
plan[e] = {
|
|
158
|
+
status: "not running",
|
|
159
|
+
cheapest: { machine_type: skus[0].machineType, name: skus[0].displayName, cost_per_hour: skus[0].costPerHour },
|
|
160
|
+
other_skus: skus.slice(1).map(o => ({ machine_type: o.machineType, name: o.displayName, cost_per_hour: o.costPerHour })),
|
|
161
|
+
};
|
|
162
|
+
estCost += (skus[0].costPerMinute ?? (skus[0].costPerHour ?? 0) / 60) * minutes;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
engines,
|
|
166
|
+
plan,
|
|
167
|
+
...(toStart.length > 0
|
|
168
|
+
? { next_step: `Show the user the price for ${toStart.join(", ")} and get a yes, then start_cloud_engine with the machine_type. A new rental takes ~1-5 min to become ready.` }
|
|
169
|
+
: { next_step: "Everything requested is running. Analyse, then stop_cloud_engine when done." }),
|
|
170
|
+
...(positions > 0
|
|
171
|
+
? {
|
|
172
|
+
estimate: {
|
|
173
|
+
positions,
|
|
174
|
+
movetime_ms: movetimeMs,
|
|
175
|
+
minutes: Math.round(minutes * 10) / 10,
|
|
176
|
+
engine_cost: Math.round(estCost * 100) / 100,
|
|
177
|
+
note: "Engine time only. Rentals bill per minute from start to stop, so startup and idle time add to this.",
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
: {}),
|
|
181
|
+
};
|
|
182
|
+
}
|