瀏覽代碼

fix(solver): cap duplicated letters that are gray and non-gray in one row

When a guess repeats a letter and the result marks one occurrence gray and
another green/yellow, the solver now treats the green+yellow count as the
exact number of that letter in the target. Previously only the lower bound
was enforced, so words like 'award' (two 'a's) slipped through after
'karma' -> 'xxxxy'.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Oleg Panashchenko 3 周之前
父節點
當前提交
42f7277d14
共有 2 個文件被更改,包括 27 次插入 和 0 次删除
  1. 3 0
      raw/15-bug-gray-yellow.md
  2. 24 0
      server/src/routes/solver.ts

+ 3 - 0
raw/15-bug-gray-yellow.md

@@ -0,0 +1,3 @@
+# Bug in server/src/routes/solver.ts
+
+When a guess has one letter twice, and result has one of the letters gray and another non-gray, the solver should exclude words, where that letter occures more than once. For example, if 'karma' guess results in 'xxxxy', the word 'award' should not be suggested, as guess results show only one occurence of the letter 'a',

+ 24 - 0
server/src/routes/solver.ts

@@ -64,6 +64,7 @@ export function createSolverRouter(wordBank: WordBank): Router {
     const greens = new Map<number, string>(); // position → letter
     const yellowForbidden = new Map<string, Set<number>>(); // letter → forbidden positions
     const yellowMinCount = new Map<string, number>(); // letter → min occurrences in target
+    const maxCounts = new Map<string, number>(); // letter → exact occurrences in target (gray + non-gray in same row)
     const grayCandidates = new Set<string>(); // letters seen as gray anywhere
     const allGreenYellow = new Set<string>(); // letters seen as g or y in ANY row
 
@@ -73,6 +74,7 @@ export function createSolverRouter(wordBank: WordBank): Router {
 
       // Per-row counts for green+yellow (min number of each letter in target)
       const rowCounts = new Map<string, number>();
+      const rowGray = new Set<string>();
 
       for (let i = 0; i < 5; i++) {
         const letter = g[i];
@@ -91,6 +93,7 @@ export function createSolverRouter(wordBank: WordBank): Router {
           yellowForbidden.get(letter)!.add(i);
         } else {
           grayCandidates.add(letter);
+          rowGray.add(letter);
         }
       }
 
@@ -98,6 +101,14 @@ export function createSolverRouter(wordBank: WordBank): Router {
       for (const [letter, count] of rowCounts) {
         yellowMinCount.set(letter, Math.max(yellowMinCount.get(letter) ?? 0, count));
       }
+
+      // A letter that is both gray and green/yellow in this row is capped
+      // at its green+yellow count (the gray marks the "extra" occurrence).
+      for (const [letter, count] of rowCounts) {
+        if (rowGray.has(letter)) {
+          maxCounts.set(letter, Math.min(maxCounts.get(letter) ?? count, count));
+        }
+      }
     }
 
     // Remove from gray set: any letter that also appears as green or yellow
@@ -156,6 +167,19 @@ export function createSolverRouter(wordBank: WordBank): Router {
       }
       if (!ok) continue;
 
+      // Exact (upper-bound) count check
+      for (const [letter, maxCount] of maxCounts) {
+        let count = 0;
+        for (let i = 0; i < 5; i++) {
+          if (word[i] === letter) count++;
+        }
+        if (count > maxCount) {
+          ok = false;
+          break;
+        }
+      }
+      if (!ok) continue;
+
       candidates.push(word);
     }