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

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.
@@ -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";
@@ -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 };
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-TYQ5WIL2.js";
21
21
  import {
22
22
  DEFAULT_REASONING_RUN_CONTROLS,
23
23
  EVIDENCE_SHARING_VERSION,
@@ -71,7 +71,7 @@ import {
71
71
  toPositionAnalysisProvider,
72
72
  validateReasoningPositionContext,
73
73
  validateSimpleAgentConfig
74
- } from "./chunk-XQDZJOYD.js";
74
+ } from "./chunk-6OXAG5H3.js";
75
75
  export {
76
76
  DEFAULT_REASONING_RUN_CONTROLS,
77
77
  EVIDENCE_SHARING_VERSION,
@@ -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-TYQ5WIL2.js";
215
215
  import {
216
216
  CHESS_REASONING_POLICY_VERSION,
217
217
  CHESS_REASONING_SCHEMA_VERSION,
@@ -316,7 +316,7 @@ import {
316
316
  validateReasoningPositionContext,
317
317
  validateSimpleAgentConfig,
318
318
  withEvidenceLicenses
319
- } from "./chunk-XQDZJOYD.js";
319
+ } from "./chunk-6OXAG5H3.js";
320
320
 
321
321
  // src/chess-reasoning/ablation-direct-synthesis.ts
322
322
  import { SQUARES } from "chess.js";
@@ -99,5 +99,5 @@
99
99
  "@ai-sdk/provider",
100
100
  "@ai-sdk/provider-utils"
101
101
  ],
102
- "runtimeBytes": 969108
102
+ "runtimeBytes": 969134
103
103
  }
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) {
package/docs/API.md CHANGED
@@ -80,3 +80,12 @@ 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.
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.14"
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.18",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "@ai-sdk/provider": "4.0.9",
53
53
  "@ai-sdk/provider-utils": "5.0.34",
54
- "@knights-eye-chess/move-classifier": "0.1.0-dev.13",
54
+ "@knights-eye-chess/move-classifier": "0.1.0-dev.14",
55
55
  "@types/json-schema": "7.0.15",
56
56
  "@types/node": "22.19.19",
57
57
  "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
  }
@@ -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';
@@ -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
+ });