shuffle smaller words better
This commit is contained in:
44
scripts/analyze-word-variety.mjs
Normal file
44
scripts/analyze-word-variety.mjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Reports how often short (1-2 tile) words repeat across generated puzzles.
|
||||||
|
// Usage: node scripts/analyze-word-variety.mjs [days=365]
|
||||||
|
import { generateDailyPuzzle } from '../src/engine/generator.js';
|
||||||
|
|
||||||
|
const days = Number(process.argv[2]) || 365;
|
||||||
|
const counts = new Map();
|
||||||
|
const targetCounts = new Map();
|
||||||
|
let totalSmall = 0;
|
||||||
|
const start = new Date(2026, 0, 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < days; i++) {
|
||||||
|
const d = new Date(start);
|
||||||
|
d.setDate(d.getDate() + i);
|
||||||
|
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
const puzzle = generateDailyPuzzle(dateStr);
|
||||||
|
for (const q of puzzle.targetQuartiles) targetCounts.set(q, (targetCounts.get(q) || 0) + 1);
|
||||||
|
for (const w of puzzle.words) {
|
||||||
|
if (w.tileCount > 2) continue;
|
||||||
|
totalSmall++;
|
||||||
|
counts.set(w.word, (counts.get(w.word) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const top20 = sorted.slice(0, 20).reduce((s, [, c]) => s + c, 0);
|
||||||
|
console.log(`Puzzles: ${days}`);
|
||||||
|
const maxTarget = Math.max(...targetCounts.values());
|
||||||
|
console.log(`Target words: ${targetCounts.size} distinct, most repeated used ${maxTarget}x`);
|
||||||
|
console.log(`Short-word slots: ${totalSmall} (${(totalSmall / days).toFixed(1)}/puzzle)`);
|
||||||
|
console.log(`Distinct short words: ${counts.size}`);
|
||||||
|
console.log(`Top-20 words fill ${((top20 / totalSmall) * 100).toFixed(1)}% of short-word slots`);
|
||||||
|
const byLen = new Map();
|
||||||
|
for (const [w, c] of counts) {
|
||||||
|
const k = w.length <= 3 ? `${w.length} letters` : '4+ letters';
|
||||||
|
const e = byLen.get(k) || { slots: 0, distinct: 0 };
|
||||||
|
e.slots += c;
|
||||||
|
e.distinct++;
|
||||||
|
byLen.set(k, e);
|
||||||
|
}
|
||||||
|
for (const [k, e] of [...byLen].sort()) {
|
||||||
|
console.log(` ${k.padEnd(11)} ${(e.slots / days).toFixed(1)}/puzzle, ${e.distinct} distinct, avg repeat ${(e.slots / e.distinct).toFixed(1)}x`);
|
||||||
|
}
|
||||||
|
console.log('Most repeated:');
|
||||||
|
console.log(sorted.slice(0, 25).map(([w, c]) => ` ${w.padEnd(12)} ${c}`).join('\n'));
|
||||||
@@ -1,28 +1,22 @@
|
|||||||
import { TARGET_WORDS_POOL } from '../data/puzzles.js';
|
import { TARGET_WORDS_POOL } from '../data/puzzles.js';
|
||||||
import { solveBoard } from './solver.js';
|
import { solveBoard } from './solver.js';
|
||||||
|
import { DICTIONARY_SET } from '../data/lexicon.js';
|
||||||
|
import { createSeededRandom } from './random.js';
|
||||||
|
|
||||||
|
export { createSeededRandom };
|
||||||
|
|
||||||
// Variety limits per board, so puzzles don't feel samey
|
// Variety limits per board, so puzzles don't feel samey
|
||||||
const MAX_SUFFIX_WORDS = 1; // words ending in -tion / -sion
|
const MAX_SUFFIX_WORDS = 1; // words ending in -tion / -sion
|
||||||
const MAX_LONG_WORDS = 2; // words with 11+ letters
|
const MAX_LONG_WORDS = 2; // words with 11+ letters
|
||||||
const LONG_WORD_LENGTH = 11;
|
const LONG_WORD_LENGTH = 11;
|
||||||
const SUFFIX_RE = /(tion|sion)$/;
|
const SUFFIX_RE = /(tion|sion)$/;
|
||||||
|
// Tiles that are short words on their own ("in", "me", "per") always show up
|
||||||
|
// as single-tile answers, so a few of them would repeat on most boards.
|
||||||
|
const MAX_SHORT_WORD_TILES = 1;
|
||||||
|
const SHORT_WORD_TILE_LENGTH = 3;
|
||||||
|
|
||||||
/** Deterministic PRNG (mulberry32) seeded from a string via FNV-1a */
|
function countShortWordTiles(tiles) {
|
||||||
export function createSeededRandom(seed) {
|
return tiles.filter(t => t.length <= SHORT_WORD_TILE_LENGTH && DICTIONARY_SET.has(t.toLowerCase())).length;
|
||||||
let h = 0x811c9dc5;
|
|
||||||
const str = String(seed);
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
h ^= str.charCodeAt(i);
|
|
||||||
h = Math.imul(h, 0x01000193);
|
|
||||||
}
|
|
||||||
let a = h >>> 0;
|
|
||||||
return () => {
|
|
||||||
a = (a + 0x6d2b79f5) >>> 0;
|
|
||||||
let t = a;
|
|
||||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
||||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shuffle(arr, rng) {
|
function shuffle(arr, rng) {
|
||||||
@@ -34,6 +28,40 @@ function shuffle(arr, rng) {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Very common tiles ("te", "ra", "er", "re") keep pairing into the same small
|
||||||
|
// words ("rate", "tire", "rest") on board after board. Each target word gets a
|
||||||
|
// selection weight that drops the more of these common tiles it contains, so
|
||||||
|
// boards lean toward a fresher mix of tiles. 0 = plain uniform shuffle.
|
||||||
|
const TILE_RARITY_STRENGTH = 0.6;
|
||||||
|
|
||||||
|
const tileFrequency = new Map();
|
||||||
|
for (const item of TARGET_WORDS_POOL) {
|
||||||
|
for (const t of item.tiles || []) {
|
||||||
|
const key = t.toLowerCase();
|
||||||
|
tileFrequency.set(key, (tileFrequency.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightCache = new WeakMap();
|
||||||
|
function tileRarityWeight(item) {
|
||||||
|
let w = weightCache.get(item);
|
||||||
|
if (w === undefined) {
|
||||||
|
// Geometric mean of 1/frequency across the word's tiles
|
||||||
|
const logSum = (item.tiles || []).reduce((s, t) => s + Math.log(tileFrequency.get(t.toLowerCase()) || 1), 0);
|
||||||
|
w = Math.exp((-TILE_RARITY_STRENGTH * logSum) / Math.max(1, item.tiles?.length || 1));
|
||||||
|
weightCache.set(item, w);
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weighted random order (Efraimidis-Spirakis): rarer-tile words tend to come first */
|
||||||
|
function weightedShuffle(arr, rng) {
|
||||||
|
return arr
|
||||||
|
.map(item => ({ item, key: Math.log(rng() || Number.MIN_VALUE) / tileRarityWeight(item) }))
|
||||||
|
.sort((a, b) => b.key - a.key)
|
||||||
|
.map(e => e.item);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Greedily picks 5 words with 20 unique tiles from a shuffled pool while
|
* Greedily picks 5 words with 20 unique tiles from a shuffled pool while
|
||||||
* respecting the variety limits. Returns null if it can't fill the board.
|
* respecting the variety limits. Returns null if it can't fill the board.
|
||||||
@@ -43,6 +71,7 @@ function pickWords(pool, { enforceVariety = true } = {}) {
|
|||||||
const usedTiles = new Set();
|
const usedTiles = new Set();
|
||||||
let suffixCount = 0;
|
let suffixCount = 0;
|
||||||
let longCount = 0;
|
let longCount = 0;
|
||||||
|
let shortWordTileCount = 0;
|
||||||
|
|
||||||
for (const item of pool) {
|
for (const item of pool) {
|
||||||
if (!item.tiles || item.tiles.length !== 4) continue;
|
if (!item.tiles || item.tiles.length !== 4) continue;
|
||||||
@@ -51,15 +80,18 @@ function pickWords(pool, { enforceVariety = true } = {}) {
|
|||||||
|
|
||||||
const isSuffix = SUFFIX_RE.test(item.word);
|
const isSuffix = SUFFIX_RE.test(item.word);
|
||||||
const isLong = item.word.length >= LONG_WORD_LENGTH;
|
const isLong = item.word.length >= LONG_WORD_LENGTH;
|
||||||
|
const shortWordTiles = countShortWordTiles(item.tiles);
|
||||||
if (enforceVariety) {
|
if (enforceVariety) {
|
||||||
if (isSuffix && suffixCount >= MAX_SUFFIX_WORDS) continue;
|
if (isSuffix && suffixCount >= MAX_SUFFIX_WORDS) continue;
|
||||||
if (isLong && longCount >= MAX_LONG_WORDS) continue;
|
if (isLong && longCount >= MAX_LONG_WORDS) continue;
|
||||||
|
if (shortWordTileCount + shortWordTiles > MAX_SHORT_WORD_TILES) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
picked.push(item);
|
picked.push(item);
|
||||||
item.tiles.forEach(t => usedTiles.add(t.toLowerCase()));
|
item.tiles.forEach(t => usedTiles.add(t.toLowerCase()));
|
||||||
if (isSuffix) suffixCount++;
|
if (isSuffix) suffixCount++;
|
||||||
if (isLong) longCount++;
|
if (isLong) longCount++;
|
||||||
|
shortWordTileCount += shortWordTiles;
|
||||||
if (picked.length === 5) return picked;
|
if (picked.length === 5) return picked;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -67,8 +99,8 @@ function pickWords(pool, { enforceVariety = true } = {}) {
|
|||||||
|
|
||||||
function selectWords(candidatePool, rng) {
|
function selectWords(candidatePool, rng) {
|
||||||
const attempts = [
|
const attempts = [
|
||||||
() => pickWords(shuffle(candidatePool, rng)),
|
() => pickWords(weightedShuffle(candidatePool, rng)),
|
||||||
() => pickWords(shuffle(TARGET_WORDS_POOL, rng)),
|
() => pickWords(weightedShuffle(TARGET_WORDS_POOL, rng)),
|
||||||
() => pickWords(shuffle(TARGET_WORDS_POOL, rng)),
|
() => pickWords(shuffle(TARGET_WORDS_POOL, rng)),
|
||||||
() => pickWords(shuffle(TARGET_WORDS_POOL, rng), { enforceVariety: false })
|
() => pickWords(shuffle(TARGET_WORDS_POOL, rng), { enforceVariety: false })
|
||||||
];
|
];
|
||||||
@@ -110,7 +142,8 @@ function themeLabel(selectedWords) {
|
|||||||
* Guarantees:
|
* Guarantees:
|
||||||
* - 20 strictly unique tiles (no duplicates anywhere on board)
|
* - 20 strictly unique tiles (no duplicates anywhere on board)
|
||||||
* - All tiles are at least 2 characters (never 1 letter)
|
* - All tiles are at least 2 characters (never 1 letter)
|
||||||
* - Variety: at most one -tion/-sion answer and two 11+ letter answers
|
* - Variety: at most one -tion/-sion answer, two 11+ letter answers, and one
|
||||||
|
* tile that is a short word by itself (e.g. "in", "per")
|
||||||
* - Anti-repetition: tracks recently used words in localStorage across games
|
* - Anti-repetition: tracks recently used words in localStorage across games
|
||||||
* (skipped when a seed is given, so seeded puzzles stay deterministic)
|
* (skipped when a seed is given, so seeded puzzles stay deterministic)
|
||||||
*/
|
*/
|
||||||
|
|||||||
18
src/engine/random.js
Normal file
18
src/engine/random.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/** Deterministic PRNG (mulberry32) seeded from a string via FNV-1a */
|
||||||
|
export function createSeededRandom(seed) {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
const str = String(seed);
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
let a = h >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DICTIONARY_SET, PREFIX_SET, getCommonnessScore } from '../data/lexicon.js';
|
import { DICTIONARY_SET, PREFIX_SET, getCommonnessScore } from '../data/lexicon.js';
|
||||||
import { capWordList, compareCandidates } from './word-cap.js';
|
import { capWordList, compareCandidates, SMALL_WORD_FUZZ, SMALL_WORD_MAX_TILES } from './word-cap.js';
|
||||||
|
import { createSeededRandom } from './random.js';
|
||||||
import { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo } from './constants.js';
|
import { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo } from './constants.js';
|
||||||
|
|
||||||
export { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo };
|
export { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo };
|
||||||
@@ -68,8 +69,22 @@ export function solveBoard(quartets, maxWords = 30) {
|
|||||||
|
|
||||||
// Rank candidates: target quartiles first, then familiar words, then tile
|
// Rank candidates: target quartiles first, then familiar words, then tile
|
||||||
// count (see compareCandidates). Shorter words win ties so the list isn't
|
// count (see compareCandidates). Shorter words win ties so the list isn't
|
||||||
// dominated by the longest possible combos.
|
// dominated by the longest possible combos. Small 1-2 tile words get a
|
||||||
words.sort((a, b) => compareCandidates(a, b, getCommonnessScore));
|
// per-board random nudge so the same common short words don't win every
|
||||||
|
// board. It's seeded from the tiles, so a given board always yields the
|
||||||
|
// same list (daily and shared puzzles stay identical for everyone).
|
||||||
|
const boardSeed = tiles.join('|');
|
||||||
|
const jitterCache = new Map();
|
||||||
|
const getJitter = (w) => {
|
||||||
|
if (w.tileCount > SMALL_WORD_MAX_TILES) return 0;
|
||||||
|
let j = jitterCache.get(w.word);
|
||||||
|
if (j === undefined) {
|
||||||
|
j = createSeededRandom(`${boardSeed}:${w.word}`)() * SMALL_WORD_FUZZ;
|
||||||
|
jitterCache.set(w.word, j);
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
};
|
||||||
|
words.sort((a, b) => compareCandidates(a, b, getCommonnessScore, getJitter));
|
||||||
|
|
||||||
// Cap at maxWords (default: 30) while keeping a fair share of 2-tile words.
|
// Cap at maxWords (default: 30) while keeping a fair share of 2-tile words.
|
||||||
// Valid words beyond the cap are still accepted in play as bonus words.
|
// Valid words beyond the cap are still accepted in play as bonus words.
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* How far (in commonness tiers) small 1-2 tile words can be randomly nudged
|
||||||
|
* when ranking. Without this the same handful of very common short words
|
||||||
|
* ("rate", "fire", "late") would win every board they can be built on. The
|
||||||
|
* familiarity gate (commonness >= 2) still uses the raw score, so obscure
|
||||||
|
* words never get promoted past everyday ones.
|
||||||
|
*/
|
||||||
|
export const SMALL_WORD_FUZZ = 1.5;
|
||||||
|
export const SMALL_WORD_MAX_TILES = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ranking for solver candidates (used by src/engine/solver.js).
|
* Ranking for solver candidates (used by src/engine/solver.js).
|
||||||
*
|
*
|
||||||
* 1. Target quartiles first.
|
* 1. Target quartiles first.
|
||||||
* 2. Familiar words (commonness >= 2) before rare ones, so long obscure
|
* 2. Familiar words (commonness >= 2) before rare ones, so long obscure
|
||||||
* combos can't crowd out everyday words.
|
* combos can't crowd out everyday words.
|
||||||
* 3. Higher commonness tier.
|
* 3. Higher commonness tier (plus optional jitter from `getJitter`).
|
||||||
* 4. More tiles (worth more points).
|
* 4. More tiles (worth more points).
|
||||||
* 5. Shorter word (avoids a bias toward the longest possible combos).
|
* 5. Shorter word (avoids a bias toward the longest possible combos).
|
||||||
* 6. Alphabetical.
|
* 6. Alphabetical.
|
||||||
*/
|
*/
|
||||||
export function compareCandidates(a, b, getCommonness) {
|
export function compareCandidates(a, b, getCommonness, getJitter = () => 0) {
|
||||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||||
const ca = getCommonness(a.word);
|
const ca = getCommonness(a.word);
|
||||||
const cb = getCommonness(b.word);
|
const cb = getCommonness(b.word);
|
||||||
const fa = ca >= 2 ? 1 : 0;
|
const fa = ca >= 2 ? 1 : 0;
|
||||||
const fb = cb >= 2 ? 1 : 0;
|
const fb = cb >= 2 ? 1 : 0;
|
||||||
if (fa !== fb) return fb - fa;
|
if (fa !== fb) return fb - fa;
|
||||||
if (ca !== cb) return cb - ca;
|
const sa = ca + getJitter(a);
|
||||||
|
const sb = cb + getJitter(b);
|
||||||
|
if (sa !== sb) return sb - sa;
|
||||||
if (a.tileCount !== b.tileCount) return b.tileCount - a.tileCount;
|
if (a.tileCount !== b.tileCount) return b.tileCount - a.tileCount;
|
||||||
if (a.word.length !== b.word.length) return a.word.length - b.word.length;
|
if (a.word.length !== b.word.length) return a.word.length - b.word.length;
|
||||||
return a.word.localeCompare(b.word);
|
return a.word.localeCompare(b.word);
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.5"
|
"version": "1.0.6"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user