From edcee45f1cd78f5243d15c0505dfb1b1689872f3 Mon Sep 17 00:00:00 2001 From: mike Date: Fri, 19 Dec 2025 16:20:03 +0100 Subject: [PATCH] clean --- .aiignore | 3 +- .env | 2 - .gitignore | 3 +- node/export_format.js | 191 ----------- node/main.js | 26 -- node/swedish_generator.js | 654 ------------------------------------- src/puzzle/ThemeGraph.java | 54 +-- 7 files changed, 31 insertions(+), 902 deletions(-) delete mode 100644 .env delete mode 100644 node/export_format.js delete mode 100644 node/main.js delete mode 100644 node/swedish_generator.js diff --git a/.aiignore b/.aiignore index 275c5aa..e2bd264 100644 --- a/.aiignore +++ b/.aiignore @@ -2,4 +2,5 @@ paper/ .git/ data/ target/ -.idea/ \ No newline at end of file +.idea/ +.env \ No newline at end of file diff --git a/.env b/.env deleted file mode 100644 index 855b496..0000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -PUZZLE_ROOT_DIR=/home/mike/dev/puzzle-generator -OUT_DIR=/home/mike/dev/puzzle-generator/data \ No newline at end of file diff --git a/.gitignore b/.gitignore index 050aff5..bf13b8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea/ /vocab/.custom/ **/.custom/ -target/ \ No newline at end of file +target/ +.env \ No newline at end of file diff --git a/node/export_format.js b/node/export_format.js deleted file mode 100644 index 8cab362..0000000 --- a/node/export_format.js +++ /dev/null @@ -1,191 +0,0 @@ -// export_format.js -"use strict"; - -const DIRS = { - "1": [-1, 0], // up - "2": [0, 1], // right - "3": [1, 0], // down - "4": [0, -1], // left -}; - -const isDigit = (ch) => ch >= "1" && ch <= "4"; -const isLetter = (ch) => ch >= "A" && ch <= "Z"; - -function toGrid2D(grid) { - if (Array.isArray(grid) && typeof grid[0] === "string") return grid.map(r => r.split("")); - return grid; // assume 2D char array -} - -function inBounds(H, W, r, c) { - return r >= 0 && r < H && c >= 0 && c < W; -} - -/** - * Extract a word run for a clue cell at (r,c) with direction digit d. - * Returns canonical representation where: - * - direction is only "horizontal"(right) or "vertical"(down) - * - startRow/startCol is the first letter cell in that canonical direction - * - arrowRow/arrowCol is immediately before the start (left or above) - * - word is read from grid in canonical order (start -> end) - */ -function extractPlacedFromClue(g, r, c, d, maxLen = 8, minLen = 2) { - const H = g.length, W = g[0].length; - const [dr, dc] = DIRS[d]; - - // collect letter cells in the ORIGINAL direction away from the clue - const cells = []; - let rr = r + dr, cc = c + dc; - while (inBounds(H, W, rr, cc) && isLetter(g[rr][cc]) && cells.length < maxLen) { - cells.push([rr, cc]); - rr += dr; - cc += dc; - } - - if (cells.length < minLen) return null; - - // Canonicalize so we always output right/down runs - // If original was right (2) or down (3): start is first cell, arrow is clue cell - // If original was left (4): start is the farthest-left cell, arrow is one cell left of start - // If original was up (1): start is the topmost cell, arrow is one cell above start - let startRow, startCol, arrowRow, arrowCol, direction; - - if (d === "2") { // right - direction = "horizontal"; - [startRow, startCol] = cells[0]; - arrowRow = r; - arrowCol = c; // clue cell is before start - } else if (d === "3") { // down - direction = "vertical"; - [startRow, startCol] = cells[0]; - arrowRow = r; - arrowCol = c; - } else if (d === "4") { // left => canonical right - direction = "horizontal"; - // farthest left is last in cells list (because we walked left) - [startRow, startCol] = cells[cells.length - 1]; - arrowRow = startRow; - arrowCol = startCol - 1; - } else if (d === "1") { // up => canonical down - direction = "vertical"; - [startRow, startCol] = cells[cells.length - 1]; // topmost - arrowRow = startRow - 1; - arrowCol = startCol; - } else { - return null; - } - - // Read the word from the grid in canonical order (right or down) - const wordChars = []; - if (direction === "horizontal") { - for (let i = 0; i < cells.length; i++) { - const ch = (inBounds(H, W, startRow, startCol + i) ? g[startRow][startCol + i] : "#"); - if (!isLetter(ch)) break; - wordChars.push(ch); - } - } else { - for (let i = 0; i < cells.length; i++) { - const ch = (inBounds(H, W, startRow + i, startCol) ? g[startRow + i][startCol] : "#"); - if (!isLetter(ch)) break; - wordChars.push(ch); - } - } - - const word = wordChars.join(""); - if (word.length < minLen || word.length > maxLen) return null; - - return { - word, - clue: word, // placeholder; you’ll replace later - startRow, - startCol, - direction, - answer: word, - arrowRow, - arrowCol, - // For cropping: - _cells: direction === "horizontal" - ? Array.from({length: word.length}, (_, i) => [startRow, startCol + i]) - : Array.from({length: word.length}, (_, i) => [startRow + i, startCol]), - _arrow: [arrowRow, arrowCol], - }; -} - -/** - * Transform your generator output into the JSON format you showed. - * @param {Object} puz - { grid: string[]|char[][], clueMap?: object } - */ -function exportFormatFromFilled(puz, difficulty = 1, rewards = {coins: 50, stars: 2, hints: 1}) { - const g = toGrid2D(puz.grid); - const H = g.length, W = g[0].length; - - // 1) extract "placed" list from all clue digits in the filled grid - const placed = []; - const seen = new Set(); // avoid duplicates by start+dir - for (let r = 0; r < H; r++) { - for (let c = 0; c < W; c++) { - const ch = g[r][c]; - if (!isDigit(ch)) continue; - - const p = extractPlacedFromClue(g, r, c, ch, 8, 2); - if (!p) continue; - - const key = `${p.startRow},${p.startCol}:${p.direction}:${p.word}`; - if (seen.has(key)) continue; - seen.add(key); - placed.push(p); - } - } - - if (placed.length === 0) { - return {gridv2: g.map(row => row.map(ch => (isLetter(ch) ? ch : "#")).join("")), words: [], difficulty, rewards}; - } - - // 2) compute bounding box around all word cells + arrow cells, with 1-cell margin - const allCells = []; - for (const p of placed) { - for (const [rr, cc] of p._cells) allCells.push([rr, cc]); - allCells.push(p._arrow); - } - - let minR = Math.min(...allCells.map(([r]) => r)) - 1; - let minC = Math.min(...allCells.map(([, c]) => c)) - 1; - let maxR = Math.max(...allCells.map(([r]) => r)) + 1; - let maxC = Math.max(...allCells.map(([, c]) => c)) + 1; - - // 3) build a map of only the used letter cells (so everything else becomes '#') - const letterAt = new Map(); - for (const p of placed) { - for (const [rr, cc] of p._cells) { - if (inBounds(H, W, rr, cc) && isLetter(g[rr][cc])) { - letterAt.set(`${rr},${cc}`, g[rr][cc]); - } - } - } - - // 4) render gridv2 - const gridv2 = []; - for (let r = minR; r <= maxR; r++) { - let row = ""; - for (let c = minC; c <= maxC; c++) { - const ch = letterAt.get(`${r},${c}`); - row += ch ? ch : "#"; - } - gridv2.push(row); - } - - // 5) words output with cropped coordinates - const words_out = placed.map(p => ({ - word: p.word, - clue: p.clue, // currently word itself - startRow: p.startRow - minR, - startCol: p.startCol - minC, - direction: p.direction, - answer: p.word, - arrowRow: p.arrowRow - minR, - arrowCol: p.arrowCol - minC, - })); - - return {gridv2, words: words_out, difficulty, rewards}; -} - -module.exports = {exportFormatFromFilled}; diff --git a/node/main.js b/node/main.js deleted file mode 100644 index 653352f..0000000 --- a/node/main.js +++ /dev/null @@ -1,26 +0,0 @@ -const {parseArgs, generatePuzzle, gridToString} = require("./swedish_generator"); -const {exportFormatFromFilled} = require("./export_format"); - -// ---- main ---- - -(function main() { - const opts = parseArgs(process.argv); - console.log(opts); - - const res = generatePuzzle(opts); - if (!res) { - console.error("Failed to generate a fillable puzzle."); - process.exit(1); - } - - // Existing logs... - console.log("\n=== FILLED PUZZLE (RAW) ==="); - console.log(gridToString(res.filled.grid)); - - // ✅ Transform to your JSON format - const puz = {grid: res.filled.grid, clueMap: res.filled.clueMap}; - const json = exportFormatFromFilled(puz, 1); - - console.log("\n=== EXPORTED JSON ==="); - console.log(JSON.stringify(json, null, 2)); -})(); diff --git a/node/swedish_generator.js b/node/swedish_generator.js deleted file mode 100644 index 295ec30..0000000 --- a/node/swedish_generator.js +++ /dev/null @@ -1,654 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("fs"); - -const W = 9, H = 8; -const MIN_LEN = 2, MAX_LEN = 8; - -const DIRS = { - "1": [-1, 0], // up - "2": [0, 1], // right - "3": [1, 0], // down - "4": [0, -1], // left -}; - -const IS_DIGIT = (ch) => ch >= "1" && ch <= "4"; -const IS_LETTER = (ch) => ch >= "A" && ch <= "Z"; -const IS_LETTER_CELL = (ch) => ch === "#" || IS_LETTER(ch); - -function usage() { - console.log(`Usage: - node swedish_generator.js [--seed N] [--pop N] [--gens N] [--tries N] [--words word-list.txt] - -Defaults: - --seed 1 - --pop 18 - --gens 100 - --tries 50 - --words ./word-list.txt -`); -} - -function parseArgs(argv) { - const out = {seed: 1, pop: 18, gens: 100, tries: 50, wordsPath: "./word-list.txt"}; - for (let i = 2; i < argv.length; i++) { - const a = argv[i]; - const v = argv[i + 1]; - if (a === "--help" || a === "-h") { - usage(); - process.exit(0); - } - if (a === "--seed") out.seed = parseInt(v, 10), i++; - else if (a === "--pop") out.pop = parseInt(v, 10), i++; - else if (a === "--gens") out.gens = parseInt(v, 10), i++; - else if (a === "--tries") out.tries = parseInt(v, 10), i++; - else if (a === "--words") out.wordsPath = v, i++; - else throw new Error(`Unknown arg: ${a}`); - } - return out; -} - -/** Seeded RNG (xorshift32) */ -function makeRng(seed) { - let x = (seed >>> 0) || 1; - return { - nextU32() { - x ^= x << 13; - x >>>= 0; - x ^= x >>> 17; - x >>>= 0; - x ^= x << 5; - x >>>= 0; - return x >>> 0; - }, - int(min, max) { - const r = this.nextU32(); - return min + (r % (max - min + 1)); - }, - float() { - return this.nextU32() / 0xFFFFFFFF; - }, - }; -} - -function clamp(x, a, b) { return Math.max(a, Math.min(b, x)); } - -function makeEmptyGrid() { - return Array.from({length: H}, () => Array.from({length: W}, () => "#")); -} - -function deepCopyGrid(g) { return g.map(r => r.slice()); } - -function gridToString(g) { return g.map(r => r.join("")).join("\n"); } - -function renderHuman(g) { - return g.map(row => row.map(ch => IS_DIGIT(ch) ? " " : ch).join("")).join("\n"); -} - -/** --- Words / index --- */ -function loadWords(wordsPath) { - let raw = ""; - try { - raw = fs.readFileSync(wordsPath, "utf8"); - } catch { - raw = "EU\nUUR\nAUTO\nBOOM\nHUIS\nKAT\nZEE\nRODE\nDRAAD\nKENNIS\nNETWERK\nPAKTE\n"; - } - - const words = raw - .split(/\r?\n/g) - .map(s => s.trim().toUpperCase()) - .filter(s => /^[A-Z]{2,8}$/.test(s)); - - // index[len] = { words: string[], pos: Array(len) of [26 arrays of indices] } - const index = new Map(); - const lenCounts = new Map(); - - for (const w of words) { - const L = w.length; - lenCounts.set(L, (lenCounts.get(L) || 0) + 1); - - if (!index.has(L)) { - const pos = Array.from({length: L}, () => - Array.from({length: 26}, () => []) - ); - index.set(L, {words: [], pos}); - } - const entry = index.get(L); - const idx = entry.words.length; - entry.words.push(w); - for (let i = 0; i < L; i++) { - entry.pos[i][w.charCodeAt(i) - 65].push(idx); - } - } - - return {words, index, lenCounts}; -} - -function intersectSorted(a, b) { - const out = []; - let i = 0, j = 0; - while (i < a.length && j < b.length) { - const x = a[i], y = b[j]; - if (x === y) { - out.push(x); - i++; - j++; - } else if (x < y) i++; - else j++; - } - return out; -} - -/** returns {indices?: number[], count: number} WITHOUT allocating huge arrays */ -function candidateInfoForPattern(entry, pattern /* array char|null */) { - const lists = []; - for (let i = 0; i < pattern.length; i++) { - const ch = pattern[i]; - if (ch && IS_LETTER(ch)) { - lists.push(entry.pos[i][ch.charCodeAt(0) - 65]); - } - } - if (lists.length === 0) { - return {indices: null, count: entry.words.length}; // unconstrained - } - lists.sort((a, b) => a.length - b.length); - let cur = lists[0]; - for (let k = 1; k < lists.length; k++) { - cur = intersectSorted(cur, lists[k]); - if (cur.length === 0) break; - } - return {indices: cur, count: cur.length}; -} - -/** --- Slots --- */ -function extractSlots(grid) { - const slots = []; - for (let r = 0; r < H; r++) { - for (let c = 0; c < W; c++) { - const d = grid[r][c]; - if (!IS_DIGIT(d)) continue; - - const [dr, dc] = DIRS[d]; - let rr = r + dr, cc = c + dc; - if (rr < 0 || rr >= H || cc < 0 || cc >= W) continue; - if (!IS_LETTER_CELL(grid[rr][cc])) continue; - - const cells = []; - while (rr >= 0 && rr < H && cc >= 0 && cc < W) { - const ch = grid[rr][cc]; - if (!IS_LETTER_CELL(ch)) break; - cells.push([rr, cc]); - rr += dr; - cc += dc; - if (cells.length > MAX_LEN) break; - } - - slots.push({clue: [r, c, d], dir: d, cells, len: cells.length}); - } - } - return slots; -} - -function hasRoomForClue(grid, r, c, d) { - const [dr, dc] = DIRS[d]; - let rr = r + dr, cc = c + dc; - let run = 0; - while (rr >= 0 && rr < H && cc >= 0 && cc < W && IS_LETTER_CELL(grid[rr][cc]) && run < MAX_LEN) { - run++; - rr += dr; - cc += dc; - } - return run >= MIN_LEN; -} - -/** --- FAST mask fitness (structural only) --- */ -function maskFitness(grid, lenCounts) { - let penalty = 0; - - // clue density (avoid all digits) - let clueCount = 0; - for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { - if (IS_DIGIT(grid[r][c])) clueCount++; - } - const targetClues = Math.round(W * H * 0.25); // ~18 - penalty += 8 * Math.abs(clueCount - targetClues); - - const slots = extractSlots(grid); - if (slots.length === 0) return 1e9; - - // coverage counts per letter cell: horiz vs vert - const covH = Array.from({length: H}, () => Array(W).fill(0)); - const covV = Array.from({length: H}, () => Array(W).fill(0)); - - for (const s of slots) { - const horiz = (s.dir === "2" || s.dir === "4"); - - if (s.len < MIN_LEN) penalty += 8000; - if (s.len > MAX_LEN) penalty += 8000 + (s.len - MAX_LEN) * 500; - - // dictionary availability only (cheap) - if (s.len >= MIN_LEN && s.len <= MAX_LEN) { - if (!lenCounts.get(s.len)) penalty += 12000; - } - - for (const [r, c] of s.cells) { - if (horiz) covH[r][c] += 1; - else covV[r][c] += 1; - } - } - - // coverage penalties per letter cell - for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { - if (!IS_LETTER_CELL(grid[r][c])) continue; - const h = covH[r][c], v = covV[r][c]; - if (h === 0 && v === 0) penalty += 1500; - else if (h > 0 && v > 0) penalty += 0; - else if (h + v === 1) penalty += 200; - else penalty += 600; - } - - // clue clustering (8-connected) - const seen = Array.from({length: H}, () => Array(W).fill(false)); - const nbrs8 = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]; - for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { - if (!IS_DIGIT(grid[r][c]) || seen[r][c]) continue; - const stack = [[r, c]]; - seen[r][c] = true; - let size = 0; - while (stack.length) { - const [x, y] = stack.pop(); - size++; - for (const [dr, dc] of nbrs8) { - const nx = x + dr, ny = y + dc; - if (nx < 0 || nx >= H || ny < 0 || ny >= W) continue; - if (seen[nx][ny]) continue; - if (!IS_DIGIT(grid[nx][ny])) continue; - seen[nx][ny] = true; - stack.push([nx, ny]); - } - } - if (size >= 2) penalty += (size - 1) * 120; - } - - // dead-end-ish letter cell (3+ walls) - const nbrs4 = [[-1, 0], [1, 0], [0, -1], [0, 1]]; - for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { - if (!IS_LETTER_CELL(grid[r][c])) continue; - let walls = 0; - for (const [dr, dc] of nbrs4) { - const rr = r + dr, cc = c + dc; - if (rr < 0 || rr >= H || cc < 0 || cc >= W) { - walls++; - continue; - } - if (!IS_LETTER_CELL(grid[rr][cc])) walls++; - } - if (walls >= 3) penalty += 400; - } - - return penalty; -} - -/** --- Mask generation (memetic-ish + hillclimb) --- */ -function randomMask(rng) { - const g = makeEmptyGrid(); - const targetClues = Math.round(W * H * 0.25); // ~18 - let placed = 0, guard = 0; - - while (placed < targetClues && guard++ < 4000) { - const r = rng.int(0, H - 1); - const c = rng.int(0, W - 1); - if (IS_DIGIT(g[r][c])) continue; - - const d = String(rng.int(1, 4)); - g[r][c] = d; - if (!hasRoomForClue(g, r, c, d)) { - g[r][c] = "#"; - continue; - } - placed++; - } - return g; -} - -function mutate(rng, grid) { - const g = deepCopyGrid(grid); - const cx = rng.int(0, H - 1); - const cy = rng.int(0, W - 1); - - const steps = 4; - for (let k = 0; k < steps; k++) { - const rr = clamp(cx + (rng.int(-2, 2) + rng.int(-2, 2)), 0, H - 1); - const cc = clamp(cy + (rng.int(-2, 2) + rng.int(-2, 2)), 0, W - 1); - - const cur = g[rr][cc]; - if (IS_DIGIT(cur)) { - g[rr][cc] = "#"; - } else { - const d = String(rng.int(1, 4)); - g[rr][cc] = d; - if (!hasRoomForClue(g, rr, cc, d)) g[rr][cc] = "#"; - } - } - return g; -} - -function crossover(rng, a, b) { - const out = makeEmptyGrid(); - const cx = (H - 1) / 2; - const cy = (W - 1) / 2; - const theta = rng.float() * Math.PI; - const nx = Math.cos(theta); - const ny = Math.sin(theta); - - for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { - const x = r - cx, y = c - cy; - const side = x * nx + y * ny; - out[r][c] = (side >= 0) ? a[r][c] : b[r][c]; - } - - // cleanup invalid clues - for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { - const ch = out[r][c]; - if (IS_DIGIT(ch) && !hasRoomForClue(out, r, c, ch)) out[r][c] = "#"; - } - return out; -} - -function hillclimb(rng, start, lenCounts, limit) { - let best = deepCopyGrid(start); - let bestF = maskFitness(best, lenCounts); - let fails = 0; - - while (fails < limit) { - const cand = mutate(rng, best); - const f = maskFitness(cand, lenCounts); - if (f < bestF) { - best = cand; - bestF = f; - fails = 0; - } else { - fails++; - } - } - return best; -} - -function similarity(a, b) { - let same = 0; - for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { - if (a[r][c] === b[r][c]) same++; - } - return same / (W * H); -} - -function generateMask(rng, lenCounts, popSize, gens) { - console.log(`generateMask init pop: ${popSize}`); - let pop = []; - for (let i = 0; i < popSize; i++) { - const g = randomMask(rng); - pop.push(hillclimb(rng, g, lenCounts, 180)); // faster init - } - - for (let gen = 0; gen < gens; gen++) { - const children = []; - const pairs = Math.max(popSize, Math.floor(popSize * 1.5)); - for (let k = 0; k < pairs; k++) { - const p1 = pop[rng.int(0, pop.length - 1)]; - const p2 = pop[rng.int(0, pop.length - 1)]; - const child = crossover(rng, p1, p2); - children.push(hillclimb(rng, child, lenCounts, 70)); // light repair - } - - pop = pop.concat(children); - pop.sort((x, y) => maskFitness(x, lenCounts) - maskFitness(y, lenCounts)); - - // similarity cull - const next = []; - for (const cand of pop) { - if (next.length >= popSize) break; - let ok = true; - for (const kept of next) { - if (similarity(cand, kept) > 0.92) { - ok = false; - break; - } - } - if (ok) next.push(cand); - } - pop = next; - - if ((gen % 10) === 0) { - const bestF = maskFitness(pop[0], lenCounts); - console.log(` gen ${gen}/${gens} bestFitness=${bestF}`); - } - } - - pop.sort((x, y) => maskFitness(x, lenCounts) - maskFitness(y, lenCounts)); - return pop[0]; -} - -/** --- Fill (CSP) with NO huge candidate arrays --- */ -function fillMask(rng, mask, dictIndex, opts = {}) { - const grid = deepCopyGrid(mask); - const slots = extractSlots(grid).filter(s => s.len >= MIN_LEN && s.len <= MAX_LEN); - - const used = new Set(); - const assigned = new Map(); - - // progress options - const logEveryMs = opts.logEveryMs ?? 250; - const timeLimitMs = opts.timeLimitMs ?? 0; // 0 = no limit - - // crossing weight precompute - const cellCount = Array.from({length: H}, () => Array(W).fill(0)); - for (const s of slots) for (const [r, c] of s.cells) cellCount[r][c]++; - - function slotKey(s) { return `${s.clue[0]},${s.clue[1]}:${s.clue[2]}`; } - - function patternForSlot(s) { - return s.cells.map(([r, c]) => { - const ch = grid[r][c]; - return IS_LETTER(ch) ? ch : null; - }); - } - - function slotScore(s) { - let cross = 0; - for (const [r, c] of s.cells) cross += (cellCount[r][c] - 1); - return cross * 10 + s.len; - } - - function placeWord(s, w) { - const undo = []; - for (let i = 0; i < s.cells.length; i++) { - const [r, c] = s.cells[i]; - const prev = grid[r][c]; - const ch = w[i]; - if (prev === "#") { - undo.push([r, c, prev]); - grid[r][c] = ch; - } else if (prev !== ch) { - return null; - } - } - return undo; - } - - function undoPlace(undo) { for (const [r, c, prev] of undo) grid[r][c] = prev; } - - // ---- progress bar ---- - const t0 = Date.now(); - let lastLog = t0; - let nodes = 0; - let backtracks = 0; - let lastMRV = 0; - - function renderProgress(final = false) { - const now = Date.now(); - if (!final && (now - lastLog) < logEveryMs) return; - lastLog = now; - - const done = assigned.size; - const total = slots.length; - const pct = total ? Math.floor((done / total) * 100) : 100; - const barLen = 22; - const filled = Math.min(barLen, Math.floor((pct / 100) * barLen)); - const bar = `[${"#".repeat(filled)}${"-".repeat(barLen - filled)}]`; - - const elapsed = ((now - t0) / 1000).toFixed(1); - const msg = - `${bar} ${done}/${total} slots | nodes=${nodes} | backtracks=${backtracks} | mrv=${lastMRV} | ${elapsed}s`; - - process.stdout.write("\r" + msg.padEnd(120)); - if (final) process.stdout.write("\n"); - } - - function chooseMRV() { - let best = null; - let bestInfo = null; - - for (const s of slots) { - const k = slotKey(s); - if (assigned.has(k)) continue; - - const entry = dictIndex.get(s.len); - if (!entry) return {slot: null, info: null}; - - const pat = patternForSlot(s); - const info = candidateInfoForPattern(entry, pat); - - if (info.count === 0) return {slot: null, info: null}; - - if ( - !best || - info.count < bestInfo.count || - (info.count === bestInfo.count && slotScore(s) > slotScore(best)) - ) { - best = s; - bestInfo = info; - if (info.count <= 1) break; - } - } - - if (!best) return {slot: null, info: {done: true}}; - return {slot: best, info: bestInfo}; - } - - const MAX_TRIES_PER_SLOT = 500; - - function backtrack() { - nodes++; - - if (timeLimitMs && (Date.now() - t0) > timeLimitMs) return false; - - const pick = chooseMRV(); - if (!pick.slot && pick.info && pick.info.done) return true; - if (!pick.slot) { - backtracks++; - return false; - } - - lastMRV = pick.info.count; - renderProgress(false); - - const s = pick.slot; - const k = slotKey(s); - const entry = dictIndex.get(s.len); - const pat = patternForSlot(s); - - const tryWord = (w) => { - if (!w) return false; - if (used.has(w)) return false; - - for (let i = 0; i < pat.length; i++) { - if (pat[i] && pat[i] !== w[i]) return false; - } - - const undo = placeWord(s, w); - if (!undo) return false; - - used.add(w); - assigned.set(k, w); - - if (backtrack()) return true; - - assigned.delete(k); - used.delete(w); - undoPlace(undo); - return false; - }; - - // constrained: iterate indices (bounded) - if (pick.info.indices && pick.info.indices.length) { - const idxs = pick.info.indices; - const L = idxs.length; - const tries = Math.min(MAX_TRIES_PER_SLOT, L); - - // safe stepping even for L=1 - const start = (L === 1) ? 0 : rng.int(0, L - 1); - const step = (L <= 1) ? 1 : rng.int(1, L - 1); - - for (let t = 0; t < tries; t++) { - const idx = idxs[(start + t * step) % L]; - const w = entry.words[idx]; - if (tryWord(w)) return true; - } - backtracks++; - return false; - } - - // unconstrained: sample without building arrays - const N = entry.words.length; - if (N === 0) { - backtracks++; - return false; - } - - const tries = Math.min(MAX_TRIES_PER_SLOT, N); - const start = (N === 1) ? 0 : rng.int(0, N - 1); - const step = (N <= 1) ? 1 : rng.int(1, N - 1); - - for (let t = 0; t < tries; t++) { - const idx = (start + t * step) % N; - const w = entry.words[idx]; - if (tryWord(w)) return true; - } - - backtracks++; - return false; - } - - renderProgress(false); - const ok = backtrack(); - renderProgress(true); - - const clueMap = {}; - for (const [k, v] of assigned.entries()) clueMap[k] = v; - return {ok, grid, clueMap, stats: {nodes, backtracks, seconds: (Date.now() - t0) / 1000}}; -} - -/** --- Top-level: try mask+fill until success --- */ -function generatePuzzle(opts) { - const rng = makeRng(opts.seed); - console.time("LOAD_WORDS"); - const dict = loadWords(opts.wordsPath); - console.timeEnd("LOAD_WORDS"); - - for (let attempt = 1; attempt <= opts.tries; attempt++) { - console.log(`\nAttempt ${attempt}/${opts.tries}`); - console.time("MASK"); - const mask = generateMask(rng, dict.lenCounts, opts.pop, opts.gens); - console.timeEnd("MASK"); - - console.time("FILL"); - const filled = fillMask(rng, mask, dict.index, {logEveryMs: 200, timeLimitMs: 30000}); - console.timeEnd("FILL"); - - if (filled.ok) return {mask, filled}; - } - return null; -} - -module.exports = {parseArgs, generatePuzzle, gridToString}; \ No newline at end of file diff --git a/src/puzzle/ThemeGraph.java b/src/puzzle/ThemeGraph.java index 186a124..17f689d 100644 --- a/src/puzzle/ThemeGraph.java +++ b/src/puzzle/ThemeGraph.java @@ -59,7 +59,7 @@ public class ThemeGraph { * Score a word against a theme (0.0 = no match, 1.0 = perfect match) */ public static double scoreWordTheme(String word, String theme) { - Set keywords = THEME_KEYWORDS.get(theme.toLowerCase()); + var keywords = THEME_KEYWORDS.get(theme.toLowerCase()); if (keywords == null) { return 0.5; // unknown theme = neutral score } @@ -72,15 +72,15 @@ public class ThemeGraph { } // Substring match (partial relevance) - for (String kw : keywords) { + for (var kw : keywords) { if (word.contains(kw) || kw.contains(word)) { return 0.7; } } // Edit distance similarity (for typos/variations) - for (String kw : keywords) { - double similarity = editDistanceSimilarity(word, kw); + for (var kw : keywords) { + var similarity = editDistanceSimilarity(word, kw); if (similarity > 0.8) { return similarity * 0.9; } @@ -94,8 +94,8 @@ public class ThemeGraph { */ public static List filterByTheme(List words, String theme, double minScore) { List filtered = new ArrayList<>(); - for (String word : words) { - double score = scoreWordTheme(word, theme); + for (var word : words) { + var score = scoreWordTheme(word, theme); if (score >= minScore) { filtered.add(word); } @@ -108,8 +108,8 @@ public class ThemeGraph { */ public static List getThemesForWord(String word) { List scores = new ArrayList<>(); - for (String theme : THEME_KEYWORDS.keySet()) { - double score = scoreWordTheme(word, theme); + for (var theme : THEME_KEYWORDS.keySet()) { + var score = scoreWordTheme(word, theme); if (score > 0.0) { scores.add(new ThemeScore(theme, score)); } @@ -124,9 +124,9 @@ public class ThemeGraph { public static String detectTheme(List words) { Map themeScores = new HashMap<>(); - for (String theme : THEME_KEYWORDS.keySet()) { + for (var theme : THEME_KEYWORDS.keySet()) { double totalScore = 0; - for (String word : words) { + for (var word : words) { totalScore += scoreWordTheme(word, theme); } themeScores.put(theme, totalScore / words.size()); @@ -142,21 +142,21 @@ public class ThemeGraph { * Simple edit distance similarity (normalized Levenshtein) */ private static double editDistanceSimilarity(String a, String b) { - int dist = levenshtein(a, b); - int maxLen = Math.max(a.length(), b.length()); + var dist = levenshtein(a, b); + var maxLen = Math.max(a.length(), b.length()); if (maxLen == 0) return 1.0; return 1.0 - ((double) dist / maxLen); } private static int levenshtein(String a, String b) { - int[][] dp = new int[a.length() + 1][b.length() + 1]; + var dp = new int[a.length() + 1][b.length() + 1]; - for (int i = 0; i <= a.length(); i++) dp[i][0] = i; - for (int j = 0; j <= b.length(); j++) dp[0][j] = j; + for (var i = 0; i <= a.length(); i++) dp[i][0] = i; + for (var j = 0; j <= b.length(); j++) dp[0][j] = j; - for (int i = 1; i <= a.length(); i++) { - for (int j = 1; j <= b.length(); j++) { - int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1; + for (var i = 1; i <= a.length(); i++) { + for (var j = 1; j <= b.length(); j++) { + var cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1; dp[i][j] = Math.min( Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost @@ -180,26 +180,26 @@ public class ThemeGraph { System.out.println("=== Theme Graph Test ===\n"); // Test word scoring - String[] testWords = {"POLITIEK", "VOETBAL", "COMPUTER", "REGEN", "AUTO"}; - for (String word : testWords) { + var testWords = new String[]{ "POLITIEK", "VOETBAL", "COMPUTER", "REGEN", "AUTO" }; + for (var word : testWords) { System.out.println("Word: " + word); - List themes = getThemesForWord(word); - for (ThemeScore ts : themes) { + var themes = getThemesForWord(word); + for (var ts : themes) { System.out.println(" " + ts); } System.out.println(); } // Test theme detection - List techWords = Arrays.asList("COMPUTER", "INTERNET", "SOFTWARE", "DATA"); - String detected = detectTheme(techWords); + var techWords = Arrays.asList("COMPUTER", "INTERNET", "SOFTWARE", "DATA"); + var detected = detectTheme(techWords); System.out.println("Detected theme for tech words: " + detected); // Test filtering - List allWords = Arrays.asList( + var allWords = Arrays.asList( "POLITIEK", "COMPUTER", "AUTO", "VOETBAL", "INTERNET", "BOOM" - ); - List filtered = filterByTheme(allWords, "technologie", 0.5); + ); + var filtered = filterByTheme(allWords, "technologie", 0.5); System.out.println("\nFiltered for 'technologie' (min 0.5): " + filtered); } }