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 { 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
|
||||
const MAX_SUFFIX_WORDS = 1; // words ending in -tion / -sion
|
||||
const MAX_LONG_WORDS = 2; // words with 11+ letters
|
||||
const LONG_WORD_LENGTH = 11;
|
||||
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 */
|
||||
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;
|
||||
};
|
||||
function countShortWordTiles(tiles) {
|
||||
return tiles.filter(t => t.length <= SHORT_WORD_TILE_LENGTH && DICTIONARY_SET.has(t.toLowerCase())).length;
|
||||
}
|
||||
|
||||
function shuffle(arr, rng) {
|
||||
@@ -34,6 +28,40 @@ function shuffle(arr, rng) {
|
||||
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
|
||||
* 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();
|
||||
let suffixCount = 0;
|
||||
let longCount = 0;
|
||||
let shortWordTileCount = 0;
|
||||
|
||||
for (const item of pool) {
|
||||
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 isLong = item.word.length >= LONG_WORD_LENGTH;
|
||||
const shortWordTiles = countShortWordTiles(item.tiles);
|
||||
if (enforceVariety) {
|
||||
if (isSuffix && suffixCount >= MAX_SUFFIX_WORDS) continue;
|
||||
if (isLong && longCount >= MAX_LONG_WORDS) continue;
|
||||
if (shortWordTileCount + shortWordTiles > MAX_SHORT_WORD_TILES) continue;
|
||||
}
|
||||
|
||||
picked.push(item);
|
||||
item.tiles.forEach(t => usedTiles.add(t.toLowerCase()));
|
||||
if (isSuffix) suffixCount++;
|
||||
if (isLong) longCount++;
|
||||
shortWordTileCount += shortWordTiles;
|
||||
if (picked.length === 5) return picked;
|
||||
}
|
||||
return null;
|
||||
@@ -67,8 +99,8 @@ function pickWords(pool, { enforceVariety = true } = {}) {
|
||||
|
||||
function selectWords(candidatePool, rng) {
|
||||
const attempts = [
|
||||
() => pickWords(shuffle(candidatePool, rng)),
|
||||
() => pickWords(shuffle(TARGET_WORDS_POOL, rng)),
|
||||
() => pickWords(weightedShuffle(candidatePool, rng)),
|
||||
() => pickWords(weightedShuffle(TARGET_WORDS_POOL, rng)),
|
||||
() => pickWords(shuffle(TARGET_WORDS_POOL, rng)),
|
||||
() => pickWords(shuffle(TARGET_WORDS_POOL, rng), { enforceVariety: false })
|
||||
];
|
||||
@@ -110,7 +142,8 @@ function themeLabel(selectedWords) {
|
||||
* Guarantees:
|
||||
* - 20 strictly unique tiles (no duplicates anywhere on board)
|
||||
* - 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
|
||||
* (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 { 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';
|
||||
|
||||
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
|
||||
// count (see compareCandidates). Shorter words win ties so the list isn't
|
||||
// dominated by the longest possible combos.
|
||||
words.sort((a, b) => compareCandidates(a, b, getCommonnessScore));
|
||||
// dominated by the longest possible combos. Small 1-2 tile words get a
|
||||
// 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.
|
||||
// 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).
|
||||
*
|
||||
* 1. Target quartiles first.
|
||||
* 2. Familiar words (commonness >= 2) before rare ones, so long obscure
|
||||
* 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).
|
||||
* 5. Shorter word (avoids a bias toward the longest possible combos).
|
||||
* 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;
|
||||
const ca = getCommonness(a.word);
|
||||
const cb = getCommonness(b.word);
|
||||
const fa = ca >= 2 ? 1 : 0;
|
||||
const fb = cb >= 2 ? 1 : 0;
|
||||
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.word.length !== b.word.length) return a.word.length - b.word.length;
|
||||
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