ruvnet-brain 4.3.33 → 4.3.35
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/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +7 -3
- package/scripts/approved-runtime.mjs +247 -26
- package/scripts/corpus-next-seed.mjs +191 -70
- package/scripts/corpus-reconcile.mjs +110 -16
- package/scripts/oracle/retrieval-accuracy.mjs +74 -6
- package/scripts/rehearse-corpus-pipeline.mjs +315 -25
- package/scripts/rehearse-seed-selection.mjs +234 -0
- package/scripts/release.mjs +13 -6
- package/scripts/single-source-check.mjs +15 -2
|
@@ -11,27 +11,48 @@
|
|
|
11
11
|
// unsigned or half-uploaded release — it falls back to the committed data/corpus-seed.json, which is
|
|
12
12
|
// the bootstrap/recovery input and is content-addressed independently.
|
|
13
13
|
//
|
|
14
|
-
// "Compatible"
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// "Compatible" means THIS RUNTIME CAN CONSUME THE SEED (ADR-0091 D4). It is NOT "the generation
|
|
15
|
+
// shipped the same runtime version as the approved pin": that rule rejected every generation the
|
|
16
|
+
// moment a code release moved the approved runtime, so the seed chain reset to the weeks-stale
|
|
17
|
+
// bootstrap on every release (ADR-0091 section 3.4). The runtime's executables never come from a
|
|
18
|
+
// seed anyway -- build-bundle.mjs copies runtime modules from the checkout and only named store
|
|
19
|
+
// files from the seed, and verifyApprovedRuntime's backward check still refuses any unpinned
|
|
20
|
+
// executable in the candidate. So --pin is gone from this script entirely. What CAN make a seed
|
|
21
|
+
// unusable is judged here, from small files, BEFORE the ~500 MB archive is ever fetched:
|
|
22
|
+
// 1. the receipt (schema 3, as before) records every store's embedding model and dimensions, and
|
|
23
|
+
// they must equal what this runtime's forge build produces (kb/forge-corpus.mjs
|
|
24
|
+
// FORGE_BUILD_FINGERPRINT) -- vectors from another model are not searchable by this one;
|
|
25
|
+
// 2. the generation's detached .recall.json must be the one its receipt binds, must have been
|
|
26
|
+
// measured against THIS runtime's frozen fixture digest, and must pass THIS runtime's
|
|
27
|
+
// readRecallReport -- the same reader and arguments corpus-seed.yml's downstream seed
|
|
28
|
+
// re-check uses, so that re-check (which has no fallback) only ever sees a generation that
|
|
29
|
+
// already passed here.
|
|
30
|
+
// "This runtime" is --runtime-root: the source tree whose readers will consume the seed. On the
|
|
31
|
+
// nightly that is the approved runtime's own sourceSha (ADR-0091 D3), which usually trails main.
|
|
32
|
+
// It defaults to this checkout, which is right whenever the checkout IS the build source.
|
|
33
|
+
//
|
|
34
|
+
// A generation that fails any check is SKIPPED with its reason, never a failure: the walk moves to
|
|
35
|
+
// the next older one. At most SEARCH_BOUND (5) generations are judged, so one bad format change can
|
|
36
|
+
// never turn into an unbounded walk of release history; after that the committed bootstrap is used.
|
|
17
37
|
//
|
|
18
38
|
// "Verified" is decided by evidence that is checkable without downloading 500 MB here: the tag must
|
|
19
39
|
// be the content-addressed corpus-sha256-<digest> form, the release must not be a draft, and it must
|
|
20
|
-
// carry
|
|
21
|
-
// own archive digest equal to the digest in the tag. The full
|
|
22
|
-
// downstream where the archive is actually fetched (corpus-seed.yml
|
|
23
|
-
// before reconciliation,
|
|
40
|
+
// carry exactly one of each of ruvnet-brain.zip, its detached .sig, corpus-receipt.json and both
|
|
41
|
+
// detached reports, with the receipt's own archive digest equal to the digest in the tag. The full
|
|
42
|
+
// byte-level proof still happens downstream where the archive is actually fetched (corpus-seed.yml
|
|
43
|
+
// re-checks sha256 and byte length before reconciliation, corpus-reconcile.mjs checks the ledger
|
|
44
|
+
// schema after extraction, and corpus-candidate.mjs re-derives the whole candidate from the bytes).
|
|
24
45
|
//
|
|
25
46
|
// Usage:
|
|
26
|
-
// node scripts/corpus-next-seed.mjs --repo owner/name [--
|
|
47
|
+
// node scripts/corpus-next-seed.mjs --repo owner/name [--runtime-root <source tree>]
|
|
27
48
|
// [--bootstrap data/corpus-seed.json] [--out <file>]
|
|
28
49
|
|
|
50
|
+
import crypto from 'node:crypto';
|
|
29
51
|
import fs from 'node:fs';
|
|
30
52
|
import os from 'node:os';
|
|
31
53
|
import path from 'node:path';
|
|
32
54
|
import { spawnSync } from 'node:child_process';
|
|
33
55
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
-
import { readApprovedRuntime, validateApprovedRuntime } from './approved-runtime.mjs';
|
|
35
56
|
|
|
36
57
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
37
58
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
@@ -39,6 +60,13 @@ const CORPUS_TAG = /^corpus-sha256-([0-9a-f]{64})$/;
|
|
|
39
60
|
const ARCHIVE_ASSET = 'ruvnet-brain.zip';
|
|
40
61
|
const SIGNATURE_ASSET = 'ruvnet-brain.zip.sig';
|
|
41
62
|
const RECEIPT_ASSET = 'corpus-receipt.json';
|
|
63
|
+
const RECALL_ASSET = 'ruvnet-brain.zip.recall.json';
|
|
64
|
+
const ACCURACY_ASSET = 'ruvnet-brain.zip.accuracy.json';
|
|
65
|
+
// Every asset corpus-seed.yml requires of a digest-derived seed, plus the two judged here. A release
|
|
66
|
+
// missing one would pass this resolver and then fail the downstream step that has no fallback.
|
|
67
|
+
const REQUIRED_ASSETS = [ARCHIVE_ASSET, SIGNATURE_ASSET, RECEIPT_ASSET, RECALL_ASSET, ACCURACY_ASSET];
|
|
68
|
+
/** ADR-0091 D4: judge at most this many of the newest generations, then use the committed bootstrap. */
|
|
69
|
+
export const SEARCH_BOUND = 5;
|
|
42
70
|
|
|
43
71
|
const defaultRun = (command, args, options) => spawnSync(command, args, { encoding: 'utf8', ...options });
|
|
44
72
|
|
|
@@ -61,69 +89,132 @@ export function validateBootstrapSeed(seed) {
|
|
|
61
89
|
return failures;
|
|
62
90
|
}
|
|
63
91
|
|
|
92
|
+
/** "forge-corpus-v1|Xenova/bge-base-en-v1.5@<rev>:768:cls" -> { model, dimensions }. */
|
|
93
|
+
export function parseBuildFingerprint(fingerprint) {
|
|
94
|
+
const match = /\|([^@|]+)@[^:|]+:(\d+):[^|]*$/.exec(String(fingerprint || ''));
|
|
95
|
+
if (!match) throw new Error(`cannot read the embedding model from FORGE_BUILD_FINGERPRINT (${fingerprint})`);
|
|
96
|
+
return { model: match[1], dimensions: Number(match[2]) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What the consuming runtime expects of a seed, read from THAT runtime's own source tree -- never
|
|
101
|
+
* restated here. A tree that cannot answer is a code defect, so this throws (a loud red night)
|
|
102
|
+
* rather than quietly seeding from the bootstrap every night, which would look exactly like "no new
|
|
103
|
+
* generation yet".
|
|
104
|
+
*/
|
|
105
|
+
export async function loadCompatibilityProfile({ runtimeRoot = ROOT, fixtureFile = null } = {}) {
|
|
106
|
+
const root = path.resolve(runtimeRoot);
|
|
107
|
+
const load = (relative) => import(pathToFileURL(path.join(root, relative)).href);
|
|
108
|
+
const [recall, forge] = await Promise.all([load('scripts/oracle/repo-recall.mjs'), load('kb/forge-corpus.mjs')]);
|
|
109
|
+
if (typeof recall.readRecallReport !== 'function' || typeof recall.loadFixture !== 'function') {
|
|
110
|
+
throw new Error(`${root} has no repo-recall reader to judge a seed with`);
|
|
111
|
+
}
|
|
112
|
+
const { model, dimensions } = parseBuildFingerprint(forge.FORGE_BUILD_FINGERPRINT);
|
|
113
|
+
return {
|
|
114
|
+
runtimeRoot: root,
|
|
115
|
+
model,
|
|
116
|
+
dimensions,
|
|
117
|
+
fixtureSha256: recall.loadFixture(fixtureFile || path.join(root, recall.DEFAULT_FIXTURE_FILE)).fixtureSha256,
|
|
118
|
+
floorValue: recall.ABSOLUTE_FLOOR,
|
|
119
|
+
readRecallReport: recall.readRecallReport,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function download(run, { repo, tag, pattern, dir }) {
|
|
124
|
+
const result = run('gh', ['release', 'download', tag, '--repo', repo, '--pattern', pattern, '--dir', dir],
|
|
125
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
126
|
+
const file = path.join(dir, pattern);
|
|
127
|
+
return !result.error && result.status === 0 && fs.existsSync(file) ? file : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Small files only (a receipt, a recall report): read whole.
|
|
131
|
+
const sha256Of = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
132
|
+
|
|
64
133
|
/**
|
|
65
134
|
* One published release, judged. Returns null when it cannot serve as a seed, with the reason
|
|
66
|
-
* recorded on `rejected` so a no-op night is explainable rather than silent.
|
|
135
|
+
* recorded on `rejected` so a no-op night is explainable rather than silent. Never downloads the
|
|
136
|
+
* archive: every check below reads the release's asset list, its receipt, or its recall report.
|
|
67
137
|
*/
|
|
68
|
-
function judgeRelease({ run, repo, tag, digest,
|
|
138
|
+
function judgeRelease({ run, repo, tag, digest, profile, rejected }) {
|
|
139
|
+
const reject = (reason) => { rejected.push({ tag, reason }); return null; };
|
|
69
140
|
let view;
|
|
70
141
|
try {
|
|
71
142
|
view = ghJson(run, ['release', 'view', tag, '--repo', repo, '--json', 'tagName,isDraft,assets']);
|
|
72
143
|
} catch (error) {
|
|
73
|
-
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
if (view?.tagName !== tag || view.isDraft) {
|
|
77
|
-
rejected.push({ tag, reason: 'release is a draft or names another tag' });
|
|
78
|
-
return null;
|
|
144
|
+
return reject(`release view failed (${error.message})`);
|
|
79
145
|
}
|
|
146
|
+
if (view?.tagName !== tag || view.isDraft) return reject('release is a draft or names another tag');
|
|
80
147
|
const assets = Array.isArray(view.assets) ? view.assets : [];
|
|
81
148
|
const named = (name) => assets.filter((asset) => asset?.name === name);
|
|
82
|
-
for (const name of
|
|
83
|
-
if (named(name).length !== 1) {
|
|
84
|
-
rejected.push({ tag, reason: `unverified: expected exactly one ${name} asset` });
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
149
|
+
for (const name of REQUIRED_ASSETS) {
|
|
150
|
+
if (named(name).length !== 1) return reject(`unverified: expected exactly one ${name} asset`);
|
|
87
151
|
}
|
|
88
152
|
const archive = named(ARCHIVE_ASSET)[0];
|
|
89
|
-
if (!Number.isSafeInteger(archive.size) || archive.size < 1)
|
|
90
|
-
rejected.push({ tag, reason: 'unverified: archive asset has no usable byte length' });
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
153
|
+
if (!Number.isSafeInteger(archive.size) || archive.size < 1) return reject('unverified: archive asset has no usable byte length');
|
|
93
154
|
|
|
94
155
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-next-seed-'));
|
|
95
156
|
try {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
if (download.error || download.status !== 0) {
|
|
99
|
-
rejected.push({ tag, reason: 'unverified: corpus receipt could not be downloaded' });
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
157
|
+
const receiptFile = download(run, { repo, tag, pattern: RECEIPT_ASSET, dir: scratch });
|
|
158
|
+
if (!receiptFile) return reject('unverified: corpus receipt could not be downloaded');
|
|
102
159
|
let receipt;
|
|
103
|
-
try { receipt = JSON.parse(fs.readFileSync(
|
|
104
|
-
catch (error) {
|
|
160
|
+
try { receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8')); }
|
|
161
|
+
catch (error) { return reject(`unverified: corpus receipt unreadable (${error.message})`); }
|
|
105
162
|
|
|
106
163
|
// ADR-086 Step 15 / A6 moved the corpus receipt to schemaVersion 3 (it now binds the detached
|
|
107
164
|
// retrieval-accuracy report). This reader has to move with it: left at 2 it would reject every
|
|
108
165
|
// schema-3 generation as unverified and silently fall back to the committed bootstrap seed every
|
|
109
166
|
// night — a degradation that looks exactly like "no new generation yet".
|
|
110
167
|
if (receipt.schemaVersion !== 3 || receipt.kind !== 'ruvnet-brain-corpus-candidate') {
|
|
111
|
-
|
|
112
|
-
return null;
|
|
168
|
+
return reject('unverified: receipt schema or kind is not a schema-3 corpus candidate');
|
|
113
169
|
}
|
|
114
|
-
if (!receipt.accuracyReport?.file ||
|
|
170
|
+
if (!receipt.accuracyReport?.file || !HEX64.test(String(receipt.accuracyReport.sha256 || ''))
|
|
115
171
|
|| !Number.isSafeInteger(receipt.accuracyReport.bytes)) {
|
|
116
|
-
|
|
117
|
-
return null;
|
|
172
|
+
return reject('unverified: receipt carries no retrieval-accuracy binding');
|
|
118
173
|
}
|
|
119
174
|
if (receipt.archive?.sha256 !== digest || receipt.archive?.bytes !== archive.size) {
|
|
120
|
-
|
|
121
|
-
|
|
175
|
+
return reject('unverified: receipt archive identity disagrees with the content-addressed tag');
|
|
176
|
+
}
|
|
177
|
+
if (receipt.recallReport?.file !== RECALL_ASSET || !HEX64.test(String(receipt.recallReport?.sha256 || ''))
|
|
178
|
+
|| !Number.isSafeInteger(receipt.recallReport?.bytes)) {
|
|
179
|
+
return reject('unverified: receipt carries no repo-recall binding');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// D4 check 1 -- embedding model and dimensions, per store, from the receipt alone.
|
|
183
|
+
const stores = Array.isArray(receipt.stores) ? receipt.stores : [];
|
|
184
|
+
if (!stores.length) return reject('unverified: receipt lists no stores');
|
|
185
|
+
const foreign = stores.filter((row) => row?.model !== profile.model || row?.dimensions !== profile.dimensions);
|
|
186
|
+
if (foreign.length) {
|
|
187
|
+
const first = foreign[0];
|
|
188
|
+
return reject(`incompatible: ${foreign.length} store(s) embedded with a different model/dimensions `
|
|
189
|
+
+ `(e.g. ${first?.name}: ${first?.model}/${first?.dimensions}); this runtime builds ${profile.model}/${profile.dimensions}`);
|
|
122
190
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
191
|
+
|
|
192
|
+
// D4 check 2 -- the recall report, the one small file that decides whether the downstream seed
|
|
193
|
+
// re-check can pass. Downloaded only now, after the receipt already qualified.
|
|
194
|
+
const recallFile = download(run, { repo, tag, pattern: RECALL_ASSET, dir: scratch });
|
|
195
|
+
if (!recallFile) return reject('unverified: repo-recall report could not be downloaded');
|
|
196
|
+
if (sha256Of(recallFile) !== receipt.recallReport.sha256 || fs.statSync(recallFile).size !== receipt.recallReport.bytes) {
|
|
197
|
+
return reject('unverified: repo-recall report is not the one the receipt binds');
|
|
198
|
+
}
|
|
199
|
+
let claimedFixture = null;
|
|
200
|
+
try { claimedFixture = JSON.parse(fs.readFileSync(recallFile, 'utf8'))?.fixture?.sha256 ?? null; } catch { /* the reader names it below */ }
|
|
201
|
+
if (claimedFixture !== profile.fixtureSha256) {
|
|
202
|
+
return reject(`incompatible: recall report was measured against fixture ${String(claimedFixture).slice(0, 12)}, `
|
|
203
|
+
+ `this runtime's frozen fixture is ${profile.fixtureSha256.slice(0, 12)}`);
|
|
126
204
|
}
|
|
205
|
+
try {
|
|
206
|
+
profile.readRecallReport({
|
|
207
|
+
reportFile: recallFile,
|
|
208
|
+
archive: { file: ARCHIVE_ASSET, sha256: digest, bytes: archive.size },
|
|
209
|
+
expectedFixtureSha256: profile.fixtureSha256,
|
|
210
|
+
// The same bar corpus-seed.yml's seed re-check uses: a published seed is graded against the
|
|
211
|
+
// fixed ABSOLUTE_FLOOR, never re-judged by a later, higher committed floor.
|
|
212
|
+
floorValue: profile.floorValue,
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return reject(`incompatible: recall report fails this runtime's reader (${error.message})`);
|
|
216
|
+
}
|
|
217
|
+
|
|
127
218
|
return {
|
|
128
219
|
origin: 'published-generation',
|
|
129
220
|
tag,
|
|
@@ -131,27 +222,29 @@ function judgeRelease({ run, repo, tag, digest, approved, rejected }) {
|
|
|
131
222
|
sha256: digest,
|
|
132
223
|
bytes: archive.size,
|
|
133
224
|
sourceCommit: typeof receipt.builderSourceSha === 'string' ? receipt.builderSourceSha : null,
|
|
134
|
-
|
|
225
|
+
// Informational only since D4: the runtime that BUILT the seed, which may be older than the
|
|
226
|
+
// runtime about to consume it. Nothing gates on it.
|
|
227
|
+
brainVersion: receipt.archiveManifestVersion ?? null,
|
|
135
228
|
};
|
|
136
229
|
} finally {
|
|
137
230
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
138
231
|
}
|
|
139
232
|
}
|
|
140
233
|
|
|
141
|
-
export function resolveNextCorpusSeed({
|
|
142
|
-
repo, run = defaultRun, root = ROOT,
|
|
234
|
+
export async function resolveNextCorpusSeed({
|
|
235
|
+
repo, run = defaultRun, root = ROOT, runtimeRoot = null, fixtureFile = null, bootstrapFile,
|
|
236
|
+
limit = 100, searchBound = SEARCH_BOUND, profile = null,
|
|
143
237
|
} = {}) {
|
|
144
238
|
if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('--repo must be owner/name');
|
|
145
|
-
|
|
146
|
-
const approved = readApprovedRuntime(pinFile || path.join(root, 'data/approved-runtime.json'));
|
|
147
|
-
const pinFailures = validateApprovedRuntime(approved);
|
|
148
|
-
if (pinFailures.length) throw new Error(`approved runtime pin is invalid: ${pinFailures.join('; ')}`);
|
|
239
|
+
if (!Number.isSafeInteger(searchBound) || searchBound < 1) throw new Error('search bound must be a positive integer');
|
|
149
240
|
|
|
150
241
|
const bootstrapPath = path.resolve(bootstrapFile || path.join(root, 'data/corpus-seed.json'));
|
|
151
242
|
const bootstrap = JSON.parse(fs.readFileSync(bootstrapPath, 'utf8'));
|
|
152
243
|
const bootstrapFailures = validateBootstrapSeed(bootstrap);
|
|
153
244
|
if (bootstrapFailures.length) throw new Error(`committed bootstrap seed is invalid: ${bootstrapFailures.join('; ')}`);
|
|
154
245
|
|
|
246
|
+
const compatibility = profile || await loadCompatibilityProfile({ runtimeRoot: runtimeRoot || root, fixtureFile });
|
|
247
|
+
|
|
155
248
|
const rejected = [];
|
|
156
249
|
let listed = [];
|
|
157
250
|
try {
|
|
@@ -174,9 +267,14 @@ export function resolveNextCorpusSeed({
|
|
|
174
267
|
}
|
|
175
268
|
candidates.sort((left, right) => right.createdAt - left.createdAt);
|
|
176
269
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
270
|
+
const judged = candidates.slice(0, searchBound);
|
|
271
|
+
for (const skipped of candidates.slice(searchBound)) {
|
|
272
|
+
rejected.push({ tag: skipped.tag, reason: `not judged: older than the ${searchBound} newest generations (ADR-0091 D4 search bound)` });
|
|
273
|
+
}
|
|
274
|
+
const expects = { model: compatibility.model, dimensions: compatibility.dimensions, fixtureSha256: compatibility.fixtureSha256 };
|
|
275
|
+
for (const candidate of judged) {
|
|
276
|
+
const resolved = judgeRelease({ run, repo, tag: candidate.tag, digest: candidate.digest, profile: compatibility, rejected });
|
|
277
|
+
if (resolved) return { seed: resolved, rejected, judged: judged.length, expects };
|
|
180
278
|
}
|
|
181
279
|
|
|
182
280
|
return {
|
|
@@ -190,36 +288,59 @@ export function resolveNextCorpusSeed({
|
|
|
190
288
|
brainVersion: null,
|
|
191
289
|
},
|
|
192
290
|
rejected,
|
|
193
|
-
|
|
291
|
+
judged: judged.length,
|
|
292
|
+
expects,
|
|
194
293
|
};
|
|
195
294
|
}
|
|
196
295
|
|
|
197
|
-
const arg = (name, fallback) => {
|
|
198
|
-
const index =
|
|
199
|
-
return index >= 0 &&
|
|
296
|
+
const arg = (argv, name, fallback) => {
|
|
297
|
+
const index = argv.indexOf(name);
|
|
298
|
+
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
200
299
|
};
|
|
201
300
|
|
|
202
|
-
function main() {
|
|
301
|
+
export async function main(argv = process.argv.slice(2), { run = defaultRun, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
302
|
+
// Loud, not ignored: a caller still passing --pin is on the pre-D4 contract and should be told.
|
|
303
|
+
if (argv.includes('--pin')) {
|
|
304
|
+
stderr.write('[corpus-next-seed] --pin was removed by ADR-0091 D4: seed selection no longer depends on the approved runtime pin\n');
|
|
305
|
+
return 2;
|
|
306
|
+
}
|
|
203
307
|
let result;
|
|
204
308
|
try {
|
|
205
|
-
result = resolveNextCorpusSeed({
|
|
206
|
-
repo: arg('--repo', process.env.GITHUB_REPOSITORY),
|
|
207
|
-
|
|
208
|
-
|
|
309
|
+
result = await resolveNextCorpusSeed({
|
|
310
|
+
repo: arg(argv, '--repo', process.env.GITHUB_REPOSITORY),
|
|
311
|
+
runtimeRoot: arg(argv, '--runtime-root', null),
|
|
312
|
+
fixtureFile: arg(argv, '--fixture', null),
|
|
313
|
+
bootstrapFile: arg(argv, '--bootstrap', null),
|
|
314
|
+
run,
|
|
209
315
|
});
|
|
210
316
|
} catch (error) {
|
|
211
|
-
|
|
317
|
+
stderr.write(`[corpus-next-seed] ${error.message}\n`);
|
|
212
318
|
return 1;
|
|
213
319
|
}
|
|
214
|
-
for (const row of result.rejected)
|
|
215
|
-
const out = arg('--out');
|
|
320
|
+
for (const row of result.rejected) stderr.write(`[corpus-next-seed] skipped ${row.tag || '(list)'}: ${row.reason}\n`);
|
|
321
|
+
const out = arg(argv, '--out', null);
|
|
216
322
|
const serialized = `${JSON.stringify(result.seed, null, 2)}\n`;
|
|
217
323
|
if (out) fs.writeFileSync(path.resolve(out), serialized);
|
|
218
|
-
|
|
219
|
-
|
|
324
|
+
stdout.write(serialized);
|
|
325
|
+
stderr.write(`[corpus-next-seed] ${result.seed.origin} ${result.seed.tag} (judged ${result.judged} generation(s) against `
|
|
326
|
+
+ `${result.expects.model}/${result.expects.dimensions}, fixture ${result.expects.fixtureSha256.slice(0, 12)})\n`);
|
|
220
327
|
return 0;
|
|
221
328
|
}
|
|
222
329
|
|
|
223
|
-
|
|
224
|
-
|
|
330
|
+
// REALPATH BOTH SIDES (ADR-0091 D12.2, applied here because this file is rewritten anyway): a plain
|
|
331
|
+
// argv[1]-vs-import.meta.url comparison is false under a symlinked path such as macOS's /var/folders
|
|
332
|
+
// temp directories, and the CLI then exits 0 having done nothing.
|
|
333
|
+
function isMain() {
|
|
334
|
+
try {
|
|
335
|
+
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (isMain()) {
|
|
342
|
+
main().then((code) => { process.exitCode = code; }).catch((error) => {
|
|
343
|
+
console.error(`[corpus-next-seed] ${error?.message || error}`);
|
|
344
|
+
process.exitCode = 1;
|
|
345
|
+
});
|
|
225
346
|
}
|
|
@@ -20,7 +20,7 @@ import { fileIdentity } from '../plugin/scripts/coverage-integrity.mjs';
|
|
|
20
20
|
import { readDiagnosticAccuracyReport } from './oracle/retrieval-accuracy.mjs';
|
|
21
21
|
import { storeRoot } from '../kb/store-root.mjs';
|
|
22
22
|
import { captureGistSources } from './gist-receipts.mjs';
|
|
23
|
-
import { projectSourceStore } from './rvf-generation.mjs';
|
|
23
|
+
import { projectSourceStore, RUNTIME_LEDGER_KIND } from './rvf-generation.mjs';
|
|
24
24
|
|
|
25
25
|
export { rebuildCorpusAggregates };
|
|
26
26
|
|
|
@@ -119,6 +119,40 @@ function filesNamed(root, wanted) {
|
|
|
119
119
|
return found;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// ADR-0091 D4 -- the ONE seed property that cannot be judged before download. corpus-next-seed.mjs
|
|
123
|
+
// judges a published generation's embedding model and recall report from small files, but the
|
|
124
|
+
// generation ledger lives only inside the archive, so its schema is checked here, right after
|
|
125
|
+
// extraction and BEFORE anything is moved. A mismatch is not a corrupt seed: it is a seed this runtime
|
|
126
|
+
// cannot consume (a ledger-schema change is a code-release event). It is thrown as a distinct error
|
|
127
|
+
// and main() exits SEED_LEDGER_INCOMPATIBLE_EXIT with the assets directory untouched, so
|
|
128
|
+
// corpus-seed.yml can re-run seed extraction ONCE from the committed bootstrap in the same job.
|
|
129
|
+
export const SEED_LEDGER_SCHEMA_VERSION = 2;
|
|
130
|
+
export const SEED_LEDGER_INCOMPATIBLE_EXIT = 3;
|
|
131
|
+
|
|
132
|
+
export class SeedLedgerIncompatibleError extends Error {
|
|
133
|
+
constructor(reason) {
|
|
134
|
+
super(`[corpus-reconcile] seed ledger is incompatible with this runtime: ${reason}`);
|
|
135
|
+
this.name = 'SeedLedgerIncompatibleError';
|
|
136
|
+
this.reason = reason;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** null when this runtime can consume the ledger, else the reason it cannot. */
|
|
141
|
+
export function seedLedgerIncompatibility(ledger) {
|
|
142
|
+
if (!ledger || typeof ledger !== 'object' || Array.isArray(ledger)) return 'RVF-GENERATIONS.json is not an object';
|
|
143
|
+
if (ledger.schemaVersion !== SEED_LEDGER_SCHEMA_VERSION || ledger.kind !== RUNTIME_LEDGER_KIND) {
|
|
144
|
+
return `RVF-GENERATIONS.json is schemaVersion ${JSON.stringify(ledger.schemaVersion ?? null)} kind ${JSON.stringify(ledger.kind ?? null)}; `
|
|
145
|
+
+ `this runtime reads schemaVersion ${SEED_LEDGER_SCHEMA_VERSION} kind ${RUNTIME_LEDGER_KIND}`;
|
|
146
|
+
}
|
|
147
|
+
if (!ledger.stores || typeof ledger.stores !== 'object' || Array.isArray(ledger.stores)) return 'RVF-GENERATIONS.json has no stores object';
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Moves the corpus root's TOP-LEVEL entries only (directories such as keys/, primer/ and l2/ move
|
|
152
|
+
// whole). Nothing is filtered out: a seed's own runtime files (.mjs, package.json) are harmless here,
|
|
153
|
+
// because build-bundle.mjs copies only named store files and the sealed prose from --assets and takes
|
|
154
|
+
// every runtime module from the checkout (ADR-0091 D4 withdrew the 0.1.0 "strip" step for that reason,
|
|
155
|
+
// and because source-coverage.mjs hard-reads capability-cards.md from these assets).
|
|
122
156
|
export function normalizeExtractedCorpus({ extractedDir, assetsDir }) {
|
|
123
157
|
const extracted = path.resolve(extractedDir || '');
|
|
124
158
|
const assets = path.resolve(assetsDir || '');
|
|
@@ -128,6 +162,11 @@ export function normalizeExtractedCorpus({ extractedDir, assetsDir }) {
|
|
|
128
162
|
if (fs.existsSync(assets) && fs.readdirSync(assets).length) fail(`bootstrap assets directory is not empty (${assets})`);
|
|
129
163
|
const ledgers = filesNamed(extracted, 'RVF-GENERATIONS.json');
|
|
130
164
|
if (ledgers.length !== 1) fail(`seed archive must contain exactly one RVF-GENERATIONS.json; found ${ledgers.length}`);
|
|
165
|
+
let seedLedger;
|
|
166
|
+
try { seedLedger = JSON.parse(fs.readFileSync(ledgers[0], 'utf8')); }
|
|
167
|
+
catch (error) { throw new SeedLedgerIncompatibleError(`RVF-GENERATIONS.json is unreadable (${error.message})`); }
|
|
168
|
+
const incompatibility = seedLedgerIncompatibility(seedLedger);
|
|
169
|
+
if (incompatibility) throw new SeedLedgerIncompatibleError(incompatibility);
|
|
131
170
|
const corpusRoot = path.dirname(ledgers[0]);
|
|
132
171
|
// A published seed's own PRIVATE-STORES.json is AUTHENTICATED HISTORICAL EVIDENCE of what that
|
|
133
172
|
// prior round excluded — never the current builder's live policy. Keep it under a distinct name
|
|
@@ -306,6 +345,33 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
|
|
|
306
345
|
+ 'eligible source(s) remain unresolved against the sealed manifest');
|
|
307
346
|
}
|
|
308
347
|
|
|
348
|
+
/**
|
|
349
|
+
* The ONE reader of an acquisition result's per-attempt history (ADR-0091 D1).
|
|
350
|
+
*
|
|
351
|
+
* WHY THIS EXISTS. cd0f032f renamed the loop's history from `rounds` to `attempts` but left two
|
|
352
|
+
* independent readers behind: main() (`reconciliation.rounds.flatMap(...)`) and the local rehearsal
|
|
353
|
+
* (scripts/rehearse-corpus-pipeline.mjs). Both threw "Cannot read properties of undefined (reading
|
|
354
|
+
* 'flatMap')" AFTER the whole generation had been acquired, so every corpus-publish run died at its
|
|
355
|
+
* last line and the rehearsal meant to catch that died at the same place. Every reader now goes
|
|
356
|
+
* through here, and a result without an `attempts` array fails by name instead of by TypeError.
|
|
357
|
+
*/
|
|
358
|
+
export function summarizeReconciliation(reconciliation) {
|
|
359
|
+
const attempts = reconciliation?.attempts;
|
|
360
|
+
if (!Array.isArray(attempts)) {
|
|
361
|
+
fail(`reconciliation result has no attempts array (keys: ${Object.keys(reconciliation || {}).join(', ') || 'none'}); `
|
|
362
|
+
+ 'acquireSealedGeneration returns { observation, coverage, attempts, ... }');
|
|
363
|
+
}
|
|
364
|
+
const across = (field) => attempts.flatMap((attempt) => attempt?.[field] || []);
|
|
365
|
+
return {
|
|
366
|
+
attempts: attempts.length,
|
|
367
|
+
observationSha256: reconciliation.observation?.observationSha256 ?? null,
|
|
368
|
+
plan: across('plan'),
|
|
369
|
+
refreshed: across('refreshed'),
|
|
370
|
+
pruned: across('pruned'),
|
|
371
|
+
rebuilt: across('rebuilt'),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
309
375
|
function defaultRun(command, args, options = {}) {
|
|
310
376
|
return spawnSync(command, args, { encoding: 'utf8', ...options });
|
|
311
377
|
}
|
|
@@ -668,7 +734,8 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
|
|
|
668
734
|
owner = 'ruvnet', builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity = null, maxAttempts = 3,
|
|
669
735
|
reconcile = (options) => acquireCorpusGeneration(options),
|
|
670
736
|
normalizeUpdaters = normalizeUpdaterManifest,
|
|
671
|
-
accuracyOracleFile = null, accuracyStores = null, accuracySample = null,
|
|
737
|
+
accuracyOracleFile = null, accuracyStores = null, accuracySample = null, accuracySamplePerPartition = null,
|
|
738
|
+
accuracyTimeoutMs = null,
|
|
672
739
|
prepare = prepareCorpusCandidate } = {}) {
|
|
673
740
|
const finalized = await reconcile({ owner, assetsDir, workspaceDir, root, maxAttempts });
|
|
674
741
|
// Every shipped repository store needs a complete updater entry, and a seed that predates the
|
|
@@ -680,7 +747,7 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
|
|
|
680
747
|
const updaters = normalizeUpdaters({
|
|
681
748
|
assetsDir,
|
|
682
749
|
coverage: finalized.coverage,
|
|
683
|
-
refreshedStores: (finalized
|
|
750
|
+
refreshedStores: summarizeReconciliation(finalized).refreshed.map((r) => r?.store || r).filter(Boolean),
|
|
684
751
|
seedIdentity: bootstrapIdentity,
|
|
685
752
|
});
|
|
686
753
|
if (updaters.missing?.length) {
|
|
@@ -691,7 +758,7 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
|
|
|
691
758
|
const candidate = await prepare({
|
|
692
759
|
root, assetsDir, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
|
|
693
760
|
coverage: finalized.coverage,
|
|
694
|
-
accuracyOracleFile, accuracyStores, accuracySample, accuracyTimeoutMs,
|
|
761
|
+
accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
|
|
695
762
|
});
|
|
696
763
|
return { reconciliation: finalized, updaters, candidate };
|
|
697
764
|
}
|
|
@@ -707,7 +774,11 @@ export function prepareCorpusCandidate({
|
|
|
707
774
|
coverage,
|
|
708
775
|
accuracyOracleFile = null,
|
|
709
776
|
accuracyStores = null,
|
|
777
|
+
// ADR-0091 D2: a whole-oracle, deterministic question sample (retrieval-accuracy.mjs
|
|
778
|
+
// --sample-questions). `accuracySamplePerPartition` is the older first-k-per-partition bound; its
|
|
779
|
+
// floor is one question per partition (196 x 2 queries, ~27 min hosted), so it cannot meet D2.
|
|
710
780
|
accuracySample = null,
|
|
781
|
+
accuracySamplePerPartition = null,
|
|
711
782
|
accuracyTimeoutMs = null,
|
|
712
783
|
run = defaultRun,
|
|
713
784
|
}) {
|
|
@@ -770,9 +841,14 @@ export function prepareCorpusCandidate({
|
|
|
770
841
|
const bundleFile = path.join(path.dirname(candidate), `${path.basename(candidate)}.zip`);
|
|
771
842
|
// ADR-086 Step 15: the benchmark runs HERE — after single-pass assembly and before the seal —
|
|
772
843
|
// against the EXTRACTED final archive through the customer query path, never against `assets`.
|
|
773
|
-
// The report is written detached, beside the archive, and the
|
|
774
|
-
//
|
|
775
|
-
//
|
|
844
|
+
// The report is written detached, beside the archive, and the receipt below binds its digest.
|
|
845
|
+
// C3 is a non-blocking diagnostic (ADR-086 amendment 2026-09-15): every reader of this report
|
|
846
|
+
// (corpus-candidate.mjs, release.mjs, corpus-seed.yml) uses readDiagnosticAccuracyReport, which
|
|
847
|
+
// checks only its schema and its binding to this archive, oracle and generator -- never whether
|
|
848
|
+
// coverage is complete. So a bounded run (--stores/--sample/--sample-questions) seals exactly like
|
|
849
|
+
// a full one; it marks itself `coverage.complete: false`, and only the retained strict reader
|
|
850
|
+
// (validateAccuracyReport, the re-arm path) would refuse it. corpus-seed.yml runs a question
|
|
851
|
+
// sample (ADR-0091 D2) because the full run cost 82 minutes on a hosted runner.
|
|
776
852
|
const accuracyReportFile = `${bundleFile}.accuracy.json`;
|
|
777
853
|
// A stale leftover report from a prior run must never be mistaken for a fresh measurement of
|
|
778
854
|
// THIS bundle -- delete it before invoking the script so only a report the script just wrote
|
|
@@ -781,7 +857,8 @@ export function prepareCorpusCandidate({
|
|
|
781
857
|
const accuracyResult = run(process.execPath, [accuracyScript, '--bundle', bundleFile,
|
|
782
858
|
'--oracle', accuracyOracle, '--out', accuracyReportFile,
|
|
783
859
|
...(accuracyStores != null ? ['--stores', String(accuracyStores)] : []),
|
|
784
|
-
...(accuracySample != null ? ['--sample', String(accuracySample)] : []),
|
|
860
|
+
...(accuracySample != null ? ['--sample-questions', String(accuracySample)] : []),
|
|
861
|
+
...(accuracySamplePerPartition != null ? ['--sample', String(accuracySamplePerPartition)] : []),
|
|
785
862
|
...(accuracyTimeoutMs != null ? ['--timeout-ms', String(accuracyTimeoutMs)] : [])],
|
|
786
863
|
{ stdio: 'inherit' }) || {};
|
|
787
864
|
// C3 was demoted to a non-blocking diagnostic on 2026-09-15 (commit a20727b7, ADR-086
|
|
@@ -833,7 +910,10 @@ function arg(argv, name, fallback = null) {
|
|
|
833
910
|
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
834
911
|
}
|
|
835
912
|
|
|
836
|
-
|
|
913
|
+
// The two injectable seams exist so a test can drive main() end to end (ADR-0091 D1): nothing
|
|
914
|
+
// called main() before, which is how its last line stayed broken for weeks. Production passes neither.
|
|
915
|
+
export async function main(argv = process.argv.slice(2), {
|
|
916
|
+
reconcileAndPrepare = reconcileAndPrepareCorpusCandidate, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
837
917
|
const root = path.resolve(arg(argv, '--root', DEFAULT_ROOT));
|
|
838
918
|
const archiveFile = path.resolve(arg(argv, '--seed-archive', ''));
|
|
839
919
|
const seedTag = arg(argv, '--seed-tag');
|
|
@@ -846,10 +926,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
846
926
|
const builderSha = String(arg(argv, '--builder-sha', '')).toLowerCase();
|
|
847
927
|
const owner = arg(argv, '--owner', 'ruvnet');
|
|
848
928
|
const accuracyOracleFile = path.resolve(arg(argv, '--accuracy-oracle', path.join(root, 'data', 'retrieval-accuracy-oracle.json')));
|
|
849
|
-
// Bounded measurement is explicit and opt-in
|
|
850
|
-
//
|
|
929
|
+
// Bounded measurement is explicit and opt-in; omit every flag below for the full C3 audit.
|
|
930
|
+
// `--accuracy-sample <n>` measures n oracle questions in total (both query modes), chosen
|
|
931
|
+
// deterministically -- what corpus-seed.yml passes (ADR-0091 D2). A bounded report still seals,
|
|
932
|
+
// because C3 is a diagnostic and its readers check binding, not completeness.
|
|
851
933
|
const accuracyStores = arg(argv, '--accuracy-stores') ? Number(arg(argv, '--accuracy-stores')) : null;
|
|
852
934
|
const accuracySample = arg(argv, '--accuracy-sample') ? Number(arg(argv, '--accuracy-sample')) : null;
|
|
935
|
+
const accuracySamplePerPartition = arg(argv, '--accuracy-sample-per-partition')
|
|
936
|
+
? Number(arg(argv, '--accuracy-sample-per-partition')) : null;
|
|
853
937
|
const accuracyTimeoutMs = arg(argv, '--accuracy-timeout-ms') ? Number(arg(argv, '--accuracy-timeout-ms')) : null;
|
|
854
938
|
|
|
855
939
|
const bootstrap = assertBootstrapIdentity({ archiveFile, tag: seedTag, sha256: seedSha256, allowPinnedTag: process.argv.includes('--allow-pinned-seed-tag') });
|
|
@@ -857,19 +941,29 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
857
941
|
fs.mkdirSync(path.dirname(assetsDir), { recursive: true });
|
|
858
942
|
const extractParent = fs.mkdtempSync(path.join(path.dirname(assetsDir), '.corpus-seed-extract-'));
|
|
859
943
|
await extractZip(archiveFile, extractParent);
|
|
860
|
-
|
|
944
|
+
try {
|
|
945
|
+
normalizeExtractedCorpus({ extractedDir: extractParent, assetsDir });
|
|
946
|
+
} catch (error) {
|
|
947
|
+
if (!(error instanceof SeedLedgerIncompatibleError)) throw error;
|
|
948
|
+
// Nothing was moved; leave --assets exactly as absent/empty as it was so the one bootstrap retry
|
|
949
|
+
// in corpus-seed.yml can reuse the same path. A distinct exit code, never a generic failure.
|
|
950
|
+
fs.rmSync(extractParent, { recursive: true, force: true });
|
|
951
|
+
stderr.write(`${error.message}\n[corpus-reconcile] seed ${seedTag} cannot be consumed by this runtime; `
|
|
952
|
+
+ `exiting ${SEED_LEDGER_INCOMPATIBLE_EXIT} so the caller can fall back to the committed bootstrap seed\n`);
|
|
953
|
+
return SEED_LEDGER_INCOMPATIBLE_EXIT;
|
|
954
|
+
}
|
|
861
955
|
const privateFence = path.join(root, 'kb', 'PRIVATE-STORES.json');
|
|
862
956
|
if (!fs.existsSync(privateFence)) fail(`canonical private-store fence missing (${privateFence})`);
|
|
863
957
|
fs.copyFileSync(privateFence, path.join(assetsDir, 'PRIVATE-STORES.json'), fs.constants.COPYFILE_EXCL);
|
|
864
958
|
fs.rmSync(extractParent, { recursive: true, force: true });
|
|
865
959
|
syncCorpusInputs({ root, assetsDir });
|
|
866
960
|
const bootstrapIdentity = { tag: bootstrap.tag, sha256: bootstrap.sha256, privateFenceEvidence: seedPrivateFenceEvidence(assetsDir) };
|
|
867
|
-
const { reconciliation, candidate } = await
|
|
961
|
+
const { reconciliation, candidate } = await reconcileAndPrepare({
|
|
868
962
|
assetsDir, workspaceDir, root, owner, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
|
|
869
|
-
accuracyOracleFile, accuracyStores, accuracySample, accuracyTimeoutMs,
|
|
963
|
+
accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
|
|
870
964
|
});
|
|
871
|
-
const plan = reconciliation
|
|
872
|
-
|
|
965
|
+
const { plan } = summarizeReconciliation(reconciliation);
|
|
966
|
+
stdout.write(`${JSON.stringify({ ok: true, seedTag, seedSha256, plan, reconciliation, ...candidate }, null, 2)}\n`);
|
|
873
967
|
return 0;
|
|
874
968
|
}
|
|
875
969
|
|