@knights-eye-chess/ai-analysis 0.1.0-dev.18 → 0.1.0-dev.24

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.24` candidate with Move Classifier `0.1.0-dev.16-board-offers.3`.
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.24.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(', '));
@@ -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
+ };
@@ -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-TYQ5WIL2.js";
20
+ } from "./chunk-V6NM6RB2.js";
21
21
  import {
22
22
  DEFAULT_REASONING_RUN_CONTROLS,
23
23
  EVIDENCE_SHARING_VERSION,
@@ -72,6 +72,9 @@ import {
72
72
  validateReasoningPositionContext,
73
73
  validateSimpleAgentConfig
74
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
+ });
package/dist/research.js CHANGED
@@ -211,7 +211,7 @@ import {
211
211
  wikibooksTargetsForTool,
212
212
  withBranchPreparation,
213
213
  withRecordedPositionTools
214
- } from "./chunk-TYQ5WIL2.js";
214
+ } from "./chunk-V6NM6RB2.js";
215
215
  import {
216
216
  CHESS_REASONING_POLICY_VERSION,
217
217
  CHESS_REASONING_SCHEMA_VERSION,
@@ -317,6 +317,9 @@ import {
317
317
  validateSimpleAgentConfig,
318
318
  withEvidenceLicenses
319
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": 969134
103
+ "runtimeBytes": 971929
103
104
  }
@@ -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
@@ -89,3 +89,27 @@ An unrestricted completed search can return one through that many valid lines;
89
89
  the original requested count remains in the witness even when fewer lines arrive.
90
90
  Explicit `rootMoves` searches must still cover their entire requested root set.
91
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.14"
3
+ "@knights-eye-chess/move-classifier": "0.1.0-dev.16-board-offers.3"
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.18",
3
+ "version": "0.1.0-dev.24",
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.14",
58
+ "@knights-eye-chess/move-classifier": "0.1.0-dev.16-board-offers.3",
55
59
  "@types/json-schema": "7.0.15",
56
60
  "@types/node": "22.19.19",
57
61
  "ai": "7.0.85",
@@ -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
+ }
@@ -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
+ });
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;
File without changes