@knights-eye-chess/ai-analysis 0.1.0-dev.17 → 0.1.0-dev.21

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/NPM-PUBLISHING.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  The owner authorized public npm prereleases on 10 October 2026. Use **`next`** and preserve any existing `latest` tag. Licensing is documented in [LICENSING.md](LICENSING.md).
4
4
 
5
- The first registry prerelease is `0.1.0-dev.14`. The source checkout now prepares
6
- `0.1.0-dev.17` with Move Classifier `0.1.0-dev.13`.
7
- This next candidate has not been published; `@next` remains the verified registry release.
5
+ The first registry prerelease was `0.1.0-dev.14`. This checkout prepares the
6
+ local `0.1.0-dev.21` candidate with Move Classifier `0.1.0-dev.15`.
7
+ This candidate has not been published; check the registry before relying on `@next`.
8
8
 
9
9
  ## Prepare the exact candidate
10
10
 
@@ -15,9 +15,9 @@ npm ci
15
15
  npm run publish:prepare
16
16
  ```
17
17
 
18
- Review `artifacts/knights-eye-chess-ai-analysis-0.1.0-dev.17.tgz`, including its files, exports, declarations, licenses and exact registry dependencies. Reusable source and offline owner tests are intentional; application traces, credentials, vendor archives and source lockfiles are excluded. The checkout stays `private: true`; publish only the staged archive. Archive contents are immutable: use a new version for changed bytes.
18
+ Review `artifacts/knights-eye-chess-ai-analysis-0.1.0-dev.21.tgz`, including its files, exports, declarations, licenses and exact registry dependencies. Reusable source and offline owner tests are intentional; application traces, credentials, vendor archives and source lockfiles are excluded. The checkout stays `private: true`; publish only the staged archive. Archive contents are immutable: use a new version for changed bytes.
19
19
 
20
- Before every coordinated release, run `node tools/release-tooling/sync.mjs --check` in the reviewed app checkout alongside all six component revisions. Record those commits and the result.
20
+ Before every coordinated release, run `node tools/release-tooling/sync.mjs --check` in the reviewed app checkout against its current component registry. Record the app and component commits and the result.
21
21
 
22
22
  ## Publish through GitHub Actions
23
23
 
package/build.mjs CHANGED
@@ -4,7 +4,11 @@ import {readFileSync,writeFileSync,readdirSync,copyFileSync,rmSync} from 'node:f
4
4
  import {createRequire} from 'node:module';
5
5
  const require=createRequire(import.meta.url);
6
6
  rmSync('dist',{recursive:true,force:true});
7
- const result=await build({entryPoints:{index:'src/chess-reasoning/answer-pipeline-exports.ts',research:'src/chess-reasoning/research-exports.ts',shared:'src/shared-exports.ts'},bundle:true,format:'esm',platform:'neutral',target:'es2022',packages:'external',splitting:true,outdir:'dist',metafile:true});
7
+ const result=await build({entryPoints:{index:'src/chess-reasoning/answer-pipeline-exports.ts',research:'src/chess-reasoning/research-exports.ts',shared:'src/shared-exports.ts',engine:'src/chess-reasoning/engine-analysis-provider.ts'},bundle:true,format:'esm',platform:'neutral',target:'es2022',packages:'external',splitting:true,outdir:'dist',metafile:true});
8
+ const engine=await build({entryPoints:['src/chess-reasoning/engine-analysis-provider.ts'],bundle:true,format:'esm',platform:'neutral',target:'es2022',packages:'external',write:false,metafile:true});
9
+ const engineImports=Object.values(engine.metafile.outputs).flatMap(output=>output.imports);
10
+ if(engineImports.length)throw Error('Engine provider must have no runtime dependencies: '+engineImports.map(item=>item.path).join(', '));
11
+ writeFileSync('dist/engine-runtime-closure.json',JSON.stringify({runtimeInputs:Object.keys(engine.metafile.inputs).sort(),externalImports:[]},null,2)+'\n');
8
12
  const shared=await build({entryPoints:['src/shared-exports.ts'],bundle:true,format:'esm',platform:'neutral',target:'es2022',packages:'external',write:false,metafile:true});
9
13
  const sharedImports=[...new Set(Object.values(shared.metafile.outputs).flatMap(output=>output.imports.filter(item=>item.external).map(item=>item.path)))];
10
14
  if(sharedImports.some(name=>name === 'ai' || name.startsWith('@ai-sdk/') || name.startsWith('@knights-eye-chess/move-classifier')))throw Error('Model or classifier runtime leaked into shared helpers: '+sharedImports.join(', '));
@@ -1284,7 +1284,7 @@ function rejectionReason(context, hash, witness) {
1284
1284
  if (witness.authority !== "bounded-engine" || !Array.isArray(witness.licenses) || witness.licenses.length !== 1 || witness.licenses[0] !== "engine.bounded-continuation") return "missing-authority-license";
1285
1285
  if (witness.contextHash !== hash) return "context-mismatch";
1286
1286
  if (!["before", "after"].includes(witness.phase) || witness.fen !== (witness.phase === "before" ? context.fenBefore : context.fenAfter)) return "root-mismatch";
1287
- if (typeof witness.engineBuild !== "string" || !witness.engineBuild || typeof witness.command !== "string" || !witness.command || !positiveInteger(witness.requestedDepth) || !positiveInteger(witness.multiPv) || witness.multiPv > 32 || !Array.isArray(witness.lines) || witness.lines.length !== witness.multiPv || !Array.isArray(witness.rootMoves) || witness.rootMoves.length > 32 || witness.rootMoves.some((root) => typeof root !== "string" || !UCI.test(root)) || new Set(witness.rootMoves).size !== witness.rootMoves.length) return "invalid-search-scope";
1287
+ if (typeof witness.engineBuild !== "string" || !witness.engineBuild || typeof witness.command !== "string" || !witness.command || !positiveInteger(witness.requestedDepth) || !positiveInteger(witness.multiPv) || witness.multiPv > 32 || !Array.isArray(witness.lines) || witness.lines.length < 1 || witness.lines.length > witness.multiPv || !Array.isArray(witness.rootMoves) || witness.rootMoves.length > 32 || witness.rootMoves.some((root) => typeof root !== "string" || !UCI.test(root)) || new Set(witness.rootMoves).size !== witness.rootMoves.length) return "invalid-search-scope";
1288
1288
  if (witness.lines.some((line) => !line || typeof line.rootMove !== "string")) return "invalid-pv";
1289
1289
  const roots = witness.lines.map((line) => line.rootMove);
1290
1290
  if (new Set(roots).size !== roots.length || witness.rootMoves.some((root) => !roots.includes(root)) || witness.rootMoves.length > 0 && roots.some((root) => !witness.rootMoves.includes(root))) return "root-set-mismatch";
@@ -0,0 +1,59 @@
1
+ // src/chess-reasoning/engine-analysis-provider.ts
2
+ function createEngineAnalysisProvider(client) {
3
+ return { async analyze(request) {
4
+ request.signal?.throwIfAborted();
5
+ const result = await client.search({
6
+ fen: request.fen,
7
+ limit: { depth: request.depth },
8
+ multiPv: request.multiPv,
9
+ rootMoves: request.rootMoves,
10
+ ...request.rootMoves.length ? { requireRootCoverage: true } : {},
11
+ signal: request.signal
12
+ });
13
+ request.signal?.throwIfAborted();
14
+ const command = "go depth " + request.depth + (request.rootMoves.length ? " searchmoves " + request.rootMoves.join(" ") : "");
15
+ if (result.noMove === true) {
16
+ if (result.bestmove !== null || result.candidates.length !== 0 || !result.engineBuild) {
17
+ throw new Error("Invalid UCI no-move result.");
18
+ }
19
+ return {
20
+ engine: result.engineBuild,
21
+ engineBuild: result.engineBuild,
22
+ command,
23
+ bestMove: null,
24
+ cacheProvenance: "engine",
25
+ lines: [],
26
+ ...result.diagnostics ? { diagnostics: result.diagnostics } : {}
27
+ };
28
+ }
29
+ const build = result.candidates[0]?.scoreProvenance?.engineBuild;
30
+ if (typeof build !== "string" || !build) throw new Error("Engine returned no attributed build identity.");
31
+ if (!result.bestmove || result.bestmove !== result.candidates[0]?.pv[0] || result.engineBuild && result.engineBuild !== build || result.weightsId && result.weightsId !== result.candidates[0]?.scoreProvenance?.weightsId) {
32
+ throw new Error("Engine returned an incomplete or inconsistent best move.");
33
+ }
34
+ if (result.candidates.some((line) => line.scoreProvenance?.engineBuild !== build || line.scoreProvenance?.weightsId !== result.candidates[0]?.scoreProvenance?.weightsId)) {
35
+ throw new Error("Engine returned inconsistent build identity.");
36
+ }
37
+ return {
38
+ engine: build,
39
+ engineBuild: build,
40
+ command,
41
+ bestMove: result.bestmove,
42
+ cacheProvenance: "engine",
43
+ ...result.diagnostics ? { diagnostics: result.diagnostics } : {},
44
+ lines: result.candidates.map((candidate) => ({
45
+ rootMove: candidate.pv[0],
46
+ pv: candidate.pv,
47
+ depth: candidate.depth,
48
+ nodes: candidate.nodes ?? null,
49
+ scoreCp: candidate.scoreCp,
50
+ mateInMoves: candidate.mate,
51
+ scoreProvenance: candidate.scoreProvenance ? { ...candidate.scoreProvenance } : void 0
52
+ }))
53
+ };
54
+ } };
55
+ }
56
+
57
+ export {
58
+ createEngineAnalysisProvider
59
+ };
@@ -50,7 +50,7 @@ import {
50
50
  validateEvidenceLicenseClaims,
51
51
  validateReasoningPositionContext,
52
52
  withEvidenceLicenses
53
- } from "./chunk-XQDZJOYD.js";
53
+ } from "./chunk-6OXAG5H3.js";
54
54
 
55
55
  // src/chess-reasoning/branch-expansion-policy.ts
56
56
  var BRANCH_EXPANSION_OPTIONS = { maxNodes: 8, maxPlies: 4, maxLines: 2, depthFloor: 14, depthMode: "decreasing", maxElapsedMs: 2e4 };
@@ -0,0 +1,6 @@
1
+ {
2
+ "runtimeInputs": [
3
+ "src/chess-reasoning/engine-analysis-provider.ts"
4
+ ],
5
+ "externalImports": []
6
+ }
package/dist/engine.js ADDED
@@ -0,0 +1,6 @@
1
+ import {
2
+ createEngineAnalysisProvider
3
+ } from "./chunk-AJJP7FYT.js";
4
+ export {
5
+ createEngineAnalysisProvider
6
+ };
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  simpleSystemPolicy,
18
18
  simpleToolDescription,
19
19
  validateSimpleToolInput
20
- } from "./chunk-PLKNJRPF.js";
20
+ } from "./chunk-V6NM6RB2.js";
21
21
  import {
22
22
  DEFAULT_REASONING_RUN_CONTROLS,
23
23
  EVIDENCE_SHARING_VERSION,
@@ -71,7 +71,10 @@ import {
71
71
  toPositionAnalysisProvider,
72
72
  validateReasoningPositionContext,
73
73
  validateSimpleAgentConfig
74
- } from "./chunk-XQDZJOYD.js";
74
+ } from "./chunk-6OXAG5H3.js";
75
+ import {
76
+ createEngineAnalysisProvider
77
+ } from "./chunk-AJJP7FYT.js";
75
78
  export {
76
79
  DEFAULT_REASONING_RUN_CONTROLS,
77
80
  EVIDENCE_SHARING_VERSION,
@@ -97,6 +100,7 @@ export {
97
100
  classifyReviewSubject,
98
101
  createContextEvidence,
99
102
  createDefaultSaliencePolicy,
103
+ createEngineAnalysisProvider,
100
104
  createNativeAnswerConnector,
101
105
  createPocToolHost,
102
106
  createProductionObservationProvider,
@@ -0,0 +1,169 @@
1
+ // test/owner/engine-analysis-provider.test.mjs
2
+ import assert from "node:assert/strict";
3
+ import { test } from "node:test";
4
+
5
+ // src/chess-reasoning/engine-analysis-provider.ts
6
+ function createEngineAnalysisProvider(client) {
7
+ return { async analyze(request2) {
8
+ request2.signal?.throwIfAborted();
9
+ const result = await client.search({
10
+ fen: request2.fen,
11
+ limit: { depth: request2.depth },
12
+ multiPv: request2.multiPv,
13
+ rootMoves: request2.rootMoves,
14
+ ...request2.rootMoves.length ? { requireRootCoverage: true } : {},
15
+ signal: request2.signal
16
+ });
17
+ request2.signal?.throwIfAborted();
18
+ const command = "go depth " + request2.depth + (request2.rootMoves.length ? " searchmoves " + request2.rootMoves.join(" ") : "");
19
+ if (result.noMove === true) {
20
+ if (result.bestmove !== null || result.candidates.length !== 0 || !result.engineBuild) {
21
+ throw new Error("Invalid UCI no-move result.");
22
+ }
23
+ return {
24
+ engine: result.engineBuild,
25
+ engineBuild: result.engineBuild,
26
+ command,
27
+ bestMove: null,
28
+ cacheProvenance: "engine",
29
+ lines: [],
30
+ ...result.diagnostics ? { diagnostics: result.diagnostics } : {}
31
+ };
32
+ }
33
+ const build = result.candidates[0]?.scoreProvenance?.engineBuild;
34
+ if (typeof build !== "string" || !build) throw new Error("Engine returned no attributed build identity.");
35
+ if (!result.bestmove || result.bestmove !== result.candidates[0]?.pv[0] || result.engineBuild && result.engineBuild !== build || result.weightsId && result.weightsId !== result.candidates[0]?.scoreProvenance?.weightsId) {
36
+ throw new Error("Engine returned an incomplete or inconsistent best move.");
37
+ }
38
+ if (result.candidates.some((line2) => line2.scoreProvenance?.engineBuild !== build || line2.scoreProvenance?.weightsId !== result.candidates[0]?.scoreProvenance?.weightsId)) {
39
+ throw new Error("Engine returned inconsistent build identity.");
40
+ }
41
+ return {
42
+ engine: build,
43
+ engineBuild: build,
44
+ command,
45
+ bestMove: result.bestmove,
46
+ cacheProvenance: "engine",
47
+ ...result.diagnostics ? { diagnostics: result.diagnostics } : {},
48
+ lines: result.candidates.map((candidate) => ({
49
+ rootMove: candidate.pv[0],
50
+ pv: candidate.pv,
51
+ depth: candidate.depth,
52
+ nodes: candidate.nodes ?? null,
53
+ scoreCp: candidate.scoreCp,
54
+ mateInMoves: candidate.mate,
55
+ scoreProvenance: candidate.scoreProvenance ? { ...candidate.scoreProvenance } : void 0
56
+ }))
57
+ };
58
+ } };
59
+ }
60
+
61
+ // test/owner/engine-analysis-provider.test.mjs
62
+ var request = { fen: "root", depth: 18, multiPv: 3, rootMoves: [], minimumPvPlies: 10 };
63
+ var line = (move, mate = null) => ({
64
+ pv: [move],
65
+ depth: 18,
66
+ nodes: 42,
67
+ scoreCp: mate === null ? 21 : null,
68
+ mate,
69
+ scoreProvenance: { kind: "root-search", engineBuild: "Stockfish test", fen: "root", originatingDepth: 18, reportedDepth: 18 }
70
+ });
71
+ test("maps one completed search without padding fewer lines or short PVs", async () => {
72
+ let calls = 0, received;
73
+ const candidates = [line("g7g8", -3), line("g7f7")];
74
+ const diagnostics = { processReused: true };
75
+ const provider = createEngineAnalysisProvider({ async search(input) {
76
+ calls++;
77
+ received = input;
78
+ return { bestmove: "g7g8", candidates, diagnostics };
79
+ } });
80
+ const result = await provider.analyze(request);
81
+ assert.equal(calls, 1);
82
+ assert.deepEqual(received, { fen: "root", limit: { depth: 18 }, multiPv: 3, rootMoves: [], signal: void 0 });
83
+ assert.equal(result.lines.length, 2);
84
+ assert.deepEqual(result.lines[0].pv, ["g7g8"]);
85
+ assert.equal(result.lines[0].mateInMoves, -3);
86
+ assert.equal(result.lines[0].scoreCp, null);
87
+ assert.equal(result.lines[0].nodes, 42);
88
+ assert.deepEqual(result.lines[0].scoreProvenance, candidates[0].scoreProvenance);
89
+ assert.equal(result.engineBuild, "Stockfish test");
90
+ assert.equal(result.cacheProvenance, "engine");
91
+ assert.equal(result.diagnostics, diagnostics);
92
+ });
93
+ test("preserves restricted roots and cancellation instead of changing search scope", async () => {
94
+ const controller = new AbortController();
95
+ let received;
96
+ const provider = createEngineAnalysisProvider({ async search(input) {
97
+ received = input;
98
+ return { bestmove: "g7g8", candidates: [line("g7g8")] };
99
+ } });
100
+ const result = await provider.analyze({ ...request, multiPv: 1, rootMoves: ["g7g8"], signal: controller.signal });
101
+ assert.equal(received.signal, controller.signal);
102
+ assert.deepEqual(received.rootMoves, ["g7g8"]);
103
+ assert.equal(received.requireRootCoverage, true);
104
+ assert.equal(result.command, "go depth 18 searchmoves g7g8");
105
+ controller.abort();
106
+ await assert.rejects(provider.analyze({ ...request, signal: controller.signal }), { name: "AbortError" });
107
+ });
108
+ test("explicit comparison requires every requested root from the engine client", async () => {
109
+ const required = ["g7g8", "g7f7"];
110
+ let seen;
111
+ const client = { async search(input) {
112
+ seen = input;
113
+ const candidates = [line("g7g8")];
114
+ if (input.requireRootCoverage && input.rootMoves.some((root) => !candidates.some((candidate) => candidate.pv[0] === root)))
115
+ throw new Error("UCI engine did not complete requested score and root coverage.");
116
+ return { bestmove: "g7g8", candidates };
117
+ } };
118
+ const provider = createEngineAnalysisProvider(client);
119
+ await assert.rejects(provider.analyze({ ...request, multiPv: 2, rootMoves: required }), /root coverage/);
120
+ assert.deepEqual(seen.rootMoves, required);
121
+ assert.equal(seen.requireRootCoverage, true);
122
+ const unrestricted = await provider.analyze({ ...request, multiPv: 2, rootMoves: [] });
123
+ assert.equal(seen.requireRootCoverage, void 0);
124
+ assert.equal(unrestricted.lines.length, 1);
125
+ });
126
+ test("does not turn a cancelled or failed engine search into evidence", async () => {
127
+ const controller = new AbortController();
128
+ const provider = createEngineAnalysisProvider({ async search() {
129
+ controller.abort();
130
+ return { candidates: [line("g7g8")] };
131
+ } });
132
+ await assert.rejects(provider.analyze({ ...request, signal: controller.signal }), { name: "AbortError" });
133
+ const failure = new Error("engine failed");
134
+ await assert.rejects(createEngineAnalysisProvider({ async search() {
135
+ throw failure;
136
+ } }).analyze(request), (error) => error === failure);
137
+ });
138
+ test("requires an attributed single engine result", async () => {
139
+ for (const candidates of [[line("g7g8"), { ...line("g7f7"), scoreProvenance: { ...line("g7f7").scoreProvenance, weightsId: "different" } }], [{ ...line("g7g8"), scoreProvenance: void 0 }], [line("g7g8"), { ...line("g7f7"), scoreProvenance: { engineBuild: "other" } }]]) {
140
+ await assert.rejects(createEngineAnalysisProvider({ async search() {
141
+ return { bestmove: "g7g8", candidates };
142
+ } }).analyze(request), /identity/);
143
+ }
144
+ });
145
+ test("explicit no-move result carries engine identity without inventing a score", async () => {
146
+ const provider = createEngineAnalysisProvider({ async search() {
147
+ return { noMove: true, bestmove: null, candidates: [], engineBuild: "Stockfish test" };
148
+ } });
149
+ const result = await provider.analyze(request);
150
+ assert.equal(result.bestMove, null);
151
+ assert.deepEqual(result.lines, []);
152
+ assert.equal(result.engineBuild, "Stockfish test");
153
+ for (const malformed of [
154
+ { noMove: true, bestmove: null, candidates: [], engineBuild: "" },
155
+ { noMove: true, bestmove: "e2e4", candidates: [], engineBuild: "Stockfish test" },
156
+ { noMove: true, bestmove: null, candidates: [line("e2e4")], engineBuild: "Stockfish test" }
157
+ ]) {
158
+ await assert.rejects(createEngineAnalysisProvider({ async search() {
159
+ return malformed;
160
+ } }).analyze(request), /no-move/);
161
+ }
162
+ });
163
+ test("scored search requires an explicit completed best move", async () => {
164
+ for (const bestmove of [void 0, "d2d4"]) {
165
+ await assert.rejects(createEngineAnalysisProvider({ async search() {
166
+ return { bestmove, candidates: [line("e2e4")] };
167
+ } }).analyze(request), /best move/);
168
+ }
169
+ });
@@ -5879,7 +5879,7 @@ function rejectionReason(context2, hash, witness) {
5879
5879
  if (witness.authority !== "bounded-engine" || !Array.isArray(witness.licenses) || witness.licenses.length !== 1 || witness.licenses[0] !== "engine.bounded-continuation") return "missing-authority-license";
5880
5880
  if (witness.contextHash !== hash) return "context-mismatch";
5881
5881
  if (!["before", "after"].includes(witness.phase) || witness.fen !== (witness.phase === "before" ? context2.fenBefore : context2.fenAfter)) return "root-mismatch";
5882
- if (typeof witness.engineBuild !== "string" || !witness.engineBuild || typeof witness.command !== "string" || !witness.command || !positiveInteger(witness.requestedDepth) || !positiveInteger(witness.multiPv) || witness.multiPv > 32 || !Array.isArray(witness.lines) || witness.lines.length !== witness.multiPv || !Array.isArray(witness.rootMoves) || witness.rootMoves.length > 32 || witness.rootMoves.some((root) => typeof root !== "string" || !UCI.test(root)) || new Set(witness.rootMoves).size !== witness.rootMoves.length) return "invalid-search-scope";
5882
+ if (typeof witness.engineBuild !== "string" || !witness.engineBuild || typeof witness.command !== "string" || !witness.command || !positiveInteger(witness.requestedDepth) || !positiveInteger(witness.multiPv) || witness.multiPv > 32 || !Array.isArray(witness.lines) || witness.lines.length < 1 || witness.lines.length > witness.multiPv || !Array.isArray(witness.rootMoves) || witness.rootMoves.length > 32 || witness.rootMoves.some((root) => typeof root !== "string" || !UCI.test(root)) || new Set(witness.rootMoves).size !== witness.rootMoves.length) return "invalid-search-scope";
5883
5883
  if (witness.lines.some((line) => !line || typeof line.rootMove !== "string")) return "invalid-pv";
5884
5884
  const roots = witness.lines.map((line) => line.rootMove);
5885
5885
  if (new Set(roots).size !== roots.length || witness.rootMoves.some((root) => !roots.includes(root)) || witness.rootMoves.length > 0 && roots.some((root) => !witness.rootMoves.includes(root))) return "root-set-mismatch";
@@ -1037,7 +1037,7 @@ function rejectionReason(context, hash, witness) {
1037
1037
  if (witness.authority !== "bounded-engine" || !Array.isArray(witness.licenses) || witness.licenses.length !== 1 || witness.licenses[0] !== "engine.bounded-continuation") return "missing-authority-license";
1038
1038
  if (witness.contextHash !== hash) return "context-mismatch";
1039
1039
  if (!["before", "after"].includes(witness.phase) || witness.fen !== (witness.phase === "before" ? context.fenBefore : context.fenAfter)) return "root-mismatch";
1040
- if (typeof witness.engineBuild !== "string" || !witness.engineBuild || typeof witness.command !== "string" || !witness.command || !positiveInteger(witness.requestedDepth) || !positiveInteger(witness.multiPv) || witness.multiPv > 32 || !Array.isArray(witness.lines) || witness.lines.length !== witness.multiPv || !Array.isArray(witness.rootMoves) || witness.rootMoves.length > 32 || witness.rootMoves.some((root) => typeof root !== "string" || !UCI.test(root)) || new Set(witness.rootMoves).size !== witness.rootMoves.length) return "invalid-search-scope";
1040
+ if (typeof witness.engineBuild !== "string" || !witness.engineBuild || typeof witness.command !== "string" || !witness.command || !positiveInteger(witness.requestedDepth) || !positiveInteger(witness.multiPv) || witness.multiPv > 32 || !Array.isArray(witness.lines) || witness.lines.length < 1 || witness.lines.length > witness.multiPv || !Array.isArray(witness.rootMoves) || witness.rootMoves.length > 32 || witness.rootMoves.some((root) => typeof root !== "string" || !UCI.test(root)) || new Set(witness.rootMoves).size !== witness.rootMoves.length) return "invalid-search-scope";
1041
1041
  if (witness.lines.some((line) => !line || typeof line.rootMove !== "string")) return "invalid-pv";
1042
1042
  const roots = witness.lines.map((line) => line.rootMove);
1043
1043
  if (new Set(roots).size !== roots.length || witness.rootMoves.some((root) => !roots.includes(root)) || witness.rootMoves.length > 0 && roots.some((root) => !witness.rootMoves.includes(root))) return "root-set-mismatch";
@@ -1768,3 +1768,54 @@ test("fresh answer witnesses preserve shipped v22 reference geometry and reject
1768
1768
  assert.equal(rejected.referenceGapCounts.unresolvedReferences, 1);
1769
1769
  assert.equal(rejected.blocks[0].tokens[0].reference, void 0);
1770
1770
  });
1771
+ test("completed unrestricted MultiPV witnesses accept fewer lines without changing requested scope", () => {
1772
+ const fen = "7r/p1p1p1kp/2p2rpQ/3q4/3P4/2P5/P1P2PP1/R3K2R b KQ - 5 18";
1773
+ const context = { kind: "position", nodeId: "two-legal", historyStartFen: fen, historyUci: [], moveUci: "", moveSan: "", fenBefore: fen, fenAfter: fen };
1774
+ const roots = ["g7g8", "g7f7"];
1775
+ assert.deepEqual(new Set(new Chess34(fen).moves({ verbose: true }).map((m) => m.from + m.to)), new Set(roots));
1776
+ const witness = {
1777
+ version: "fresh-engine-pv-witness/1",
1778
+ acquisition: "same-answer-engine",
1779
+ acquisitionId: 1,
1780
+ evidenceId: "server-engine-acquisition:two-legal",
1781
+ contextHash: reasoningContextHash(context),
1782
+ phase: "before",
1783
+ fen,
1784
+ engineBuild: "test-engine",
1785
+ command: "go depth 18",
1786
+ requestedDepth: 18,
1787
+ multiPv: 3,
1788
+ rootMoves: [],
1789
+ authority: "bounded-engine",
1790
+ licenses: ["engine.bounded-continuation"],
1791
+ lines: roots.map((rootMove, index) => ({
1792
+ rootMove,
1793
+ pv: [rootMove],
1794
+ depth: 18,
1795
+ scoreCp: -200 - index,
1796
+ mateInMoves: null,
1797
+ scoreProvenance: { kind: "root-search", fen, engineBuild: "test-engine", command: "go depth 18", originatingDepth: 18, reportedDepth: 18 }
1798
+ }))
1799
+ };
1800
+ const admitted = admitFreshEnginePvWitnesses(context, [witness]);
1801
+ assert.equal(admitted.audit.acceptedLines, 2);
1802
+ assert.equal(witness.multiPv, 3, "requested MultiPV remains provenance, not returned-line count");
1803
+ assert.equal(admitFreshEnginePvWitnesses(context, [{ ...witness, lines: [witness.lines[0]] }]).audit.acceptedLines, 1);
1804
+ for (const patch of [{ lines: [] }, { multiPv: 1 }, { rootMoves: roots, lines: [witness.lines[0]] }]) {
1805
+ assert.equal(admitFreshEnginePvWitnesses(context, [{ ...witness, ...patch }]).audit.acceptedLines, 0);
1806
+ }
1807
+ for (const change of [(line) => {
1808
+ line.depth = 17;
1809
+ }, (line) => {
1810
+ line.scoreCp = null;
1811
+ }, (line) => {
1812
+ line.pv = ["g7g6"];
1813
+ }]) {
1814
+ const invalid = structuredClone(witness);
1815
+ change(invalid.lines[1]);
1816
+ assert.equal(admitFreshEnginePvWitnesses(context, [invalid]).audit.acceptedLines, 0);
1817
+ }
1818
+ const rendered = simpleProseReferences("[Kg8](move:before:g7g8)", context, [], void 0, void 0, void 0, { freshEnginePvWitnesses: [witness] });
1819
+ assert.equal(rendered.referenceGapCounts.unresolvedReferences, 0);
1820
+ assert.equal(rendered.freshEnginePvAdmission.acceptedLines, 2);
1821
+ });
package/dist/research.js CHANGED
@@ -211,7 +211,7 @@ import {
211
211
  wikibooksTargetsForTool,
212
212
  withBranchPreparation,
213
213
  withRecordedPositionTools
214
- } from "./chunk-PLKNJRPF.js";
214
+ } from "./chunk-V6NM6RB2.js";
215
215
  import {
216
216
  CHESS_REASONING_POLICY_VERSION,
217
217
  CHESS_REASONING_SCHEMA_VERSION,
@@ -316,7 +316,10 @@ import {
316
316
  validateReasoningPositionContext,
317
317
  validateSimpleAgentConfig,
318
318
  withEvidenceLicenses
319
- } from "./chunk-XQDZJOYD.js";
319
+ } from "./chunk-6OXAG5H3.js";
320
+ import {
321
+ createEngineAnalysisProvider
322
+ } from "./chunk-AJJP7FYT.js";
320
323
 
321
324
  // src/chess-reasoning/ablation-direct-synthesis.ts
322
325
  import { SQUARES } from "chess.js";
@@ -2119,6 +2122,7 @@ export {
2119
2122
  createContextEvidence,
2120
2123
  createDefaultObservationProvider,
2121
2124
  createDefaultSaliencePolicy,
2125
+ createEngineAnalysisProvider,
2122
2126
  createIdeaGraph,
2123
2127
  createIdentityState,
2124
2128
  createMechanicalFactRequest,
@@ -26,6 +26,7 @@
26
26
  "src/chess-reasoning/compact-search-preparation.ts",
27
27
  "src/chess-reasoning/concept-registry.ts",
28
28
  "src/chess-reasoning/conversation-context.ts",
29
+ "src/chess-reasoning/engine-analysis-provider.ts",
29
30
  "src/chess-reasoning/engine-search-failure.ts",
30
31
  "src/chess-reasoning/evidence-citation-normalization.ts",
31
32
  "src/chess-reasoning/evidence-licenses.ts",
@@ -99,5 +100,5 @@
99
100
  "@ai-sdk/provider",
100
101
  "@ai-sdk/provider-utils"
101
102
  ],
102
- "runtimeBytes": 969108
103
+ "runtimeBytes": 971929
103
104
  }
package/dist/shared.js CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  toPositionAnalysisProvider,
63
63
  validateReasoningPositionContext,
64
64
  validateSimpleAgentConfig
65
- } from "./chunk-XQDZJOYD.js";
65
+ } from "./chunk-6OXAG5H3.js";
66
66
 
67
67
  // src/player-perspective.ts
68
68
  function normalizedPlayerName(value) {
@@ -9,6 +9,8 @@ export { createPocToolHost } from './answer-tool-host.js';
9
9
  export type { AnswerToolHostOptions } from './answer-tool-host.js';
10
10
  export type { PositionAnalysisProvider, PositionAnalysisRequest, PositionAnalysisResult, PositionAnalysisLine, PositionAnalysisSegment, CompatiblePositionAnalysisProvider, LegacyPositionAnalysisProvider, LegacyPositionAnalysisResult, LegacyPositionAnalysisLine } from './position-analysis-contracts.js';
11
11
  export { toPositionAnalysis, toLegacyPositionAnalysis, toPositionAnalysisProvider, toLegacyPositionAnalysisProvider } from './position-analysis-contracts.js';
12
+ export { createEngineAnalysisProvider } from './engine-analysis-provider.js';
13
+ export type { AnalysisEngineClient } from './engine-analysis-provider.js';
12
14
  export { createProductionObservationProvider } from './production-observation-provider.js';
13
15
  export { validateReasoningPositionContext, reasoningContextHash, createContextEvidence, createQueryContract, DEFAULT_REASONING_RUN_CONTROLS, REASONING_POC_VERSION } from './poc-contracts.js';
14
16
  export type { ReasoningPositionContext, ReasoningToolSet, ObservationProvider, ReasoningEvidenceRecord } from './poc-contracts.js';
@@ -0,0 +1,42 @@
1
+ import type { PositionAnalysisProvider } from './position-analysis-contracts.js';
2
+ /** Consumer-owned structural contract. No dependency on an engine implementation. */
3
+ export type AnalysisEngineClient = {
4
+ search(request: {
5
+ fen: string;
6
+ limit: {
7
+ depth: number;
8
+ };
9
+ multiPv: number;
10
+ rootMoves: string[];
11
+ requireRootCoverage?: boolean;
12
+ signal?: AbortSignal;
13
+ }): Promise<({
14
+ noMove?: false;
15
+ bestmove: string;
16
+ engineBuild?: string;
17
+ weightsId?: string;
18
+ candidates: Array<{
19
+ pv: string[];
20
+ depth: number;
21
+ nodes?: number | null;
22
+ scoreCp: number | null;
23
+ mate: number | null;
24
+ scoreProvenance?: {
25
+ engineBuild: string;
26
+ weightsId?: string;
27
+ };
28
+ }>;
29
+ } | {
30
+ noMove: true;
31
+ bestmove: null;
32
+ candidates: [];
33
+ engineBuild: string;
34
+ weightsId?: string;
35
+ }) & {
36
+ diagnostics?: unknown;
37
+ }>;
38
+ };
39
+ /** Adapt a host-owned depth-search client without acquiring or closing it.
40
+ * Short PVs remain short; evidence admission stays with the existing consumers.
41
+ */
42
+ export declare function createEngineAnalysisProvider(client: AnalysisEngineClient): PositionAnalysisProvider;
package/docs/API.md CHANGED
@@ -80,3 +80,36 @@ remain in the canonical tool wrappers and host.
80
80
 
81
81
 
82
82
  See the [pipeline source](../src/chess-reasoning/answer-pipeline.ts) and [connector contract](../src/chess-reasoning/answer-model-connector.ts).
83
+
84
+
85
+ ### Completed MultiPV results
86
+
87
+ Fresh engine presentation witnesses interpret `multiPv` as the requested maximum.
88
+ An unrestricted completed search can return one through that many valid lines;
89
+ the original requested count remains in the witness even when fewer lines arrive.
90
+ Explicit `rootMoves` searches must still cover their entire requested root set.
91
+ Depth, score, provenance, distinct-root and legal-PV checks remain unchanged.
92
+
93
+ ## Host-owned engine clients
94
+
95
+ `createEngineAnalysisProvider(client)` is exported from the package root and the
96
+ small `@knights-eye-chess/ai-analysis/engine` entry point. The latter imports no
97
+ model SDK or engine implementation. AI Analysis declares its own structural
98
+ `AnalysisEngineClient` contract; the host wires a compatible implementation.
99
+
100
+ The adapter translates a depth request to `limit: { depth }` and maps completed
101
+ engine candidates to AI lines without changing scores, mate units, achieved depth,
102
+ PV length or provenance. A valid result can contain fewer lines than requested.
103
+ Engine/weights identity remains attributed; different engines are not blended.
104
+ This is not evidence admission or a calibration of another engine's scores for
105
+ the Knight's Eye classifier. Nodes/time-limited human-likelihood evidence needs
106
+ its own consumer contract rather than masquerading as depth-search evidence.
107
+
108
+ The host owns lifecycle and closes the client. `minimumPvPlies` does not make this
109
+ adapter perform extra searches or pad PVs. Existing downstream admission and
110
+ continuation handling remain responsible for evidence use.
111
+ Scored results require an explicit completed `bestmove` matching the first scored
112
+ root. An explicit `noMove: true` result with empty candidates, `bestmove: null`,
113
+ and top-level `engineBuild` maps to no scored lines and a null best move. This is
114
+ only a UCI no-move report: the host checks legal moves at the FEN before claiming
115
+ checkmate, stalemate, or another chess terminal condition.
package/npm-release.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "registryDependencies": {
3
- "@knights-eye-chess/move-classifier": "0.1.0-dev.13"
3
+ "@knights-eye-chess/move-classifier": "0.1.0-dev.15"
4
4
  }
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knights-eye-chess/ai-analysis",
3
- "version": "0.1.0-dev.17",
3
+ "version": "0.1.0-dev.21",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -33,6 +33,10 @@
33
33
  "./shared": {
34
34
  "types": "./dist/types/shared-exports.d.ts",
35
35
  "import": "./dist/shared.js"
36
+ },
37
+ "./engine": {
38
+ "types": "./dist/types/chess-reasoning/engine-analysis-provider.d.ts",
39
+ "import": "./dist/engine.js"
36
40
  }
37
41
  },
38
42
  "scripts": {
@@ -51,7 +55,7 @@
51
55
  "dependencies": {
52
56
  "@ai-sdk/provider": "4.0.9",
53
57
  "@ai-sdk/provider-utils": "5.0.34",
54
- "@knights-eye-chess/move-classifier": "0.1.0-dev.13",
58
+ "@knights-eye-chess/move-classifier": "0.1.0-dev.15",
55
59
  "@types/json-schema": "7.0.15",
56
60
  "@types/node": "22.19.19",
57
61
  "ai": "7.0.85",
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  export function assertReleaseTooling(root = fileURLToPath(new URL('../', import.meta.url))) {
8
8
  const manifest = JSON.parse(readFileSync(path.join(root, 'scripts/release-tooling.json'), 'utf8'));
9
9
  if (manifest.schema !== 1 || manifest.canonical !== 'knightseye-net/tools/release-tooling') throw Error('Invalid release tooling stamp');
10
- const required = ['scripts/pack.mjs', 'scripts/publish-reviewed.mjs', '.github/workflows/npm-publish.yml', 'scripts/verify-release.mjs', 'scripts/update-dependency.mjs', 'scripts/check-release-tooling.mjs', '.github/workflows/quality.yml'];
10
+ const required = ['scripts/check-review-marker.mjs', 'scripts/pack.mjs', 'scripts/publish-reviewed.mjs', '.github/workflows/npm-publish.yml', 'scripts/verify-release.mjs', 'scripts/update-dependency.mjs', 'scripts/check-release-tooling.mjs', '.github/workflows/quality.yml'];
11
11
  if (!manifest.sha256 || required.some(relative => !/^[a-f0-9]{64}$/.test(manifest.sha256[relative] || ''))) throw Error('Incomplete release tooling stamp');
12
12
  for (const [relative, expected] of Object.entries(manifest.sha256)) {
13
13
  if (path.isAbsolute(relative) || relative.split('/').includes('..')) throw Error(`Invalid tooling path: ${relative}`);
@@ -0,0 +1,79 @@
1
+ // Canonical source: knightseye-net/tools/release-tooling/templates/check-review-marker.mjs.
2
+ import { execFileSync } from 'node:child_process';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+
6
+ const shaPattern = /^[a-f0-9]{40}$/;
7
+ export async function requireReview({ repository, sha, branch, reviewer, token, checkoutSha,
8
+ fetchImpl = fetch }) {
9
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository || '') || !shaPattern.test(sha || '') ||
10
+ checkoutSha !== sha || !['dev', 'production'].includes(branch) || reviewer !== 'Shedal' || !token) {
11
+ throw Error('Review gate requires repository, exact checkout SHA, release branch, Shedal and GitHub token');
12
+ }
13
+ const prefix = `/repos/${repository}`;
14
+ async function get(route) {
15
+ let response;
16
+ try {
17
+ response = await fetchImpl(`https://api.github.com${prefix}${route}`, {
18
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json',
19
+ 'X-GitHub-Api-Version': '2022-11-28' }, signal: AbortSignal.timeout(30000), redirect: 'error',
20
+ });
21
+ } catch { throw Error('Review evidence API unavailable'); }
22
+ if (!response.ok) throw Error(`Review evidence API failed: HTTP ${response.status}`);
23
+ try { return await response.json(); } catch { throw Error('Invalid review evidence JSON'); }
24
+ }
25
+ async function list(route) {
26
+ const all = [];
27
+ for (let page = 1; page <= 100; page++) {
28
+ const items = await get(`${route}?per_page=100&page=${page}`);
29
+ if (!Array.isArray(items)) throw Error('Invalid review evidence list');
30
+ all.push(...items);
31
+ if (items.length < 100) return all;
32
+ }
33
+ throw Error('Review evidence pagination limit reached');
34
+ }
35
+ const associated = await list(`/commits/${sha}/pulls`);
36
+ const matches = [];
37
+ const seen = new Set();
38
+ for (const candidate of associated) {
39
+ if (!Number.isSafeInteger(candidate.number) || candidate.number <= 0) throw Error('Invalid associated PR');
40
+ if (seen.has(candidate.number)) continue;
41
+ seen.add(candidate.number);
42
+ // Resolve current PR metadata, not the commit-association response's snapshot.
43
+ const pr = await get(`/pulls/${candidate.number}`);
44
+ if (pr.number === candidate.number && pr.merged === true && pr.merged_at &&
45
+ pr.merge_commit_sha === sha && pr.base?.ref === branch &&
46
+ pr.base?.repo?.full_name === repository) matches.push(pr);
47
+ }
48
+ if (matches.length !== 1) throw Error('Expected one merged release PR for the exact checkout SHA');
49
+ const pr = matches[0];
50
+ const comments = await list(`/issues/${pr.number}/comments`);
51
+ const decisions = [];
52
+ for (const comment of comments) {
53
+ if (comment.user?.login !== reviewer || typeof comment.body !== 'string') continue;
54
+ // Standalone line permits Claude's normal generated footer after the decision.
55
+ const markers = comment.body.split(/\r?\n/).filter(line =>
56
+ new RegExp(`^claude-review: (approved|rejected) ${sha}$`).test(line));
57
+ if (!markers.length) continue;
58
+ const created = Date.parse(comment.created_at), updated = Date.parse(comment.updated_at);
59
+ if (!Number.isSafeInteger(comment.id) || !Number.isFinite(created) || !Number.isFinite(updated) || updated < created) {
60
+ throw Error('Invalid review decision metadata');
61
+ }
62
+ const edited = updated !== created;
63
+ decisions.push({ id: comment.id, time: updated, approved: !edited && markers.length === 1 &&
64
+ markers[0] === `claude-review: approved ${sha}` });
65
+ }
66
+ decisions.sort((a, b) => a.time - b.time || a.id - b.id);
67
+ const latest = decisions.at(-1);
68
+ if (!latest?.approved) throw Error('Exact commit has no current unedited approval; post a fresh review decision');
69
+ return { repository, sha, reviewer, pullRequest: pr.number,
70
+ reviewUrl: `https://github.com/${repository}/pull/${pr.number}#issuecomment-${latest.id}` };
71
+ }
72
+
73
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
74
+ const checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
75
+ const receipt = await requireReview({ repository: process.env.GITHUB_REPOSITORY,
76
+ sha: process.env.GITHUB_SHA, branch: process.env.REVIEW_RELEASE_BRANCH,
77
+ reviewer: process.env.REVIEWER_LOGIN, token: process.env.GH_TOKEN, checkoutSha });
78
+ console.log(JSON.stringify(receipt));
79
+ }
@@ -1,8 +1,9 @@
1
1
  // Generated from knightseye-net/tools/release-tooling; edit the canonical template and sync.
2
+ import { requireReview } from './check-review-marker.mjs';
2
3
  import { assertReleaseTooling } from './check-release-tooling.mjs';
3
4
  import { readFileSync } from 'node:fs';
4
5
  import { createHash } from 'node:crypto';
5
- import { spawnSync } from 'node:child_process';
6
+ import { spawnSync, execFileSync } from 'node:child_process';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import path from 'node:path';
8
9
 
@@ -81,5 +82,8 @@ export function publishReviewed({root, version, sha256, env = process.env, run =
81
82
  }
82
83
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
83
84
  assertReleaseTooling();
85
+ await requireReview({ repository: process.env.GITHUB_REPOSITORY, sha: process.env.GITHUB_SHA,
86
+ checkoutSha: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: fileURLToPath(new URL('../', import.meta.url)), encoding: 'utf8' }).trim(),
87
+ branch: 'dev', reviewer: 'Shedal', token: process.env.GH_TOKEN });
84
88
  console.log(JSON.stringify(publishReviewed({root: fileURLToPath(new URL('../', import.meta.url)), version: process.argv[2], sha256: process.argv[3]})));
85
89
  }
@@ -3,11 +3,12 @@
3
3
  "canonical": "knightseye-net/tools/release-tooling",
4
4
  "sha256": {
5
5
  "scripts/pack.mjs": "334aa4041e59b14fd2b072969f50beea64449136f7f9146d63d7277fc2e4808a",
6
- "scripts/publish-reviewed.mjs": "b91c7aa578bc34c490b42006038de4ee8b9c596a592aa079bacd836d02feb647",
7
- ".github/workflows/npm-publish.yml": "b25cec136db205d69a407284dae2bb66f3d56be13ed2a1367f0db167a232050d",
6
+ "scripts/check-review-marker.mjs": "1d06c9139a835e47f78eef1bff221d5ccdc57c10f0731796ca6f5f0e3ca49e7f",
7
+ "scripts/publish-reviewed.mjs": "e046dda0c47cb4f7b2d044e22d7c719d5f1fda02762bef4d013125da6258fbd3",
8
+ ".github/workflows/npm-publish.yml": "0bb481c699566136e271babf8bdb7860b545c0e789034c0e06f5c0556d84b250",
8
9
  "scripts/verify-release.mjs": "d0f9baead779e553385bb7c26f1238ad4e60a189a818d4db4f721a1e5f88dedc",
9
10
  "scripts/update-dependency.mjs": "9919f172be33973f124eabf56c1dc70e41e941e601783ef01aba1c47dd70db9d",
10
- "scripts/check-release-tooling.mjs": "29563619bdc3e86ef10dec1e983274e36138193f04d33f2455501b59a4016e0d",
11
+ "scripts/check-release-tooling.mjs": "667e9c096ef28cddaefb90be92601828e7268c39f8a285f6644d3784db6f5064",
11
12
  ".github/workflows/quality.yml": "a014920a9f89dc494a020bee5f69aec779cf58825f491690f9ed1ec089ed6fa0"
12
13
  }
13
14
  }
@@ -9,6 +9,8 @@ export {createPocToolHost} from './answer-tool-host';
9
9
  export type {AnswerToolHostOptions} from './answer-tool-host';
10
10
  export type {PositionAnalysisProvider,PositionAnalysisRequest,PositionAnalysisResult,PositionAnalysisLine,PositionAnalysisSegment,CompatiblePositionAnalysisProvider,LegacyPositionAnalysisProvider,LegacyPositionAnalysisResult,LegacyPositionAnalysisLine} from './position-analysis-contracts';
11
11
  export {toPositionAnalysis,toLegacyPositionAnalysis,toPositionAnalysisProvider,toLegacyPositionAnalysisProvider} from './position-analysis-contracts';
12
+ export {createEngineAnalysisProvider} from './engine-analysis-provider';
13
+ export type {AnalysisEngineClient} from './engine-analysis-provider';
12
14
  export {createProductionObservationProvider} from './production-observation-provider';
13
15
  export {validateReasoningPositionContext,reasoningContextHash,createContextEvidence,createQueryContract,DEFAULT_REASONING_RUN_CONTROLS,REASONING_POC_VERSION} from './poc-contracts';
14
16
  export type {ReasoningPositionContext,ReasoningToolSet,ObservationProvider,ReasoningEvidenceRecord} from './poc-contracts';
@@ -0,0 +1,62 @@
1
+ import type { PositionAnalysisProvider, PositionAnalysisResult } from './position-analysis-contracts';
2
+
3
+ /** Consumer-owned structural contract. No dependency on an engine implementation. */
4
+ export type AnalysisEngineClient = {
5
+ search(request: {
6
+ fen: string; limit: { depth: number }; multiPv: number; rootMoves: string[];
7
+ requireRootCoverage?: boolean; signal?: AbortSignal;
8
+ }): Promise<({
9
+ noMove?: false; bestmove: string; engineBuild?: string; weightsId?: string;
10
+ candidates: Array<{
11
+ pv: string[]; depth: number; nodes?: number | null; scoreCp: number | null; mate: number | null;
12
+ scoreProvenance?: { engineBuild: string; weightsId?: string };
13
+ }>;
14
+ } | {
15
+ noMove: true; bestmove: null; candidates: []; engineBuild: string; weightsId?: string;
16
+ }) & { diagnostics?: unknown }>;
17
+ };
18
+
19
+ /** Adapt a host-owned depth-search client without acquiring or closing it.
20
+ * Short PVs remain short; evidence admission stays with the existing consumers.
21
+ */
22
+ export function createEngineAnalysisProvider(client: AnalysisEngineClient): PositionAnalysisProvider {
23
+ return { async analyze(request): Promise<PositionAnalysisResult> {
24
+ request.signal?.throwIfAborted();
25
+ const result = await client.search({ fen: request.fen, limit: { depth: request.depth },
26
+ multiPv: request.multiPv, rootMoves: request.rootMoves,
27
+ ...(request.rootMoves.length ? { requireRootCoverage: true } : {}), signal: request.signal });
28
+ request.signal?.throwIfAborted();
29
+ const command = 'go depth ' + request.depth + (request.rootMoves.length ? ' searchmoves ' + request.rootMoves.join(' ') : '');
30
+ if (result.noMove === true) {
31
+ if (result.bestmove !== null || result.candidates.length !== 0 || !result.engineBuild) {
32
+ throw new Error('Invalid UCI no-move result.');
33
+ }
34
+ // A UCI no-move report is unscored; the host must check the legal board
35
+ // before interpreting it as checkmate, stalemate, or another terminal.
36
+ return { engine: result.engineBuild, engineBuild: result.engineBuild, command,
37
+ bestMove: null, cacheProvenance: 'engine', lines: [],
38
+ ...(result.diagnostics ? { diagnostics: result.diagnostics } : {}) };
39
+ }
40
+ const build = result.candidates[0]?.scoreProvenance?.engineBuild;
41
+ if (typeof build !== 'string' || !build) throw new Error('Engine returned no attributed build identity.');
42
+ if (!result.bestmove || result.bestmove !== result.candidates[0]?.pv[0] ||
43
+ (result.engineBuild && result.engineBuild !== build) ||
44
+ (result.weightsId && result.weightsId !== result.candidates[0]?.scoreProvenance?.weightsId)) {
45
+ throw new Error('Engine returned an incomplete or inconsistent best move.');
46
+ }
47
+ // A provider result represents one engine search, not a blend of engines.
48
+ if (result.candidates.some(line => line.scoreProvenance?.engineBuild !== build ||
49
+ line.scoreProvenance?.weightsId !== result.candidates[0]?.scoreProvenance?.weightsId)) {
50
+ throw new Error('Engine returned inconsistent build identity.');
51
+ }
52
+ return {
53
+ engine: build, engineBuild: build,
54
+ command, bestMove: result.bestmove,
55
+ cacheProvenance: 'engine',
56
+ ...(result.diagnostics ? { diagnostics: result.diagnostics } : {}),
57
+ lines: result.candidates.map(candidate => ({ rootMove: candidate.pv[0], pv: candidate.pv,
58
+ depth: candidate.depth, nodes: candidate.nodes ?? null, scoreCp: candidate.scoreCp,
59
+ mateInMoves: candidate.mate, scoreProvenance: candidate.scoreProvenance ? { ...candidate.scoreProvenance } : undefined })),
60
+ };
61
+ } };
62
+ }
@@ -52,7 +52,7 @@ function rejectionReason(context: ReasoningPositionContext, hash: string, witnes
52
52
  || witness.fen !== (witness.phase === 'before' ? context.fenBefore : context.fenAfter)) return 'root-mismatch';
53
53
  if (typeof witness.engineBuild !== 'string' || !witness.engineBuild || typeof witness.command !== 'string' || !witness.command
54
54
  || !positiveInteger(witness.requestedDepth) || !positiveInteger(witness.multiPv) || witness.multiPv > 32
55
- || !Array.isArray(witness.lines) || witness.lines.length !== witness.multiPv
55
+ || !Array.isArray(witness.lines) || witness.lines.length < 1 || witness.lines.length > witness.multiPv
56
56
  || !Array.isArray(witness.rootMoves) || witness.rootMoves.length > 32
57
57
  || witness.rootMoves.some(root => typeof root !== 'string' || !UCI.test(root))
58
58
  || new Set(witness.rootMoves).size !== witness.rootMoves.length) return 'invalid-search-scope';
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert/strict';
2
+ import {test} from 'node:test';
3
+ import {createEngineAnalysisProvider} from '../../src/chess-reasoning/engine-analysis-provider.ts';
4
+ const request={fen:'root',depth:18,multiPv:3,rootMoves:[],minimumPvPlies:10};
5
+ const line=(move,mate=null)=>({pv:[move],depth:18,nodes:42,scoreCp:mate===null?21:null,mate,
6
+ scoreProvenance:{kind:'root-search',engineBuild:'Stockfish test',fen:'root',originatingDepth:18,reportedDepth:18}});
7
+ test('maps one completed search without padding fewer lines or short PVs',async()=>{
8
+ let calls=0,received;
9
+ const candidates=[line('g7g8',-3),line('g7f7')];
10
+ const diagnostics={processReused:true};
11
+ const provider=createEngineAnalysisProvider({async search(input){calls++;received=input;return{bestmove:'g7g8',candidates,diagnostics};}});
12
+ const result=await provider.analyze(request);
13
+ assert.equal(calls,1);assert.deepEqual(received,{fen:'root',limit:{depth:18},multiPv:3,rootMoves:[],signal:undefined});
14
+ assert.equal(result.lines.length,2);assert.deepEqual(result.lines[0].pv,['g7g8']);
15
+ assert.equal(result.lines[0].mateInMoves,-3);assert.equal(result.lines[0].scoreCp,null);
16
+ assert.equal(result.lines[0].nodes,42);assert.deepEqual(result.lines[0].scoreProvenance,candidates[0].scoreProvenance);
17
+ assert.equal(result.engineBuild,'Stockfish test');assert.equal(result.cacheProvenance,'engine');assert.equal(result.diagnostics,diagnostics);
18
+ });
19
+ test('preserves restricted roots and cancellation instead of changing search scope',async()=>{
20
+ const controller=new AbortController();let received;
21
+ const provider=createEngineAnalysisProvider({async search(input){received=input;return{bestmove:'g7g8',candidates:[line('g7g8')]};}});
22
+ const result=await provider.analyze({...request,multiPv:1,rootMoves:['g7g8'],signal:controller.signal});
23
+ assert.equal(received.signal,controller.signal);assert.deepEqual(received.rootMoves,['g7g8']);
24
+ assert.equal(received.requireRootCoverage,true);
25
+ assert.equal(result.command,'go depth 18 searchmoves g7g8');
26
+ controller.abort();await assert.rejects(provider.analyze({...request,signal:controller.signal}),{name:'AbortError'});
27
+ });
28
+ test('explicit comparison requires every requested root from the engine client',async()=>{
29
+ const required=['g7g8','g7f7'];let seen;
30
+ const client={async search(input){
31
+ seen=input;
32
+ const candidates=[line('g7g8')];
33
+ if(input.requireRootCoverage && input.rootMoves.some(root=>!candidates.some(candidate=>candidate.pv[0]===root)))
34
+ throw new Error('UCI engine did not complete requested score and root coverage.');
35
+ return {bestmove:'g7g8',candidates};
36
+ }};
37
+ const provider=createEngineAnalysisProvider(client);
38
+ await assert.rejects(provider.analyze({...request,multiPv:2,rootMoves:required}),/root coverage/);
39
+ assert.deepEqual(seen.rootMoves,required);assert.equal(seen.requireRootCoverage,true);
40
+ const unrestricted=await provider.analyze({...request,multiPv:2,rootMoves:[]});
41
+ assert.equal(seen.requireRootCoverage,undefined);assert.equal(unrestricted.lines.length,1);
42
+ });
43
+ test('does not turn a cancelled or failed engine search into evidence',async()=>{
44
+ const controller=new AbortController();
45
+ const provider=createEngineAnalysisProvider({async search(){controller.abort();return{candidates:[line('g7g8')]};}});
46
+ await assert.rejects(provider.analyze({...request,signal:controller.signal}),{name:'AbortError'});
47
+ const failure=new Error('engine failed');
48
+ await assert.rejects(createEngineAnalysisProvider({async search(){throw failure;}}).analyze(request),error=>error===failure);
49
+ });
50
+ test('requires an attributed single engine result',async()=>{
51
+ for(const candidates of [[line('g7g8'),{...line('g7f7'),scoreProvenance:{...line('g7f7').scoreProvenance,weightsId:'different'}}],[{...line('g7g8'),scoreProvenance:undefined}],[line('g7g8'),{...line('g7f7'),scoreProvenance:{engineBuild:'other'}}]]){
52
+ await assert.rejects(createEngineAnalysisProvider({async search(){return{bestmove:'g7g8',candidates};}}).analyze(request),/identity/);
53
+ }
54
+ });
55
+ test('explicit no-move result carries engine identity without inventing a score',async()=>{
56
+ const provider=createEngineAnalysisProvider({async search(){return{noMove:true,bestmove:null,candidates:[],engineBuild:'Stockfish test'};}});
57
+ const result=await provider.analyze(request);
58
+ assert.equal(result.bestMove,null);assert.deepEqual(result.lines,[]);
59
+ assert.equal(result.engineBuild,'Stockfish test');
60
+ for(const malformed of [{noMove:true,bestmove:null,candidates:[],engineBuild:''},
61
+ {noMove:true,bestmove:'e2e4',candidates:[],engineBuild:'Stockfish test'},
62
+ {noMove:true,bestmove:null,candidates:[line('e2e4')],engineBuild:'Stockfish test'}]) {
63
+ await assert.rejects(createEngineAnalysisProvider({async search(){return malformed;}}).analyze(request),/no-move/);
64
+ }
65
+ });
66
+ test('scored search requires an explicit completed best move',async()=>{
67
+ for(const bestmove of [undefined,'d2d4']) {
68
+ await assert.rejects(createEngineAnalysisProvider({async search(){return{bestmove,candidates:[line('e2e4')]};}}).analyze(request),/best move/);
69
+ }
70
+ });
@@ -24,3 +24,31 @@ test('fresh answer witnesses preserve shipped v22 reference geometry and reject
24
24
  assert.equal(rejected.referenceGapCounts.unresolvedReferences,1);
25
25
  assert.equal(rejected.blocks[0].tokens[0].reference,undefined);
26
26
  });
27
+
28
+
29
+ test('completed unrestricted MultiPV witnesses accept fewer lines without changing requested scope', () => {
30
+ const fen='7r/p1p1p1kp/2p2rpQ/3q4/3P4/2P5/P1P2PP1/R3K2R b KQ - 5 18';
31
+ const context={kind:'position',nodeId:'two-legal',historyStartFen:fen,historyUci:[],moveUci:'',moveSan:'',fenBefore:fen,fenAfter:fen};
32
+ const roots=['g7g8','g7f7'];
33
+ assert.deepEqual(new Set(new Chess(fen).moves({verbose:true}).map(m=>m.from+m.to)),new Set(roots));
34
+ const witness={version:'fresh-engine-pv-witness/1',acquisition:'same-answer-engine',acquisitionId:1,
35
+ evidenceId:'server-engine-acquisition:two-legal',contextHash:reasoningContextHash(context),phase:'before',fen,
36
+ engineBuild:'test-engine',command:'go depth 18',requestedDepth:18,multiPv:3,rootMoves:[],
37
+ authority:'bounded-engine',licenses:['engine.bounded-continuation'],
38
+ lines:roots.map((rootMove,index)=>({rootMove,pv:[rootMove],depth:18,scoreCp:-200-index,mateInMoves:null,
39
+ scoreProvenance:{kind:'root-search',fen,engineBuild:'test-engine',command:'go depth 18',originatingDepth:18,reportedDepth:18}}))};
40
+ const admitted=admitFreshEnginePvWitnesses(context,[witness]);
41
+ assert.equal(admitted.audit.acceptedLines,2);
42
+ assert.equal(witness.multiPv,3,'requested MultiPV remains provenance, not returned-line count');
43
+ assert.equal(admitFreshEnginePvWitnesses(context,[{...witness,lines:[witness.lines[0]]}]).audit.acceptedLines,1);
44
+ for (const patch of [{lines:[]},{multiPv:1},{rootMoves:roots,lines:[witness.lines[0]]}]) {
45
+ assert.equal(admitFreshEnginePvWitnesses(context,[{...witness,...patch}]).audit.acceptedLines,0);
46
+ }
47
+ for (const change of [line=>{line.depth=17;},line=>{line.scoreCp=null;},line=>{line.pv=['g7g6'];}]) {
48
+ const invalid=structuredClone(witness);change(invalid.lines[1]);
49
+ assert.equal(admitFreshEnginePvWitnesses(context,[invalid]).audit.acceptedLines,0);
50
+ }
51
+ const rendered=simpleProseReferences('[Kg8](move:before:g7g8)',context,[],undefined,undefined,undefined,{freshEnginePvWitnesses:[witness]});
52
+ assert.equal(rendered.referenceGapCounts.unresolvedReferences,0);
53
+ assert.equal(rendered.freshEnginePvAdmission.acceptedLines,2);
54
+ });
package/test-types.mts CHANGED
@@ -21,3 +21,8 @@ const oldAsCurrent:PositionAnalysisResult=old;
21
21
  const badMate:PositionAnalysisResult={...modern,lines:[{...modern.lines[0],mateInMoves:'3'}]};
22
22
  void runAnswerPipeline({connector,requestId:'parallel',question:'x',selected:selection,bridge:history,analyzer,preparationAnalyzer:analyzer,parallelPreparation:true,observationProvider:createProductionObservationProvider({context:selection.context}),prepareRouting:async()=>({question:'x',selectionKey:'x',deliveryId:'x',elapsedMs:0,omitSelectedContext:false,decision:{version:QUESTION_ROUTING_VERSION,status:'fallback',omitSelectedContext:false,includeGameRecord:false,reviewGame:false,reason:'explicit'}})});
23
23
  void analyzer;void oldAsCurrent;void badMate;
24
+
25
+ import {createEngineAnalysisProvider,type AnalysisEngineClient} from '@knights-eye-chess/ai-analysis/engine';
26
+ const engineClient:AnalysisEngineClient={async search(request){return{bestmove:'e2e4',candidates:[{pv:['e2e4'],depth:request.limit.depth,scoreCp:20,mate:null,scoreProvenance:{engineBuild:'test'}}]};}};
27
+ const engineProvider:PositionAnalysisProvider=createEngineAnalysisProvider(engineClient);
28
+ void engineProvider;