diff --git a/models/addy-new/benchmarks/Qwen_Qwen3-8B/correctness-faultmap.png b/models/addy-new/benchmarks/Qwen_Qwen3-8B/correctness-faultmap.png new file mode 100644 index 0000000..f04440b Binary files /dev/null and b/models/addy-new/benchmarks/Qwen_Qwen3-8B/correctness-faultmap.png differ diff --git a/models/addy-new/benchmarks/Qwen_Qwen3-8B/correctness-heatmap.png b/models/addy-new/benchmarks/Qwen_Qwen3-8B/correctness-heatmap.png new file mode 100644 index 0000000..34b1242 Binary files /dev/null and b/models/addy-new/benchmarks/Qwen_Qwen3-8B/correctness-heatmap.png differ diff --git a/models/addy-new/benchmarks/google_gemma-4-26B-A4B-it/correctness-faultmap.png b/models/addy-new/benchmarks/google_gemma-4-26B-A4B-it/correctness-faultmap.png new file mode 100644 index 0000000..03fb986 Binary files /dev/null and b/models/addy-new/benchmarks/google_gemma-4-26B-A4B-it/correctness-faultmap.png differ diff --git a/models/addy-new/benchmarks/google_gemma-4-26B-A4B-it/correctness-heatmap.png b/models/addy-new/benchmarks/google_gemma-4-26B-A4B-it/correctness-heatmap.png new file mode 100644 index 0000000..77f3429 Binary files /dev/null and b/models/addy-new/benchmarks/google_gemma-4-26B-A4B-it/correctness-heatmap.png differ diff --git a/models/addy-new/scripts/correctness-chart.ts b/models/addy-new/scripts/correctness-chart.ts index 22ad138..aa3124d 100644 --- a/models/addy-new/scripts/correctness-chart.ts +++ b/models/addy-new/scripts/correctness-chart.ts @@ -7,9 +7,13 @@ * - "heatmap" (default): green = all correct, red = any mistake, black = untested. * - "faultmap": white = any wrong answer, black = everything else. * The testing script (`correctness-map.ts`) calls `dumpImage` every 1000 tests; - * this file can also be run on its own to (re)chart an already-saved grid. + * this file can also be run on its own to (re)chart an already-saved grid — + * either an `addy-new` checkpoint, or an OpenAI-endpoint benchmark grid (the + * ones written by `correctness-map-openai.ts`) via `--model `. * - * Usage: bun run models/addy-new/scripts/correctness-chart.ts [type] + * Usage: + * bun run models/addy-new/scripts/correctness-chart.ts [type] + * bun run models/addy-new/scripts/correctness-chart.ts --model [type] */ import { join } from "path"; @@ -22,6 +26,11 @@ import { gridPath, loadGrid, } from "./correctness-grid.ts"; +import { + benchmarkFolderPath, + benchmarkGridPath, + loadBenchmarkGrid, +} from "./correctness-grid-benchmark.ts"; const NULL = 0; const TRUE = 2; @@ -104,7 +113,7 @@ const margin = { top: 96, right: 40, bottom: 70, left: 80 }; const buildSvg = async ( grid: Uint8Array, - checkpointId: number, + label: string, testCount: number, correctCount: number, type: RenderType, @@ -147,7 +156,7 @@ const buildSvg = async ( type === "faultmap" ? `each pixel = ${BOX}×${BOX} pairs (white = any wrong answer, black = correct/untested)` : `each pixel = ${BOX}×${BOX} pairs (red = any mistake, black = untested)`; - const title = `${MODEL_NAME} checkpoint ${checkpointId} — ${mapLabel}`; + const title = `${label} — ${mapLabel}`; const subtitle = `${testCount.toLocaleString()} tests · ${accuracy.toFixed(2)}% correct · ${summary}`; const legend = @@ -181,6 +190,32 @@ const buildSvg = async ( `; }; +/** + * Render a grid to `correctness-.png` inside `outputDir`, titled with + * `label`. The reusable core shared by the checkpoint charts and external + * benchmarks; defaults to the "heatmap" rendering. + */ +export const renderCorrectnessImage = async ({ + grid, + outputDir, + label, + testCount, + correctCount, + type = "heatmap", +}: { + grid: Uint8Array; + outputDir: string; + label: string; + testCount: number; + correctCount: number; + type?: RenderType; +}) => { + const svg = await buildSvg(grid, label, testCount, correctCount, type); + const outputPath = join(outputDir, `correctness-${type}.png`); + await sharp(Buffer.from(svg)).png().toFile(outputPath); + return outputPath; +}; + /** * Render the grid to `correctness-.png` in the checkpoint folder. * Defaults to the "heatmap" rendering. @@ -191,36 +226,86 @@ export const dumpImage = async ( testCount: number, correctCount: number, type: RenderType = "heatmap", -) => { - const svg = await buildSvg(grid, checkpointId, testCount, correctCount, type); - const outputPath = join( - checkpointFolderPath(checkpointId), - `correctness-${type}.png`, - ); - await sharp(Buffer.from(svg)).png().toFile(outputPath); - return outputPath; -}; +) => + renderCorrectnessImage({ + grid, + outputDir: checkpointFolderPath(checkpointId), + label: `${MODEL_NAME} checkpoint ${checkpointId}`, + testCount, + correctCount, + type, + }); + +const USAGE = [ + "Usage:", + " bun run models/addy-new/scripts/correctness-chart.ts [type]", + " bun run models/addy-new/scripts/correctness-chart.ts --model [type]", + ` type: ${RENDER_TYPES.join(" | ")} (default: heatmap)`, +].join("\n"); const main = async () => { - const checkpointArg = process.argv[2]; - if (checkpointArg === undefined || Number.isNaN(Number(checkpointArg))) { - console.error( - "Usage: bun run models/addy-new/scripts/correctness-chart.ts [type]", - ); - console.error(` type: ${RENDER_TYPES.join(" | ")} (default: heatmap)`); - process.exit(1); + // Split `--model ` (chart an OpenAI-endpoint benchmark) from the + // positional args (checkpoint mode); whatever's left is the optional type. + const args = process.argv.slice(2); + let model: string | undefined; + const positional: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--model") { + model = args[++i]; + } else { + positional.push(args[i]!); + } } - const checkpointId = Number(checkpointArg); - const typeArg = process.argv[3] ?? "heatmap"; + // In model mode the lone positional is the type; in checkpoint mode it's the + // checkpoint id followed by an optional type. + const typeArg = (model !== undefined ? positional[0] : positional[1]) ?? "heatmap"; if (!RENDER_TYPES.includes(typeArg as RenderType)) { console.error( `Unknown type "${typeArg}" — expected one of: ${RENDER_TYPES.join(", ")}`, ); + console.error(USAGE); process.exit(1); } const type = typeArg as RenderType; + if (model !== undefined) { + if (model.length === 0) { + console.error("--model requires a model name."); + console.error(USAGE); + process.exit(1); + } + + const { grid, testCount, correctCount } = loadBenchmarkGrid(model); + if (testCount === 0) { + console.error( + `No grid data at ${benchmarkGridPath(model)} — run the benchmark script first.`, + ); + process.exit(1); + } + + const outputPath = await renderCorrectnessImage({ + grid, + outputDir: benchmarkFolderPath(model), + label: model, + testCount, + correctCount, + type, + }); + const accuracy = (correctCount / testCount) * 100; + console.log( + `${testCount.toLocaleString()} tests · ${accuracy.toFixed(2)}% correct · wrote ${outputPath}`, + ); + return; + } + + const checkpointArg = positional[0]; + if (checkpointArg === undefined || Number.isNaN(Number(checkpointArg))) { + console.error(USAGE); + process.exit(1); + } + const checkpointId = Number(checkpointArg); + const { grid, testCount, correctCount } = loadGrid(checkpointId); if (testCount === 0) { console.error( diff --git a/models/addy-new/scripts/correctness-map-openai.ts b/models/addy-new/scripts/correctness-map-openai.ts new file mode 100644 index 0000000..d29b904 --- /dev/null +++ b/models/addy-new/scripts/correctness-map-openai.ts @@ -0,0 +1,253 @@ +/** + * Correctness benchmark for an OpenAI-compatible endpoint (e.g. vLLM-hosted + * open-source LLMs), mirroring the `addy-new` correctness map so results can be + * compared apples-to-apples against our own model. + * + * Given a model name (passed straight into the OpenAI client's `model` field), + * it forever samples random pairs (a, b) with a, b < 10_000, asks the model to + * compute "a+b=" and records whether the answer is exactly correct. Results are + * folded into a single grid file (`correctness_grid.bin`) living in a folder + * named after the model, and every 1000 tests the grid is saved and a heatmap + * PNG is dumped via the shared charting module. + * + * The grid format/encoding is identical to `correctness-grid.ts` (and reuses its + * constants), so the existing chart script can also read these grids. + * + * The endpoint base URL is hardcoded below. The API key is required and comes + * from `--api-key` or the `OPENAI_API_KEY` env var. + * + * This is an independent benchmark script: it does not import or modify any + * inference/training code. + * + * Usage: + * bun run models/addy-new/scripts/correctness-map-openai.ts \ + * [--api-key ] + */ + +import OpenAI from "openai"; + +import { GRID_SIZE, TRUE, FALSE } from "./correctness-grid.ts"; +import { + benchmarkFolderPath, + benchmarkGridPath, + loadBenchmarkGrid, + saveBenchmarkGrid, +} from "./correctness-grid-benchmark.ts"; +import { renderCorrectnessImage } from "./correctness-chart.ts"; + +const DUMP_EVERY = 1000; +const PROGRESS_WIDTH = 30; + +// Requests are started at this fixed rate regardless of how many are already in +// flight (unlimited concurrency). Raise it to pile more load onto the backend; +// the progress line's in-flight count and last-call duration reveal when the +// GPU can no longer keep up. +const LAUNCH_RATE_PER_SECOND = 500; + +// ── progress ────────────────────────────────────────────────────────────── + +const writeProgress = ( + fraction: number, + inFlight: number, + lastDurationMs: number | null, +) => { + const filled = Math.round(fraction * PROGRESS_WIDTH); + const bar = "-".repeat(filled) + " ".repeat(PROGRESS_WIDTH - filled); + const pct = Math.round(fraction * 100) + .toString() + .padStart(3); + const last = + lastDurationMs === null ? "—" : `${Math.round(lastDurationMs)}ms`; + // Trailing spaces clear leftovers when the line shrinks (e.g. in-flight drops). + process.stdout.write( + `\r[${bar}] ${pct}% · ${inFlight.toLocaleString()} in flight · last ${last} `, + ); +}; + +// ── args ────────────────────────────────────────────────────────────────── + +type Args = { + model: string; + apiKey: string; +}; + +const parseArgs = (): Args => { + const args = process.argv.slice(2); + let model: string | undefined; + let apiKey: string | undefined = process.env.OPENAI_API_KEY; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--api-key") { + apiKey = args[++i]; + } else { + model = arg; + } + } + + if (model === undefined || model.length === 0) { + console.error( + "Usage: bun run models/addy-new/scripts/correctness-map-openai.ts [--api-key ]", + ); + process.exit(1); + } + + if (apiKey === undefined || apiKey.length === 0) { + console.error( + "An API key is required: pass --api-key or set OPENAI_API_KEY.", + ); + process.exit(1); + } + + return { model, apiKey }; +}; + +// ── correctness test ────────────────────────────────────────────────────── + +const SYSTEM_PROMPT = + "You are a calculator. Respond with only the integer result and nothing else."; + +/** Ask the model for a+b and return whether the reply is exactly correct. */ +const isCorrect = async ( + client: OpenAI, + model: string, + a: number, + b: number, +): Promise => { + const response = await client.chat.completions.create({ + model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: `${a}+${b}=` }, + ], + temperature: 0, + max_tokens: 16, + // Disable reasoning/thinking traces (e.g. Qwen3) via vLLM's chat template so + // we get only the answer — not in the OpenAI types, hence the cast. + chat_template_kwargs: { enable_thinking: false }, + } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming); + + const reply = response.choices[0]?.message?.content?.trim() ?? ""; + return reply === String(a + b); +}; + +// ── main ────────────────────────────────────────────────────────────────── + +const main = async () => { + const { model, apiKey } = parseArgs(); + + const client = new OpenAI({ + baseURL: "https://hvuq8u925pnz6y-8000.proxy.runpod.net/v1", + apiKey, + }); + + const { + grid, + testCount: resumedTestCount, + correctCount: resumedCorrectCount, + } = loadBenchmarkGrid(model); + let testCount = resumedTestCount; + let correctCount = resumedCorrectCount; + + if (testCount > 0) { + console.log( + `Resuming from ${benchmarkGridPath(model)} — ${testCount.toLocaleString()} cells already tested.`, + ); + } + + console.log( + `Benchmarking "${model}" against ${client.baseURL} at ${LAUNCH_RATE_PER_SECOND} req/s...`, + ); + console.log("Sampling forever — Ctrl-C to stop.\n"); + + let inFlight = 0; + let lastDurationMs: number | null = null; + + // The persist/chart step is async, so serialize it: with unlimited concurrency + // multiple completions could otherwise overlap their grid writes / chart dumps. + let dumpQueue: Promise = Promise.resolve(); + + // Fold one completed result into the grid + counts (synchronous, hence atomic + // under JS's single thread) and persist/re-chart every DUMP_EVERY new tests. + const onResult = (a: number, b: number, correct: boolean) => { + // A cell may be re-sampled; only count genuinely new tests so the counts + // stay in sync with the grid's non-null cells (matching loadGrid). + const previous = grid[a * GRID_SIZE + b]!; + grid[a * GRID_SIZE + b] = correct ? TRUE : FALSE; + + if (previous === TRUE) correctCount--; + else if (previous === FALSE) { + // already counted as a test, nothing to add + } else { + testCount++; + } + if (correct) correctCount++; + + const intoBatch = testCount % DUMP_EVERY; + if (intoBatch === 0) { + writeProgress(1, inFlight, lastDurationMs); + process.stdout.write("\n"); + + // Snapshot the counts now; they keep advancing while the dump runs. + const dumpTestCount = testCount; + const dumpCorrectCount = correctCount; + dumpQueue = dumpQueue.then(async () => { + saveBenchmarkGrid(grid, model); + const outputPath = await renderCorrectnessImage({ + grid, + outputDir: benchmarkFolderPath(model), + label: model, + testCount: dumpTestCount, + correctCount: dumpCorrectCount, + }); + const accuracy = (dumpCorrectCount / dumpTestCount) * 100; + console.log( + `${dumpTestCount.toLocaleString()} tests · ${accuracy.toFixed(2)}% correct · wrote ${outputPath}`, + ); + process.stdout.write("\n"); + }); + } else { + writeProgress(intoBatch / DUMP_EVERY, inFlight, lastDurationMs); + } + }; + + // Fire one request without awaiting it, so the ticker keeps launching on + // schedule no matter how long the call takes. + const launchOne = () => { + const a = Math.floor(Math.random() * GRID_SIZE); + const b = Math.floor(Math.random() * GRID_SIZE); + + inFlight++; + const start = performance.now(); + + isCorrect(client, model, a, b) + .then((correct) => { + lastDurationMs = performance.now() - start; + inFlight--; + onResult(a, b, correct); + }) + .catch((error) => { + // Endpoint/network error: don't record a cell, just note it. We do NOT + // stall the scheduler — the whole point is to keep applying load. + lastDurationMs = performance.now() - start; + inFlight--; + const message = error instanceof Error ? error.message : String(error); + process.stdout.write(`\nRequest failed (${message})\n`); + }); + }; + + // Self-paced ticker: advance a cursor by the interval each tick so the launch + // rate stays steady and doesn't drift or bunch up under event-loop pressure. + // This timer also keeps the process alive. + const intervalMs = 1000 / LAUNCH_RATE_PER_SECOND; + let nextLaunchTime = performance.now(); + const tick = () => { + launchOne(); + nextLaunchTime += intervalMs; + const delay = Math.max(0, nextLaunchTime - performance.now()); + setTimeout(tick, delay); + }; + tick(); +}; + +main(); diff --git a/package.json b/package.json index 02cf5b5..60dc531 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@inquirer/prompts": "^8.4.2", "commander": "^14.0.3", + "openai": "^6.39.1", "typegpu": "^0.11.3", "zod": "^4.4.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff69e6d..66bebad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: commander: specifier: ^14.0.3 version: 14.0.3 + openai: + specifier: ^6.39.1 + version: 6.39.1(zod@4.4.3) typegpu: specifier: ^0.11.3 version: 0.11.3 @@ -849,6 +852,18 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + openai@6.39.1: + resolution: {integrity: sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1652,6 +1667,10 @@ snapshots: obug@2.1.1: {} + openai@6.39.1(zod@4.4.3): + optionalDependencies: + zod: 4.4.3 + pathe@2.0.3: {} picocolors@1.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dbb26c8 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: true + sharp: true