|
| 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