/** Content generation, not a test runner. Reads frozen v1; writes only research assets. * node --experimental-strip-types scripts/export-research.ts */ import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { tracePuzzle, TRACE_VERSION } from "../src/lib/puzzle-rating.ts"; import type { PuzzleRating, RatedDifficulty, TechniqueStep, PuzzleTechnique } from "../src/lib/puzzle-rating.ts"; const root = new URL("../", import.meta.url); const source = readFileSync(new URL("src/lib/puzzle-catalog.ts", root), "utf8"); const marker = "export const PUZZLE_CATALOG: Record = "; const markerIndex = source.indexOf(marker); if (markerIndex < 0) throw new Error("Expected the frozen catalog declaration; export format must be updated explicitly."); const catalog: Record = JSON.parse(source.slice(markerIndex + marker.length).trim().replace(/;$/, "")); const hash = (value: string) => createHash("sha256").update(value).digest("hex"); const grades: RatedDifficulty[] = ["easy", "medium", "hard", "expert"]; const median = (values: number[]) => { const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; }; const rows = grades.flatMap((difficulty) => catalog[difficulty].map((entry, index) => { const { result, steps } = tracePuzzle(entry.puzzle); const first = steps.findIndex((step) => step.eliminations.length > 0); return { id: `v1-${difficulty}-${String(index + 1).padStart(4, "0")}`, difficulty, puzzle: entry.puzzle, solution: entry.solution, clues: entry.rating.clues, hardestTechnique: entry.rating.hardestTechnique, recordedSteps: entry.rating.steps, traceStatus: result.status, firstEliminationStep: first < 0 ? null : first + 1, emptyAtFirstElimination: first < 0 ? null : steps[first].before.values.filter((value) => value === 0).length, firstEliminationTechnique: first < 0 ? null : steps[first].technique, placements: steps.reduce((sum, step) => sum + step.placements.length, 0), productiveEliminationPasses: steps.filter((step) => step.eliminations.length > 0).length, explicitCandidateRemovals: steps.reduce((sum, step) => sum + step.eliminations.length, 0), steps, }; })); const summaries = grades.map((difficulty) => { const group = rows.filter((row) => row.difficulty === difficulty); const clues = group.map((row) => row.clues); const stalledPositions = group.flatMap((row) => row.emptyAtFirstElimination === null ? [] : [row.emptyAtFirstElimination]); return { difficulty, count: group.length, minClues: Math.min(...clues), maxClues: Math.max(...clues), medianClues: median(clues), withElimination: stalledPositions.length, medianEmptyAtFirstElimination: stalledPositions.length ? median(stalledPositions) : null, minEmptyAtFirstElimination: stalledPositions.length ? Math.min(...stalledPositions) : null, maxEmptyAtFirstElimination: stalledPositions.length ? Math.max(...stalledPositions) : null, }; }); const histogram = Array.from(new Set(rows.map((row) => row.clues))).sort((a, b) => a - b).map((clues) => ({ clues, counts: grades.map((difficulty) => rows.filter((row) => row.clues === clues && row.difficulty === difficulty).length), })); type Example = { id: string; puzzleId: string; stepNumber: number; step: TechniqueStep }; function examplesFor(technique: PuzzleTechnique, variant?: TechniqueStep["variant"]): Example[] { return rows.flatMap((row) => { const index = row.steps.findIndex((step) => step.technique === technique && (variant === undefined || step.variant === variant)); return index < 0 ? [] : [{ id: `${row.id}-step-${index + 1}`, puzzleId: row.id, stepNumber: index + 1, step: row.steps[index] }]; }).slice(0, 2); } const examples = { pointing: examplesFor("locked-candidates", "pointing"), claiming: examplesFor("locked-candidates", "claiming"), nakedPair: examplesFor("naked-pair"), hiddenPair: examplesFor("hidden-pair"), }; // Select the first pair in stable catalog order; no claim of representativeness. const lowerClue = rows.find((row) => row.difficulty === "easy" && rows.some((other) => grades.indexOf(other.difficulty) > 0 && other.clues > row.clues)); const higherClue = lowerClue ? rows.find((row) => grades.indexOf(row.difficulty) > 0 && row.clues > lowerClue.clues) : undefined; const firstHard = rows.find((row) => row.difficulty === "hard" && row.firstEliminationStep !== null); const firstHardIndex = firstHard ? firstHard.firstEliminationStep! - 1 : -1; const hardCase = firstHard ? { puzzleId: firstHard.id, stepNumber: firstHardIndex + 1, singlesBefore: firstHardIndex, step: firstHard.steps[firstHardIndex], nextPlacement: firstHard.steps.slice(firstHardIndex + 1).find((step) => step.placements.length > 0) ?? null, } : null; const publicRows = rows.map((row) => ({ id: row.id, difficulty: row.difficulty, puzzle: row.puzzle, solution: row.solution, clues: row.clues, hardestTechnique: row.hardestTechnique, recordedSteps: row.recordedSteps, traceStatus: row.traceStatus, firstEliminationStep: row.firstEliminationStep, emptyAtFirstElimination: row.emptyAtFirstElimination, firstEliminationTechnique: row.firstEliminationTechnique, placements: row.placements, productiveEliminationPasses: row.productiveEliminationPasses, explicitCandidateRemovals: row.explicitCandidateRemovals, })); const summary = { datasetVersion: "catalog-v1", traceVersion: TRACE_VERSION, catalogSha256: hash(JSON.stringify(catalog)), graderSha256: hash(readFileSync(new URL("src/lib/puzzle-rating.ts", root), "utf8")), exporterSha256: hash(readFileSync(new URL("scripts/export-research.ts", root), "utf8")), total: rows.length, traceStatuses: { solved: rows.filter((row) => row.traceStatus === "solved").length, stalled: rows.filter((row) => row.traceStatus === "stalled").length, invalid: rows.filter((row) => row.traceStatus === "invalid").length }, summaries, histogram, comparison: lowerClue && higherClue ? [lowerClue, higherClue].map((row) => ({ id: row.id, clues: row.clues, difficulty: row.difficulty })) : [], examples, hardCase, }; const csvColumns = ["id", "difficulty", "clues", "hardestTechnique", "firstEliminationStep", "emptyAtFirstElimination", "firstEliminationTechnique", "placements", "productiveEliminationPasses", "explicitCandidateRemovals", "traceStatus", "puzzle", "solution"] as const; const csv = [csvColumns.join(","), ...publicRows.map((row) => csvColumns.map((column) => row[column] ?? "").join(","))].join("\n") + "\n"; mkdirSync(new URL("src/lib/research/", root), { recursive: true }); mkdirSync(new URL("public/research/", root), { recursive: true }); writeFileSync(new URL("src/lib/research/catalog-v1-summary.json", root), JSON.stringify(summary, null, 2) + "\n"); writeFileSync(new URL("public/research/catalog-v1.json", root), JSON.stringify({ ...summary, rows: publicRows }, null, 2) + "\n"); writeFileSync(new URL("public/research/catalog-v1.csv", root), csv); writeFileSync(new URL("public/research/export-research.ts.txt", root), readFileSync(new URL("scripts/export-research.ts", root))); writeFileSync(new URL("public/research/puzzle-rating.ts.txt", root), readFileSync(new URL("src/lib/puzzle-rating.ts", root))); writeFileSync(new URL("public/research/puzzle-catalog.ts.txt", root), source); process.stdout.write(JSON.stringify({ total: summary.total, summaries, traceStatuses: summary.traceStatuses, catalogSha256: summary.catalogSha256, exampleIds: Object.fromEntries(Object.entries(examples).map(([key, items]) => [key, items.map((item) => item.id)])) }, null, 2) + "\n");