Gather data

This commit is contained in:
mike
2026-01-03 22:28:16 +01:00
parent 734fb72c55
commit 29ed7fe254
7 changed files with 614 additions and 723 deletions

View File

@@ -5,216 +5,218 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import static puzzle.ExportFormat.*;
public class ClueGenerator {
private static final String OLLAMA_URL = "http://localhost:11434/api/chat";
private static final String MODEL = "qwen2.5:14b";
private static final String HINTS_FILE = "/data/puzzle/export_with_hints.csv";
private static Map<String, String> prebuiltClues = null;
private static synchronized void ensurePrebuiltCluesLoaded() {
if (prebuiltClues != null) return;
prebuiltClues = new HashMap<>();
try {
List<String> lines = Files.readAllLines(Path.of(HINTS_FILE), StandardCharsets.UTF_8);
for (String line : lines) {
String[] parts = line.split(",", 3);
if (parts.length >= 3) {
String word = parts[0].trim().toUpperCase(Locale.ROOT);
String rawClue = parts[2].trim();
if (rawClue.startsWith("\"") && rawClue.endsWith("\"")) {
rawClue = rawClue.substring(1, rawClue.length() - 1).replace("\"\"", "\"");
}
if (!word.isEmpty() && !rawClue.isEmpty()) {
prebuiltClues.put(word, rawClue);
}
}
private static final String OLLAMA_URL = "http://localhost:11434/api/chat";
private static final String MODEL = "qwen2.5:14b";
private static final String HINTS_FILE = "/home/mike/dev/puzzle-generator/nl_score_hints.csv";
private static Map<String, String> prebuiltClues = null;
private static synchronized void ensurePrebuiltCluesLoaded() {
if (prebuiltClues != null) return;
prebuiltClues = new HashMap<>();
try {
var lines = Files.readAllLines(Path.of(HINTS_FILE), StandardCharsets.UTF_8);
for (var line : lines) {
var parts = line.split(",", 3);
if (parts.length >= 3) {
var word = parts[0].trim().toUpperCase(Locale.ROOT);
var rawClue = parts[2].trim();
if (rawClue.startsWith("\"") && rawClue.endsWith("\"")) {
rawClue = rawClue.substring(1, rawClue.length() - 1).replace("\"\"", "\"");
}
if (!word.isEmpty() && !rawClue.isEmpty()) {
prebuiltClues.put(word, rawClue);
}
}
} catch (IOException e) {
System.err.println("Warning: " + HINTS_FILE + " not found or could not be read.");
}
}
public static ExportFormat.ExportedPuzzle applyClues(ExportFormat.ExportedPuzzle puzzle) {
if (puzzle == null || puzzle.words().isEmpty()) {
return puzzle;
}
ensurePrebuiltCluesLoaded();
Map<String, String> finalClueMap = new HashMap<>();
List<String> wordsMissingClues = new ArrayList<>();
for (var w : puzzle.words()) {
String wordUpper = w.word().toUpperCase(Locale.ROOT);
if (prebuiltClues.containsKey(wordUpper)) {
finalClueMap.put(w.word(), prebuiltClues.get(wordUpper));
} else {
wordsMissingClues.add(w.word());
}
}
if (!wordsMissingClues.isEmpty()) {
Map<String, String> generatedClues = generateClues(wordsMissingClues);
finalClueMap.putAll(generatedClues);
}
List<ExportFormat.WordOut> wordsWithClues = new ArrayList<>();
for (var w : puzzle.words()) {
String clue = finalClueMap.getOrDefault(w.word(), w.word());
wordsWithClues.add(new ExportFormat.WordOut(
w.word(),
clue,
w.startRow(),
w.startCol(),
w.direction(),
w.answer(),
w.arrowRow(),
w.arrowCol(),
w.isReversed()
));
}
return new ExportFormat.ExportedPuzzle(puzzle.gridv2(), wordsWithClues, puzzle.difficulty(), puzzle.rewards());
}
public static Map<String, String> generateClues(List<String> words) {
if (words == null || words.isEmpty()) {
}
} catch (IOException e) {
System.err.println("Warning: " + HINTS_FILE + " not found or could not be read.");
}
}
public static ExportedPuzzle applyClues(ExportedPuzzle puzzle) {
if (puzzle == null || puzzle.words().isEmpty()) {
return puzzle;
}
ensurePrebuiltCluesLoaded();
Map<String, String> finalClueMap = new HashMap<>();
List<String> wordsMissingClues = new ArrayList<>();
for (var w : puzzle.words()) {
var wordUpper = w.word().toUpperCase(Locale.ROOT);
if (prebuiltClues.containsKey(wordUpper)) {
finalClueMap.put(w.word(), prebuiltClues.get(wordUpper));
} else {
wordsMissingClues.add(w.word());
}
}
if (!wordsMissingClues.isEmpty()) {
var generatedClues = generateClues(wordsMissingClues);
finalClueMap.putAll(generatedClues);
}
List<WordOut> wordsWithClues = new ArrayList<>();
for (var w : puzzle.words()) {
var clue = finalClueMap.getOrDefault(w.word(), w.word());
wordsWithClues.add(new WordOut(
w.word(),
clue,
w.startRow(),
w.startCol(),
w.direction(),
w.answer(),
w.arrowRow(),
w.arrowCol(),
w.isReversed(),
w.complex()
));
}
return new ExportedPuzzle(puzzle.gridv2(), wordsWithClues, puzzle.difficulty(), puzzle.rewards());
}
public static Map<String, String> generateClues(List<String> words) {
if (words == null || words.isEmpty()) {
return Collections.emptyMap();
}
var prompt = createCluePrompt(words);
try {
var jsonRequest = String.format(
"{\"model\":\"%s\",\"messages\":[{\"role\":\"user\",\"content\":\"%s\"}],\"stream\":false,\"temperature\":0.7}",
MODEL, escapeJson(prompt)
);
var responseBody = curlPostJson(OLLAMA_URL, jsonRequest, 120);
var content = extractChatContent(responseBody);
if (content == null || content.isEmpty()) {
return Collections.emptyMap();
}
String prompt = createCluePrompt(words);
try {
String jsonRequest = String.format(
"{\"model\":\"%s\",\"messages\":[{\"role\":\"user\",\"content\":\"%s\"}],\"stream\":false,\"temperature\":0.7}",
MODEL, escapeJson(prompt)
);
String responseBody = curlPostJson(OLLAMA_URL, jsonRequest, 120);
String content = extractChatContent(responseBody);
if (content == null || content.isEmpty()) {
return Collections.emptyMap();
}
return parseCluesFromReply(words, content);
} catch (Exception e) {
System.err.println("Failed to generate clues: " + e.getMessage());
return Collections.emptyMap();
}
}
private static String createCluePrompt(List<String> words) {
return "Je bent een expert in het maken van kruiswoordpuzzels. Geef voor elk van de onderstaande woorden een korte, uitdagende maar duidelijke cryptische of beschrijvende aanwijzing in het Nederlands.\n\n" +
"Output ALLEEN in dit formaat:\n" +
"woord1:aanwijzing\n" +
"woord2:aanwijzing\n\n" +
"GEEN andere tekst of uitleg. Sla GEEN woorden over.\n\n" +
"Lijst:\n" +
String.join("\n", words);
}
private static Map<String, String> parseCluesFromReply(List<String> expectedWords, String reply) {
Map<String, String> wordClueMap = new HashMap<>();
var lines = reply.split("\n");
for (var line : lines) {
line = line.trim();
if (line.contains(":")) {
var parts = line.split(":", 2);
if (parts.length == 2) {
var wordPart = parts[0].trim().replaceAll("^[\\d+.)*\\-\\s]+", "").toLowerCase();
var clue = parts[1].trim();
if (!clue.isEmpty()) {
wordClueMap.put(wordPart, clue);
}
}
return parseCluesFromReply(words, content);
} catch (Exception e) {
System.err.println("Failed to generate clues: " + e.getMessage());
return Collections.emptyMap();
}
}
private static String createCluePrompt(List<String> words) {
return "Je bent een expert in het maken van kruiswoordpuzzels. Geef voor elk van de onderstaande woorden een korte, uitdagende maar duidelijke cryptische of beschrijvende aanwijzing in het Nederlands.\n\n" +
"Output ALLEEN in dit formaat:\n" +
"woord1:aanwijzing\n" +
"woord2:aanwijzing\n\n" +
"GEEN andere tekst of uitleg. Sla GEEN woorden over.\n\n" +
"Lijst:\n" +
String.join("\n", words);
}
private static Map<String, String> parseCluesFromReply(List<String> expectedWords, String reply) {
Map<String, String> wordClueMap = new HashMap<>();
String[] lines = reply.split("\n");
for (String line : lines) {
line = line.trim();
if (line.contains(":")) {
String[] parts = line.split(":", 2);
if (parts.length == 2) {
String wordPart = parts[0].trim().replaceAll("^[\\d+.)*\\-\\s]+", "").toLowerCase();
String clue = parts[1].trim();
if (!clue.isEmpty()) {
wordClueMap.put(wordPart, clue);
}
}
}
}
Map<String, String> results = new HashMap<>();
for (String word : expectedWords) {
String clue = wordClueMap.get(word.toLowerCase());
if (clue != null) {
results.put(word, clue);
}
}
return results;
}
private static String curlPostJson(String url, String jsonBody, int timeoutSeconds) throws Exception {
var tempFile = Files.createTempFile("clue-request-", ".json");
try {
Files.writeString(tempFile, jsonBody, StandardCharsets.UTF_8);
List<String> cmd = new ArrayList<>();
cmd.add("curl");
cmd.add("-fsSL");
cmd.add("--connect-timeout");
cmd.add("10");
cmd.add("--max-time");
cmd.add(String.valueOf(timeoutSeconds));
cmd.add("-H");
cmd.add("Content-Type: application/json");
cmd.add("-d");
cmd.add("@" + tempFile);
cmd.add(url);
var p = new ProcessBuilder(cmd)
.redirectErrorStream(true)
.start();
var bytes = p.getInputStream().readAllBytes();
var code = p.waitFor();
if (code != 0) {
throw new IOException("curl POST failed (" + code + ") url=" + url + "\nOutput:\n" +
new String(bytes, StandardCharsets.UTF_8));
}
return new String(bytes, StandardCharsets.UTF_8);
} finally {
Files.deleteIfExists(tempFile);
}
}
private static String extractChatContent(String json) {
if (json == null) return null;
var choices = json.indexOf("\"choices\"");
var p = (choices >= 0) ? choices : 0;
var i = json.indexOf("\"content\"", p);
if (i < 0) {
// Fallback for Ollama non-chat format if needed, but we used /api/chat
// Ollama /api/chat returns {"model":"...","message":{"role":"assistant","content":"..."}}
i = json.indexOf("\"content\"");
if (i < 0) return null;
}
var colon = json.indexOf(':', i);
if (colon < 0) return null;
var q = json.indexOf('"', colon + 1);
if (q < 0) return null;
var sb = new StringBuilder();
var esc = false;
for (var k = q + 1; k < json.length(); k++) {
var ch = json.charAt(k);
if (esc) {
if (ch == 'n') sb.append('\n');
else if (ch == 't') sb.append('\t');
else if (ch == 'r') sb.append('\r');
else sb.append(ch);
esc = false;
} else {
if (ch == '\\') esc = true;
else if (ch == '"') break;
else sb.append(ch);
}
}
return sb.toString();
}
private static String escapeJson(String str) {
return str.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n");
}
}
}
Map<String, String> results = new HashMap<>();
for (var word : expectedWords) {
var clue = wordClueMap.get(word.toLowerCase());
if (clue != null) {
results.put(word, clue);
}
}
return results;
}
private static String curlPostJson(String url, String jsonBody, int timeoutSeconds) throws Exception {
var tempFile = Files.createTempFile("clue-request-", ".json");
try {
Files.writeString(tempFile, jsonBody, StandardCharsets.UTF_8);
List<String> cmd = new ArrayList<>();
cmd.add("curl");
cmd.add("-fsSL");
cmd.add("--connect-timeout");
cmd.add("10");
cmd.add("--max-time");
cmd.add(String.valueOf(timeoutSeconds));
cmd.add("-H");
cmd.add("Content-Type: application/json");
cmd.add("-d");
cmd.add("@" + tempFile);
cmd.add(url);
var p = new ProcessBuilder(cmd)
.redirectErrorStream(true)
.start();
var bytes = p.getInputStream().readAllBytes();
var code = p.waitFor();
if (code != 0) {
throw new IOException("curl POST failed (" + code + ") url=" + url + "\nOutput:\n" +
new String(bytes, StandardCharsets.UTF_8));
}
return new String(bytes, StandardCharsets.UTF_8);
} finally {
Files.deleteIfExists(tempFile);
}
}
private static String extractChatContent(String json) {
if (json == null) return null;
var choices = json.indexOf("\"choices\"");
var p = (choices >= 0) ? choices : 0;
var i = json.indexOf("\"content\"", p);
if (i < 0) {
// Fallback for Ollama non-chat format if needed, but we used /api/chat
// Ollama /api/chat returns {"model":"...","message":{"role":"assistant","content":"..."}}
i = json.indexOf("\"content\"");
if (i < 0) return null;
}
var colon = json.indexOf(':', i);
if (colon < 0) return null;
var q = json.indexOf('"', colon + 1);
if (q < 0) return null;
var sb = new StringBuilder();
var esc = false;
for (var k = q + 1; k < json.length(); k++) {
var ch = json.charAt(k);
if (esc) {
if (ch == 'n') sb.append('\n');
else if (ch == 't') sb.append('\t');
else if (ch == 'r') sb.append('\r');
else sb.append(ch);
esc = false;
} else {
if (ch == '\\') esc = true;
else if (ch == '"') break;
else sb.append(ch);
}
}
return sb.toString();
}
private static String escapeJson(String str) {
return str.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n");
}
}

View File

@@ -1,260 +0,0 @@
package puzzle;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.*;
/**
* DailyGenerator - Generates daily themed puzzles with JSON output
*/
public class DailyGenerator {
private static String env(String name, String defaultValue) {
var val = System.getenv(name);
return (val == null || val.isEmpty()) ? defaultValue : val;
}
private static int envInt(String name, int defaultValue) {
try {
return Integer.parseInt(env(name, String.valueOf(defaultValue)));
} catch (NumberFormatException e) {
return defaultValue;
}
}
private static boolean envBool(String name, boolean defaultValue) {
var val = env(name, String.valueOf(defaultValue));
return "true".equalsIgnoreCase(val) || "1".equals(val);
}
static void main(String[] args) {
var outDir = env("OUT_DIR", "/home/mike/dev/puzzle-generator/data/");
var wordsPath = env("WORDS_PATH", "/data/puzzle/export_with_hints.csv");
var puzzlesPerDay = envInt("PUZZLES_PER_DAY", 3);
var seed = envInt("SEED", (int) System.currentTimeMillis());
var themeFilter = envBool("THEME_FILTER", true);
var themeMinScore = Double.parseDouble(env("THEME_MIN_SCORE", "0.0"));
var today = LocalDate.now();
var dateStr = today.toString();
System.out.println("=== Daily Puzzle Generator ===");
System.out.println("Date: " + dateStr);
System.out.println("Output: " + outDir);
System.out.println("Puzzles per day: " + puzzlesPerDay);
System.out.println("Theme filtering: " + themeFilter);
System.out.println();
// Load word list
SwedishGenerator.Dict dict;
var llmScores = SwedishGenerator.loadScores();
try {
dict = SwedishGenerator.loadWords(wordsPath, llmScores);
System.out.println("Loaded " + dict.words.size() + " words");
} catch (Exception e) {
System.err.println("Failed to load words: " + e.getMessage());
System.exit(1);
return;
}
// Create output directory
try {
Files.createDirectories(Paths.get(outDir));
} catch (IOException e) {
System.err.println("Failed to create output dir: " + e.getMessage());
System.exit(1);
return;
}
// Generate puzzles
List<String> generatedFiles = new ArrayList<>();
var themes = new String[]{ "algemeen", "nieuws", "technologie", "sport", "weer", "economie" };
for (var i = 1; i <= puzzlesPerDay; i++) {
System.out.println("\n--- Generating puzzle " + i + "/" + puzzlesPerDay + " ---");
// Select theme
var theme = themes[new Random(seed + i).nextInt(themes.length)];
System.out.println("Theme: " + theme);
// Filter word list by theme
List<String> filteredWords = dict.words;
if (themeFilter && !theme.equals("algemeen")) {
System.out.println("Filtered to " + filteredWords.size() + " words for theme '" + theme + "'");
// If too few words, fall back to general
if (filteredWords.size() < 50) {
System.out.println("Not enough themed words, using general list");
filteredWords = dict.words;
theme = "algemeen";
}
}
// Create filtered dict
var themedDict = filterDict(dict, filteredWords);
// Generate puzzle
var opts = new Main.Opts();
opts.seed = seed + i;
opts.pop = 18;
opts.gens = 100;
opts.tries = 50;
opts.wordsPath = wordsPath;
opts.minSimplicity = 0; // default
var result = generateWithFilteredDict(opts, themedDict, llmScores);
if (result == null) {
System.out.println("Failed to generate puzzle " + i);
continue;
}
System.out.println("Generated puzzle with " + result.filled().clueMap.size() + " words");
// Export to JSON
var exported = ExportFormat.exportFormatFromFilled(
result, 1, new ExportFormat.Rewards(50, 2, 1)
);
// Generate clues via LLM
System.out.println("Generating clues for " + exported.words().size() + " words...");
exported = ClueGenerator.applyClues(exported);
// Write to JSON file
var filename = String.format("crossword_%s_%02d_%s.json", dateStr, i, safeSlug(theme));
var outputPath = Paths.get(outDir, filename);
try {
var json = toJson(exported, dateStr, theme);
Files.writeString(outputPath, json, StandardCharsets.UTF_8);
generatedFiles.add(filename);
System.out.println("Saved: " + filename);
} catch (IOException e) {
System.err.println("Failed to write " + filename + ": " + e.getMessage());
}
}
// Write index.json
try {
var indexJson = toIndexJson(dateStr, generatedFiles);
Files.writeString(Paths.get(outDir, "index.json"), indexJson, StandardCharsets.UTF_8);
System.out.println("\n✓ Generated " + generatedFiles.size() + " puzzles for " + dateStr);
} catch (IOException e) {
System.err.println("Failed to write index.json: " + e.getMessage());
}
}
private static SwedishGenerator.Dict filterDict(SwedishGenerator.Dict dict, List<String> allowedWords) {
Set<String> allowed = new HashSet<>(allowedWords);
var newIndex = new HashMap<Integer, SwedishGenerator.DictEntry>();
var newLenCounts = new HashMap<Integer, Integer>();
for (var word : dict.words) {
if (!allowed.contains(word)) continue;
var L = word.length();
newLenCounts.put(L, newLenCounts.getOrDefault(L, 0) + 1);
var entry = newIndex.get(L);
if (entry == null) {
entry = new SwedishGenerator.DictEntry(L);
newIndex.put(L, entry);
}
var idx = entry.words.size();
entry.words.add(word);
for (var i = 0; i < L; i++) {
var letter = word.charAt(i) - 'A';
if (letter >= 0 && letter < 26) {
entry.pos[i][letter].add(idx);
}
}
}
return new SwedishGenerator.Dict(new ArrayList<>(allowed), newIndex, newLenCounts);
}
private static SwedishGenerator.PuzzleResult generateWithFilteredDict(Main.Opts opts, SwedishGenerator.Dict dict, Map<String, Integer> llmScores) {
var rng = new SwedishGenerator.Rng(opts.seed);
for (var attempt = 1; attempt <= opts.tries; attempt++) {
var mask = SwedishGenerator.generateMask(rng, dict.lenCounts, opts.pop, opts.gens, true);
var filled = SwedishGenerator.fillMask(rng, mask, dict.index, llmScores, 200, 30000, true);
if (filled.ok) {
return new SwedishGenerator.PuzzleResult(mask, filled);
}
}
return null;
}
private static String toJson(ExportFormat.ExportedPuzzle puzzle, String date, String theme) {
var sb = new StringBuilder();
sb.append("{\n");
sb.append(" \"date\": \"").append(escapeJson(date)).append("\",\n");
sb.append(" \"theme\": \"").append(escapeJson(theme)).append("\",\n");
sb.append(" \"difficulty\": ").append(puzzle.difficulty()).append(",\n");
sb.append(" \"rewards\": {\n");
sb.append(" \"coins\": ").append(puzzle.rewards().coins()).append(",\n");
sb.append(" \"stars\": ").append(puzzle.rewards().stars()).append(",\n");
sb.append(" \"hints\": ").append(puzzle.rewards().hints()).append("\n");
sb.append(" },\n");
sb.append(" \"gridv2\": [\n");
for (var i = 0; i < puzzle.gridv2().size(); i++) {
sb.append(" \"").append(escapeJson(puzzle.gridv2().get(i))).append("\"");
if (i < puzzle.gridv2().size() - 1) sb.append(",");
sb.append("\n");
}
sb.append(" ],\n");
sb.append(" \"words\": [\n");
for (var i = 0; i < puzzle.words().size(); i++) {
var w = puzzle.words().get(i);
sb.append(" {\n");
sb.append(" \"word\": \"").append(escapeJson(w.word())).append("\",\n");
sb.append(" \"clue\": \"").append(escapeJson(w.clue())).append("\",\n");
sb.append(" \"startRow\": ").append(w.startRow()).append(",\n");
sb.append(" \"startCol\": ").append(w.startCol()).append(",\n");
sb.append(" \"direction\": \"").append(escapeJson(w.direction())).append("\",\n");
sb.append(" \"answer\": \"").append(escapeJson(w.answer())).append("\",\n");
sb.append(" \"arrowRow\": ").append(w.arrowRow()).append(",\n");
sb.append(" \"arrowCol\": ").append(w.arrowCol()).append(",\n");
sb.append(" \"isReversed\": ").append(w.isReversed()).append("\n");
sb.append(" }");
if (i < puzzle.words().size() - 1) sb.append(",");
sb.append("\n");
}
sb.append(" ]\n");
sb.append("}\n");
return sb.toString();
}
private static String toIndexJson(String date, List<String> files) {
var sb = new StringBuilder();
sb.append("{\n");
sb.append(" \"date\": \"").append(escapeJson(date)).append("\",\n");
sb.append(" \"files\": [\n");
for (var i = 0; i < files.size(); i++) {
sb.append(" \"").append(escapeJson(files.get(i))).append("\"");
if (i < files.size() - 1) sb.append(",");
sb.append("\n");
}
sb.append(" ]\n");
sb.append("}\n");
return sb.toString();
}
private static String escapeJson(String s) {
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
private static String safeSlug(String s) {
return s.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", "");
}
}

View File

@@ -1,6 +1,7 @@
package puzzle;
import java.util.*;
import static puzzle.SwedishGenerator.*;
/**
* ExportFormat.java
@@ -13,7 +14,7 @@ import java.util.*;
*/
public final class ExportFormat {
private ExportFormat() { }
private ExportFormat() { }
private static boolean isLetter(char ch) { return ch >= 'A' && ch <= 'Z'; }
@@ -23,20 +24,16 @@ public final class ExportFormat {
// ---------- Public API ----------
public static ExportedPuzzle exportFormatFromFilled(SwedishGenerator.PuzzleResult puz) {
return exportFormatFromFilled(puz, 1, new Rewards(50, 2, 1));
}
public static ExportedPuzzle exportFormatFromFilled(SwedishGenerator.PuzzleResult puz, int difficulty, Rewards rewards) {
public static ExportedPuzzle exportFormatFromFilled(PuzzleResult puz, int difficulty, Rewards rewards) {
Objects.requireNonNull(puz, "puz");
var g = puz.filled().grid;
var H = g.length;
var W = g[0].length;
// 1) extract "placed" list from all clue digits in the filled grid
List<Placed> placed = new ArrayList<>();
var allSlots = SwedishGenerator.extractSlots(g);
var clueMap = puz.filled().clueMap;
List<Placed> placed = new ArrayList<>();
var allSlots = extractSlots(g);
var clueMap = puz.filled().clueMap;
for (var s : allSlots) {
var word = clueMap.get(s.key());
@@ -113,24 +110,25 @@ public final class ExportFormat {
p.word, // answer
p.arrowRow - minR,
p.arrowCol - minC,
p.isReversed
p.isReversed,
puz.dict().words().get(p.word).difficulty()
));
}
return new ExportedPuzzle(gridv2, wordsOut, difficulty, rewards);
}
static final String HORIZONTAL = "h", VERTICAL = "v";
/**
* Convert a generator Slot + assigned word into a Placed object for export.
*/
private static Placed extractPlacedFromSlot(SwedishGenerator.Slot s, String word) {
int r = s.clueR;
int c = s.clueC;
char d = s.dir;
private static Placed extractPlacedFromSlot(Slot s, String word) {
int r = s.clueR();
int c = s.clueC();
char d = s.dir();
List<int[]> cells = new ArrayList<>();
for (int i = 0; i < s.len; i++) {
cells.add(new int[]{ s.rs[i], s.cs[i] });
for (int i = 0; i < s.len(); i++) {
cells.add(new int[]{ s.rs()[i], s.cs()[i] });
}
// Canonicalize: always output right/down
@@ -139,26 +137,26 @@ public final class ExportFormat {
boolean isReversed = false;
if (d == '2') { // right -> horizontal
direction = "horizontal";
direction = HORIZONTAL;
startRow = cells.get(0)[0];
startCol = cells.get(0)[1];
arrowRow = r;
arrowCol = c;
} else if (d == '3' || d == '5') { // down or down-bent -> vertical
direction = "vertical";
direction = VERTICAL;
startRow = cells.get(0)[0];
startCol = cells.get(0)[1];
arrowRow = r;
arrowCol = c;
} else if (d == '4') { // left -> horizontal (REVERSED)
direction = "horizontal";
direction = HORIZONTAL;
isReversed = true;
startRow = cells.get(0)[0];
startCol = cells.get(0)[1];
arrowRow = r;
arrowCol = c;
} else if (d == '1') { // up -> vertical (REVERSED)
direction = "vertical";
direction = VERTICAL;
isReversed = true;
startRow = cells.get(0)[0];
startCol = cells.get(0)[1];
@@ -195,21 +193,13 @@ public final class ExportFormat {
* @param cells word cells
* @param arrow [arrowRow, arrowCol] */
private record Placed(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol, List<int[]> cells, int[] arrow,
boolean isReversed) {
}
boolean isReversed) { }
public record Rewards(int coins, int stars, int hints) {
}
public record Rewards(int coins, int stars, int hints) { }
/**
* @param direction "horizontal" | "vertical" */
public record WordOut(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol, boolean isReversed) {
}
public record WordOut(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol, boolean isReversed, int complex) { }
public record ExportedPuzzle(List<String> gridv2, List<WordOut> words, int difficulty, Rewards rewards) {
}
public record ExportedPuzzle(List<String> gridv2, List<WordOut> words, int difficulty, Rewards rewards) { }
}

View File

@@ -2,195 +2,317 @@ package puzzle;
import puzzle.SwedishGenerator.PuzzleResult;
import puzzle.SwedishGenerator.Rng;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.*;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Locale;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.*;
import java.util.concurrent.*;
import static puzzle.SwedishGenerator.fillMask;
import static puzzle.SwedishGenerator.generateMask;
import static puzzle.SwedishGenerator.loadScores;
import static puzzle.SwedishGenerator.loadWords;
public class Main {
// ---------------- CLI ----------------
final static String OUT_DIR = envOrDefault("OUT_DIR", "/data/puzzle");
final static Path PUZZLE_DIR = Paths.get(OUT_DIR, "puzzles");
static final Path INDEX_FILE = PUZZLE_DIR.resolve("index.json");
static final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
static final String CREATED_AT = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"));
static final String FILE_ID = CREATED_AT.replace(":", "-") + "_" + (System.currentTimeMillis() / 1000);
static final String FILE_NAME = FILE_ID + ".json";
static final Path OUTPUT_PATH = PUZZLE_DIR.resolve(FILE_NAME);
static final String DATE_STRING = now.toLocalDate().toString();
public static class Opts {
public int seed = 1234;
public int pop = 18;
public int gens = 300;
public String wordsPath = "/data/puzzle/pool.txt";
public double minSimplicity = 0; // 0 means no limit
public int threads = Math.max(1, Runtime.getRuntime().availableProcessors());
public int tries = threads;
public int seed = (int) (System.nanoTime() ^ System.currentTimeMillis());
public int pop = 18;
public int gens = 500;
public String wordsPath = "nl_score_hints.csv";
public double minSimplicity = 0; // 0 means no limit
public int threads = Math.max(1, Runtime.getRuntime().availableProcessors());
public int tries = threads;
public boolean reindex = false;
}
public void main(String[] args) {
var opts = parseArgs(args);
if (opts.reindex) {
section("Reindex");
info("OutputDir : " + OUT_DIR);
rebuildIndex();
return;
}
section("Puzzle Generator");
info("OutputDir : " + OUT_DIR);
info("WordsFile : " + opts.wordsPath);
section("Settings");
printSettings(opts);
var res = generatePuzzle(opts);
if (res == null) {
err("Search status : UNSOLVED");
err("Reason : No solution found within tries.");
System.exit(1);
return;
}
section("Result");
info(String.format(Locale.ROOT, "simplicity : %.2f", res.filled().simplicity));
section("Mask");
System.out.print(indentLines(SwedishGenerator.gridToString(res.mask()), " "));
section("Grid (raw)");
System.out.print(indentLines(SwedishGenerator.gridToString(res.filled().grid), " "));
section("Grid (human)");
System.out.print(indentLines(SwedishGenerator.renderHuman(res.filled().grid), " "));
var exported = ExportFormat.exportFormatFromFilled(res, 1, new ExportFormat.Rewards(50, 2, 1));
section("Clues");
info("status : generating...");
info("generatedFor : " + exported.words().size());
exported = ClueGenerator.applyClues(exported);
info("status : done");
section("Words");
printWordsTable(exported.words());
section("Gridv2");
for (var row : exported.gridv2()) System.out.println(" " + row);
// Export to JSON file
var theme = "algemeen";
section("Export");
info("file : " + OUTPUT_PATH);
try {
Files.createDirectories(PUZZLE_DIR);
var json = toJson(exported, DATE_STRING, theme);
Files.writeString(OUTPUT_PATH, json, StandardCharsets.UTF_8);
// Update index.json
var pathInIndex = "/puzzles/" + FILE_NAME;
var indexRecord = toIndexRecordJson(FILE_ID, pathInIndex, DATE_STRING, theme, exported.difficulty(), CREATED_AT);
if (1 != 1) updateIndex(PUZZLE_DIR.toString(), indexRecord);
else rebuildIndex();
info("indexUpdated : " + INDEX_FILE);
} catch (IOException e) {
err("Failed to write: " + FILE_NAME);
err("Reason : " + e.getMessage());
System.exit(2);
}
}
// ---------------- Output helpers ----------------
private static void info(String msg) { System.out.println("[INFO ] " + msg); }
private static void warn(String msg) { System.out.println("[WARN ] " + msg); }
private static void err(String msg) { System.err.println("[ERROR] " + msg); }
private static void section(String title) {
System.out.println();
System.out.println(title);
}
private static String envOrDefault(String key, String def) {
var v = System.getenv(key);
return (v == null || v.isBlank()) ? def : v;
}
private static void printSettings(Opts o) {
System.out.printf(Locale.ROOT, " %-14s: %d%n", "seed", o.seed);
System.out.printf(Locale.ROOT, " %-14s: %d%n", "population", o.pop);
System.out.printf(Locale.ROOT, " %-14s: %d%n", "generations", o.gens);
System.out.printf(Locale.ROOT, " %-14s: %s%n", "wordsPath", o.wordsPath);
System.out.printf(Locale.ROOT, " %-14s: %.2f%n", "minSimplicity", o.minSimplicity);
System.out.printf(Locale.ROOT, " %-14s: %d%n", "threads", o.threads);
System.out.printf(Locale.ROOT, " %-14s: %d%n", "maxTries", o.tries);
}
private static String fmtPoint(int r, int c) { return String.format(Locale.ROOT, "(%d,%d)", r, c); }
private static void printWordsTable(List<ExportFormat.WordOut> words) {
System.out.println(" # WORD CX DIR START ARROW CLUE");
var i = 1;
for (var w : words) {
System.out.printf(
Locale.ROOT,
" %-2d %-12s %-3s %-3s %-9s %-9s %s%n",
i++,
safe(w.word(), 12),
safe("" + w.complex(), 3),
safe(w.direction(), 3),
fmtPoint(w.startRow(), w.startCol()),
fmtPoint(w.arrowRow(), w.arrowCol()),
w.clue() == null ? "" : w.clue()
);
}
}
private static String safe(String s, int max) {
if (s == null) return "";
if (s.length() <= max) return s;
return s.substring(0, Math.max(0, max - 1)) + "";
}
private static String indentLines(String s, String indent) {
if (s == null || s.isEmpty()) return "";
var lines = s.split("\\R", -1);
var sb = new StringBuilder();
for (var line : lines) sb.append(indent).append(line).append('\n');
return sb.toString();
}
static void usage() {
System.out.println("""
Usage:
java SwedishGenerator [--seed N] [--pop N] [--gens N] [--tries N] [--words word-list.txt] [--min-simplicity N.N] [--threads N]
Defaults:
--seed 1234
--pop 18
--gens 1000
--tries 5
--words /data/puzzle/pool.txt
--min-simplicity 0 (no limit)
--threads %d
""".formatted(Math.max(1, Runtime.getRuntime().availableProcessors())));
Usage:
java puzzle.Main [--seed N] [--pop N] [--gens N] [--tries N] [--words FILE] [--min-simplicity N.N] [--threads N] [--reindex]
Defaults:
--pop 18
--gens 600
--tries = threads
--words nl_score_hints.csv
--min-simplicity 0 (no limit)
--threads %d
""".formatted(Math.max(1, Runtime.getRuntime().availableProcessors())));
}
static Opts parseArgs(String[] argv) {
var out = new Opts();
for (int i = 0; i < argv.length; i++) {
String a = argv[i];
String v = (i + 1 < argv.length) ? argv[i + 1] : null;
for (var i = 0; i < argv.length; i++) {
var a = argv[i];
var v = (i + 1 < argv.length) ? argv[i + 1] : null;
if (a.equals("--help") || a.equals("-h")) {
usage();
System.exit(0);
}
if (a.equals("--seed")) { out.seed = Integer.parseInt(v); i++; }
else if (a.equals("--pop")) { out.pop = Integer.parseInt(v); i++; }
else if (a.equals("--gens")) { out.gens = Integer.parseInt(v); i++; }
else if (a.equals("--tries")) { out.tries = Integer.parseInt(v); i++; }
else if (a.equals("--words")) { out.wordsPath = v; i++; }
else if (a.equals("--min-simplicity")) { out.minSimplicity = Double.parseDouble(v); i++; }
else if (a.equals("--threads")) { out.threads = Integer.parseInt(v); i++; }
else throw new IllegalArgumentException("Unknown arg: " + a);
if (a.equals("--seed")) {
out.seed = Integer.parseInt(v);
i++;
} else if (a.equals("--pop")) {
out.pop = Integer.parseInt(v);
i++;
} else if (a.equals("--gens")) {
out.gens = Integer.parseInt(v);
i++;
} else if (a.equals("--tries")) {
out.tries = Integer.parseInt(v);
i++;
} else if (a.equals("--words")) {
out.wordsPath = v;
i++;
} else if (a.equals("--min-simplicity")) {
out.minSimplicity = Double.parseDouble(v);
i++;
} else if (a.equals("--threads")) {
out.threads = Integer.parseInt(v);
i++;
} else if (a.equals("--reindex")) {
out.reindex = true;
} else {
throw new IllegalArgumentException("Unknown arg: " + a);
}
}
return out;
}
public static void main(String[] args) {
var opts = parseArgs(args);
var res = generatePuzzle(opts);
if (res == null) {
System.out.println("No solution found within tries.");
System.exit(1);
}
System.out.println("\n=== GENERATED MASK ===");
System.out.println(SwedishGenerator.gridToString(res.mask()));
System.out.println("\n=== FILLED PUZZLE (RAW) ===");
System.out.println(SwedishGenerator.gridToString(res.filled().grid));
System.out.println("\n=== FILLED PUZZLE (HUMAN) ===");
System.out.println(SwedishGenerator.renderHuman(res.filled().grid));
System.out.printf(Locale.ROOT, "Puzzle Simplicity: %.2f%n", res.filled().simplicity);
var out = ExportFormat.exportFormatFromFilled(res, 1, new ExportFormat.Rewards(50, 2, 1));
// Generate clues via LLM
System.out.println("Generating clues for " + out.words().size() + " words...");
out = ClueGenerator.applyClues(out);
System.out.println("gridv2:");
for (String row : out.gridv2()) System.out.println(row);
System.out.println("words: " + out.words().size());
for (var w : out.words()) {
var simplicityOfWord =
System.out.printf("%s %s start=(%d,%d) arrow=(%d,%d)%n",
w.word(), w.direction(), w.startRow(), w.startCol(), w.arrowRow(), w.arrowCol());
}
// Export to JSON file
var now = OffsetDateTime.now(ZoneOffset.UTC);
var createdAt = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"));
var dateStr = now.toLocalDate().toString();
var id = createdAt.replace(":", "-") + "_" + (System.currentTimeMillis() / 1000);
var theme = "algemeen";
var filename = id + ".json";
var outDir = "/data/puzzle/puzzles";
var outputPath = Paths.get(outDir, filename);
try {
Files.createDirectories(Paths.get(outDir));
var json = toJson(out, dateStr, theme);
Files.writeString(outputPath, json, StandardCharsets.UTF_8);
System.out.println("\nSaved to: " + outputPath);
// Update index.json
var pathInIndex = "/puzzles/" + filename;
var indexRecord = toIndexRecordJson(id, pathInIndex, dateStr, theme, out.difficulty(), createdAt);
updateIndex(outDir, indexRecord);
} catch (IOException e) {
System.err.println("Failed to write " + filename + ": " + e.getMessage());
}
}
// ---------------- Generation ----------------
// Package-private method for testing
static PuzzleResult generatePuzzle(Opts opts) {
PuzzleResult generatePuzzle(Opts opts) {
var llmScores = loadScores();
var tLoad0 = System.nanoTime();
var dict = loadWords(opts.wordsPath, llmScores);
var tLoad1 = System.nanoTime();
System.out.printf(Locale.ROOT, "LOAD_WORDS: %.3fs%n %s words%n", (tLoad1 - tLoad0) / 1e9, dict.words.size());
var tLoad0 = System.nanoTime();
var dict = loadWords(opts.wordsPath, llmScores);
var tLoad1 = System.nanoTime();
section("Load");
info(String.format(Locale.ROOT, "words : %,d", dict.words().size()));
info(String.format(Locale.ROOT, "loadTime : %.3f s", (tLoad1 - tLoad0) / 1e9));
section("Search");
if (opts.threads > 1) {
System.out.println("Running in multi-threaded mode with " + opts.threads + " threads...");
info("mode : multi-threaded (" + opts.threads + ")");
var executor = Executors.newFixedThreadPool(opts.threads);
try {
var tasks = new ArrayList<Callable<PuzzleResult>>();
for (int i = 1; i <= opts.tries; i++) {
final int attempt = i;
for (var i = 1; i <= opts.tries; i++) {
final var attempt = i;
tasks.add(() -> {
var threadRng = new Rng(opts.seed + attempt);
var mask = generateMask(threadRng, dict.lenCounts, opts.pop, opts.gens, false);
var filled = fillMask(threadRng, mask, dict.index, llmScores, 200, 60000, false);
var mask = generateMask(threadRng, dict.lenCounts(), opts.pop, opts.gens, false);
var filled = fillMask(threadRng, mask, dict.index(), llmScores, 200, 60000, false);
if (filled.ok && (opts.minSimplicity <= 0 || filled.simplicity >= opts.minSimplicity)) {
System.out.println("\nSolution found on attempt " + attempt);
return new PuzzleResult(mask, filled);
info("status : SOLVED");
info("foundAtTry : " + attempt);
return new PuzzleResult(dict, mask, filled);
}
throw new RuntimeException("No solution found in attempt " + attempt);
throw new RuntimeException("No solution in try " + attempt);
});
}
return executor.invokeAny(tasks);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
warn("status : INTERRUPTED");
} catch (ExecutionException e) {
// all failed
warn("status : UNSOLVED");
} finally {
executor.shutdownNow();
}
return null;
} else {
info("mode : single-threaded");
var rng = new Rng(opts.seed);
for (var attempt = 1; attempt <= opts.tries; attempt++) {
System.out.println("\nAttempt " + attempt + "/" + opts.tries);
info("try : " + attempt + "/" + opts.tries);
var tMask0 = System.nanoTime();
var mask = generateMask(rng, dict.lenCounts, opts.pop, opts.gens, true);
var tMask1 = System.nanoTime();
System.out.printf(Locale.ROOT, "MASK: %.3fs%n", (tMask1 - tMask0) / 1e9);
var tFill0 = System.nanoTime();
var filled = fillMask(rng, mask, dict.index, llmScores, 200, 60000, true);
var tFill1 = System.nanoTime();
System.out.printf(Locale.ROOT, "FILL: %.3fms | Simplicity: %.2f%n", (tFill1 - tFill0) / 1e6, filled.simplicity);
var mask = generateMask(rng, dict.lenCounts(), opts.pop, opts.gens, true);
var filled = fillMask(rng, mask, dict.index(), llmScores, 200, 60000, true);
if (filled.ok && (opts.minSimplicity <= 0 || filled.simplicity >= opts.minSimplicity)) {
return new PuzzleResult(mask, filled);
info("status : SOLVED");
info("foundAtTry : " + attempt);
return new PuzzleResult(dict, mask, filled);
}
if (filled.ok) {
System.out.printf(Locale.ROOT, "Puzzle simplicity %.2f is below min %.2f, retrying...%n",
filled.simplicity, opts.minSimplicity);
warn(String.format(Locale.ROOT,
"simplicity : %.2f (below min %.2f)",
filled.simplicity, opts.minSimplicity
));
}
}
info("status : UNSOLVED");
return null;
}
return null;
}
// ---------------- Export (unchanged logic) ----------------
private static String toJson(ExportFormat.ExportedPuzzle puzzle, String date, String theme) {
var sb = new StringBuilder();
sb.append("{\n");
@@ -230,7 +352,7 @@ public class Main {
sb.append("}\n");
return sb.toString();
}
private static String escapeJson(String s) {
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
@@ -238,42 +360,96 @@ public class Main {
.replace("\r", "\\r")
.replace("\t", "\\t");
}
private static String safeSlug(String s) {
return s.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", "");
}
private static String toIndexRecordJson(String id, String path, String date, String theme, int difficulty, String createdAt) {
return String.format(
"{\"id\":\"%s\",\"path\":\"%s\",\"date\":\"%s\",\"theme\":\"%s\",\"difficulty\":%d,\"createdAt\":\"%s\"}",
escapeJson(id), escapeJson(path), escapeJson(date), escapeJson(theme), difficulty, escapeJson(createdAt)
);
Locale.ROOT,
"{\"id\":\"%s\",\"path\":\"%s\",\"date\":\"%s\",\"theme\":\"%s\",\"difficulty\":%d,\"createdAt\":\"%s\"}",
escapeJson(id), escapeJson(path), escapeJson(date), escapeJson(theme), difficulty, escapeJson(createdAt)
);
}
private static void updateIndex(String outDir, String newRecordJson) {
Path indexPath = Paths.get(outDir, "index.json");
var indexPath = Paths.get(outDir, "index.json");
try {
String content;
if (Files.exists(indexPath)) {
content = Files.readString(indexPath, StandardCharsets.UTF_8).trim();
} else {
content = "";
}
var content = Files.exists(indexPath) ? Files.readString(indexPath, StandardCharsets.UTF_8).trim() : "";
if (content.isEmpty() || content.equals("[]")) {
content = "[\n " + newRecordJson + "\n]";
} else {
int firstBracket = content.indexOf('[');
if (firstBracket != -1) {
content = content.substring(0, firstBracket + 1) + "\n " + newRecordJson + "," + content.substring(firstBracket + 1);
} else {
content = "[\n " + newRecordJson + "\n]";
}
var firstBracket = content.indexOf('[');
if (firstBracket != -1) {
content = content.substring(0, firstBracket + 1) + "\n " + newRecordJson + "," + content.substring(firstBracket + 1);
} else {
content = "[\n " + newRecordJson + "\n]";
}
}
Files.writeString(indexPath, content, StandardCharsets.UTF_8);
System.out.println("Updated index.json at: " + indexPath);
info("indexUpdated : " + indexPath);
} catch (IOException e) {
System.err.println("Failed to update index.json: " + e.getMessage());
err("Failed to update index.json: " + e.getMessage());
}
}
private void rebuildIndex() {
if (!Files.exists(PUZZLE_DIR)) {
err("Puzzles directory does not exist: " + PUZZLE_DIR);
return;
}
info("Rebuilding index from: " + PUZZLE_DIR);
List<String> records = new ArrayList<>();
try (var stream = Files.list(PUZZLE_DIR)) {
stream.filter(p -> p.toString().endsWith(".json") && !p.getFileName().toString().equals("index.json"))
.sorted(Comparator.comparing(Path::getFileName).reversed())
.forEach(path -> {
try {
var filename = path.getFileName().toString();
var id = filename.substring(0, filename.length() - 5);
var content = Files.readString(path, StandardCharsets.UTF_8);
var date = extractValue(content, "date");
var theme = extractValue(content, "theme");
var difficulty = 1;
try { difficulty = Integer.parseInt(extractValue(content, "difficulty")); } catch (Exception ignored) { }
var createdAt = id;
if (id.length() >= 20 && id.charAt(10) == 'T') {
var parts = id.split("_");
var dtPart = parts[0]; // 2025-12-24T04-25-06Z
if (dtPart.length() >= 19) {
createdAt = dtPart.substring(0, 13) + ":" + dtPart.substring(14, 16) + ":" + dtPart.substring(17);
}
}
var pathInIndex = "/puzzles/" + filename;
records.add(toIndexRecordJson(id, pathInIndex, date, theme, difficulty, createdAt));
} catch (IOException e) {
err("Failed to read " + path + ": " + e.getMessage());
}
});
} catch (IOException e) {
err("Failed to list puzzles: " + e.getMessage());
return;
}
var indexPath = PUZZLE_DIR.resolve("index.json");
var content = "[\n " + String.join(",\n ", records) + "\n]";
try {
Files.writeString(indexPath, content, StandardCharsets.UTF_8);
info("Successfully rebuilt index.json with " + records.size() + " records.");
} catch (IOException e) {
err("Failed to write index.json: " + e.getMessage());
}
}
private static String extractValue(String json, String key) {
var pattern = java.util.regex.Pattern.compile("\"" + key + "\":\\s*\"?([^\",\\n\\r}]*)\"?");
var matcher = pattern.matcher(json);
if (matcher.find()) return matcher.group(1).trim();
return "";
}
}

View File

@@ -21,7 +21,7 @@ public class MainTest {
opts.tries = 1;
// Act
var result = Main.generatePuzzle(opts);
var result = new Main().generatePuzzle(opts);
// Assert
/* assertNotNull(result);

View File

@@ -6,8 +6,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
/**
* SwedishGenerator.java
@@ -145,35 +144,31 @@ public class SwedishGenerator {
}
}
static class WordDifficulty {
final String word;
final int difficulty;
final int score;
static record WordDifficulty(String word, int difficulty, int score) {
public WordDifficulty(String word, int score) {
this.word = word;
this.score = score;
var difficulty1 = 0 + ((8 - word.length()) * 30) + ((10 - score) * 15);
this(word, difficulty1, score);
// We want LONGER and SIMPLER words to be tried earlier (lower difficulty value).
// word.length() is 2 to 8.
// score is 1 to 10.
// Base difficulty starts high and decreases with length and score.
// Length impact: up to 8 * 10 = 80
// Score impact: up to 10 * 15 = 150
var difficulty1 = 0 + ((8 - word.length()) * 30) + ((10 - score) * 15);
this.difficulty = difficulty1;
}
}
static Map<String, Integer> loadScores() {
var scores = new HashMap<String, Integer>();
try {
var lines = Files.readAllLines(Path.of("/data/puzzle/export_with_hints.csv"), StandardCharsets.UTF_8);
var scoresPath = System.getenv("SCORES_PATH");
if (scoresPath == null || scoresPath.isBlank()) scoresPath = "export_real_words_with_hints.csv";
var lines = Files.readAllLines(Path.of(scoresPath), StandardCharsets.UTF_8);
var first = true;
for (var line : lines) {
if (first) {
first = false;
continue;
if (line.startsWith("WOORD")) continue;
}
var parts = line.split(",", 3);
if (parts.length >= 2) {
@@ -182,10 +177,7 @@ public class SwedishGenerator {
var score = 10 - Integer.parseInt(parts[1].trim());
scores.put(word, score);
} catch (NumberFormatException ignored) {
System.err.println("Illegal number format: " + line);
}
} else {
System.err.println("Illegal word: " + line);
}
}
} catch (IOException e) {
@@ -194,48 +186,55 @@ public class SwedishGenerator {
return scores;
}
static final class Dict {
final ArrayList<String> words;
final HashMap<Integer, DictEntry> index; // len -> DictEntry
final HashMap<Integer, Integer> lenCounts; // len -> count
Dict(ArrayList<String> words, HashMap<Integer, DictEntry> index, HashMap<Integer, Integer> lenCounts) {
this.words = words;
this.index = index;
this.lenCounts = lenCounts;
}
}
public static record Dict(Map<String, WordDifficulty> words,
HashMap<Integer, DictEntry> index,
HashMap<Integer, Integer> lenCounts) { }
static Dict loadWords(String wordsPath, Map<String, Integer> llmScores) {
String raw;
try {
raw = Files.readString(Path.of(wordsPath), StandardCharsets.UTF_8);
} catch (IOException e) {
raw = "EU\nUUR\nAUTO\nBOOM\nHUIS\nKAT\nZEE\nRODE\nDRAAD\nKENNIS\nNETWERK\nPAKTE\n";
raw = "WOORD,level_1_to_10,hint\nEU,2,hint\nUUR,2,hint\nAUTO,2,hint\nBOOM,2,hint\nHUIS,2,hint\nKAT,2,hint\nZEE,2,hint\nRODE,2,hint\nDRAAD,2,hint\nKENNIS,2,hint\nNETWERK,2,hint\nPAKTE,2,hint\n";
}
var words = new ArrayList<WordDifficulty>();
var map = new HashMap<String, WordDifficulty>();
boolean first = true;
for (var line : raw.split("\\R")) {
var word = line.split(",", 3)[0].trim();
var s = word.trim().toUpperCase(Locale.ROOT);
if (line.isBlank()) continue;
var parts = line.split(",", 3);
var word = parts[0].trim();
if (first && word.equalsIgnoreCase("WOORD")) {
first = false;
continue;
}
first = false;
var s = word.toUpperCase(Locale.ROOT);
if (s.matches("^[A-Z]{2,8}$")) {
var score = llmScores.getOrDefault(s, SIMPLICITY_DEFAULT_SCORE); // Default to middle
words.add(new WordDifficulty(s, score));
int score = SIMPLICITY_DEFAULT_SCORE;
if (parts.length >= 2) {
try {
// CSV has level 1-10. llmScores use 10-level.
score = 10 - Integer.parseInt(parts[1].trim());
} catch (NumberFormatException e) {
score = llmScores.getOrDefault(s, SIMPLICITY_DEFAULT_SCORE);
System.err.println("Warning: " + word + " csv not found, using default scores.");
}
} else {
score = llmScores.getOrDefault(s, SIMPLICITY_DEFAULT_SCORE);
System.err.println("Warning: " + word + " csv not found, using default scores.");
}
map.put(s, new WordDifficulty(s, score));
}
}
var words = map.values().stream().collect(Collectors.toCollection(ArrayList::new));
// Sort words by difficulty in ascending order
words.sort(Comparator.comparingInt(wd -> wd.difficulty));
var dictWords = new ArrayList<String>();
for (var wd : words) {
dictWords.add(wd.word);
}
var index = new HashMap<Integer, DictEntry>();
var lenCounts = new HashMap<Integer, Integer>();
for (var w : dictWords) {
var L = w.length();
for (var w : words) {
var L = w.word.length();
lenCounts.put(L, lenCounts.getOrDefault(L, 0) + 1);
var entry = index.get(L);
@@ -245,15 +244,15 @@ public class SwedishGenerator {
}
var idx = entry.words.size();
entry.words.add(w);
entry.words.add(w.word);
for (var i = 0; i < L; i++) {
var letter = w.charAt(i) - 'A';
var letter = w.word.charAt(i) - 'A';
if (letter >= 0 && letter < 26) entry.pos[i][letter].add(idx);
}
}
return new Dict(dictWords, index, lenCounts);
return new Dict(map, index, lenCounts);
}
static int[] intersectSorted(int[] a, int aLen, int[] b, int bLen) {
@@ -271,10 +270,8 @@ public class SwedishGenerator {
return Arrays.copyOf(out, k);
}
static final class CandidateInfo {
static final record CandidateInfo(int[] indices, int count) {
int[] indices; // null => unconstrained
int count;
}
static CandidateInfo candidateInfoForPattern(DictEntry entry, char[] pattern /* 0 means null */) {
var lists = new ArrayList<IntList>();
@@ -285,11 +282,8 @@ public class SwedishGenerator {
}
}
var ci = new CandidateInfo();
if (lists.isEmpty()) {
ci.indices = null;
ci.count = entry.words.size();
return ci;
return new CandidateInfo(null, entry.words.size());
}
var first = lists.get(0);
@@ -305,9 +299,7 @@ public class SwedishGenerator {
if (curLen == 0) break;
}
ci.indices = cur;
ci.count = curLen;
return ci;
return new CandidateInfo(cur, curLen);
}
static int indexToDifficulty(DictEntry entry, int index, Map<String, Integer> llmScores) {
var word = entry.words.get(index);
@@ -317,19 +309,10 @@ public class SwedishGenerator {
// ---------------- Slots ----------------
static final class Slot {
static record Slot(int clueR, int clueC, char dir, int[] rs, int[] cs, int len) {
final int clueR, clueC;
final char dir; // '1'..'5'
final int[] rs, cs; // cells
final int len;
Slot(int clueR, int clueC, char dir, int[] rs, int[] cs) {
this.clueR = clueR;
this.clueC = clueC;
this.dir = dir;
this.rs = rs;
this.cs = cs;
this.len = rs.length;
public Slot(int clueR, int clueC, char dir, int[] rs, int[] cs) {
this(clueR, clueC, dir, rs, cs, rs.length);
}
String key() { return clueR + "," + clueC + ":" + dir; }
}
@@ -917,7 +900,7 @@ public class SwedishGenerator {
}
// ---------------- Top-level generatePuzzle ----------------
public record PuzzleResult(char[][] mask, FillResult filled) { }
public record PuzzleResult(Dict dict, char[][] mask, FillResult filled) { }
public static PuzzleResult generatePuzzle(Main.Opts opts) {
var llmScores = loadScores();
@@ -940,7 +923,7 @@ public class SwedishGenerator {
if (filled.ok && (opts.minSimplicity <= 0 || filled.simplicity >= opts.minSimplicity)) {
System.out.println("\nSolution found on attempt " + attempt);
return new PuzzleResult(mask, filled);
return new PuzzleResult(dict, mask, filled);
}
throw new RuntimeException("No solution found in attempt " + attempt);
});
@@ -970,7 +953,7 @@ public class SwedishGenerator {
System.out.printf(Locale.ROOT, "FILL: %.3fms | Simplicity: %.2f%n", (tFill1 - tFill0) / 1e6, filled.simplicity);
if (filled.ok && (opts.minSimplicity <= 0 || filled.simplicity >= opts.minSimplicity)) {
return new PuzzleResult(mask, filled);
return new PuzzleResult(dict, mask, filled);
}
if (filled.ok) {
System.out.printf(Locale.ROOT, "Puzzle simplicity %.2f is below min %.2f, retrying...%n",

View File

@@ -65,7 +65,7 @@ public class ThemePoolBuilderLength {
String endpoint = "https://jarvis-lan.appmodel.nl/api/ollama/";
List<String> feeds = new ArrayList<>(DEFAULT_FEEDS);
String outDir = "/data/puzzle";
String outDir = System.getenv("OUT_DIR") != null ? System.getenv("OUT_DIR") : "/data/puzzle";
int bridgeN = 30000;
int themeN = 800;
int relatedN = 2200;