|
|
@@ -1,31 +1,121 @@
|
|
|
import { getFeedback, matchesFeedback } from './feedback.js';
|
|
|
|
|
|
+// --- Optimized feedback-to-integer encoding ---
|
|
|
+// Encodes a 5-letter feedback pattern as a base-3 integer (0-242):
|
|
|
+// g (green) = 0
|
|
|
+// y (yellow) = 1
|
|
|
+// x (gray) = 2
|
|
|
+// This avoids string allocation/compare overhead in the hot inner loop.
|
|
|
+
|
|
|
+function feedbackToInt(target: string, guess: string): number {
|
|
|
+ const tu = target.toLowerCase(), gu = guess.toLowerCase();
|
|
|
+ const counts: Record<string, number> = {};
|
|
|
+ for (const ch of tu) counts[ch] = (counts[ch] ?? 0) + 1;
|
|
|
+
|
|
|
+ // Pass 1: greens (0)
|
|
|
+ const r: number[] = [2, 2, 2, 2, 2]; // default gray
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
+ if (gu[i] === tu[i]) { r[i] = 0; counts[gu[i]]--; }
|
|
|
+ }
|
|
|
+ // Pass 2: yellows (1)
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
+ if (r[i] === 0) continue;
|
|
|
+ if ((counts[gu[i]] ?? 0) > 0) { r[i] = 1; counts[gu[i]]--; }
|
|
|
+ }
|
|
|
+
|
|
|
+ return r[0] * 81 + r[1] * 27 + r[2] * 9 + r[3] * 3 + r[4];
|
|
|
+}
|
|
|
+
|
|
|
+// --- Cached best first guess ---
|
|
|
+// The optimal first guess depends only on the guessable word list, not the
|
|
|
+// target. We compute it once per module load by sampling the dictionary,
|
|
|
+// then reuse it for every target — turning O(N²) into O(S*N) once, where
|
|
|
+// S ≈ 100 sample guesses.
|
|
|
+
|
|
|
+let cachedFirstGuess: string | null = null;
|
|
|
+let cachedDictLength = 0;
|
|
|
+
|
|
|
+function computeBestFirstGuess(allWords: string[]): string {
|
|
|
+ // Sample ~100 potential first guesses evenly from the dictionary.
|
|
|
+ // Common words (start of the list) are better first guesses, so we
|
|
|
+ // bias the sample toward the front.
|
|
|
+ const sampleSize = 100;
|
|
|
+ const step = Math.max(1, Math.floor(allWords.length / sampleSize));
|
|
|
+ const samples: string[] = [];
|
|
|
+ for (let i = 0; i < allWords.length && samples.length < sampleSize; i += step) {
|
|
|
+ samples.push(allWords[i]);
|
|
|
+ }
|
|
|
+
|
|
|
+ let bestGuess = samples[0];
|
|
|
+ let bestEntropy = Infinity;
|
|
|
+
|
|
|
+ for (const guess of samples) {
|
|
|
+ const buckets = new Map<number, number>();
|
|
|
+ for (const c of allWords) {
|
|
|
+ const fb = feedbackToInt(c, guess);
|
|
|
+ buckets.set(fb, (buckets.get(fb) ?? 0) + 1);
|
|
|
+ }
|
|
|
+ let expected = 0;
|
|
|
+ for (const count of buckets.values()) {
|
|
|
+ expected += (count / allWords.length) * count;
|
|
|
+ }
|
|
|
+ if (expected < bestEntropy) {
|
|
|
+ bestEntropy = expected;
|
|
|
+ bestGuess = guess;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return bestGuess;
|
|
|
+}
|
|
|
+
|
|
|
+function getBestFirstGuess(allWords: string[]): string {
|
|
|
+ // Invalidate cache if dictionary changed (e.g. different language)
|
|
|
+ if (cachedFirstGuess && cachedDictLength === allWords.length) {
|
|
|
+ return cachedFirstGuess;
|
|
|
+ }
|
|
|
+ cachedFirstGuess = computeBestFirstGuess(allWords);
|
|
|
+ cachedDictLength = allWords.length;
|
|
|
+ return cachedFirstGuess;
|
|
|
+}
|
|
|
+
|
|
|
+// --- Main solver ---
|
|
|
+
|
|
|
export function solveEntropy(target: string, allWords: string[], maxAttempts = 6): { attempts: number; steps: string[] } {
|
|
|
let candidates = [...allWords];
|
|
|
const steps: string[] = [];
|
|
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
|
- let bestGuess = candidates[0];
|
|
|
+ let bestGuess: string;
|
|
|
let bestEntropy = Infinity;
|
|
|
- const pool = attempt === 1 ? allWords : candidates;
|
|
|
|
|
|
- for (const guess of pool) {
|
|
|
- const buckets: Map<string, number> = new Map();
|
|
|
- for (const c of candidates) {
|
|
|
- const fb = getFeedback(c, guess);
|
|
|
- buckets.set(fb, (buckets.get(fb) ?? 0) + 1);
|
|
|
+ if (attempt === 1) {
|
|
|
+ // Use cached optimal first guess — avoids O(N²) per-target cost.
|
|
|
+ // Falls back to computing it if this is the first call.
|
|
|
+ bestGuess = getBestFirstGuess(allWords);
|
|
|
+ } else {
|
|
|
+ // For subsequent attempts, candidates is small (<500 typically);
|
|
|
+ // exhaustive search is fast.
|
|
|
+ const pool = candidates;
|
|
|
+ for (const guess of pool) {
|
|
|
+ const buckets = new Map<number, number>();
|
|
|
+ for (const c of candidates) {
|
|
|
+ const fb = feedbackToInt(c, guess);
|
|
|
+ buckets.set(fb, (buckets.get(fb) ?? 0) + 1);
|
|
|
+ }
|
|
|
+ let expected = 0;
|
|
|
+ for (const count of buckets.values()) {
|
|
|
+ expected += (count / candidates.length) * count;
|
|
|
+ }
|
|
|
+ if (expected < bestEntropy) { bestEntropy = expected; bestGuess = guess; }
|
|
|
}
|
|
|
- let expected = 0;
|
|
|
- for (const count of buckets.values()) expected += (count / candidates.length) * count;
|
|
|
- if (expected < bestEntropy) { bestEntropy = expected; bestGuess = guess; }
|
|
|
}
|
|
|
|
|
|
- const fb = getFeedback(target, bestGuess);
|
|
|
- steps.push(bestGuess);
|
|
|
+ const fb = getFeedback(target, bestGuess!);
|
|
|
+ steps.push(bestGuess!);
|
|
|
|
|
|
if (fb === 'ggggg') return { attempts: attempt, steps };
|
|
|
|
|
|
- candidates = candidates.filter((c) => matchesFeedback(c, bestGuess, fb));
|
|
|
+ candidates = candidates.filter((c) => matchesFeedback(c, bestGuess!, fb));
|
|
|
if (candidates.length === 0) return { attempts: maxAttempts + 1, steps };
|
|
|
}
|
|
|
|