Skip to content

Commit 68b4fe1

Browse files
committed
Add publishable ContextBench report validator
1 parent b578547 commit 68b4fe1

1 file changed

Lines changed: 352 additions & 0 deletions

File tree

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2+
import { createHash } from 'node:crypto';
3+
import { dirname, join, resolve } from 'node:path';
4+
5+
function arg(name, fallback = null) {
6+
const index = process.argv.indexOf(`--${name}`);
7+
if (index === -1) return fallback;
8+
return process.argv[index + 1] || fallback;
9+
}
10+
11+
const summaryPath = arg('summary', join(process.env.ROOT || '/tmp/contextbench-five-lane-score', 'summary.json'));
12+
const protocolPath = arg('protocol', 'tests/fixtures/contextbench-benchmark-protocol.json');
13+
const lanesPath = arg('lanes', 'tests/fixtures/contextbench-lanes.json');
14+
const taskManifestPath = arg('task-manifest', 'tests/fixtures/contextbench-task-manifest.json');
15+
const outPath = arg('out', join(dirname(summaryPath), 'publishable-summary.json'));
16+
const validationOutPath = arg('validation-out', join(dirname(summaryPath), 'publishable-validation.json'));
17+
const humanizedOutPath = arg('humanized-out', join(dirname(summaryPath), 'humanized-summary.md'));
18+
19+
function readJson(path) {
20+
return JSON.parse(readFileSync(path, 'utf8'));
21+
}
22+
23+
function hashFile(path) {
24+
return `sha256:${createHash('sha256').update(readFileSync(path)).digest('hex')}`;
25+
}
26+
27+
function writeJson(path, data) {
28+
mkdirSync(dirname(path), { recursive: true });
29+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
30+
}
31+
32+
function numberOrNull(value) {
33+
const numeric = Number(value);
34+
return Number.isFinite(numeric) ? numeric : null;
35+
}
36+
37+
function metricValue(metric) {
38+
if (!metric || typeof metric !== 'object') return null;
39+
return numberOrNull(metric.value);
40+
}
41+
42+
function hasMetricOrReason(metric) {
43+
if (!metric || typeof metric !== 'object') return false;
44+
if (Number.isFinite(Number(metric.value))) return true;
45+
return typeof metric.unavailableReason === 'string' && metric.unavailableReason.length > 0;
46+
}
47+
48+
function mean(values) {
49+
const clean = values.map(numberOrNull).filter((value) => value !== null);
50+
if (clean.length === 0) return null;
51+
return clean.reduce((sum, value) => sum + value, 0) / clean.length;
52+
}
53+
54+
function median(values) {
55+
const clean = values.map(numberOrNull).filter((value) => value !== null).sort((a, b) => a - b);
56+
if (clean.length === 0) return null;
57+
const middle = Math.floor(clean.length / 2);
58+
if (clean.length % 2) return clean[middle];
59+
return (clean[middle - 1] + clean[middle]) / 2;
60+
}
61+
62+
function stddev(values) {
63+
const clean = values.map(numberOrNull).filter((value) => value !== null);
64+
if (clean.length < 2) return null;
65+
const avg = mean(clean);
66+
const variance = clean.reduce((sum, value) => sum + ((value - avg) ** 2), 0) / (clean.length - 1);
67+
return Math.sqrt(variance);
68+
}
69+
70+
function deterministicRandom(seed) {
71+
let state = seed >>> 0;
72+
return () => {
73+
state = (1664525 * state + 1013904223) >>> 0;
74+
return state / 2 ** 32;
75+
};
76+
}
77+
78+
function bootstrapCi(values, iterations = 2000) {
79+
const clean = values.map(numberOrNull).filter((value) => value !== null);
80+
if (clean.length === 0) return { low: null, high: null, method: 'bootstrap_mean_95_ci', samples: 0 };
81+
if (clean.length === 1) return { low: clean[0], high: clean[0], method: 'single_sample_no_interval', samples: 1 };
82+
const rand = deterministicRandom(0xC0FFEE);
83+
const sampledMeans = [];
84+
for (let i = 0; i < iterations; i += 1) {
85+
let total = 0;
86+
for (let j = 0; j < clean.length; j += 1) {
87+
total += clean[Math.floor(rand() * clean.length)];
88+
}
89+
sampledMeans.push(total / clean.length);
90+
}
91+
sampledMeans.sort((a, b) => a - b);
92+
return {
93+
low: sampledMeans[Math.floor(iterations * 0.025)],
94+
high: sampledMeans[Math.floor(iterations * 0.975)],
95+
method: 'deterministic_bootstrap_mean_95_ci',
96+
samples: clean.length,
97+
};
98+
}
99+
100+
function qualityFromRow(row) {
101+
const final = row.score?.final || {};
102+
return {
103+
lane: row.lane_id,
104+
task: row.task_id,
105+
fileCoverage: final.file?.coverage ?? null,
106+
filePrecision: final.file?.precision ?? null,
107+
symbolCoverage: final.symbol?.coverage ?? null,
108+
symbolPrecision: final.symbol?.precision ?? null,
109+
spanCoverage: final.span?.coverage ?? null,
110+
spanPrecision: final.span?.precision ?? null,
111+
lineCoverage: final.line?.coverage ?? null,
112+
linePrecision: final.line?.precision ?? null,
113+
editlocRecall: row.score?.editloc?.recall ?? null,
114+
editlocPrecision: row.score?.editloc?.precision ?? null,
115+
qualitySource: 'official ContextBench evaluator',
116+
};
117+
}
118+
119+
function costFromRow(row) {
120+
const t = row.timeMetrics || {};
121+
return {
122+
lane: row.lane_id,
123+
setupDurationMs: metricValue(t.setupDurationMs),
124+
indexDurationMs: metricValue(t.indexDurationMs),
125+
queryDurationMs: metricValue(t.queryDurationMs),
126+
selectorDurationMs: metricValue(t.selectorDurationMs),
127+
evaluatorDurationMs: metricValue(t.evaluatorDurationMs),
128+
rowWallDurationMs: metricValue(t.rowWallDurationMs),
129+
unavailable: Object.fromEntries(Object.entries(t)
130+
.filter(([, metric]) => metric?.unavailableReason)
131+
.map(([key, metric]) => [key, metric.unavailableReason])),
132+
};
133+
}
134+
135+
function tokensFromRow(row) {
136+
const tokenMetrics = row.tokenMetrics || {};
137+
const candidatePack = tokenMetrics.candidatePack || {};
138+
const prediction = tokenMetrics.prediction || {};
139+
const selectorUsage = tokenMetrics.selectorUsage || {};
140+
return {
141+
lane: row.lane_id,
142+
estimator: tokenMetrics.estimator || null,
143+
candidateCount: candidatePack.candidateCount ?? row.candidateCount ?? null,
144+
candidateBytes: metricValue(candidatePack.bytes),
145+
candidateEstimatedTokens: metricValue(candidatePack.estimatedTokens),
146+
predictionBytes: metricValue(prediction.bytes),
147+
predictionEstimatedTokens: metricValue(prediction.estimatedTokens),
148+
selectorModel: selectorUsage.model || row.model || null,
149+
selectorInputTokens: metricValue(selectorUsage.inputTokens),
150+
selectorOutputTokens: metricValue(selectorUsage.outputTokens),
151+
selectorTotalTokens: metricValue(selectorUsage.totalTokens),
152+
unavailable: {
153+
candidateBytes: candidatePack.bytes?.unavailableReason || null,
154+
candidateEstimatedTokens: candidatePack.estimatedTokens?.unavailableReason || null,
155+
selectorUsage: selectorUsage.totalTokens?.unavailableReason || null,
156+
},
157+
};
158+
}
159+
160+
function reliabilityFromRow(row) {
161+
return {
162+
lane: row.lane_id,
163+
task: row.task_id,
164+
status: row.status,
165+
officialEvaluatorScoreable: row.officialEvaluatorScoreable === true,
166+
setupStatus: row.reliability?.setupStatus ?? row.setupStatus,
167+
indexStatus: row.reliability?.indexStatus ?? row.indexStatus,
168+
toolCallable: row.reliability?.toolCallable ?? row.toolCallable,
169+
nonEmptyPrediction: row.reliability?.nonEmptyPrediction ?? row.nonEmptyPrediction,
170+
candidateCount: row.reliability?.candidateCount ?? row.candidateCount,
171+
sourceRun: row.reliability?.sourceRun ?? null,
172+
sourceJob: row.reliability?.sourceJob ?? null,
173+
sourceArtifact: row.reliability?.sourceArtifact ?? null,
174+
sourceDigest: row.reliability?.sourceDigest ?? null,
175+
};
176+
}
177+
178+
function laneStatistics(qualityRows) {
179+
const metrics = ['fileCoverage', 'filePrecision', 'spanCoverage', 'spanPrecision', 'lineCoverage', 'linePrecision'];
180+
const byLane = new Map();
181+
for (const row of qualityRows) {
182+
const list = byLane.get(row.lane) || [];
183+
list.push(row);
184+
byLane.set(row.lane, list);
185+
}
186+
return Object.fromEntries([...byLane.entries()].map(([lane, rows]) => [
187+
lane,
188+
Object.fromEntries(metrics.map((metric) => {
189+
const values = rows.map((row) => row[metric]);
190+
return [metric, {
191+
mean: mean(values),
192+
median: median(values),
193+
stddev: stddev(values),
194+
bootstrap95Ci: bootstrapCi(values),
195+
}];
196+
})),
197+
]));
198+
}
199+
200+
function validate(summary, protocol, taskManifest, qualityRows, reliabilityRows) {
201+
const requiredLanes = summary.requiredLanes || [];
202+
const scoreableLanes = new Set(qualityRows.map((row) => row.lane));
203+
const failures = reliabilityRows.filter((row) => !row.officialEvaluatorScoreable);
204+
const checks = [
205+
{
206+
id: 'official_evaluator_only_quality_rows',
207+
pass: qualityRows.every((row) => row.qualitySource === 'official ContextBench evaluator'),
208+
detail: 'Every quality row must come from the official ContextBench evaluator.',
209+
},
210+
{
211+
id: 'failures_excluded_from_quality_table',
212+
pass: failures.every((failure) => !qualityRows.some((row) => row.lane === failure.lane && row.task === failure.task)),
213+
detail: 'setup_failed/tool_error/judge_failed/empty rows are reliability rows, not quality rows.',
214+
},
215+
{
216+
id: 'required_lanes_scoreable',
217+
pass: requiredLanes.length >= 5 && requiredLanes.every((lane) => scoreableLanes.has(lane)),
218+
detail: `Required lanes: ${requiredLanes.join(', ') || 'none'}`,
219+
},
220+
{
221+
id: 'setup_index_query_costs_separate',
222+
pass: summary.setupIndexCostReportedSeparately === true && summary.rows.every((row) => ['setupDurationMs', 'indexDurationMs', 'queryDurationMs'].every((key) => hasMetricOrReason(row.timeMetrics?.[key]))),
223+
detail: 'Setup, indexing, and query costs must not be folded into quality metrics.',
224+
},
225+
{
226+
id: 'timing_metrics_present',
227+
pass: summary.rows.every((row) => ['evaluatorDurationMs', 'rowWallDurationMs', 'selectorDurationMs'].every((key) => hasMetricOrReason(row.timeMetrics?.[key]))),
228+
detail: 'Each row needs evaluator, row-wall, and selector timing fields or explicit unavailable reasons.',
229+
},
230+
{
231+
id: 'token_metrics_present',
232+
pass: summary.rows.every((row) => row.tokenMetrics?.prediction?.estimatedTokens && row.tokenMetrics?.candidatePack?.estimatedTokens && row.tokenMetrics?.selectorUsage?.totalTokens),
233+
detail: 'Each row needs prediction, candidate-pack, and selector token fields; unavailable provider usage must be explicit.',
234+
},
235+
{
236+
id: 'protocol_is_frozen_and_non_claiming',
237+
pass: protocol.status === 'protocol_frozen' && protocol.claimAllowed === false,
238+
detail: 'This proof can be publishable evidence, but not a broad benchmark-win claim.',
239+
},
240+
{
241+
id: 'manifest_preselected_without_lane_outputs',
242+
pass: typeof taskManifest.no_lane_outputs_observed_attestation === 'string' && taskManifest.no_lane_outputs_observed_attestation.length > 0,
243+
detail: 'Task selection must not depend on lane outputs.',
244+
},
245+
];
246+
return {
247+
status: checks.every((check) => check.pass) ? 'pass' : 'fail',
248+
checks,
249+
};
250+
}
251+
252+
function formatNumber(value) {
253+
if (value === null || value === undefined) return 'n/a';
254+
if (Number.isInteger(value)) return String(value);
255+
return Number(value).toFixed(3);
256+
}
257+
258+
function markdownTable(rows, columns) {
259+
const header = `| ${columns.map((column) => column.label).join(' | ')} |`;
260+
const divider = `| ${columns.map(() => '---').join(' | ')} |`;
261+
const body = rows.map((row) => `| ${columns.map((column) => formatNumber(column.value(row))).join(' | ')} |`);
262+
return [header, divider, ...body].join('\n');
263+
}
264+
265+
function humanizedMarkdown(report) {
266+
const table = markdownTable(report.qualityTable, [
267+
{ label: 'Lane', value: (row) => row.lane },
268+
{ label: 'File cov', value: (row) => row.fileCoverage },
269+
{ label: 'File prec', value: (row) => row.filePrecision },
270+
{ label: 'Span cov', value: (row) => row.spanCoverage },
271+
{ label: 'Line cov', value: (row) => row.lineCoverage },
272+
]);
273+
const costTable = markdownTable(report.costTable, [
274+
{ label: 'Lane', value: (row) => row.lane },
275+
{ label: 'Setup ms', value: (row) => row.setupDurationMs },
276+
{ label: 'Index ms', value: (row) => row.indexDurationMs },
277+
{ label: 'Query ms', value: (row) => row.queryDurationMs },
278+
{ label: 'Eval ms', value: (row) => row.evaluatorDurationMs },
279+
{ label: 'Row ms', value: (row) => row.rowWallDurationMs },
280+
]);
281+
const tokenTable = markdownTable(report.tokenTable, [
282+
{ label: 'Lane', value: (row) => row.lane },
283+
{ label: 'Candidates', value: (row) => row.candidateCount },
284+
{ label: 'Cand tokens', value: (row) => row.candidateEstimatedTokens },
285+
{ label: 'Pred tokens', value: (row) => row.predictionEstimatedTokens },
286+
{ label: 'Selector tokens', value: (row) => row.selectorTotalTokens },
287+
]);
288+
return `# ContextBench Publishable Pilot Summary\n\nThis is a real five-lane ContextBench pilot: the quality numbers below come only from the official ContextBench evaluator, and setup/index/query costs are reported separately. It is still a pilot because it covers one frozen task; it should not be turned into a broad win claim.\n\n## Quality\n\n${table}\n\n## Time Cost\n\n${costTable}\n\n## Token Cost\n\n${tokenTable}\n\n## What This Means\n\nThe run is useful because every required lane produced a non-empty prediction and received an official score. The remaining n/a values are measurement gaps, not hidden failures: selector provider token telemetry was not captured in this proof, and some readiness artifacts report candidate counts without candidate bytes.\n\n## What Not To Claim\n\nDo not claim product superiority from this artifact alone. A claim-bearing benchmark still needs the full frozen task slice, repeated runs, and the same reporting discipline used here.\n`;
289+
}
290+
291+
if (!existsSync(summaryPath)) throw new Error(`summary not found: ${summaryPath}`);
292+
if (!existsSync(protocolPath)) throw new Error(`protocol not found: ${protocolPath}`);
293+
if (!existsSync(lanesPath)) throw new Error(`lanes fixture not found: ${lanesPath}`);
294+
if (!existsSync(taskManifestPath)) throw new Error(`task manifest not found: ${taskManifestPath}`);
295+
296+
const summary = readJson(summaryPath);
297+
const protocol = readJson(protocolPath);
298+
const lanes = readJson(lanesPath);
299+
const taskManifest = readJson(taskManifestPath);
300+
const rows = summary.rows || [];
301+
const qualityRows = rows.filter((row) => row.officialEvaluatorScoreable === true && row.status === 'completed').map(qualityFromRow);
302+
const reliabilityRows = rows.map(reliabilityFromRow);
303+
const costTable = rows.map(costFromRow);
304+
const tokenTable = rows.map(tokensFromRow);
305+
const validation = validate(summary, protocol, taskManifest, qualityRows, reliabilityRows);
306+
307+
const targetTaskIds = [...new Set(rows.map((row) => row.task_id).filter(Boolean))];
308+
const report = {
309+
createdAt: new Date().toISOString(),
310+
publicationStatus: 'pilot_evidence_not_broad_claim',
311+
sourceSummary: resolve(summaryPath),
312+
protocol: {
313+
path: protocolPath,
314+
hash: hashFile(protocolPath),
315+
version: protocol.protocolVersion,
316+
status: protocol.status,
317+
claimAllowed: protocol.claimAllowed,
318+
},
319+
fixtures: {
320+
lanes: { path: lanesPath, hash: hashFile(lanesPath), laneCount: lanes.lanes?.length ?? null },
321+
taskManifest: { path: taskManifestPath, hash: hashFile(taskManifestPath), manifestHash: taskManifest.manifest_hash ?? null },
322+
},
323+
scope: {
324+
model: summary.model,
325+
targetTaskIds,
326+
attemptedRows: summary.attemptedRows,
327+
scoreableRows: summary.scoreableRows,
328+
requiredLanes: summary.requiredLanes || [],
329+
caveats: summary.caveats || [],
330+
},
331+
qualityTable: qualityRows,
332+
costTable,
333+
tokenTable,
334+
reliabilityTable: reliabilityRows,
335+
statistics: laneStatistics(qualityRows),
336+
biasAudit: validation,
337+
limitations: [
338+
'One-task proof run: suitable as pilot evidence, not a broad benchmark claim.',
339+
'Selector usage telemetry is explicit but unavailable where the selector ran outside provider usage capture.',
340+
'Candidate token estimates use a deterministic utf8-byte heuristic unless a lane emits provider-grade token counts.',
341+
'Failures remain in reliability tables and are never counted as quality rows.',
342+
],
343+
};
344+
345+
writeJson(outPath, report);
346+
writeJson(validationOutPath, validation);
347+
mkdirSync(dirname(humanizedOutPath), { recursive: true });
348+
writeFileSync(humanizedOutPath, humanizedMarkdown(report));
349+
console.log('CONTEXTBENCH_PUBLISHABLE_REPORT_JSON_START');
350+
console.log(JSON.stringify({ outPath, validationOutPath, humanizedOutPath, validation }, null, 2));
351+
console.log('CONTEXTBENCH_PUBLISHABLE_REPORT_JSON_END');
352+
if (validation.status !== 'pass') process.exitCode = 1;

0 commit comments

Comments
 (0)