Przeglądaj źródła

feat: add vowel-first and greedy solver strategies

- Vowel-first: maximize vowel coverage in early guesses, then hunt consonants
- Greedy position-masking: respect greens, sort by positional match quality
- Difficulty now averages all 4 strategies (entropy, frequency, vowel-first, greedy)
- More robust word ranking with 4 independent algorithms

Co-Authored-By: Claude <noreply@anthropic.com>
Oleg Panashchenko 2 tygodni temu
rodzic
commit
f6eead70b0
4 zmienionych plików z 201 dodań i 33 usunięć
  1. 30 30
      data/word-bank.json
  2. 71 0
      solver/src/greedy.ts
  3. 11 3
      solver/src/index.ts
  4. 89 0
      solver/src/vowel-first.ts

+ 30 - 30
data/word-bank.json

@@ -772,17 +772,17 @@
     {
       "id": 1,
       "word": "crane",
-      "difficulty": 3
+      "difficulty": 2.5
     },
     {
       "id": 2,
       "word": "apple",
-      "difficulty": 3.5
+      "difficulty": 3.25
     },
     {
       "id": 3,
       "word": "grape",
-      "difficulty": 3
+      "difficulty": 2.75
     },
     {
       "id": 4,
@@ -792,7 +792,7 @@
     {
       "id": 5,
       "word": "hello",
-      "difficulty": 3
+      "difficulty": 2.5
     },
     {
       "id": 6,
@@ -802,7 +802,7 @@
     {
       "id": 7,
       "word": "large",
-      "difficulty": 2
+      "difficulty": 2.25
     },
     {
       "id": 8,
@@ -812,27 +812,27 @@
     {
       "id": 9,
       "word": "brain",
-      "difficulty": 3
+      "difficulty": 2.75
     },
     {
       "id": 10,
       "word": "cloud",
-      "difficulty": 3
+      "difficulty": 2.5
     },
     {
       "id": 11,
       "word": "dance",
-      "difficulty": 3
+      "difficulty": 2.5
     },
     {
       "id": 12,
       "word": "flame",
-      "difficulty": 3
+      "difficulty": 2.75
     },
     {
       "id": 13,
       "word": "giant",
-      "difficulty": 3
+      "difficulty": 2.75
     },
     {
       "id": 14,
@@ -847,32 +847,32 @@
     {
       "id": 16,
       "word": "mango",
-      "difficulty": 3
+      "difficulty": 2.5
     },
     {
       "id": 17,
       "word": "noble",
-      "difficulty": 3
+      "difficulty": 2.75
     },
     {
       "id": 18,
       "word": "ocean",
-      "difficulty": 2.5
+      "difficulty": 2.25
     },
     {
       "id": 19,
       "word": "piano",
-      "difficulty": 3
+      "difficulty": 2.75
     },
     {
       "id": 20,
       "word": "queen",
-      "difficulty": 3
+      "difficulty": 2.75
     },
     {
       "id": 21,
       "word": "sugar",
-      "difficulty": 3
+      "difficulty": 2.75
     },
     {
       "id": 22,
@@ -892,22 +892,22 @@
     {
       "id": 25,
       "word": "yield",
-      "difficulty": 3
+      "difficulty": 2.75
     },
     {
       "id": 26,
       "word": "zebra",
-      "difficulty": 3
+      "difficulty": 3.25
     },
     {
       "id": 27,
       "word": "ghost",
-      "difficulty": 3
+      "difficulty": 3.5
     },
     {
       "id": 28,
       "word": "judge",
-      "difficulty": 3.5
+      "difficulty": 3.25
     },
     {
       "id": 29,
@@ -917,7 +917,7 @@
     {
       "id": 30,
       "word": "lucky",
-      "difficulty": 3
+      "difficulty": 3.5
     },
     {
       "id": 31,
@@ -927,47 +927,47 @@
     {
       "id": 32,
       "word": "prize",
-      "difficulty": 5
+      "difficulty": 4.5
     },
     {
       "id": 33,
       "word": "quiet",
-      "difficulty": 3
+      "difficulty": 3.25
     },
     {
       "id": 34,
       "word": "solar",
-      "difficulty": 4
+      "difficulty": 4.75
     },
     {
       "id": 35,
       "word": "truly",
-      "difficulty": 2.5
+      "difficulty": 3.5
     },
     {
       "id": 36,
       "word": "vital",
-      "difficulty": 3.5
+      "difficulty": 4.25
     },
     {
       "id": 37,
       "word": "wheat",
-      "difficulty": 2.5
+      "difficulty": 3.25
     },
     {
       "id": 38,
       "word": "xenon",
-      "difficulty": 3.5
+      "difficulty": 3.25
     },
     {
       "id": 39,
       "word": "yacht",
-      "difficulty": 3
+      "difficulty": 3.25
     },
     {
       "id": 40,
       "word": "zebra",
-      "difficulty": 3
+      "difficulty": 3.25
     }
   ]
 }

+ 71 - 0
solver/src/greedy.ts

@@ -0,0 +1,71 @@
+/**
+ * Greedy position-masking Wordle solver.
+ * Strategy: strictly respect known greens (lock positions).
+ * For yellows, test words that have the letter in different positions.
+ * For grays, eliminate words containing that letter.
+ *
+ * Simpler than entropy — models how many humans actually play.
+ */
+
+function getFeedback(target: string, guess: string): string {
+  const tu = target.toLowerCase();
+  const gu = guess.toLowerCase();
+  const counts: Record<string, number> = {};
+  for (const ch of tu) counts[ch] = (counts[ch] ?? 0) + 1;
+  const result: string[] = Array(5).fill('x');
+  for (let i = 0; i < 5; i++) {
+    if (gu[i] === tu[i]) { result[i] = 'g'; counts[gu[i]]--; }
+  }
+  for (let i = 0; i < 5; i++) {
+    if (result[i] === 'g') continue;
+    if ((counts[gu[i]] ?? 0) > 0) { result[i] = 'y'; counts[gu[i]]--; }
+  }
+  return result.join('');
+}
+
+function matchesFeedback(candidate: string, guess: string, feedback: string): boolean {
+  return getFeedback(candidate, guess) === feedback;
+}
+
+/**
+ * Solve using greedy position-masking.
+ * Pick any candidate — the filtering does the work.
+ */
+export function solveGreedy(target: string, allWords: string[], maxAttempts = 6): number {
+  let candidates = [...allWords];
+
+  // Good human-style starting word
+  const starters = ['crane', 'slate', 'arise', 'stare'];
+  const starter = starters.find((w) => allWords.includes(w)) ?? allWords[0];
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    const guess = attempt === 1 ? starter : candidates[0];
+    const fb = getFeedback(target, guess);
+
+    if (fb === 'ggggg') return attempt;
+
+    candidates = candidates.filter((c) => matchesFeedback(c, guess, fb));
+    if (candidates.length === 0) return maxAttempts + 1;
+
+    // Greedy: prefer candidates with letters in positions matching known greens
+    if (attempt < maxAttempts) {
+      const greenPositions: number[] = [];
+      for (let i = 0; i < 5; i++) {
+        if (fb[i] === 'g') greenPositions.push(i);
+      }
+      // Sort candidates by how many greens they match from previous feedback
+      if (greenPositions.length > 0) {
+        candidates.sort((a, b) => {
+          let aMatch = 0, bMatch = 0;
+          for (const pos of greenPositions) {
+            if (a[pos] === guess[pos]) aMatch++;
+            if (b[pos] === guess[pos]) bMatch++;
+          }
+          return bMatch - aMatch; // more matches first
+        });
+      }
+    }
+  }
+
+  return maxAttempts + 1;
+}

+ 11 - 3
solver/src/index.ts

@@ -4,6 +4,8 @@ import { fileURLToPath } from 'url';
 import type { WordBank } from '@wordle/shared';
 import { solveEntropy } from './entropy.js';
 import { solveFrequency } from './frequency.js';
+import { solveVowelFirst } from './vowel-first.js';
+import { solveGreedy } from './greedy.js';
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const BANK_PATH = resolve(__dirname, '../../data/word-bank.json');
@@ -22,14 +24,20 @@ const newTargets = [];
 for (const target of bank.targets) {
   const entropyAttempts = solveEntropy(target.word, allWords, MAX_ATTEMPTS);
   const freqAttempts = solveFrequency(target.word, allWords, MAX_ATTEMPTS);
+  const vowelAttempts = solveVowelFirst(target.word, allWords, MAX_ATTEMPTS);
+  const greedyAttempts = solveGreedy(target.word, allWords, MAX_ATTEMPTS);
 
-  if (entropyAttempts > MAX_ATTEMPTS && freqAttempts > MAX_ATTEMPTS) {
-    console.log(`  REMOVED: ${target.word} (unsolvable in ${MAX_ATTEMPTS})`);
+  const allAttempts = [entropyAttempts, freqAttempts, vowelAttempts, greedyAttempts];
+  const passCount = allAttempts.filter((a) => a <= MAX_ATTEMPTS).length;
+
+  if (passCount === 0) {
+    console.log(`  REMOVED: ${target.word} (unsolvable in ${MAX_ATTEMPTS} by any strategy)`);
     failed++;
     continue;
   }
 
-  const difficulty = Math.round(((entropyAttempts + freqAttempts) / 2) * 100) / 100;
+  const avgAttempts = allAttempts.filter((a) => a <= MAX_ATTEMPTS).reduce((s, a) => s + a, 0) / passCount;
+  const difficulty = Math.round(avgAttempts * 100) / 100;
 
   newTargets.push({
     ...target,

+ 89 - 0
solver/src/vowel-first.ts

@@ -0,0 +1,89 @@
+/**
+ * Vowel-first Wordle solver.
+ * Strategy: maximize vowel coverage in early guesses,
+ * then switch to consonant hunting with positional constraints.
+ */
+
+const VOWELS = new Set(['a', 'e', 'i', 'o', 'u']);
+const VOWEL_HEAVY_STARTS = ['adieu', 'audio', 'raise', 'arose', 'irate', 'alone', 'ouija'];
+
+function getFeedback(target: string, guess: string): string {
+  const tu = target.toLowerCase();
+  const gu = guess.toLowerCase();
+  const counts: Record<string, number> = {};
+  for (const ch of tu) counts[ch] = (counts[ch] ?? 0) + 1;
+  const result: string[] = Array(5).fill('x');
+  for (let i = 0; i < 5; i++) {
+    if (gu[i] === tu[i]) { result[i] = 'g'; counts[gu[i]]--; }
+  }
+  for (let i = 0; i < 5; i++) {
+    if (result[i] === 'g') continue;
+    if ((counts[gu[i]] ?? 0) > 0) { result[i] = 'y'; counts[gu[i]]--; }
+  }
+  return result.join('');
+}
+
+function matchesFeedback(candidate: string, guess: string, feedback: string): boolean {
+  return getFeedback(candidate, guess) === feedback;
+}
+
+function countNewVowels(word: string, knownVowels: Set<string>): number {
+  let count = 0;
+  for (const ch of word) {
+    if (VOWELS.has(ch) && !knownVowels.has(ch)) count++;
+  }
+  return count;
+}
+
+function scoreVowel(word: string, knownVowels: Set<string>): number {
+  const newVowels = countNewVowels(word, knownVowels);
+  const uniqueLetters = new Set(word.split('')).size;
+  return newVowels * 3 + uniqueLetters;
+}
+
+/**
+ * Solve using vowel-first strategy.
+ */
+export function solveVowelFirst(target: string, allWords: string[], maxAttempts = 6): number {
+  let candidates = [...allWords];
+  const knownVowels = new Set<string>();
+  let guesses: { word: string; fb: string }[] = [];
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    let bestGuess: string;
+
+    if (attempt === 1) {
+      // Pick best vowel-heavy start word from shortlist
+      bestGuess = VOWEL_HEAVY_STARTS.find((w) => allWords.includes(w)) ?? 'arise';
+    } else if (attempt <= 3 && knownVowels.size < 5) {
+      // Still hunting vowels — pick word with most new vowels
+      const pool = candidates.length > 0 ? candidates : allWords;
+      bestGuess = pool[0];
+      let bestScore = -1;
+      for (const w of pool) {
+        const s = scoreVowel(w, knownVowels);
+        if (s > bestScore) { bestScore = s; bestGuess = w; }
+      }
+    } else {
+      // Consonant phase — pick from remaining candidates
+      bestGuess = candidates[0];
+    }
+
+    const fb = getFeedback(target, bestGuess);
+    guesses.push({ word: bestGuess, fb });
+
+    if (fb === 'ggggg') return attempt;
+
+    // Track known vowels from greens and yellows
+    for (let i = 0; i < 5; i++) {
+      if ((fb[i] === 'g' || fb[i] === 'y') && VOWELS.has(bestGuess[i])) {
+        knownVowels.add(bestGuess[i]);
+      }
+    }
+
+    candidates = candidates.filter((c) => matchesFeedback(c, bestGuess, fb));
+    if (candidates.length === 0) return maxAttempts + 1;
+  }
+
+  return maxAttempts + 1;
+}