ruvnet-brain 4.3.35 → 4.3.37
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 +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +6 -1
- package/plugin/hooks/hook-contracts.json +6 -4
- package/plugin/scripts/continuity-hook-policy.mjs +10 -3
- package/plugin/scripts/coverage-integrity.mjs +58 -2
- package/plugin/scripts/hook-shim.mjs +3 -1
- package/plugin/scripts/session-snapshot-hook.mjs +11 -1
- package/plugin/scripts/turn-outcome-capture.mjs +292 -0
- 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 +69 -12
- package/scripts/corpus-reconcile.mjs +340 -61
- package/scripts/corpus-store-failure.mjs +82 -0
- package/scripts/corpus-watchdog.mjs +334 -0
- package/scripts/derive-passage-content-map.mjs +71 -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/public-verification-inputs.mjs +42 -4
- package/scripts/rehearse-corpus-pipeline.mjs +350 -20
- package/scripts/release-transaction-provider.mjs +29 -6
- package/scripts/release.mjs +156 -18
- package/scripts/retrieval-canary.mjs +83 -24
- package/scripts/retrieval-passage-identity.mjs +58 -0
- package/scripts/source-coverage.mjs +19 -3
- package/scripts/sync-census.mjs +0 -0
- package/scripts/wired-check.mjs +4 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/knowledge-input-digest.mjs — "has anything the corpus is built FROM changed since the seed?"
|
|
3
|
+
//
|
|
4
|
+
// WHY (2026-09-29 nightly redesign). The old no-change test compared the NEW archive's digest with
|
|
5
|
+
// the seed archive's digest. It could never match: a rebuilt archive carries fresh builtUtc stamps,
|
|
6
|
+
// a fresh selection receipt and fresh aggregate receipts even when not one input moved, so every
|
|
7
|
+
// quiet night paid the full ~3 hours and minted a new generation anyway. The decision now happens
|
|
8
|
+
// BEFORE anything is built, over the inputs themselves, computed the same way for the seed (from the
|
|
9
|
+
// sealed evidence it carries) and for tonight (from the sealed observation and this checkout):
|
|
10
|
+
//
|
|
11
|
+
// sourceObservationSha256 the sealed observation digest. Included on purpose (the conservative
|
|
12
|
+
// clause of the design): the aggregate builders consume more of the
|
|
13
|
+
// observation than head SHAs -- concepts.sources.json binds
|
|
14
|
+
// observationSha256 and the ruv-gists ledger row uses it as sourceCommit,
|
|
15
|
+
// and coverage rows ship each repository's description/homepage for routing.
|
|
16
|
+
// repositories every eligible repository store -> the commit tonight wants (upstream.sha)
|
|
17
|
+
// versus the commit the seed actually contains (artifact.sourceCommit),
|
|
18
|
+
// so a store the seed carried at old bytes can never read as unchanged.
|
|
19
|
+
// gists every gist -> wanted version versus ingested version, same rule.
|
|
20
|
+
// publicInputs the byte identities of the fenced public prose (primers, topics, L2,
|
|
21
|
+
// capability cards, aliases) and the topic-ownership map.
|
|
22
|
+
// embedding the model:dimensions the runtime embeds with / the seed was embedded with.
|
|
23
|
+
//
|
|
24
|
+
// Every uncertainty resolves toward BUILDING: a seed without the evidence (the pre-contract bootstrap)
|
|
25
|
+
// yields null, and null is never "unchanged".
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
import { canonicalJson, digest as sha256Of } from './coverage-integrity.mjs';
|
|
32
|
+
import { materializePublicInputs, SELECTION_FILE } from './public-inputs.mjs';
|
|
33
|
+
import { parseBuildFingerprint } from './corpus-next-seed.mjs';
|
|
34
|
+
|
|
35
|
+
export const KNOWLEDGE_INPUT_KIND = 'ruvnet-brain-knowledge-input';
|
|
36
|
+
const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
37
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
38
|
+
const COMMIT = /^[0-9a-f]{7,64}$/;
|
|
39
|
+
|
|
40
|
+
function fail(message) {
|
|
41
|
+
throw new Error(`[knowledge-input-digest] ${message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lower = (value) => (value === null || value === undefined ? null : String(value).toLowerCase());
|
|
45
|
+
|
|
46
|
+
/** Canonical form: every list sorted, every commit lowercased, nothing else admitted. */
|
|
47
|
+
export function normalize(components) {
|
|
48
|
+
const c = components || {};
|
|
49
|
+
if (!HEX64.test(String(c.sourceObservationSha256 || ''))) fail('sourceObservationSha256 must be 64 lowercase hex');
|
|
50
|
+
if (![c.repositories, c.gists, c.publicInputs?.files, c.embedding].every(Array.isArray)) {
|
|
51
|
+
fail('repositories, gists, publicInputs.files and embedding must be arrays');
|
|
52
|
+
}
|
|
53
|
+
const repositories = c.repositories.map((row) => ({ store: lower(row?.store), sha: lower(row?.sha) }))
|
|
54
|
+
.sort((a, b) => String(a.store).localeCompare(String(b.store)));
|
|
55
|
+
if (repositories.some((row) => !row.store)) fail('a repository component has no store');
|
|
56
|
+
if (new Set(repositories.map((row) => row.store)).size !== repositories.length) fail('duplicate repository store');
|
|
57
|
+
const gists = c.gists.map((row) => ({ id: String(row?.id || ''), sha: lower(row?.sha) }))
|
|
58
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
59
|
+
if (gists.some((row) => !row.id)) fail('a gist component has no id');
|
|
60
|
+
const files = c.publicInputs.files.map((row) => ({ path: String(row?.path || ''), sha256: lower(row?.sha256), bytes: row?.bytes }))
|
|
61
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
62
|
+
if (files.some((row) => !row.path || !HEX64.test(String(row.sha256)) || !Number.isSafeInteger(row.bytes))) {
|
|
63
|
+
fail('a public input file row is malformed');
|
|
64
|
+
}
|
|
65
|
+
const ownership = Object.fromEntries(Object.entries(c.publicInputs.ownership || {})
|
|
66
|
+
.map(([slug, repo]) => [String(slug), String(repo)]).sort(([a], [b]) => a.localeCompare(b)));
|
|
67
|
+
const embedding = [...new Set(c.embedding.map(String))].sort();
|
|
68
|
+
if (!embedding.length) fail('no embedding model recorded');
|
|
69
|
+
return {
|
|
70
|
+
schemaVersion: 1, kind: KNOWLEDGE_INPUT_KIND, sourceObservationSha256: c.sourceObservationSha256,
|
|
71
|
+
repositories, gists, publicInputs: { files, ownership }, embedding,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One sha256 over the canonical components. Ordering of the inputs never matters. */
|
|
76
|
+
export function digest(components) {
|
|
77
|
+
return sha256Of(canonicalJson(normalize(components)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const eligibleRepositories = (coverage) => (coverage?.rows || [])
|
|
81
|
+
.filter((row) => row?.kind === 'repository' && row?.disposition === 'eligible');
|
|
82
|
+
const gistRows = (coverage) => (coverage?.rows || []).filter((row) => row?.kind === 'gist');
|
|
83
|
+
const gistId = (row) => String(row?.key || '').replace(/^gist:/, '');
|
|
84
|
+
|
|
85
|
+
function readJsonIfPresent(file) {
|
|
86
|
+
if (!fs.existsSync(file)) return null;
|
|
87
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The seed's knowledge inputs, read ONLY from evidence the sealed seed carries (its extracted assets):
|
|
92
|
+
* CORPUS-COVERAGE.json, PUBLIC-INPUT-SELECTION.json and RVF-GENERATIONS.json. null when any is absent
|
|
93
|
+
* or unreadable -- the caller must then build.
|
|
94
|
+
*/
|
|
95
|
+
export function fromSeed(assetsDir) {
|
|
96
|
+
const assets = path.resolve(assetsDir || '');
|
|
97
|
+
const coverage = readJsonIfPresent(path.join(assets, 'CORPUS-COVERAGE.json'));
|
|
98
|
+
const selection = readJsonIfPresent(path.join(assets, SELECTION_FILE));
|
|
99
|
+
const ledger = readJsonIfPresent(path.join(assets, 'RVF-GENERATIONS.json'));
|
|
100
|
+
if (coverage?.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(coverage.rows)
|
|
101
|
+
|| !HEX64.test(String(coverage.sourceObservationSha256 || ''))) return null;
|
|
102
|
+
if (!Array.isArray(selection?.files) || !ledger?.stores || typeof ledger.stores !== 'object') return null;
|
|
103
|
+
const embedding = Object.values(ledger.stores)
|
|
104
|
+
.filter((store) => store?.model && Number.isSafeInteger(store?.dimensions))
|
|
105
|
+
.map((store) => `${store.model}:${store.dimensions}`);
|
|
106
|
+
if (!embedding.length) return null;
|
|
107
|
+
return {
|
|
108
|
+
sourceObservationSha256: coverage.sourceObservationSha256,
|
|
109
|
+
// What the seed CONTAINS: a carried (STALE) or MISSING store reads as its old/absent commit.
|
|
110
|
+
repositories: eligibleRepositories(coverage).map((row) => ({ store: row.artifact?.store, sha: row.artifact?.sourceCommit ?? null })),
|
|
111
|
+
gists: gistRows(coverage).map((row) => ({ id: gistId(row), sha: row.artifact?.sourceCommit ?? null })),
|
|
112
|
+
publicInputs: { files: selection.files, ownership: selection.ownership || {} },
|
|
113
|
+
embedding,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The runtime's embedding profile, read from THAT runtime's own forge fingerprint. */
|
|
118
|
+
export async function runtimeEmbedding(root = DEFAULT_ROOT) {
|
|
119
|
+
const forge = await import(pathToFileURL(path.join(path.resolve(root), 'kb', 'forge-corpus.mjs')).href);
|
|
120
|
+
const { model, dimensions } = parseBuildFingerprint(forge.FORGE_BUILD_FINGERPRINT);
|
|
121
|
+
return [`${model}:${dimensions}`];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Tonight's knowledge inputs: the coverage measured from the sealed observation (what every source
|
|
126
|
+
* WANTS to be), plus the public prose this checkout would select, materialized into a throwaway
|
|
127
|
+
* directory by the one canonical selector -- never re-implemented here.
|
|
128
|
+
*/
|
|
129
|
+
export async function fromCoverage(coverage, root = DEFAULT_ROOT, {
|
|
130
|
+
materialize = materializePublicInputs, embedding = null } = {}) {
|
|
131
|
+
if (coverage?.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(coverage.rows)) {
|
|
132
|
+
fail('tonight requires a measured ruvnet-brain-corpus-coverage object');
|
|
133
|
+
}
|
|
134
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'knowledge-input-'));
|
|
135
|
+
let publicInputs;
|
|
136
|
+
try {
|
|
137
|
+
const selected = materialize({ builderRoot: path.resolve(root), outDir: path.join(scratch, 'public') });
|
|
138
|
+
publicInputs = { files: selected.selectionReceipt.files, ownership: selected.selectionReceipt.ownership || {} };
|
|
139
|
+
} finally {
|
|
140
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
sourceObservationSha256: coverage.sourceObservationSha256,
|
|
144
|
+
repositories: eligibleRepositories(coverage).map((row) => {
|
|
145
|
+
if (!COMMIT.test(lower(row.upstream?.sha) || '')) fail(`${row.artifact?.store || row.key} has no upstream sha`);
|
|
146
|
+
return { store: row.artifact?.store, sha: row.upstream.sha };
|
|
147
|
+
}),
|
|
148
|
+
gists: gistRows(coverage).map((row) => ({ id: gistId(row), sha: row.upstream?.sha ?? null })),
|
|
149
|
+
publicInputs,
|
|
150
|
+
embedding: embedding || await runtimeEmbedding(root),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** { unchanged, seedSha256, tonightSha256 }. A seed without evidence is never unchanged. */
|
|
155
|
+
export function compareKnowledgeInputs({ seed, tonight }) {
|
|
156
|
+
const tonightSha256 = digest(tonight);
|
|
157
|
+
if (!seed) return { unchanged: false, seedSha256: null, tonightSha256, reason: 'the seed carries no knowledge-input evidence' };
|
|
158
|
+
const seedSha256 = digest(seed);
|
|
159
|
+
return {
|
|
160
|
+
unchanged: seedSha256 === tonightSha256, seedSha256, tonightSha256,
|
|
161
|
+
reason: seedSha256 === tonightSha256 ? 'every knowledge input equals the seed' : 'at least one knowledge input differs from the seed',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -34,6 +34,8 @@ import os from 'node:os';
|
|
|
34
34
|
import path from 'node:path';
|
|
35
35
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
36
36
|
import { extractZip } from '../../kb/zip-extract.mjs';
|
|
37
|
+
import { validateCoverageLedger } from '../coverage-integrity.mjs';
|
|
38
|
+
import { retiredFixtureStores } from '../fixture-denominator.mjs';
|
|
37
39
|
|
|
38
40
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
39
41
|
|
|
@@ -153,30 +155,50 @@ export function scoreQuestion({ store, expectedPath, results }) {
|
|
|
153
155
|
};
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Roll per-question rows into the counts the predicates are evaluated on.
|
|
160
|
+
*
|
|
161
|
+
* ADR-0091 D7.4: a row marked `retired: true` (its repository has no row at all in a complete sealed
|
|
162
|
+
* coverage observation) is excluded from EVERY count, and `retired` is added -- but only when it is
|
|
163
|
+
* non-zero, so a report with no retirements is byte-for-byte the pre-D7 shape and readable in both
|
|
164
|
+
* directions. An older reader of a report WITH retirements re-derives different totals and fails
|
|
165
|
+
* closed, which is the intended behavior (D4's pre-download check turns that into a seed fallback).
|
|
166
|
+
*/
|
|
157
167
|
export function tally(rows) {
|
|
158
|
-
const
|
|
168
|
+
const live = rows.filter((r) => r.retired !== true);
|
|
169
|
+
const retired = rows.length - live.length;
|
|
170
|
+
const completed = live.filter((r) => !r.error).length;
|
|
159
171
|
return {
|
|
160
|
-
questions:
|
|
172
|
+
questions: live.length,
|
|
161
173
|
completed,
|
|
162
|
-
errors:
|
|
163
|
-
repoCoverage:
|
|
164
|
-
hitTop1:
|
|
165
|
-
hitTop5:
|
|
174
|
+
errors: live.length - completed,
|
|
175
|
+
repoCoverage: live.filter((r) => r.repoCovered).length,
|
|
176
|
+
hitTop1: live.filter((r) => r.exactFileRank === 1).length,
|
|
177
|
+
hitTop5: live.filter((r) => r.exactFileRank != null && r.exactFileRank <= DEFAULT_K).length,
|
|
178
|
+
...(retired > 0 ? { retired } : {}),
|
|
166
179
|
};
|
|
167
180
|
}
|
|
168
181
|
|
|
182
|
+
const FLOOR_FAILURE = /below the accepted floor/;
|
|
183
|
+
/** ADR-0091 D7.6: the integrity failures. A floor miss is recorded in the report, never enforced. */
|
|
184
|
+
export const blockingFailures = (failures) => failures.filter((failure) => !FLOOR_FAILURE.test(failure));
|
|
185
|
+
|
|
169
186
|
/**
|
|
170
187
|
* EVERY blocking predicate, in one place, evaluated over counts alone. Returns the full failure
|
|
171
188
|
* list rather than the first failure, so one run tells an operator everything that is wrong.
|
|
189
|
+
* The denominator is the frozen fixture minus retired questions (ADR-0091 D7.4) -- the SAME exclusion
|
|
190
|
+
* tally applies, or the totals re-derivation and the gate would disagree.
|
|
172
191
|
*/
|
|
173
192
|
export function evaluateGate({ totals, floorValue, fixtureCount }) {
|
|
174
193
|
const failures = [];
|
|
175
|
-
|
|
194
|
+
const retired = Number.isSafeInteger(totals.retired) && totals.retired > 0 ? totals.retired : 0;
|
|
195
|
+
const expected = fixtureCount - retired;
|
|
196
|
+
const of = retired ? `${expected} (${fixtureCount} frozen, ${retired} retired)` : `${fixtureCount}`;
|
|
197
|
+
if (totals.questions !== expected) failures.push(`asked ${totals.questions} of ${of} frozen questions`);
|
|
176
198
|
if (totals.errors !== 0) failures.push(`${totals.errors} question(s) failed to complete`);
|
|
177
|
-
if (totals.completed !==
|
|
178
|
-
if (totals.repoCoverage !==
|
|
179
|
-
failures.push(`${
|
|
199
|
+
if (totals.completed !== expected) failures.push(`${totals.completed} of ${of} questions completed`);
|
|
200
|
+
if (totals.repoCoverage !== expected) {
|
|
201
|
+
failures.push(`${expected - totals.repoCoverage} repository(ies) returned nothing of their own`);
|
|
180
202
|
}
|
|
181
203
|
if (totals.hitTop5 < floorValue) {
|
|
182
204
|
failures.push(`exact-file Hit@5 regressed to ${totals.hitTop5}, below the accepted floor of ${floorValue}`);
|
|
@@ -184,7 +206,73 @@ export function evaluateGate({ totals, floorValue, fixtureCount }) {
|
|
|
184
206
|
return { verdict: failures.length === 0 ? 'PASS' : 'FAIL', failures };
|
|
185
207
|
}
|
|
186
208
|
|
|
187
|
-
|
|
209
|
+
function parseSealedCoverage(bytes, label) {
|
|
210
|
+
let coverage;
|
|
211
|
+
try { coverage = JSON.parse(Buffer.from(bytes).toString('utf8')); }
|
|
212
|
+
catch (error) { fail(`${label} is unreadable (${error.message})`); }
|
|
213
|
+
const checked = validateCoverageLedger(coverage);
|
|
214
|
+
if (coverage?.kind !== 'ruvnet-brain-corpus-coverage' || !checked.valid) {
|
|
215
|
+
fail(`${label} is not a valid sealed ruvnet-brain-corpus-coverage ledger (${checked.failures.join('; ') || coverage?.kind})`);
|
|
216
|
+
}
|
|
217
|
+
return coverage;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const sortedLower = (values) => [...new Set(values.map((value) => String(value).toLowerCase()))].sort();
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* ADR-0091 D7.3 -- readers verify, never trust. The retired set a report CLAIMS (its `retirement`
|
|
224
|
+
* block and the rows it marks `retired: true`) is recomputed here, independently, from coverage bytes
|
|
225
|
+
* the reader obtained itself, through the one shared retirement function (scripts/fixture-
|
|
226
|
+
* denominator.mjs, also used by the release canary). Returns the failures; empty means consistent.
|
|
227
|
+
*
|
|
228
|
+
* A reader with no verifiable coverage treats ANY claimed retirement as invalid, and every claimed
|
|
229
|
+
* store must be retired by the coverage the claim names. The check is claimed ⊆ recomputed, not
|
|
230
|
+
* equality: retirement only ever REMOVES a question from the denominator, so the unsafe direction is
|
|
231
|
+
* an over-claim (hiding an unanswered question). An under-claim keeps a question in the denominator,
|
|
232
|
+
* which can only make the gate stricter. A report that claims nothing needs no coverage at all, so
|
|
233
|
+
* the pre-D7 path (every report today) reads exactly as it did.
|
|
234
|
+
*/
|
|
235
|
+
export function retirementFailures({ report, coverageBytes = null, fixtureStores = null }) {
|
|
236
|
+
const rows = Array.isArray(report?.rows) ? report.rows : [];
|
|
237
|
+
const markedRows = rows.filter((row) => row?.retired === true);
|
|
238
|
+
const claim = report?.retirement;
|
|
239
|
+
const claims = claim !== undefined || markedRows.length > 0 || report?.totals?.retired !== undefined;
|
|
240
|
+
if (!claims) return [];
|
|
241
|
+
if (!claim || typeof claim !== 'object' || !HEX64.test(String(claim.coverageSha256 || ''))
|
|
242
|
+
|| !Array.isArray(claim.stores) || !claim.stores.length) {
|
|
243
|
+
return ['repo-recall report marks questions retired without a well-formed retirement block'];
|
|
244
|
+
}
|
|
245
|
+
const failures = [];
|
|
246
|
+
const claimed = sortedLower(claim.stores);
|
|
247
|
+
if (JSON.stringify(claimed) !== JSON.stringify(sortedLower(markedRows.map((row) => row.store)))) {
|
|
248
|
+
failures.push('repo-recall report\'s retirement block does not name exactly the rows it marks retired');
|
|
249
|
+
}
|
|
250
|
+
// A retired question was never asked, so it can carry no evidence of an answer.
|
|
251
|
+
if (markedRows.some((row) => row.repoCovered !== false || row.exactFileRank !== null || row.error !== undefined)) {
|
|
252
|
+
failures.push('repo-recall report credits a retired question with an answer');
|
|
253
|
+
}
|
|
254
|
+
if (coverageBytes == null) {
|
|
255
|
+
return [...failures, 'repo-recall report claims retired question(s) but no coverage was supplied to verify them against'];
|
|
256
|
+
}
|
|
257
|
+
if (sha256(Buffer.from(coverageBytes)) !== claim.coverageSha256) {
|
|
258
|
+
return [...failures, 'repo-recall report\'s retirement was measured against different coverage bytes than the ones supplied'];
|
|
259
|
+
}
|
|
260
|
+
let coverage;
|
|
261
|
+
try { coverage = parseSealedCoverage(coverageBytes, 'retirement coverage'); }
|
|
262
|
+
catch (error) { return [...failures, error.message]; }
|
|
263
|
+
const recomputed = new Set(retiredFixtureStores({ coverage, fixtureStores: fixtureStores ?? rows.map((row) => row.store) }));
|
|
264
|
+
const unsupported = claimed.filter((store) => !recomputed.has(store));
|
|
265
|
+
if (unsupported.length) {
|
|
266
|
+
failures.push(`repo-recall report claims [${unsupported.join(', ')}] retired, but the coverage it names does not `
|
|
267
|
+
+ 'retire them (they have a repository row, or the enumeration is not complete)');
|
|
268
|
+
}
|
|
269
|
+
return failures;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function validateRecallReport({
|
|
273
|
+
report, archive, expectedFixtureSha256 = null, floorValue = null, floorFile = null,
|
|
274
|
+
coverageBytes = null, fixtureStores = null,
|
|
275
|
+
} = {}) {
|
|
188
276
|
const failures = [];
|
|
189
277
|
if (!report || typeof report !== 'object') fail('repo-recall report is not an object');
|
|
190
278
|
if (report.schemaVersion !== RECALL_SCHEMA_VERSION || report.kind !== RECALL_KIND) {
|
|
@@ -203,6 +291,7 @@ export function validateRecallReport({ report, archive, expectedFixtureSha256 =
|
|
|
203
291
|
if (canonical(recomputed) !== canonical(report.totals)) {
|
|
204
292
|
failures.push('repo-recall report totals do not re-derive from its own rows');
|
|
205
293
|
}
|
|
294
|
+
failures.push(...retirementFailures({ report, coverageBytes, fixtureStores }));
|
|
206
295
|
}
|
|
207
296
|
if (failures.length) fail(`repo-recall report invalid: ${failures.join('; ')}`);
|
|
208
297
|
// NEVER trust the report's own `floor.value`. A report that declares its own bar could declare
|
|
@@ -231,14 +320,16 @@ export function validateRecallReport({ report, archive, expectedFixtureSha256 =
|
|
|
231
320
|
// quality on the release path is gated by scripts/retrieval-canary.mjs through a real
|
|
232
321
|
// installed host, which is the instrument that belongs in that role.
|
|
233
322
|
const gate = evaluateGate({ totals: report.totals, floorValue: floor, fixtureCount: report.fixture.questionCount });
|
|
234
|
-
const blocking = gate.failures
|
|
323
|
+
const blocking = blockingFailures(gate.failures);
|
|
235
324
|
report.gate = { blocking: blocking.length > 0, verdict: gate.verdict, failures: gate.failures, enforced: blocking };
|
|
236
325
|
if (blocking.length) fail(`repo-recall integrity FAILED: ${blocking.join('; ')}`);
|
|
237
326
|
return report;
|
|
238
327
|
}
|
|
239
328
|
|
|
240
329
|
/** Read a detached report beside an archive and enforce the gate. Mirrors readAccuracyReport. */
|
|
241
|
-
export function readRecallReport({
|
|
330
|
+
export function readRecallReport({
|
|
331
|
+
reportFile, archive, expectedFixtureSha256 = null, floorValue = null, floorFile = null, coverageBytes = null, fixtureStores = null,
|
|
332
|
+
} = {}) {
|
|
242
333
|
const resolved = path.resolve(reportFile || '');
|
|
243
334
|
if (!resolved || !fs.existsSync(resolved)) fail(`detached repo-recall report missing (${resolved || 'no path supplied'})`);
|
|
244
335
|
const stat = fs.lstatSync(resolved);
|
|
@@ -246,7 +337,7 @@ export function readRecallReport({ reportFile, archive, expectedFixtureSha256 =
|
|
|
246
337
|
let parsed;
|
|
247
338
|
try { parsed = JSON.parse(fs.readFileSync(resolved, 'utf8')); }
|
|
248
339
|
catch (error) { fail(`detached repo-recall report unreadable/corrupt (${error.message})`); }
|
|
249
|
-
const report = validateRecallReport({ report: parsed, archive, expectedFixtureSha256, floorValue, floorFile });
|
|
340
|
+
const report = validateRecallReport({ report: parsed, archive, expectedFixtureSha256, floorValue, floorFile, coverageBytes, fixtureStores });
|
|
250
341
|
return { identity: { file: path.basename(resolved), sha256: sha256File(resolved), bytes: stat.size }, report };
|
|
251
342
|
}
|
|
252
343
|
|
|
@@ -256,9 +347,22 @@ export function readRecallReport({ reportFile, archive, expectedFixtureSha256 =
|
|
|
256
347
|
* forge-ask-all.mjs — the exact bytes a customer installs — not the checkout's copy.
|
|
257
348
|
*/
|
|
258
349
|
export async function runRepoRecall({
|
|
259
|
-
kbDir, fixtureFile, floorFile, archive = null, k = DEFAULT_K, searchAll = null, now = () => new Date(),
|
|
350
|
+
kbDir, fixtureFile, floorFile, archive = null, k = DEFAULT_K, searchAll = null, now = () => new Date(), coverageFile = null,
|
|
260
351
|
} = {}) {
|
|
261
352
|
const fixture = loadFixture(fixtureFile);
|
|
353
|
+
// ADR-0091 D7.2: a fixture repository with NO row in a complete sealed coverage observation is
|
|
354
|
+
// retired -- its question is not asked (there is no store to ask) and it leaves the denominator.
|
|
355
|
+
// The fixture itself is never edited (D7.5): its digest is what seeds and the canary re-verify.
|
|
356
|
+
let retirement = null;
|
|
357
|
+
if (coverageFile) {
|
|
358
|
+
const resolved = path.resolve(coverageFile);
|
|
359
|
+
if (!fs.existsSync(resolved)) fail(`retirement coverage missing (${resolved})`);
|
|
360
|
+
const bytes = fs.readFileSync(resolved);
|
|
361
|
+
const coverage = parseSealedCoverage(bytes, 'retirement coverage');
|
|
362
|
+
const stores = retiredFixtureStores({ coverage, fixtureStores: fixture.questions.map((q) => q.store) });
|
|
363
|
+
if (stores.length) retirement = { coverageSha256: sha256(bytes), stores };
|
|
364
|
+
}
|
|
365
|
+
const retiredSet = new Set(retirement?.stores || []);
|
|
262
366
|
// Fail-soft, same reason as the reader: a floor recorded against another fixture is a note, not a
|
|
263
367
|
// reason to stop. Nothing here refuses a candidate any more.
|
|
264
368
|
let floor = null; let floorValue = ABSOLUTE_FLOOR;
|
|
@@ -291,6 +395,11 @@ export async function runRepoRecall({
|
|
|
291
395
|
|
|
292
396
|
const rows = [];
|
|
293
397
|
for (const question of fixture.questions) {
|
|
398
|
+
if (retiredSet.has(question.store.toLowerCase())) {
|
|
399
|
+
rows.push({ store: question.store, expectedPath: question.expectedPath, retired: true,
|
|
400
|
+
repoCovered: false, exactFileRank: null, returnedPaths: [] });
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
294
403
|
try {
|
|
295
404
|
const out = await search({ dir: kbDir, query: question.query, k, repos: [question.store] });
|
|
296
405
|
// searchAll reports a store that could not be OPENED as an "ERR: ..." string in perRepo and
|
|
@@ -346,6 +455,8 @@ export async function runRepoRecall({
|
|
|
346
455
|
questionCount: fixture.questions.length,
|
|
347
456
|
shape: 'exactly one human-written question per repository',
|
|
348
457
|
},
|
|
458
|
+
// Present ONLY when something retired, so a report with none is the pre-D7 shape byte for byte.
|
|
459
|
+
...(retirement ? { retirement } : {}),
|
|
349
460
|
protocol: { entryPoint, k, repositoryScope: 'explicit', scoring: 'exact labeled file path within top-k of results from the requested repository' },
|
|
350
461
|
floor: { value: floorValue, committed: floor?.hitTop5Floor ?? null, absolute: ABSOLUTE_FLOOR, acceptedForRelease: floor?.acceptedForRelease ?? null },
|
|
351
462
|
totals,
|
|
@@ -370,7 +481,7 @@ const arg = (argv, name, fallback = null) => {
|
|
|
370
481
|
* Measure a SEALED archive: extract it, find the store root, and grade that — never the build
|
|
371
482
|
* directory the archive was assembled from. Returns the report bound to the archive's own digest.
|
|
372
483
|
*/
|
|
373
|
-
export async function runRepoRecallOnBundle({ bundleFile, fixtureFile, floorFile, k = DEFAULT_K } = {}) {
|
|
484
|
+
export async function runRepoRecallOnBundle({ bundleFile, fixtureFile, floorFile, k = DEFAULT_K, coverageFile = null } = {}) {
|
|
374
485
|
const bundle = path.resolve(bundleFile || '');
|
|
375
486
|
if (!bundle || !fs.existsSync(bundle) || !fs.statSync(bundle).isFile()) {
|
|
376
487
|
fail(`archive missing (${bundle || 'no path supplied'})`);
|
|
@@ -389,17 +500,19 @@ export async function runRepoRecallOnBundle({ bundleFile, fixtureFile, floorFile
|
|
|
389
500
|
};
|
|
390
501
|
walk(tmp);
|
|
391
502
|
if (roots.length !== 1) fail(`expected exactly one ARCHIVE-MANIFEST.json in the archive, found ${roots.length}`);
|
|
392
|
-
return await runRepoRecall({ kbDir: roots[0], fixtureFile, floorFile, archive, k });
|
|
503
|
+
return await runRepoRecall({ kbDir: roots[0], fixtureFile, floorFile, archive, k, coverageFile });
|
|
393
504
|
} finally {
|
|
394
505
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
395
506
|
}
|
|
396
507
|
}
|
|
397
508
|
|
|
398
|
-
|
|
509
|
+
// `searchAll` is a test seam only (the CLI never passes it): it lets the exit-code contract be proven
|
|
510
|
+
// without an embedded corpus. Unset, --kb grades the archive's own shipped entry point as always.
|
|
511
|
+
export async function main(argv = process.argv.slice(2), { searchAll = null } = {}) {
|
|
399
512
|
const kbDir = arg(argv, '--kb');
|
|
400
513
|
const bundleFile = arg(argv, '--bundle');
|
|
401
514
|
if (!kbDir && !bundleFile) {
|
|
402
|
-
process.stderr.write('usage: repo-recall.mjs (--bundle <archive.zip> | --kb <extracted root>) [--out <report.json>] [--fixture <file>] [--floor <file>]\n');
|
|
515
|
+
process.stderr.write('usage: repo-recall.mjs (--bundle <archive.zip> | --kb <extracted root>) [--out <report.json>] [--fixture <file>] [--floor <file>] [--coverage <sealed coverage>]\n');
|
|
403
516
|
return 64;
|
|
404
517
|
}
|
|
405
518
|
const { report, gate } = bundleFile
|
|
@@ -407,11 +520,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
407
520
|
bundleFile: path.resolve(bundleFile),
|
|
408
521
|
fixtureFile: arg(argv, '--fixture'),
|
|
409
522
|
floorFile: arg(argv, '--floor'),
|
|
523
|
+
coverageFile: arg(argv, '--coverage'),
|
|
410
524
|
})
|
|
411
525
|
: await runRepoRecall({
|
|
412
526
|
kbDir: path.resolve(kbDir),
|
|
527
|
+
searchAll,
|
|
413
528
|
fixtureFile: arg(argv, '--fixture'),
|
|
414
529
|
floorFile: arg(argv, '--floor'),
|
|
530
|
+
coverageFile: arg(argv, '--coverage'),
|
|
415
531
|
});
|
|
416
532
|
const out = arg(argv, '--out') || (bundleFile ? `${path.resolve(bundleFile)}.recall.json` : null);
|
|
417
533
|
if (out) fs.writeFileSync(path.resolve(out), `${JSON.stringify(report, null, 2)}\n`);
|
|
@@ -423,10 +539,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
423
539
|
repositoriesAnswering: `${t.repoCoverage}/${t.questions}`,
|
|
424
540
|
exactFileTop1: `${t.hitTop1}/${t.questions}`,
|
|
425
541
|
exactFileTop5: `${t.hitTop5}/${t.questions}`,
|
|
542
|
+
...(t.retired ? { retired: t.retired } : {}),
|
|
426
543
|
floor: report.floor.value,
|
|
427
544
|
failures: gate.failures,
|
|
545
|
+
enforced: blockingFailures(gate.failures),
|
|
428
546
|
}, null, 2)}\n`);
|
|
429
|
-
|
|
547
|
+
// ADR-0091 D7.6: the exit code derives from the INTEGRITY failures only. A floor miss stays in the
|
|
548
|
+
// report (state/failures) and is never fatal -- exiting 1 on it would silently restore the blocking
|
|
549
|
+
// ratchet ADR-086's 2026-09-15 amendment removed, the moment anyone re-accepts the floor.
|
|
550
|
+
return blockingFailures(gate.failures).length ? 1 : 0;
|
|
430
551
|
}
|
|
431
552
|
|
|
432
553
|
// Realpath both sides: argv[1] is whatever the caller typed, while Node resolves import.meta.url
|
|
@@ -11,6 +11,7 @@ import { extractZip } from '../kb/zip-extract.mjs';
|
|
|
11
11
|
import { canonicalJson, digest, validateCoverageLedger, validateCoverageLink } from './coverage-integrity.mjs';
|
|
12
12
|
import { validatePublicInventory } from './public-inventory.mjs';
|
|
13
13
|
import { verifySeedBaseline } from './corpus-candidate.mjs';
|
|
14
|
+
import { loadFixture, readRecallReport } from './oracle/repo-recall.mjs';
|
|
14
15
|
import {
|
|
15
16
|
buildRetrievalCanaryPlan,
|
|
16
17
|
validateRetrievalQueryEvidence,
|
|
@@ -250,6 +251,19 @@ function retrospectiveBaselineFromTree({ extractedRoot, bundleFile, expectedTag,
|
|
|
250
251
|
return { receipt, bytes, fileSha256: crypto.createHash('sha256').update(bytes).digest('hex'), root, archiveManifest };
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
/**
|
|
255
|
+
* ADR-0091 D6.3: does the baseline archive carry the seed's own published tag? A code-release seed
|
|
256
|
+
* (vX.Y.Z) records that tag in its generation ledger. A corpus-generation seed CANNOT: its tag is
|
|
257
|
+
* corpus-sha256-<the archive's own digest>, which no file inside the archive can contain, and its
|
|
258
|
+
* ledger names the runtime that built it. So a content-addressed tag is proven by the archive digest
|
|
259
|
+
* it names; every other tag must equal the ledger's releaseTag, exactly as before.
|
|
260
|
+
*/
|
|
261
|
+
export function baselineTagMatches({ publishedTag, ledgerReleaseTag, archiveSha256 }) {
|
|
262
|
+
const contentAddressed = /^corpus-sha256-([0-9a-f]{64})$/.exec(String(publishedTag || ''));
|
|
263
|
+
if (contentAddressed) return contentAddressed[1] === archiveSha256;
|
|
264
|
+
return typeof publishedTag === 'string' && publishedTag.length > 0 && ledgerReleaseTag === publishedTag;
|
|
265
|
+
}
|
|
266
|
+
|
|
253
267
|
function observedBaselineFromTree({ extractedRoot, bundleFile, expectedTag, expectedSha256, expectedBytes }) {
|
|
254
268
|
const ledgerFile = findNamed(extractedRoot, 'RVF-GENERATIONS.json');
|
|
255
269
|
const root = path.dirname(ledgerFile);
|
|
@@ -259,7 +273,9 @@ function observedBaselineFromTree({ extractedRoot, bundleFile, expectedTag, expe
|
|
|
259
273
|
fail('historical baseline generation ledger is malformed');
|
|
260
274
|
}
|
|
261
275
|
const archive = namedIdentity(bundleFile);
|
|
262
|
-
if (
|
|
276
|
+
if (!baselineTagMatches({ publishedTag: expectedTag, ledgerReleaseTag: ledger.releaseTag, archiveSha256: archive.sha256 })) {
|
|
277
|
+
fail('historical baseline differs from the expected public release tag');
|
|
278
|
+
}
|
|
263
279
|
if (!HEX64.test(String(expectedSha256 || '')) || archive.sha256 !== expectedSha256) {
|
|
264
280
|
fail('historical baseline differs from the expected public archive SHA-256');
|
|
265
281
|
}
|
|
@@ -415,9 +431,27 @@ function writeExactOutputs(outDir, outputs) {
|
|
|
415
431
|
return root;
|
|
416
432
|
}
|
|
417
433
|
|
|
434
|
+
/**
|
|
435
|
+
* The stores a corpus generation's OWN repo-recall measurement retrieved (exact file within top-k). The
|
|
436
|
+
* report is bound to the exact baseline archive (sha256 + bytes) and to the frozen fixture the canary
|
|
437
|
+
* samples from, and is re-derived through the same reader the corpus pipeline uses — a report for another
|
|
438
|
+
* archive or another fixture is refused, never silently accepted.
|
|
439
|
+
*/
|
|
440
|
+
export function measuredHitStores({ recallFile, baselineArchive, oracleFile }) {
|
|
441
|
+
const stat = fs.statSync(baselineArchive);
|
|
442
|
+
// A report that claims retired questions must be verified against the coverage it names; the
|
|
443
|
+
// generation's sealed coverage sits beside its report in the seed directory when it exists.
|
|
444
|
+
const siblingCoverage = path.join(path.dirname(path.resolve(recallFile)), 'CORPUS-COVERAGE.json');
|
|
445
|
+
const { report } = readRecallReport({ reportFile: recallFile,
|
|
446
|
+
archive: { sha256: sha256File(baselineArchive), bytes: stat.size },
|
|
447
|
+
expectedFixtureSha256: loadFixture(oracleFile).fixtureSha256,
|
|
448
|
+
coverageBytes: fs.existsSync(siblingCoverage) ? fs.readFileSync(siblingCoverage) : null });
|
|
449
|
+
return new Set(report.rows.filter((row) => Number.isInteger(row.exactFileRank)).map((row) => String(row.store).toLowerCase()));
|
|
450
|
+
}
|
|
451
|
+
|
|
418
452
|
export async function createPublicVerificationInputs({ baselineBundle, candidateBundle,
|
|
419
453
|
candidatePackage, oracleFile, repo = process.cwd(), outDir = 'release-evidence', baselineMode = 'verified',
|
|
420
|
-
baselineReceipt = null } = {}) {
|
|
454
|
+
baselineReceipt = null, baselineRecall = null } = {}) {
|
|
421
455
|
const baselineArchive = trustedFile(baselineBundle, 'baseline archive');
|
|
422
456
|
const candidateArchive = trustedFile(candidateBundle, 'candidate archive');
|
|
423
457
|
const packageFile = trustedFile(candidatePackage, 'candidate package');
|
|
@@ -476,7 +510,8 @@ export async function createPublicVerificationInputs({ baselineBundle, candidate
|
|
|
476
510
|
fail('baseline archive bytes differ from release coverage');
|
|
477
511
|
}
|
|
478
512
|
if (seed.receiptSha256 !== baselineProof.fileSha256) fail('baseline receipt differs from release coverage');
|
|
479
|
-
if (seed.tag
|
|
513
|
+
if (!baselineTagMatches({ publishedTag: seed.tag, ledgerReleaseTag: baselineProof.receipt.releaseTag,
|
|
514
|
+
archiveSha256: baselineProof.receipt.archive.sha256 })) {
|
|
480
515
|
fail('baseline release tag differs from release coverage');
|
|
481
516
|
}
|
|
482
517
|
const baselineStores = baselineProof.receipt.stores.map(({ name }) => name);
|
|
@@ -497,9 +532,11 @@ export async function createPublicVerificationInputs({ baselineBundle, candidate
|
|
|
497
532
|
verifyQueryOracleSource(queryEvidence, candidateResult.candidate.sourceSha, {
|
|
498
533
|
cwd: path.resolve(repo), allowSquashedSource: true,
|
|
499
534
|
});
|
|
535
|
+
const knownHitStores = baselineRecall
|
|
536
|
+
? measuredHitStores({ recallFile: baselineRecall, baselineArchive, oracleFile: oraclePath }) : null;
|
|
500
537
|
const plan = buildRetrievalCanaryPlan({ coverage: candidateResult.coverage, baseline,
|
|
501
538
|
candidate: candidateResult.candidate, coverageIdentity: candidateResult.coverageIdentity,
|
|
502
|
-
queryEvidence, assetsDir: candidateTree.root, allowNoDelta: true });
|
|
539
|
+
queryEvidence, assetsDir: candidateTree.root, allowNoDelta: true, knownHitStores });
|
|
503
540
|
writeExactOutputs(outDir, {
|
|
504
541
|
[baselineMode === 'observed' ? 'baseline-observation-receipt.json' : 'baseline-verification-receipt.json']: baselineProof.bytes,
|
|
505
542
|
'COVERAGE.json': candidateResult.coverageBytes,
|
|
@@ -558,6 +595,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
558
595
|
outDir: arg(argv, '--out-dir') || 'release-evidence',
|
|
559
596
|
baselineMode: argv.includes('--receipted-baseline') ? 'receipted' : argv.includes('--observed-baseline') ? 'observed' : 'verified',
|
|
560
597
|
baselineReceipt: arg(argv, '--baseline-receipt'),
|
|
598
|
+
baselineRecall: arg(argv, '--baseline-recall'),
|
|
561
599
|
});
|
|
562
600
|
console.log(JSON.stringify({ ok: true, sourceSha: result.candidate.sourceSha,
|
|
563
601
|
coverageGeneration: result.coverage.releaseCoverageGeneration, cases: result.plan.cases.length }));
|