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.
@@ -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" is decided against the SAME approved runtime pin that gates promotion
15
- // (scripts/approved-runtime.mjs): a generation whose archive shipped a different brainVersion is a
16
- // different runtime, and seeding from it would drag unapproved executables forward through reuse.
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 all three of ruvnet-brain.zip, its detached .sig, and corpus-receipt.json, with the receipt's
21
- // own archive digest equal to the digest in the tag. The full byte-level proof still happens
22
- // downstream where the archive is actually fetched (corpus-seed.yml re-checks sha256 and byte length
23
- // before reconciliation, and corpus-candidate.mjs re-derives the whole candidate from the bytes).
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 [--pin data/approved-runtime.json]
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, approved, rejected }) {
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
- rejected.push({ tag, reason: `release view failed (${error.message})` });
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 [ARCHIVE_ASSET, SIGNATURE_ASSET, RECEIPT_ASSET]) {
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 download = run('gh', ['release', 'download', tag, '--repo', repo, '--pattern', RECEIPT_ASSET, '--dir', scratch],
97
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
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(path.join(scratch, RECEIPT_ASSET), 'utf8')); }
104
- catch (error) { rejected.push({ tag, reason: `unverified: corpus receipt unreadable (${error.message})` }); return null; }
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
- rejected.push({ tag, reason: 'unverified: receipt schema or kind is not a schema-3 corpus candidate' });
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 || !/^[a-f0-9]{64}$/.test(String(receipt.accuracyReport.sha256 || ''))
170
+ if (!receipt.accuracyReport?.file || !HEX64.test(String(receipt.accuracyReport.sha256 || ''))
115
171
  || !Number.isSafeInteger(receipt.accuracyReport.bytes)) {
116
- rejected.push({ tag, reason: 'unverified: receipt carries no retrieval-accuracy binding' });
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
- rejected.push({ tag, reason: 'unverified: receipt archive identity disagrees with the content-addressed tag' });
121
- return null;
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
- if (receipt.archiveManifestVersion !== approved.brainVersion || receipt.archiveManifestReleaseTag !== approved.releaseTag) {
124
- rejected.push({ tag, reason: `incompatible: generation shipped runtime ${receipt.archiveManifestReleaseTag}, approved runtime is ${approved.releaseTag}` });
125
- return null;
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
- brainVersion: receipt.archiveManifestVersion,
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, pinFile, bootstrapFile, limit = 100,
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
- for (const candidate of candidates) {
178
- const resolved = judgeRelease({ run, repo, tag: candidate.tag, digest: candidate.digest, approved, rejected });
179
- if (resolved) return { seed: resolved, rejected, approvedRuntime: approved.releaseTag };
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
- approvedRuntime: approved.releaseTag,
291
+ judged: judged.length,
292
+ expects,
194
293
  };
195
294
  }
196
295
 
197
- const arg = (name, fallback) => {
198
- const index = process.argv.indexOf(name);
199
- return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
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
- pinFile: arg('--pin'),
208
- bootstrapFile: arg('--bootstrap'),
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
- console.error(`[corpus-next-seed] ${error.message}`);
317
+ stderr.write(`[corpus-next-seed] ${error.message}\n`);
212
318
  return 1;
213
319
  }
214
- for (const row of result.rejected) console.error(`[corpus-next-seed] skipped ${row.tag || '(list)'}: ${row.reason}`);
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
- process.stdout.write(serialized);
219
- console.error(`[corpus-next-seed] ${result.seed.origin} ${result.seed.tag} (approved runtime ${result.approvedRuntime})`);
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
- if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
224
- process.exitCode = main();
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, accuracyTimeoutMs = 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.attempts || []).flatMap((a) => (a.refreshed || []).map((r) => r?.store || r)).filter(Boolean),
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 seal below binds its digest. A
774
- // bounded run (--stores/--sample) still writes a report, but it marks itself incomplete and the
775
- // seal refuses it, so a bounded measurement can never be presented as a corpus-wide pass.
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
- export async function main(argv = process.argv.slice(2)) {
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. It never yields a sealable candidate — the seal
850
- // refuses an incomplete report — so these flags exist for measuring, not for shipping.
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
- normalizeExtractedCorpus({ extractedDir: extractParent, assetsDir });
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 reconcileAndPrepareCorpusCandidate({
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.rounds.flatMap((round) => round.plan);
872
- process.stdout.write(`${JSON.stringify({ ok: true, seedTag, seedSha256, plan, reconciliation, ...candidate }, null, 2)}\n`);
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