Browse Source

refactor: lazy cache for replays/difficulty — no startup pre-computation

Cache starts empty. First API request for a word computes with 3 fast
solvers (~20ms) and returns immediately, then fires all 4 solvers
(including entropy) in the background. Once the background computation
completes (~14s), the cache is upgraded with full results.

Server startup is now instant. Play Again uses cached difficulty with
a 3.0 default for words not yet played.
Oleg Panashchenko 1 week ago
parent
commit
8af03b085a

+ 12 - 15
server/src/index.ts

@@ -3,7 +3,7 @@ import { readFileSync, existsSync } from 'fs';
 import { resolve, dirname } from 'path';
 import { fileURLToPath } from 'url';
 import type { WordBank } from '@wordle/shared';
-import { computeDifficulty } from '@wordle/shared';
+import type { CachedResult } from '@wordle/shared';
 import { createDailyRouter } from './routes/daily.js';
 import { createGuessRouter } from './routes/guess.js';
 import { createPlayAgainRouter } from './routes/play-again.js';
@@ -16,37 +16,34 @@ function loadBank(filename: string): WordBank {
   return JSON.parse(readFileSync(path, 'utf-8'));
 }
 
-/** Pre-compute difficulty scores for all targets at startup (used by Play Again). */
-function buildDifficultyMap(bank: WordBank): Map<number, number> {
-  const map = new Map<number, number>();
-  for (const t of bank.targets) {
-    map.set(t.id, computeDifficulty(t.word, bank.guessable));
-  }
-  return map;
+// Lazy cache — empty at startup, populated on first API hit for each word.
+// Background computation upgrades entries from 3-solver to full 4-solver results.
+function createCache(): Map<number, CachedResult> {
+  return new Map();
 }
 
-function mountApi(prefix: string, bank: WordBank, difficultyMap: Map<number, number>) {
+function mountApi(prefix: string, bank: WordBank, cache: Map<number, CachedResult>) {
   return [
     [prefix + '/daily', createDailyRouter(bank)],
     [prefix + '/validate', createValidateRouter(bank)],
-    [prefix + '/guess', createGuessRouter(bank)],
-    [prefix + '/play-again', createPlayAgainRouter(bank, difficultyMap)],
+    [prefix + '/guess', createGuessRouter(bank, cache)],
+    [prefix + '/play-again', createPlayAgainRouter(bank, cache)],
   ] as const;
 }
 
 const enBank = loadBank('word-bank.json');
 const ruBank = loadBank('word-bank-ru.json');
 
-const enDifficulty = buildDifficultyMap(enBank);
-const ruDifficulty = buildDifficultyMap(ruBank);
+const enCache = createCache();
+const ruCache = createCache();
 
 const app = express();
 app.use(express.json());
 
 // Mount API routes for both languages
 for (const [path, router] of [
-  ...mountApi('/api', enBank, enDifficulty),
-  ...mountApi('/ru/api', ruBank, ruDifficulty),
+  ...mountApi('/api', enBank, enCache),
+  ...mountApi('/ru/api', ruBank, ruCache),
 ]) {
   app.use(path, router);
 }

+ 5 - 4
server/src/play-again.ts

@@ -1,4 +1,4 @@
-import type { TargetWord } from '@wordle/shared';
+import type { TargetWord, CachedResult } from '@wordle/shared';
 
 /**
  * Select a target word weighted by proximity to the player's skill level.
@@ -8,12 +8,13 @@ import type { TargetWord } from '@wordle/shared';
  *    is to the player's skill (Gaussian-like: 1 / (1 + (diff - skill)^2))
  * 2. Pick randomly, weighted by those weights.
  *
- * If all difficulties are equal (default 3.0), this degenerates to uniform random.
+ * Uncached words get default difficulty 3.0 — they converge to real values
+ * as words get played and cached.
  */
 export function selectPlayAgainWord(
   targets: TargetWord[],
   skillMetric: number,
-  difficultyMap: Map<number, number>,
+  cache: Map<number, CachedResult>,
 ): TargetWord {
   if (targets.length === 0) {
     throw new Error('No target words available');
@@ -21,7 +22,7 @@ export function selectPlayAgainWord(
 
   // Compute weights: closer difficulty = higher weight
   const weights = targets.map((t) => {
-    const diff = difficultyMap.get(t.id) ?? 3.0;
+    const diff = cache.get(t.id)?.difficulty ?? 3.0;
     const delta = diff - skillMetric;
     return 1 / (1 + delta * delta);
   });

+ 23 - 7
server/src/routes/guess.ts

@@ -1,10 +1,10 @@
 import { Router, Request, Response } from 'express';
-import type { WordBank, GuessResponse } from '@wordle/shared';
+import type { WordBank, GuessResponse, CachedResult } from '@wordle/shared';
 import { isValidGuess, getTargetWord } from '../validate.js';
 import { computeFeedback } from '../feedback.js';
-import { computeReplays } from '@wordle/shared';
+import { computeReplays, computeReplaysFull } from '@wordle/shared';
 
-export function createGuessRouter(wordBank: WordBank): Router {
+export function createGuessRouter(wordBank: WordBank, cache: Map<number, CachedResult>): Router {
   const router = Router();
 
   router.post('/', (req: Request, res: Response) => {
@@ -51,11 +51,27 @@ export function createGuessRouter(wordBank: WordBank): Router {
       response.word = target.word;
     }
 
-    // Compute difficulty and replays at runtime on final guess (win or loss)
+    // Compute difficulty and replays on final guess (win or loss)
     if (correct || isLastAttempt) {
-      const { difficulty, replays } = computeReplays(target.word, wordBank.guessable);
-      response.difficulty = difficulty;
-      response.replays = replays;
+      let result = cache.get(wordId);
+
+      if (!result) {
+        // Cache miss — compute with 3 fast solvers for immediate response
+        result = computeReplays(target.word, wordBank.guessable);
+        cache.set(wordId, result);
+
+        // Fire background computation with all 4 solvers (including entropy)
+        // Once complete, the cache is upgraded for the next time this word is played
+        const targetWord = target.word;
+        const guessable = wordBank.guessable;
+        setTimeout(() => {
+          const full = computeReplaysFull(targetWord, guessable);
+          cache.set(wordId, full);
+        }, 0);
+      }
+
+      response.difficulty = result.difficulty;
+      response.replays = result.replays;
     }
 
     res.json(response);

+ 3 - 3
server/src/routes/play-again.ts

@@ -1,8 +1,8 @@
 import { Router, Request, Response } from 'express';
-import type { WordBank } from '@wordle/shared';
+import type { WordBank, CachedResult } from '@wordle/shared';
 import { selectPlayAgainWord } from '../play-again.js';
 
-export function createPlayAgainRouter(wordBank: WordBank, difficultyMap: Map<number, number>): Router {
+export function createPlayAgainRouter(wordBank: WordBank, cache: Map<number, CachedResult>): Router {
   const router = Router();
 
   router.post('/', (req: Request, res: Response) => {
@@ -13,7 +13,7 @@ export function createPlayAgainRouter(wordBank: WordBank, difficultyMap: Map<num
       return;
     }
 
-    const target = selectPlayAgainWord(wordBank.targets, skillMetric, difficultyMap);
+    const target = selectPlayAgainWord(wordBank.targets, skillMetric, cache);
     res.json({ wordId: target.id });
   });
 

+ 26 - 10
shared/src/solvers/index.ts

@@ -13,20 +13,31 @@ interface SolverResult {
   steps: string[];
 }
 
-// Entropy is excluded from runtime computation — it takes ~14s per target (O(N²))
-// and the 3 fast solvers provide sufficient coverage in under 1ms per target.
-const SOLVERS: Array<{ name: string; fn: (t: string, w: string[], m: number) => SolverResult }> = [
+/** 3 fast solvers used for immediate API response (~20ms per target). */
+const FAST_SOLVERS: Array<{ name: string; fn: (t: string, w: string[], m: number) => SolverResult }> = [
   { name: 'Frequency', fn: solveFrequency },
   { name: 'Vowel First', fn: solveVowelFirst },
   { name: 'Greedy', fn: solveGreedy },
 ];
 
-/** Run all solvers against a target word, returning replays and the aggregate difficulty score. */
-export function computeReplays(target: string, allWords: string[]): {
+/** All 4 solvers including entropy — used for background pre-computation (~14s per target). */
+const ALL_SOLVERS: Array<{ name: string; fn: (t: string, w: string[], m: number) => SolverResult }> = [
+  { name: 'Entropy', fn: solveEntropy },
+  ...FAST_SOLVERS,
+];
+
+/** A cached replay+difficulty result, keyed by word ID. */
+export interface CachedResult {
   difficulty: number;
   replays: SolverReplay[];
-} {
-  const results = SOLVERS.map((s) => ({
+}
+
+function buildReplays(
+  solvers: Array<{ name: string; fn: (t: string, w: string[], m: number) => SolverResult }>,
+  target: string,
+  allWords: string[],
+): CachedResult {
+  const results = solvers.map((s) => ({
     name: s.name,
     ...s.fn(target, allWords, MAX_ATTEMPTS),
   }));
@@ -43,7 +54,12 @@ export function computeReplays(target: string, allWords: string[]): {
   return { difficulty, replays };
 }
 
-/** Run all solvers and return just the difficulty score (faster for pre-caching). */
-export function computeDifficulty(target: string, allWords: string[]): number {
-  return computeReplays(target, allWords).difficulty;
+/** Fast 3-solver computation for immediate API response (~20ms). */
+export function computeReplays(target: string, allWords: string[]): CachedResult {
+  return buildReplays(FAST_SOLVERS, target, allWords);
+}
+
+/** Full 4-solver computation including entropy — slower (~14s) but richer replays. */
+export function computeReplaysFull(target: string, allWords: string[]): CachedResult {
+  return buildReplays(ALL_SOLVERS, target, allWords);
 }