ruvnet-brain 4.3.34 → 4.3.36
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/plugin/scripts/coverage-integrity.mjs +58 -2
- package/scripts/approved-runtime.mjs +247 -26
- package/scripts/build-bundle.mjs +27 -1
- package/scripts/code-release-corpus.mjs +276 -0
- package/scripts/corpus-candidate.mjs +19 -3
- package/scripts/corpus-coverage-sidecar.mjs +202 -0
- package/scripts/corpus-currency.mjs +71 -0
- package/scripts/corpus-dispatch-decision.mjs +138 -0
- package/scripts/corpus-next-seed.mjs +250 -72
- package/scripts/corpus-reconcile.mjs +448 -75
- package/scripts/corpus-store-failure.mjs +82 -0
- package/scripts/corpus-watchdog.mjs +334 -0
- package/scripts/fixture-denominator.mjs +72 -0
- package/scripts/knowledge-input-digest.mjs +163 -0
- package/scripts/oracle/repo-recall.mjs +142 -21
- package/scripts/oracle/retrieval-accuracy.mjs +74 -6
- package/scripts/public-verification-inputs.mjs +18 -2
- package/scripts/rehearse-corpus-pipeline.mjs +651 -31
- package/scripts/rehearse-seed-selection.mjs +234 -0
- package/scripts/release.mjs +163 -18
- package/scripts/retrieval-canary.mjs +42 -20
- package/scripts/single-source-check.mjs +15 -2
- package/scripts/source-coverage.mjs +19 -3
|
@@ -16,11 +16,15 @@ import { buildCoverage, observeSourceUniverse, renderMarkdown } from './source-c
|
|
|
16
16
|
import { promoteArtifactSet } from '../kb/incremental-refresh.mjs';
|
|
17
17
|
import { rebuildCorpusAggregates } from './corpus-aggregates.mjs';
|
|
18
18
|
import { assertCapabilityOnlyStore, isCapabilityOnly, CAPABILITY_RETIRED_SUFFIXES } from '../kb/capability-only.mjs';
|
|
19
|
-
import { fileIdentity } from '../plugin/scripts/coverage-integrity.mjs';
|
|
19
|
+
import { eligibleRepositoryStanding, fileIdentity, validateCoverageLedger } from '../plugin/scripts/coverage-integrity.mjs';
|
|
20
|
+
import {
|
|
21
|
+
CORPUS_QA_FAILED_EXIT, FAILURE_CLASS, StoreWorkerError, degradedBound, degradedPublication, failureReason, isRetryable,
|
|
22
|
+
} from './corpus-store-failure.mjs';
|
|
20
23
|
import { readDiagnosticAccuracyReport } from './oracle/retrieval-accuracy.mjs';
|
|
21
24
|
import { storeRoot } from '../kb/store-root.mjs';
|
|
22
25
|
import { captureGistSources } from './gist-receipts.mjs';
|
|
23
|
-
import { projectSourceStore } from './rvf-generation.mjs';
|
|
26
|
+
import { projectSourceStore, RUNTIME_LEDGER_KIND } from './rvf-generation.mjs';
|
|
27
|
+
import { compareKnowledgeInputs, fromCoverage as knowledgeFromCoverage, fromSeed as knowledgeFromSeed } from './knowledge-input-digest.mjs';
|
|
24
28
|
|
|
25
29
|
export { rebuildCorpusAggregates };
|
|
26
30
|
|
|
@@ -119,6 +123,44 @@ function filesNamed(root, wanted) {
|
|
|
119
123
|
return found;
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
// ADR-0091 D4 -- the ONE seed property that cannot be judged before download. corpus-next-seed.mjs
|
|
127
|
+
// judges a published generation's embedding model and recall report from small files, but the
|
|
128
|
+
// generation ledger lives only inside the archive, so its schema is checked here, right after
|
|
129
|
+
// extraction and BEFORE anything is moved. A mismatch is not a corrupt seed: it is a seed this runtime
|
|
130
|
+
// cannot consume (a ledger-schema change is a code-release event). It is thrown as a distinct error
|
|
131
|
+
// and main() exits SEED_LEDGER_INCOMPATIBLE_EXIT with the assets directory untouched, so
|
|
132
|
+
// corpus-seed.yml can re-run seed extraction ONCE from the committed bootstrap in the same job.
|
|
133
|
+
export const SEED_LEDGER_SCHEMA_VERSION = 2;
|
|
134
|
+
export const SEED_LEDGER_INCOMPATIBLE_EXIT = 3;
|
|
135
|
+
// ADR-0091 D5 + D10: the generation was built and SEALED with carried/missing stores, but degraded
|
|
136
|
+
// publication is not yet allowed (no soaked tolerant-validator transition). It must not be published;
|
|
137
|
+
// the next night re-plans the carried stores automatically because their sourceCommit still differs.
|
|
138
|
+
export const DEGRADED_UNPUBLISHED_EXIT = 4;
|
|
139
|
+
|
|
140
|
+
export class SeedLedgerIncompatibleError extends Error {
|
|
141
|
+
constructor(reason) {
|
|
142
|
+
super(`[corpus-reconcile] seed ledger is incompatible with this runtime: ${reason}`);
|
|
143
|
+
this.name = 'SeedLedgerIncompatibleError';
|
|
144
|
+
this.reason = reason;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** null when this runtime can consume the ledger, else the reason it cannot. */
|
|
149
|
+
export function seedLedgerIncompatibility(ledger) {
|
|
150
|
+
if (!ledger || typeof ledger !== 'object' || Array.isArray(ledger)) return 'RVF-GENERATIONS.json is not an object';
|
|
151
|
+
if (ledger.schemaVersion !== SEED_LEDGER_SCHEMA_VERSION || ledger.kind !== RUNTIME_LEDGER_KIND) {
|
|
152
|
+
return `RVF-GENERATIONS.json is schemaVersion ${JSON.stringify(ledger.schemaVersion ?? null)} kind ${JSON.stringify(ledger.kind ?? null)}; `
|
|
153
|
+
+ `this runtime reads schemaVersion ${SEED_LEDGER_SCHEMA_VERSION} kind ${RUNTIME_LEDGER_KIND}`;
|
|
154
|
+
}
|
|
155
|
+
if (!ledger.stores || typeof ledger.stores !== 'object' || Array.isArray(ledger.stores)) return 'RVF-GENERATIONS.json has no stores object';
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Moves the corpus root's TOP-LEVEL entries only (directories such as keys/, primer/ and l2/ move
|
|
160
|
+
// whole). Nothing is filtered out: a seed's own runtime files (.mjs, package.json) are harmless here,
|
|
161
|
+
// because build-bundle.mjs copies only named store files and the sealed prose from --assets and takes
|
|
162
|
+
// every runtime module from the checkout (ADR-0091 D4 withdrew the 0.1.0 "strip" step for that reason,
|
|
163
|
+
// and because source-coverage.mjs hard-reads capability-cards.md from these assets).
|
|
122
164
|
export function normalizeExtractedCorpus({ extractedDir, assetsDir }) {
|
|
123
165
|
const extracted = path.resolve(extractedDir || '');
|
|
124
166
|
const assets = path.resolve(assetsDir || '');
|
|
@@ -128,6 +170,11 @@ export function normalizeExtractedCorpus({ extractedDir, assetsDir }) {
|
|
|
128
170
|
if (fs.existsSync(assets) && fs.readdirSync(assets).length) fail(`bootstrap assets directory is not empty (${assets})`);
|
|
129
171
|
const ledgers = filesNamed(extracted, 'RVF-GENERATIONS.json');
|
|
130
172
|
if (ledgers.length !== 1) fail(`seed archive must contain exactly one RVF-GENERATIONS.json; found ${ledgers.length}`);
|
|
173
|
+
let seedLedger;
|
|
174
|
+
try { seedLedger = JSON.parse(fs.readFileSync(ledgers[0], 'utf8')); }
|
|
175
|
+
catch (error) { throw new SeedLedgerIncompatibleError(`RVF-GENERATIONS.json is unreadable (${error.message})`); }
|
|
176
|
+
const incompatibility = seedLedgerIncompatibility(seedLedger);
|
|
177
|
+
if (incompatibility) throw new SeedLedgerIncompatibleError(incompatibility);
|
|
131
178
|
const corpusRoot = path.dirname(ledgers[0]);
|
|
132
179
|
// A published seed's own PRIVATE-STORES.json is AUTHENTICATED HISTORICAL EVIDENCE of what that
|
|
133
180
|
// prior round excluded — never the current builder's live policy. Keep it under a distinct name
|
|
@@ -252,7 +299,7 @@ async function measureFreshness({ closingObservation, observation }) {
|
|
|
252
299
|
* generation; `latest` is never substituted, and an exhausted partial generation is never accepted.
|
|
253
300
|
*/
|
|
254
301
|
export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = null, observe, build,
|
|
255
|
-
readLedger: currentLedger, execute, prune, rebuild, preflight = null, closingObservation = null } = {}) {
|
|
302
|
+
readLedger: currentLedger, execute, prune, rebuild, preflight = null, closingObservation = null, unchanged = null } = {}) {
|
|
256
303
|
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10
|
|
257
304
|
|| [observe, build, currentLedger, execute, prune, rebuild].some((fn) => typeof fn !== 'function')) {
|
|
258
305
|
fail('bounded acquisition configuration is invalid');
|
|
@@ -260,15 +307,33 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
|
|
|
260
307
|
// ONE discovery pass. This observation is the sealed manifest every later step consumes; it is never
|
|
261
308
|
// re-taken, so upstream churn cannot restart or invalidate the generation.
|
|
262
309
|
const observation = await observe();
|
|
310
|
+
// NO-CHANGE, DECIDED BEFORE ANYTHING IS BUILT (2026-09-29 nightly redesign). When every knowledge
|
|
311
|
+
// input equals the seed's (scripts/knowledge-input-digest.mjs), the night ends here: no gist
|
|
312
|
+
// preflight, no clone, no embedding, no aggregate rebuild, nothing sealed or published.
|
|
313
|
+
if (typeof unchanged === 'function') {
|
|
314
|
+
const knowledgeInput = await unchanged(observation);
|
|
315
|
+
if (knowledgeInput?.unchanged === true) {
|
|
316
|
+
return { noChange: true, observation, attempts: [], consistencyModel: CONSISTENCY_MODEL, knowledgeInput };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
263
319
|
// Validate/fetch the source most likely to fail late (gist detail/raw access) before any expensive
|
|
264
320
|
// repository clone and embedding work. Its verified bodies are the existing capture cache consumed
|
|
265
321
|
// by the later aggregate build, so preflight does not double-fetch or weaken source binding.
|
|
266
322
|
const preflightResult = typeof preflight === 'function' ? await preflight(observation) : null;
|
|
267
323
|
const attempts = [];
|
|
324
|
+
// ADR-0091 D5: stores whose refresh FAILED this generation, keyed by folded store name, with what
|
|
325
|
+
// they became -- { carry } | { failure } | { integrity }. They are never re-executed by a later
|
|
326
|
+
// attempt of this loop and never count as remaining/unresolved: before D5 one stuck store cost all
|
|
327
|
+
// three attempts (each re-running a ~22-minute aggregate rebuild) and then failed the night anyway.
|
|
328
|
+
// The loop now re-attempts only for what it was built for: a gist revision that moved mid-fetch.
|
|
329
|
+
const storeOutcomes = {};
|
|
330
|
+
const recorded = (store) => Object.hasOwn(storeOutcomes, String(store || '').toLowerCase());
|
|
268
331
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
269
|
-
const coverage = await build(observation);
|
|
270
|
-
const plan = planReconciliation({ coverage, ledger: currentLedger(), assetsDir });
|
|
332
|
+
const coverage = await build(observation, storeOutcomes);
|
|
333
|
+
const plan = planReconciliation({ coverage, ledger: currentLedger(), assetsDir }).filter((item) => !recorded(item.store));
|
|
271
334
|
const reconciliation = await execute(plan, attempt);
|
|
335
|
+
recordStoreOutcomes(storeOutcomes, reconciliation);
|
|
336
|
+
assertIsolatedFailures({ coverage, storeOutcomes });
|
|
272
337
|
const pruning = await prune(coverage, attempt);
|
|
273
338
|
let aggregates;
|
|
274
339
|
try {
|
|
@@ -288,14 +353,17 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
|
|
|
288
353
|
// row still pinned the PRE-rebuild ruv-gists digest and build-bundle refused the candidate with
|
|
289
354
|
// "coverage row gist:... was measured against different ruv-gists RVF bytes than this corpus
|
|
290
355
|
// carries", 56 minutes into an otherwise complete run.
|
|
291
|
-
const settled = await build(observation);
|
|
292
|
-
const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir })
|
|
293
|
-
|
|
356
|
+
const settled = await build(observation, storeOutcomes);
|
|
357
|
+
const remaining = planReconciliation({ coverage: settled, ledger: currentLedger(), assetsDir })
|
|
358
|
+
.filter((item) => !recorded(item.store));
|
|
359
|
+
const unresolved = settled.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT'
|
|
360
|
+
&& !(row.kind === 'repository' && recorded(row.artifact?.store)));
|
|
294
361
|
attempts.push({ attempt, plan, ...reconciliation, ...pruning, ...aggregates,
|
|
295
362
|
remainingArtifacts: remaining.length, unresolvedSources: unresolved.length });
|
|
296
363
|
if (!remaining.length && !unresolved.length) {
|
|
297
364
|
return {
|
|
298
365
|
observation, coverage: settled, attempts, consistencyModel: CONSISTENCY_MODEL,
|
|
366
|
+
degraded: degradedSummary(storeOutcomes),
|
|
299
367
|
freshness: await measureFreshness({ closingObservation, observation }),
|
|
300
368
|
};
|
|
301
369
|
}
|
|
@@ -306,6 +374,78 @@ export async function acquireSealedGeneration({ maxAttempts = 3, assetsDir = nul
|
|
|
306
374
|
+ 'eligible source(s) remain unresolved against the sealed manifest');
|
|
307
375
|
}
|
|
308
376
|
|
|
377
|
+
function recordStoreOutcomes(storeOutcomes, reconciliation) {
|
|
378
|
+
for (const { store, carry } of reconciliation?.carried || []) storeOutcomes[store.toLowerCase()] = { carry };
|
|
379
|
+
for (const { store, failure } of reconciliation?.missing || []) storeOutcomes[store.toLowerCase()] = { failure };
|
|
380
|
+
for (const { store, integrity } of reconciliation?.integrityFailures || []) storeOutcomes[store.toLowerCase()] = { integrity };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function degradedSummary(storeOutcomes) {
|
|
384
|
+
const entries = Object.entries(storeOutcomes || {}).sort(([a], [b]) => a.localeCompare(b));
|
|
385
|
+
return {
|
|
386
|
+
carried: entries.filter(([, outcome]) => outcome.carry).map(([store, outcome]) => ({ store, ...outcome.carry })),
|
|
387
|
+
missing: entries.filter(([, outcome]) => outcome.failure).map(([store, outcome]) => ({ store, ...outcome.failure })),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Fail the generation the moment isolation stops being the right answer, BEFORE pruning and the
|
|
393
|
+
* ~22-minute aggregate rebuild are spent on it:
|
|
394
|
+
* - any integrity failure (carried bytes that no longer match the seed ledger, or a same-commit
|
|
395
|
+
* rebuild that failed) -- such a row is FAILED, and FAILED is never shippable, so no bound can
|
|
396
|
+
* admit it;
|
|
397
|
+
* - more carried + missing stores than max(3, 5% of eligible) -- the failure is systemic, and
|
|
398
|
+
* publishing around it would hide a forge or network regression.
|
|
399
|
+
*/
|
|
400
|
+
export function assertIsolatedFailures({ coverage, storeOutcomes }) {
|
|
401
|
+
const outcomes = Object.entries(storeOutcomes || {});
|
|
402
|
+
const integrity = outcomes.filter(([, outcome]) => outcome.integrity);
|
|
403
|
+
if (integrity.length) {
|
|
404
|
+
fail(`integrity failure in ${integrity.length} store(s): `
|
|
405
|
+
+ `${integrity.map(([store, outcome]) => `${store} (${outcome.integrity})`).join('; ')} -- the generation fails`);
|
|
406
|
+
}
|
|
407
|
+
const eligible = (coverage?.rows || []).filter((row) => row.kind === 'repository' && row.disposition === 'eligible').length;
|
|
408
|
+
const isolated = outcomes.length;
|
|
409
|
+
const bound = degradedBound(eligible);
|
|
410
|
+
if (isolated > bound) {
|
|
411
|
+
fail(`systemic failure: ${isolated} of ${eligible} eligible store(s) failed to refresh `
|
|
412
|
+
+ `(${outcomes.map(([store]) => store).join(', ')}); the bound is max(3, 5% of eligible) = ${bound} -- the generation fails`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The ONE reader of an acquisition result's per-attempt history (ADR-0091 D1).
|
|
418
|
+
*
|
|
419
|
+
* WHY THIS EXISTS. cd0f032f renamed the loop's history from `rounds` to `attempts` but left two
|
|
420
|
+
* independent readers behind: main() (`reconciliation.rounds.flatMap(...)`) and the local rehearsal
|
|
421
|
+
* (scripts/rehearse-corpus-pipeline.mjs). Both threw "Cannot read properties of undefined (reading
|
|
422
|
+
* 'flatMap')" AFTER the whole generation had been acquired, so every corpus-publish run died at its
|
|
423
|
+
* last line and the rehearsal meant to catch that died at the same place. Every reader now goes
|
|
424
|
+
* through here, and a result without an `attempts` array fails by name instead of by TypeError.
|
|
425
|
+
*/
|
|
426
|
+
export function summarizeReconciliation(reconciliation) {
|
|
427
|
+
const attempts = reconciliation?.attempts;
|
|
428
|
+
if (!Array.isArray(attempts)) {
|
|
429
|
+
fail(`reconciliation result has no attempts array (keys: ${Object.keys(reconciliation || {}).join(', ') || 'none'}); `
|
|
430
|
+
+ 'acquireSealedGeneration returns { observation, coverage, attempts, ... }');
|
|
431
|
+
}
|
|
432
|
+
const across = (field) => attempts.flatMap((attempt) => attempt?.[field] || []);
|
|
433
|
+
return {
|
|
434
|
+
attempts: attempts.length,
|
|
435
|
+
observationSha256: reconciliation.observation?.observationSha256 ?? null,
|
|
436
|
+
plan: across('plan'),
|
|
437
|
+
refreshed: across('refreshed'),
|
|
438
|
+
pruned: across('pruned'),
|
|
439
|
+
rebuilt: across('rebuilt'),
|
|
440
|
+
// ADR-0091 D5: the stores this generation carries (STALE) or lacks (MISSING) after an isolated
|
|
441
|
+
// refresh failure. Empty lists on an all-CURRENT generation.
|
|
442
|
+
degraded: {
|
|
443
|
+
carried: reconciliation.degraded?.carried || [],
|
|
444
|
+
missing: reconciliation.degraded?.missing || [],
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
309
449
|
function defaultRun(command, args, options = {}) {
|
|
310
450
|
return spawnSync(command, args, { encoding: 'utf8', ...options });
|
|
311
451
|
}
|
|
@@ -319,7 +459,7 @@ function checked(run, command, args, options = {}) {
|
|
|
319
459
|
return result;
|
|
320
460
|
}
|
|
321
461
|
|
|
322
|
-
function defaultRunAsync(command, args, options = {}) {
|
|
462
|
+
export function defaultRunAsync(command, args, options = {}) {
|
|
323
463
|
return new Promise((resolve) => {
|
|
324
464
|
const inherited = options.stdio === 'inherit';
|
|
325
465
|
const child = spawn(command, args, { ...options, encoding: undefined,
|
|
@@ -335,15 +475,6 @@ function defaultRunAsync(command, args, options = {}) {
|
|
|
335
475
|
});
|
|
336
476
|
}
|
|
337
477
|
|
|
338
|
-
async function checkedAsync(run, command, args, options = {}) {
|
|
339
|
-
const result = await run(command, args, options) || {};
|
|
340
|
-
if (result.error || result.status !== 0) {
|
|
341
|
-
const detail = String(result.stderr || result.stdout || result.error?.message || `exit ${result.status}`).trim();
|
|
342
|
-
fail(`${command} ${args.join(' ')} failed${detail ? ` (${detail})` : ''}`);
|
|
343
|
-
}
|
|
344
|
-
return result;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
478
|
const storeArtifacts = (store) => STORE_ARTIFACT_SUFFIXES.map((suffix) => `${store}${suffix}`);
|
|
348
479
|
|
|
349
480
|
function writeJsonAtomic(file, value) {
|
|
@@ -439,6 +570,81 @@ function validateWorkerOutput({ output, item }) {
|
|
|
439
570
|
return { ...payload, receiptSha256: crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'), output };
|
|
440
571
|
}
|
|
441
572
|
|
|
573
|
+
// ADR-0091 D5: the commit date of the bytes a carried store keeps, when the PREVIOUS generation's
|
|
574
|
+
// sealed coverage proves it. A row whose observed upstream SHA IS the carried commit dates that exact
|
|
575
|
+
// commit; a row that was itself carried passes its own carriedCommittedAt on. Anything else -- no
|
|
576
|
+
// prior coverage (the bootstrap lineage), an invalid one, a different commit -- is null, never
|
|
577
|
+
// estimated (D7.1 reads this for `oldestCarried`).
|
|
578
|
+
export function readPriorCoverage(assetsDir) {
|
|
579
|
+
const file = path.join(path.resolve(assetsDir || ''), 'CORPUS-COVERAGE.json');
|
|
580
|
+
if (!fs.existsSync(file)) return null;
|
|
581
|
+
try {
|
|
582
|
+
const coverage = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
583
|
+
return coverage?.kind === 'ruvnet-brain-corpus-coverage' && validateCoverageLedger(coverage).valid ? coverage : null;
|
|
584
|
+
} catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function carriedCommittedAt({ priorCoverage, store, sourceCommit }) {
|
|
590
|
+
const folded = String(store || '').toLowerCase();
|
|
591
|
+
const commit = String(sourceCommit || '').toLowerCase();
|
|
592
|
+
const row = (priorCoverage?.rows || []).find((candidate) => candidate?.kind === 'repository'
|
|
593
|
+
&& String(candidate?.artifact?.store || '').toLowerCase() === folded);
|
|
594
|
+
const iso = (value) => (typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null);
|
|
595
|
+
if (!row || !HEX40.test(commit)) return null;
|
|
596
|
+
if (String(row.upstream?.sha || '').toLowerCase() === commit) return iso(row.upstream?.committedAt);
|
|
597
|
+
if (String(row.carry?.carriedSourceCommit || '').toLowerCase() === commit) return iso(row.carry?.carriedCommittedAt);
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ADR-0091 D5: what a store whose refresh FAILED becomes. planReconciliation's byte check never
|
|
602
|
+
// covers a store it plans (it runs only when sourceCommit already equals upstream), so the seed
|
|
603
|
+
// bytes are re-hashed HERE before they may stand in for the missed refresh. The ledger binds one file
|
|
604
|
+
// per store (the .big.rvf: file, bytes, sha256); the rest of the family must be present as regular
|
|
605
|
+
// files. The seed archive itself was digest-verified on download (assertBootstrapIdentity).
|
|
606
|
+
export function dispositionForFailedStore({ assetsDir, ledger, item, attempts, reason, priorCoverage = null }) {
|
|
607
|
+
const assets = path.resolve(assetsDir || '');
|
|
608
|
+
const generation = Object.entries(ledger?.stores || {})
|
|
609
|
+
.find(([name]) => name.toLowerCase() === item.store.toLowerCase())?.[1] || null;
|
|
610
|
+
if (!generation) return { store: item.store, failure: { reason, attempts } };
|
|
611
|
+
const carried = String(generation.sourceCommit || '').toLowerCase();
|
|
612
|
+
if (!HEX40.test(carried)) {
|
|
613
|
+
return { store: item.store, integrity: 'carried bytes have no exact 40-hex ledger sourceCommit to carry' };
|
|
614
|
+
}
|
|
615
|
+
if (carried === item.upstreamSha) {
|
|
616
|
+
// Planned for a policy or receipt reason at the SAME commit (a capability-only clean rebuild, or
|
|
617
|
+
// seed bytes that already failed the receipt check): the seed bytes are exactly what the rebuild
|
|
618
|
+
// was meant to replace, so they cannot stand in for it.
|
|
619
|
+
return { store: item.store, integrity: 'the refresh was a same-commit rebuild the seed bytes cannot stand in for' };
|
|
620
|
+
}
|
|
621
|
+
const regular = (name) => {
|
|
622
|
+
try { const stat = fs.lstatSync(path.join(assets, name)); return stat.isFile() && !stat.isSymbolicLink(); }
|
|
623
|
+
catch { return false; }
|
|
624
|
+
};
|
|
625
|
+
const rvf = `${item.store}.big.rvf`;
|
|
626
|
+
const bound = generation.file === rvf && regular(rvf)
|
|
627
|
+
&& generation.bytes === fs.statSync(path.join(assets, rvf)).size
|
|
628
|
+
&& generation.sha256 === sha256File(path.join(assets, rvf))
|
|
629
|
+
&& REQUIRED_STORE_ARTIFACT_SUFFIXES.every((suffix) => regular(`${item.store}${suffix}`));
|
|
630
|
+
if (!bound) return { store: item.store, integrity: 'carried bytes differ from the seed generation ledger' };
|
|
631
|
+
return { store: item.store, carry: {
|
|
632
|
+
reason,
|
|
633
|
+
carriedSourceCommit: carried,
|
|
634
|
+
missedUpstream: item.upstreamSha,
|
|
635
|
+
attempts,
|
|
636
|
+
carriedCommittedAt: carriedCommittedAt({ priorCoverage, store: item.store, sourceCommit: carried }),
|
|
637
|
+
} };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Only a TRANSIENT failure is retried, exactly once, in a fresh directory: `<store>-retry1`. The first
|
|
641
|
+
// attempt's directory is never reused -- a clone or a half-written worker output from the failed
|
|
642
|
+
// attempt must not be mistaken for the retry's own (the pre-D5 path collided).
|
|
643
|
+
export function workerRootFor(workspace, store, retry) {
|
|
644
|
+
return path.join(workspace, 'workers', retry === 0 ? store : `${store}-retry${retry}`);
|
|
645
|
+
}
|
|
646
|
+
export const MAX_TRANSIENT_RETRIES = 1;
|
|
647
|
+
|
|
442
648
|
export async function executeReconciliation({
|
|
443
649
|
plan,
|
|
444
650
|
assetsDir,
|
|
@@ -447,6 +653,8 @@ export async function executeReconciliation({
|
|
|
447
653
|
run = defaultRunAsync,
|
|
448
654
|
concurrency = 5,
|
|
449
655
|
signal,
|
|
656
|
+
priorCoverage = null,
|
|
657
|
+
log = (line) => console.log(line),
|
|
450
658
|
}) {
|
|
451
659
|
if (!Array.isArray(plan) || !Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 10) {
|
|
452
660
|
fail('reconciliation plan or worker concurrency is invalid');
|
|
@@ -466,50 +674,94 @@ export async function executeReconciliation({
|
|
|
466
674
|
const lowerStores = orderedPlan.map(({ store }) => store.toLowerCase());
|
|
467
675
|
if (new Set(lowerStores).size !== lowerStores.length) fail('reconciliation plan has duplicate or case-fold-colliding stores');
|
|
468
676
|
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
// those orphaned lanes could then surface as an unhandled rejection, or simply keep doing
|
|
475
|
-
// unnecessary work after the round was already lost. Now: the first failure aborts the shared
|
|
476
|
-
// signal, every lane observes it (both at its own loop-top and via the signal threaded into every
|
|
477
|
-
// child-process spawn below) and returns promptly, and `Promise.all` -- which no lane's promise
|
|
478
|
-
// ever rejects out of directly -- only resolves once every lane has actually stopped. Only then do
|
|
479
|
-
// we throw the FIRST real error (an aborted sibling's own error is discarded, never overwrites it).
|
|
480
|
-
// An externally supplied `signal` (a caller discarding this whole round) aborts the same
|
|
481
|
-
// controller, so both cancellation paths join through the one place.
|
|
677
|
+
// ADR-0091 D5: ONE store failing no longer aborts the round. Before D5 the first worker error
|
|
678
|
+
// aborted every sibling (2 of 9 corpus runs died that way: one deterministic `ruvector` QA miss
|
|
679
|
+
// threw away 93 other stores' refreshes). Now each store's failure is recorded and its lane moves
|
|
680
|
+
// on; the shared AbortController below exists ONLY for an externally supplied `signal` -- a caller
|
|
681
|
+
// discarding the whole round -- which still stops and joins every lane (required proof 4).
|
|
482
682
|
const controller = new AbortController();
|
|
483
683
|
if (signal) {
|
|
484
684
|
if (signal.aborted) controller.abort(signal.reason);
|
|
485
685
|
else signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
|
|
486
686
|
}
|
|
487
687
|
|
|
488
|
-
const
|
|
688
|
+
const stage = async (item, name, classify, command, args, options = {}) => {
|
|
689
|
+
const result = await run(command, args, { ...options, signal: controller.signal }) || {};
|
|
690
|
+
if (controller.signal.aborted) throw abortError(controller.signal);
|
|
691
|
+
if (result.error || result.status !== 0) {
|
|
692
|
+
const detail = String(result.stderr || result.stdout || result.error?.message || `exit ${result.status}`).trim().slice(0, 400);
|
|
693
|
+
throw new StoreWorkerError({ store: item.store, stage: name, failureClass: classify(result), detail });
|
|
694
|
+
}
|
|
695
|
+
return result;
|
|
696
|
+
};
|
|
697
|
+
const transient = () => FAILURE_CLASS.TRANSIENT;
|
|
698
|
+
// forge-refresh's exit status is the structured reason: CORPUS_QA_FAILED_EXIT means corpus-qa refused
|
|
699
|
+
// the candidate (deterministic, never retried); a spawn error is runner I/O (transient); any other
|
|
700
|
+
// non-zero exit is a build failure (not retried: a retry is a full re-embed with no reason to differ).
|
|
701
|
+
const forgeClass = (result) => (result.status === CORPUS_QA_FAILED_EXIT ? FAILURE_CLASS.QA
|
|
702
|
+
: result.error ? FAILURE_CLASS.TRANSIENT : FAILURE_CLASS.BUILD);
|
|
703
|
+
|
|
704
|
+
const worker = async (item, retry) => {
|
|
489
705
|
if (controller.signal.aborted) throw abortError(controller.signal);
|
|
490
706
|
if (!SAFE_STORE.test(item.store) || !HEX40.test(item.upstreamSha) || !repositorySlug(item.url)) {
|
|
491
|
-
|
|
707
|
+
throw new StoreWorkerError({ store: item?.store || item?.name || 'unknown store', stage: 'plan item',
|
|
708
|
+
failureClass: FAILURE_CLASS.INTEGRITY, detail: 'unsafe reconciliation item' });
|
|
709
|
+
}
|
|
710
|
+
const workerRoot = workerRootFor(workspace, item.store, retry);
|
|
711
|
+
if (fs.existsSync(workerRoot)) {
|
|
712
|
+
throw new StoreWorkerError({ store: item.store, stage: 'worker directory', failureClass: FAILURE_CLASS.INTEGRITY,
|
|
713
|
+
detail: 'a fresh worker directory already exists' });
|
|
492
714
|
}
|
|
493
|
-
const workerRoot = path.join(workspace, 'workers', item.store);
|
|
494
715
|
const cloneDir = path.join(workerRoot, 'clone');
|
|
495
716
|
const output = path.join(workerRoot, 'assets');
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
717
|
+
try {
|
|
718
|
+
fs.mkdirSync(workerRoot, { recursive: true });
|
|
719
|
+
seedWorkerAssets({ assets, output, store: item.store, ledger: canonicalLedger, source: canonicalSource });
|
|
720
|
+
} catch (error) {
|
|
721
|
+
// An errno (disk, file table) is runner I/O; our own refusal is an integrity failure.
|
|
722
|
+
throw new StoreWorkerError({ store: item.store, stage: 'worker seed copy',
|
|
723
|
+
failureClass: typeof error?.code === 'string' ? FAILURE_CLASS.TRANSIENT : FAILURE_CLASS.INTEGRITY, detail: error.message });
|
|
724
|
+
}
|
|
725
|
+
await stage(item, 'git clone', transient, 'git', ['clone', '--no-checkout', '--filter=blob:none', item.url, cloneDir]);
|
|
726
|
+
await stage(item, 'git fetch', transient, 'git', ['-C', cloneDir, 'fetch', '--depth=1', 'origin', item.upstreamSha]);
|
|
727
|
+
await stage(item, 'git checkout', transient, 'git', ['-C', cloneDir, 'checkout', '--detach', 'FETCH_HEAD']);
|
|
728
|
+
const head = await stage(item, 'git rev-parse', transient, 'git', ['-C', cloneDir, 'rev-parse', 'HEAD']);
|
|
502
729
|
if (String(head.stdout || '').trim().toLowerCase() !== item.upstreamSha) {
|
|
503
|
-
|
|
730
|
+
throw new StoreWorkerError({ store: item.store, stage: 'exact-sha checkout', failureClass: FAILURE_CLASS.INTEGRITY,
|
|
731
|
+
detail: 'fresh clone did not resolve the exact upstream SHA' });
|
|
504
732
|
}
|
|
505
|
-
await
|
|
733
|
+
await stage(item, 'forge-refresh', forgeClass, process.execPath, [forge, '--repo', cloneDir, '--out', output, '--name', item.store,
|
|
506
734
|
...(FULL_HINTS[item.store] ? ['--full', FULL_HINTS[item.store]] : []),
|
|
507
735
|
...(KEEP_DIRS[item.store] ? ['--keep', KEEP_DIRS[item.store]] : []),
|
|
508
|
-
], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' }
|
|
509
|
-
|
|
736
|
+
], { stdio: 'inherit', env: { ...process.env, RUVNET_BIG_SHARDS: '1' } });
|
|
737
|
+
try {
|
|
738
|
+
return validateWorkerOutput({ output, item });
|
|
739
|
+
} catch (error) {
|
|
740
|
+
throw new StoreWorkerError({ store: item.store, stage: 'worker output validation', failureClass: FAILURE_CLASS.INTEGRITY,
|
|
741
|
+
detail: error.message });
|
|
742
|
+
}
|
|
510
743
|
};
|
|
511
744
|
|
|
512
|
-
const
|
|
745
|
+
const runStore = async (item) => {
|
|
746
|
+
let lastError = null;
|
|
747
|
+
let attempts = 0;
|
|
748
|
+
for (let retry = 0; retry <= MAX_TRANSIENT_RETRIES; retry += 1) {
|
|
749
|
+
attempts += 1;
|
|
750
|
+
try {
|
|
751
|
+
return { ok: true, attempts, result: await worker(item, retry) };
|
|
752
|
+
} catch (error) {
|
|
753
|
+
if (controller.signal.aborted) throw error;
|
|
754
|
+
lastError = error;
|
|
755
|
+
const retrying = isRetryable(error) && retry < MAX_TRANSIENT_RETRIES;
|
|
756
|
+
log(`[corpus-reconcile] ${item.store}: attempt ${attempts} failed -- ${error.message}`
|
|
757
|
+
+ (retrying ? '; retrying once in a fresh worker directory' : '; not retried'));
|
|
758
|
+
if (!retrying) break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return { ok: false, attempts, error: lastError };
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const outcomes = new Array(orderedPlan.length);
|
|
513
765
|
let next = 0;
|
|
514
766
|
let firstError = null;
|
|
515
767
|
await Promise.all(Array.from({ length: Math.min(concurrency, orderedPlan.length) }, async () => {
|
|
@@ -517,17 +769,35 @@ export async function executeReconciliation({
|
|
|
517
769
|
if (controller.signal.aborted) return;
|
|
518
770
|
const index = next++;
|
|
519
771
|
try {
|
|
520
|
-
|
|
772
|
+
outcomes[index] = await runStore(orderedPlan[index]);
|
|
521
773
|
} catch (error) {
|
|
774
|
+
// Only an external cancellation reaches here; every lane is already observing the same signal.
|
|
522
775
|
if (!firstError) firstError = error;
|
|
523
|
-
controller.abort(error);
|
|
524
776
|
return;
|
|
525
777
|
}
|
|
526
778
|
}
|
|
527
779
|
}));
|
|
528
780
|
if (!firstError && controller.signal.aborted) firstError = abortError(controller.signal);
|
|
529
781
|
if (firstError) throw firstError;
|
|
530
|
-
|
|
782
|
+
|
|
783
|
+
const carried = [];
|
|
784
|
+
const missing = [];
|
|
785
|
+
const integrityFailures = [];
|
|
786
|
+
outcomes.forEach((outcome, index) => {
|
|
787
|
+
if (!outcome || outcome.ok) return;
|
|
788
|
+
const disposition = dispositionForFailedStore({ assetsDir: assets, ledger: canonicalLedger, item: orderedPlan[index],
|
|
789
|
+
attempts: outcome.attempts, reason: failureReason(outcome.error), priorCoverage });
|
|
790
|
+
if (disposition.carry) carried.push(disposition);
|
|
791
|
+
else if (disposition.failure) missing.push(disposition);
|
|
792
|
+
else integrityFailures.push(disposition);
|
|
793
|
+
log(`[corpus-reconcile] ${orderedPlan[index].store}: ${disposition.carry ? 'CARRIED at its verified seed bytes (STALE)'
|
|
794
|
+
: disposition.failure ? 'MISSING (no prior bytes)' : `INTEGRITY FAILURE (${disposition.integrity})`}`);
|
|
795
|
+
});
|
|
796
|
+
// The merge reads SUCCESSFUL results only. `outcomes` is indexed by plan position, so a failed
|
|
797
|
+
// store leaves a slot with no worker result; the pre-D5 merge read `result.files` off every slot.
|
|
798
|
+
const results = outcomes.filter((outcome) => outcome?.ok).map((outcome) => outcome.result);
|
|
799
|
+
const failed = { carried, missing, integrityFailures };
|
|
800
|
+
if (!results.length) return { refreshed: [], workers: [], ...failed };
|
|
531
801
|
|
|
532
802
|
const merge = path.join(workspace, 'merge-candidate');
|
|
533
803
|
fs.mkdirSync(merge);
|
|
@@ -563,7 +833,7 @@ export async function executeReconciliation({
|
|
|
563
833
|
assertCapabilityOnlyStore(assets, store);
|
|
564
834
|
}
|
|
565
835
|
return { refreshed: results.map(({ store }) => store),
|
|
566
|
-
workers: results.map(({ output: _output, ...receipt }) => receipt) };
|
|
836
|
+
workers: results.map(({ output: _output, ...receipt }) => receipt), ...failed };
|
|
567
837
|
}
|
|
568
838
|
|
|
569
839
|
// syncCorpusInputs — Step 3 (2026-09-13): this used to ALSO sync public-prose inputs
|
|
@@ -611,7 +881,8 @@ async function observeSourceOnly({ owner, assetsDir }) {
|
|
|
611
881
|
export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, workspaceDir,
|
|
612
882
|
root = DEFAULT_ROOT, maxAttempts = 3, closingObservation = null,
|
|
613
883
|
observe = null,
|
|
614
|
-
build = (observation) => buildCoverage({ owner, kbDir: assetsDir, policyDir: assetsDir, observation
|
|
884
|
+
build = (observation, storeOutcomes = null) => buildCoverage({ owner, kbDir: assetsDir, policyDir: assetsDir, observation,
|
|
885
|
+
storeOutcomes }),
|
|
615
886
|
readLedger = () => readJson(path.join(path.resolve(assetsDir || ''), 'RVF-GENERATIONS.json'),
|
|
616
887
|
'RVF generation ledger'),
|
|
617
888
|
execute = executeReconciliation,
|
|
@@ -630,6 +901,14 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
|
|
|
630
901
|
rebuild = (coverage, observation, _attempt, capturedGists) => rebuildCorpusAggregates({
|
|
631
902
|
assetsDir, observation, coverage, root, cache: capturedGists,
|
|
632
903
|
}),
|
|
904
|
+
// The seed's knowledge inputs come from the evidence it carries (read FIRST: a seed without it --
|
|
905
|
+
// the pre-contract bootstrap -- always builds, and costs no second coverage measurement); tonight's
|
|
906
|
+
// from the coverage `build` measures off the sealed observation, plus this checkout's public prose.
|
|
907
|
+
unchanged = async (observation) => {
|
|
908
|
+
const seed = knowledgeFromSeed(assetsDir);
|
|
909
|
+
if (!seed) return { unchanged: false, reason: 'the seed carries no knowledge-input evidence' };
|
|
910
|
+
return compareKnowledgeInputs({ seed, tonight: await knowledgeFromCoverage(await build(observation, {}), root) });
|
|
911
|
+
},
|
|
633
912
|
} = {}) {
|
|
634
913
|
if (!assetsDir || !workspaceDir) fail('stable reconciliation requires explicit assets and workspace directories');
|
|
635
914
|
const workspace = path.resolve(workspaceDir || '');
|
|
@@ -638,6 +917,8 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
|
|
|
638
917
|
assertPathNotOverlapping('reconciliation workspace directory', workspace, forbidden);
|
|
639
918
|
assertPathNotOverlapping('reconciliation workspace directory', workspace,
|
|
640
919
|
[{ label: 'the assets directory', dir: assetsDir }]);
|
|
920
|
+
// Read ONCE, before anything is rebuilt: the seed's own sealed coverage dates a carried store's bytes.
|
|
921
|
+
const priorCoverage = readPriorCoverage(assetsDir);
|
|
641
922
|
return acquireSealedGeneration({
|
|
642
923
|
maxAttempts,
|
|
643
924
|
closingObservation,
|
|
@@ -646,11 +927,12 @@ export async function acquireCorpusGeneration({ owner = 'ruvnet', assetsDir, wor
|
|
|
646
927
|
build,
|
|
647
928
|
readLedger,
|
|
648
929
|
execute: (plan, attempt) => execute({
|
|
649
|
-
plan, assetsDir, workspaceDir: path.join(workspace, `attempt-${attempt}`), root,
|
|
930
|
+
plan, assetsDir, workspaceDir: path.join(workspace, `attempt-${attempt}`), root, priorCoverage,
|
|
650
931
|
}),
|
|
651
932
|
prune,
|
|
652
933
|
rebuild,
|
|
653
934
|
preflight,
|
|
935
|
+
unchanged,
|
|
654
936
|
});
|
|
655
937
|
}
|
|
656
938
|
|
|
@@ -668,9 +950,12 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
|
|
|
668
950
|
owner = 'ruvnet', builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity = null, maxAttempts = 3,
|
|
669
951
|
reconcile = (options) => acquireCorpusGeneration(options),
|
|
670
952
|
normalizeUpdaters = normalizeUpdaterManifest,
|
|
671
|
-
accuracyOracleFile = null, accuracyStores = null, accuracySample = null,
|
|
953
|
+
accuracyOracleFile = null, accuracyStores = null, accuracySample = null, accuracySamplePerPartition = null,
|
|
954
|
+
accuracyTimeoutMs = null,
|
|
672
955
|
prepare = prepareCorpusCandidate } = {}) {
|
|
673
956
|
const finalized = await reconcile({ owner, assetsDir, workspaceDir, root, maxAttempts });
|
|
957
|
+
// Nothing the corpus is built from changed since the seed: nothing to normalize, seal or measure.
|
|
958
|
+
if (finalized?.noChange === true) return { reconciliation: finalized, noChange: true, updaters: null, candidate: null };
|
|
674
959
|
// Every shipped repository store needs a complete updater entry, and a seed that predates the
|
|
675
960
|
// convention leaves inherited stores without one -- measured 2026-09-15: 100 of 194 repository
|
|
676
961
|
// stores, none of them refreshed that run, which build-bundle rightly refused to ship. Normalize
|
|
@@ -680,7 +965,7 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
|
|
|
680
965
|
const updaters = normalizeUpdaters({
|
|
681
966
|
assetsDir,
|
|
682
967
|
coverage: finalized.coverage,
|
|
683
|
-
refreshedStores: (finalized
|
|
968
|
+
refreshedStores: summarizeReconciliation(finalized).refreshed.map((r) => r?.store || r).filter(Boolean),
|
|
684
969
|
seedIdentity: bootstrapIdentity,
|
|
685
970
|
});
|
|
686
971
|
if (updaters.missing?.length) {
|
|
@@ -691,7 +976,7 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
|
|
|
691
976
|
const candidate = await prepare({
|
|
692
977
|
root, assetsDir, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
|
|
693
978
|
coverage: finalized.coverage,
|
|
694
|
-
accuracyOracleFile, accuracyStores, accuracySample, accuracyTimeoutMs,
|
|
979
|
+
accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
|
|
695
980
|
});
|
|
696
981
|
return { reconciliation: finalized, updaters, candidate };
|
|
697
982
|
}
|
|
@@ -707,7 +992,11 @@ export function prepareCorpusCandidate({
|
|
|
707
992
|
coverage,
|
|
708
993
|
accuracyOracleFile = null,
|
|
709
994
|
accuracyStores = null,
|
|
995
|
+
// ADR-0091 D2: a whole-oracle, deterministic question sample (retrieval-accuracy.mjs
|
|
996
|
+
// --sample-questions). `accuracySamplePerPartition` is the older first-k-per-partition bound; its
|
|
997
|
+
// floor is one question per partition (196 x 2 queries, ~27 min hosted), so it cannot meet D2.
|
|
710
998
|
accuracySample = null,
|
|
999
|
+
accuracySamplePerPartition = null,
|
|
711
1000
|
accuracyTimeoutMs = null,
|
|
712
1001
|
run = defaultRun,
|
|
713
1002
|
}) {
|
|
@@ -732,8 +1021,7 @@ export function prepareCorpusCandidate({
|
|
|
732
1021
|
if (!coverage || coverage.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(coverage.rows)) {
|
|
733
1022
|
fail('prepareCorpusCandidate requires an already-measured coverage object; it never re-observes live sources');
|
|
734
1023
|
}
|
|
735
|
-
const
|
|
736
|
-
if (blockers.length) fail(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT`);
|
|
1024
|
+
const degraded = assessCandidateCoverage(coverage);
|
|
737
1025
|
assertPathNotOverlapping('candidate output directory', candidate, forbiddenOutputRoots(sourceRoot));
|
|
738
1026
|
const buildScript = path.join(sourceRoot, 'scripts', 'build-bundle.mjs');
|
|
739
1027
|
const receiptScript = path.join(sourceRoot, 'scripts', 'corpus-candidate.mjs');
|
|
@@ -770,9 +1058,14 @@ export function prepareCorpusCandidate({
|
|
|
770
1058
|
const bundleFile = path.join(path.dirname(candidate), `${path.basename(candidate)}.zip`);
|
|
771
1059
|
// ADR-086 Step 15: the benchmark runs HERE — after single-pass assembly and before the seal —
|
|
772
1060
|
// 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
|
-
//
|
|
1061
|
+
// The report is written detached, beside the archive, and the receipt below binds its digest.
|
|
1062
|
+
// C3 is a non-blocking diagnostic (ADR-086 amendment 2026-09-15): every reader of this report
|
|
1063
|
+
// (corpus-candidate.mjs, release.mjs, corpus-seed.yml) uses readDiagnosticAccuracyReport, which
|
|
1064
|
+
// checks only its schema and its binding to this archive, oracle and generator -- never whether
|
|
1065
|
+
// coverage is complete. So a bounded run (--stores/--sample/--sample-questions) seals exactly like
|
|
1066
|
+
// a full one; it marks itself `coverage.complete: false`, and only the retained strict reader
|
|
1067
|
+
// (validateAccuracyReport, the re-arm path) would refuse it. corpus-seed.yml runs a question
|
|
1068
|
+
// sample (ADR-0091 D2) because the full run cost 82 minutes on a hosted runner.
|
|
776
1069
|
const accuracyReportFile = `${bundleFile}.accuracy.json`;
|
|
777
1070
|
// A stale leftover report from a prior run must never be mistaken for a fresh measurement of
|
|
778
1071
|
// THIS bundle -- delete it before invoking the script so only a report the script just wrote
|
|
@@ -781,7 +1074,8 @@ export function prepareCorpusCandidate({
|
|
|
781
1074
|
const accuracyResult = run(process.execPath, [accuracyScript, '--bundle', bundleFile,
|
|
782
1075
|
'--oracle', accuracyOracle, '--out', accuracyReportFile,
|
|
783
1076
|
...(accuracyStores != null ? ['--stores', String(accuracyStores)] : []),
|
|
784
|
-
...(accuracySample != null ? ['--sample', String(accuracySample)] : []),
|
|
1077
|
+
...(accuracySample != null ? ['--sample-questions', String(accuracySample)] : []),
|
|
1078
|
+
...(accuracySamplePerPartition != null ? ['--sample', String(accuracySamplePerPartition)] : []),
|
|
785
1079
|
...(accuracyTimeoutMs != null ? ['--timeout-ms', String(accuracyTimeoutMs)] : [])],
|
|
786
1080
|
{ stdio: 'inherit' }) || {};
|
|
787
1081
|
// C3 was demoted to a non-blocking diagnostic on 2026-09-15 (commit a20727b7, ADR-086
|
|
@@ -804,10 +1098,12 @@ export function prepareCorpusCandidate({
|
|
|
804
1098
|
// THE BLOCKING RETRIEVAL GATE (ADR-086 amendment 2026-09-15). Same placement and same discipline
|
|
805
1099
|
// as the C3 run above — the EXTRACTED final archive through the customer query path — but this is
|
|
806
1100
|
// the measurement that can refuse a candidate. It asks the 194 frozen human questions, one per
|
|
807
|
-
// repository, and fails on any error
|
|
808
|
-
// exact-file Hit@5
|
|
1101
|
+
// repository, and fails on any error or any repository that returns nothing of its own. The
|
|
1102
|
+
// exact-file Hit@5 floor is RECORDED in the report and never fails the CLI (ADR-0091 D7.6).
|
|
1103
|
+
// `--coverage` is this candidate's sealed observation: a fixture repository with no row in it is
|
|
1104
|
+
// retired (D7.2) instead of asked a question it has no store to answer.
|
|
809
1105
|
const recallReportFile = `${bundleFile}.recall.json`;
|
|
810
|
-
checked(run, process.execPath, [recallScript, '--bundle', bundleFile, '--out', recallReportFile],
|
|
1106
|
+
checked(run, process.execPath, [recallScript, '--bundle', bundleFile, '--out', recallReportFile, '--coverage', policy],
|
|
811
1107
|
{ stdio: 'inherit' });
|
|
812
1108
|
// The candidate receipt is derived ENTIRELY from the sealed bundle's own bytes plus the detached,
|
|
813
1109
|
// digest-bound reports — the separate assets/policy directory used to build it is no longer an
|
|
@@ -817,23 +1113,59 @@ export function prepareCorpusCandidate({
|
|
|
817
1113
|
: [];
|
|
818
1114
|
checked(run, process.execPath, [receiptScript, '--bundle', bundleFile,
|
|
819
1115
|
'--receipt', receipt, '--builder-source-sha', builderSha,
|
|
820
|
-
'--accuracy-report', accuracyReportFile, '--recall-report', recallReportFile,
|
|
1116
|
+
'--accuracy-report', accuracyReportFile, '--recall-report', recallReportFile, '--coverage', policy,
|
|
821
1117
|
...bootstrapArgs], { stdio: 'inherit' });
|
|
822
1118
|
checked(run, process.execPath, [receiptScript, '--verify', '--bundle', bundleFile,
|
|
823
1119
|
'--receipt', receipt, '--accuracy-report', accuracyReportFile,
|
|
824
|
-
'--recall-report', recallReportFile], { stdio: 'inherit' });
|
|
1120
|
+
'--recall-report', recallReportFile, '--coverage', policy], { stdio: 'inherit' });
|
|
825
1121
|
return {
|
|
826
1122
|
bundleFile, receiptFile: receipt, coverageFile: policy,
|
|
827
|
-
accuracyReportFile, accuracyOracleFile: accuracyOracle, recallReportFile,
|
|
1123
|
+
accuracyReportFile, accuracyOracleFile: accuracyOracle, recallReportFile, degraded,
|
|
828
1124
|
};
|
|
829
1125
|
}
|
|
830
1126
|
|
|
1127
|
+
/**
|
|
1128
|
+
* ADR-0091 D5 -- the gate that replaced "every eligible row is CURRENT". An eligible row passes when
|
|
1129
|
+
* it is CURRENT, or when it is a repository row the shipped validator itself accepts
|
|
1130
|
+
* (eligibleRepositoryStanding: STALE with a verified `carry`, MISSING with a `failure`). Everything
|
|
1131
|
+
* else -- a STALE/MISSING row with no record, FAILED, UNVERIFIED, any non-CURRENT gist -- still fails
|
|
1132
|
+
* closed, and so does a count of carried + missing stores above max(3, 5% of eligible).
|
|
1133
|
+
*/
|
|
1134
|
+
export function assessCandidateCoverage(coverage) {
|
|
1135
|
+
const eligible = coverage.rows.filter((row) => row.disposition === 'eligible');
|
|
1136
|
+
const carried = [];
|
|
1137
|
+
const missing = [];
|
|
1138
|
+
const blockers = [];
|
|
1139
|
+
for (const row of eligible) {
|
|
1140
|
+
const standing = row.kind === 'repository' ? eligibleRepositoryStanding(row)
|
|
1141
|
+
: row.status === 'CURRENT' && row.carry === undefined && row.failure === undefined ? 'shipped' : null;
|
|
1142
|
+
if (standing === null) blockers.push(row);
|
|
1143
|
+
else if (row.carry) carried.push({ store: row.artifact.store, ...row.carry });
|
|
1144
|
+
else if (row.failure) missing.push({ store: row.artifact.store, ...row.failure });
|
|
1145
|
+
}
|
|
1146
|
+
if (blockers.length) {
|
|
1147
|
+
fail(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT and carry no verified carry/failure record `
|
|
1148
|
+
+ `(${blockers.slice(0, 5).map((row) => `${row.artifact?.store || row.key}:${row.status}`).join(', ')}`
|
|
1149
|
+
+ `${blockers.length > 5 ? ', ...' : ''})`);
|
|
1150
|
+
}
|
|
1151
|
+
const repositories = eligible.filter((row) => row.kind === 'repository').length;
|
|
1152
|
+
const bound = degradedBound(repositories);
|
|
1153
|
+
if (carried.length + missing.length > bound) {
|
|
1154
|
+
fail(`degraded coverage: ${carried.length} carried + ${missing.length} missing store(s) exceed `
|
|
1155
|
+
+ `max(3, 5% of ${repositories} eligible) = ${bound}`);
|
|
1156
|
+
}
|
|
1157
|
+
return { carried, missing, bound, eligibleRepositories: repositories };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
831
1160
|
function arg(argv, name, fallback = null) {
|
|
832
1161
|
const index = argv.indexOf(name);
|
|
833
1162
|
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
834
1163
|
}
|
|
835
1164
|
|
|
836
|
-
|
|
1165
|
+
// The two injectable seams exist so a test can drive main() end to end (ADR-0091 D1): nothing
|
|
1166
|
+
// called main() before, which is how its last line stayed broken for weeks. Production passes neither.
|
|
1167
|
+
export async function main(argv = process.argv.slice(2), {
|
|
1168
|
+
reconcileAndPrepare = reconcileAndPrepareCorpusCandidate, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
837
1169
|
const root = path.resolve(arg(argv, '--root', DEFAULT_ROOT));
|
|
838
1170
|
const archiveFile = path.resolve(arg(argv, '--seed-archive', ''));
|
|
839
1171
|
const seedTag = arg(argv, '--seed-tag');
|
|
@@ -846,30 +1178,71 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
846
1178
|
const builderSha = String(arg(argv, '--builder-sha', '')).toLowerCase();
|
|
847
1179
|
const owner = arg(argv, '--owner', 'ruvnet');
|
|
848
1180
|
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
|
-
//
|
|
1181
|
+
// Bounded measurement is explicit and opt-in; omit every flag below for the full C3 audit.
|
|
1182
|
+
// `--accuracy-sample <n>` measures n oracle questions in total (both query modes), chosen
|
|
1183
|
+
// deterministically -- what corpus-seed.yml passes (ADR-0091 D2). A bounded report still seals,
|
|
1184
|
+
// because C3 is a diagnostic and its readers check binding, not completeness.
|
|
851
1185
|
const accuracyStores = arg(argv, '--accuracy-stores') ? Number(arg(argv, '--accuracy-stores')) : null;
|
|
852
1186
|
const accuracySample = arg(argv, '--accuracy-sample') ? Number(arg(argv, '--accuracy-sample')) : null;
|
|
1187
|
+
const accuracySamplePerPartition = arg(argv, '--accuracy-sample-per-partition')
|
|
1188
|
+
? Number(arg(argv, '--accuracy-sample-per-partition')) : null;
|
|
853
1189
|
const accuracyTimeoutMs = arg(argv, '--accuracy-timeout-ms') ? Number(arg(argv, '--accuracy-timeout-ms')) : null;
|
|
854
1190
|
|
|
855
|
-
|
|
1191
|
+
// `--no-change-out <file>`: always written (true or false) once reconciliation returns, so
|
|
1192
|
+
// corpus-seed.yml never has to infer a no-change night from a missing file.
|
|
1193
|
+
const noChangeOut = arg(argv, '--no-change-out');
|
|
1194
|
+
// The argv this main() was HANDED, never process.argv: an injected invocation must mean what it says.
|
|
1195
|
+
const bootstrap = assertBootstrapIdentity({ archiveFile, tag: seedTag, sha256: seedSha256, allowPinnedTag: argv.includes('--allow-pinned-seed-tag') });
|
|
856
1196
|
if (fs.existsSync(assetsDir) && fs.readdirSync(assetsDir).length) fail(`bootstrap assets directory is not empty (${assetsDir})`);
|
|
857
1197
|
fs.mkdirSync(path.dirname(assetsDir), { recursive: true });
|
|
858
1198
|
const extractParent = fs.mkdtempSync(path.join(path.dirname(assetsDir), '.corpus-seed-extract-'));
|
|
859
1199
|
await extractZip(archiveFile, extractParent);
|
|
860
|
-
|
|
1200
|
+
try {
|
|
1201
|
+
normalizeExtractedCorpus({ extractedDir: extractParent, assetsDir });
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
if (!(error instanceof SeedLedgerIncompatibleError)) throw error;
|
|
1204
|
+
// Nothing was moved; leave --assets exactly as absent/empty as it was so the one bootstrap retry
|
|
1205
|
+
// in corpus-seed.yml can reuse the same path. A distinct exit code, never a generic failure.
|
|
1206
|
+
fs.rmSync(extractParent, { recursive: true, force: true });
|
|
1207
|
+
stderr.write(`${error.message}\n[corpus-reconcile] seed ${seedTag} cannot be consumed by this runtime; `
|
|
1208
|
+
+ `exiting ${SEED_LEDGER_INCOMPATIBLE_EXIT} so the caller can fall back to the committed bootstrap seed\n`);
|
|
1209
|
+
return SEED_LEDGER_INCOMPATIBLE_EXIT;
|
|
1210
|
+
}
|
|
861
1211
|
const privateFence = path.join(root, 'kb', 'PRIVATE-STORES.json');
|
|
862
1212
|
if (!fs.existsSync(privateFence)) fail(`canonical private-store fence missing (${privateFence})`);
|
|
863
1213
|
fs.copyFileSync(privateFence, path.join(assetsDir, 'PRIVATE-STORES.json'), fs.constants.COPYFILE_EXCL);
|
|
864
1214
|
fs.rmSync(extractParent, { recursive: true, force: true });
|
|
865
1215
|
syncCorpusInputs({ root, assetsDir });
|
|
866
1216
|
const bootstrapIdentity = { tag: bootstrap.tag, sha256: bootstrap.sha256, privateFenceEvidence: seedPrivateFenceEvidence(assetsDir) };
|
|
867
|
-
const { reconciliation, candidate } = await
|
|
1217
|
+
const { reconciliation, candidate, noChange = false } = await reconcileAndPrepare({
|
|
868
1218
|
assetsDir, workspaceDir, root, owner, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
|
|
869
|
-
accuracyOracleFile, accuracyStores, accuracySample, accuracyTimeoutMs,
|
|
1219
|
+
accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
|
|
870
1220
|
});
|
|
871
|
-
|
|
872
|
-
|
|
1221
|
+
if (noChangeOut) {
|
|
1222
|
+
fs.mkdirSync(path.dirname(path.resolve(noChangeOut)), { recursive: true });
|
|
1223
|
+
fs.writeFileSync(path.resolve(noChangeOut), `${JSON.stringify({
|
|
1224
|
+
noChange: noChange === true,
|
|
1225
|
+
knowledgeInputSha256: noChange === true ? reconciliation?.knowledgeInput?.tonightSha256 ?? null : null,
|
|
1226
|
+
observationSha256: reconciliation?.observation?.observationSha256 ?? null,
|
|
1227
|
+
})}\n`);
|
|
1228
|
+
}
|
|
1229
|
+
if (noChange === true) {
|
|
1230
|
+
stdout.write(`${JSON.stringify({ ok: true, noChange: true, seedTag, seedSha256,
|
|
1231
|
+
knowledgeInput: reconciliation?.knowledgeInput ?? null }, null, 2)}\n`);
|
|
1232
|
+
return 0;
|
|
1233
|
+
}
|
|
1234
|
+
const { plan } = summarizeReconciliation(reconciliation);
|
|
1235
|
+
const degraded = candidate.degraded || { carried: [], missing: [] };
|
|
1236
|
+
const isDegraded = degraded.carried.length + degraded.missing.length > 0;
|
|
1237
|
+
const publication = isDegraded ? degradedPublication() : { allowed: true, reason: 'every eligible row is CURRENT' };
|
|
1238
|
+
stdout.write(`${JSON.stringify({ ok: publication.allowed, seedTag, seedSha256, plan, reconciliation, ...candidate,
|
|
1239
|
+
degraded: { ...degraded, publishable: publication.allowed, reason: publication.reason } }, null, 2)}\n`);
|
|
1240
|
+
if (!publication.allowed) {
|
|
1241
|
+
stderr.write(`::warning title=Degraded corpus generation sealed, not published::${degraded.carried.length} carried `
|
|
1242
|
+
+ `(${degraded.carried.map((row) => row.store).join(', ') || 'none'}), ${degraded.missing.length} missing `
|
|
1243
|
+
+ `(${degraded.missing.map((row) => row.store).join(', ') || 'none'}); ${publication.reason}\n`);
|
|
1244
|
+
return DEGRADED_UNPUBLISHED_EXIT;
|
|
1245
|
+
}
|
|
873
1246
|
return 0;
|
|
874
1247
|
}
|
|
875
1248
|
|