Files
quartiles/scripts/find-chunk-words.mjs
2026-10-24 14:29:08 -04:00

91 lines
3.8 KiB
JavaScript

// Finds common English words that can be built from exactly 4 existing tiles
// (chunks) in the target-word pool, but aren't target words yet.
//
// Usage: node scripts/find-chunk-words.mjs [--max-tier=35] [--json]
import wl from 'wordlist-english';
import { DICTIONARY_SET } from '../src/data/lexicon.js';
import { TARGET_WORDS_POOL } from '../src/data/puzzles.js';
const args = Object.fromEntries(process.argv.slice(2).map(a => a.replace(/^--/, '').split('=')));
const MAX_TIER = Number(args['max-tier'] || 35);
const TIERS = [10, 20, 35, 40, 50].filter(t => t <= MAX_TIER);
// Every chunk currently used by a target word
const chunks = new Set(TARGET_WORDS_POOL.flatMap(w => w.tiles.map(t => t.toLowerCase())));
const existing = new Set(TARGET_WORDS_POOL.map(w => w.word));
// Common words only (frequency tiers from wordlist-english)
const candidates = new Set();
for (const t of TIERS) for (const w of wl[`english/${t}`] || []) candidates.add(w);
// Try to split a word into exactly 4 distinct chunks (each >= 2 letters).
// Returns the most balanced split, or null.
function split(word) {
let best = null;
const n = word.length;
for (let a = 2; a <= n - 6; a++) {
const t1 = word.slice(0, a);
if (!chunks.has(t1)) continue;
for (let b = a + 2; b <= n - 4; b++) {
const t2 = word.slice(a, b);
if (!chunks.has(t2)) continue;
for (let c = b + 2; c <= n - 2; c++) {
const t3 = word.slice(b, c);
const t4 = word.slice(c);
if (!chunks.has(t3) || !chunks.has(t4)) continue;
const tiles = [t1, t2, t3, t4];
if (new Set(tiles).size !== 4) continue;
const lens = tiles.map(t => t.length);
const spread = Math.max(...lens) - Math.min(...lens);
if (!best || spread < best.spread) best = { tiles, spread };
}
}
}
return best && best.tiles;
}
// Skip inflected forms whose base word exists (walked, happier, quickly, kittens...)
const allWords = new Set(wl.english);
function isInflection(w) {
const bases = [];
if (w.endsWith('ies')) bases.push(w.slice(0, -3) + 'y');
if (w.endsWith('es')) bases.push(w.slice(0, -2));
if (w.endsWith('s')) bases.push(w.slice(0, -1));
if (w.endsWith('ed')) bases.push(w.slice(0, -2), w.slice(0, -1), w.slice(0, -3));
if (w.endsWith('ied')) bases.push(w.slice(0, -3) + 'y');
if (w.endsWith('ing')) bases.push(w.slice(0, -3), w.slice(0, -3) + 'e', w.slice(0, -4));
if (w.endsWith('ly')) bases.push(w.slice(0, -2), w.slice(0, -3) + 'e');
if (w.endsWith('ily')) bases.push(w.slice(0, -3) + 'y');
if (w.endsWith('er')) bases.push(w.slice(0, -2), w.slice(0, -1), w.slice(0, -3));
if (w.endsWith('est')) bases.push(w.slice(0, -3), w.slice(0, -2), w.slice(0, -4));
if (w.endsWith('ness')) bases.push(w.slice(0, -4));
return bases.some(b => b.length >= 3 && allWords.has(b));
}
// Skip near-duplicates of words already in the pool (e.g. "dolphin" vs "dolphins")
function isInflectionOfExisting(w) {
const stems = [w.replace(/s$/, ''), w.replace(/es$/, ''), w.replace(/ies$/, 'y'), w + 's', w + 'es', w.replace(/y$/, 'ies')];
return stems.some(s => s !== w && existing.has(s));
}
const found = [];
for (const word of candidates) {
if (word.length < 8 || word.length > 14) continue;
if (!/^[a-z]+$/.test(word)) continue;
if (existing.has(word)) continue;
if (word.endsWith('tion')) continue;
if (!DICTIONARY_SET.has(word)) continue;
if (isInflectionOfExisting(word)) continue;
if (!('keep-inflections' in args) && isInflection(word)) continue;
const tiles = split(word);
if (tiles) found.push({ word, tiles });
}
found.sort((a, b) => a.word.localeCompare(b.word));
if ('json' in args) {
console.log(JSON.stringify(found));
} else {
for (const f of found) console.log(`${f.word}|${f.tiles.join('-')}`);
console.error(`\n${chunks.size} chunks, ${found.length} new words found (tiers <= ${MAX_TIER})`);
}