sato2ru commited on
Commit
d3bb4c0
Β·
2 Parent(s): 612de7f34a9cd5

force reindex

Browse files
Files changed (4) hide show
  1. Dockerfile +12 -0
  2. README.md +3 -190
  3. app.py +187 -96
  4. requirements.txt +4 -2
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY app.py .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,192 +1,5 @@
1
  ---
2
- language: en
3
- tags:
4
- - wordle
5
- - pytorch
6
- - reinforcement-learning
7
- - supervised-learning
8
- - game-ai
9
- - nlp
10
- license: mit
11
  ---
12
-
13
- # 🟩 Wordle AI Solver
14
-
15
- Neural network models for solving Wordle puzzles. This repo contains two models β€” a supervised baseline and a reinforcement learning variant β€” both deployable via the [live app](https://wordle-solver-tan.vercel.app).
16
-
17
- ---
18
-
19
- ## Files
20
-
21
- | File | Description |
22
- |------|-------------|
23
- | `model_weights.pt` | Supervised model (WordleNet) |
24
- | `config.json` | Supervised model config |
25
- | `rl_model_weights.pt` | RL model (REINFORCE-filtered) |
26
- | `rl_config.json` | RL model config |
27
- | `answers.json` | 2,315 valid Wordle answers |
28
- | `allowed.json` | 12,972 valid guess words |
29
-
30
- ---
31
-
32
- ## Model Comparison
33
-
34
- | | 🧠 Supervised | πŸ€– Reinforcement |
35
- |---|---|---|
36
- | **Training method** | CrossEntropy on entropy-optimal games | REINFORCE with elite game filtering |
37
- | **Win rate** | 100% | 98.2% |
38
- | **Avg guesses** | 3.46 | 3.75 |
39
- | **Opener** | CRANE | CRANE |
40
- | **Parameters** | ~13M | ~13M |
41
-
42
- ---
43
-
44
- ## Architecture
45
-
46
- Both models share the same encoder:
47
-
48
- ```
49
- Input: 390-dim binary vector
50
- (26 letters Γ— 5 positions Γ— 3 states: grey/yellow/green)
51
-
52
- Hidden: Linear(390 β†’ 512) β†’ BatchNorm1d β†’ ReLU β†’ Dropout(0.3)
53
- Linear(512 β†’ 512) β†’ BatchNorm1d β†’ ReLU β†’ Dropout(0.3)
54
- Linear(512 β†’ 256) β†’ BatchNorm1d β†’ ReLU
55
-
56
- Output: Linear(256 β†’ 12972)
57
- logits over all 12,972 allowed guess words
58
- ```
59
-
60
- Board encoding:
61
- ```python
62
- vec[letter_index * 15 + position * 3 + state] = 1.0
63
- # letter_index: 0-25 (a-z)
64
- # position: 0-4
65
- # state: 0=grey, 1=yellow, 2=green
66
- ```
67
-
68
- ---
69
-
70
- ## Training
71
-
72
- ### Supervised Model
73
- Trained on ~10,000 (board_state, best_guess) pairs generated by an entropy-optimal solver that plays all 2,315 Wordle games. The solver picks the guess maximising expected information gain at each step:
74
-
75
- $$E[\text{Info}] = \sum_{p} P(p) \cdot \log_2\left(\frac{1}{P(p)}\right)$$
76
-
77
- ### RL Model
78
- 1. **Warm start** from supervised weights
79
- 2. **Elite game collection** β€” greedy rollouts with constraint-filtered action masking, keeping only games solved in ≀3 guesses (~11% hit rate)
80
- 3. **REINFORCE training** β€” supervised loss on elite (state, action) pairs
81
- 4. **Benchmark** against all 2,315 answers using constraint-filtered suggestion logic
82
-
83
- The RL model learns purely from reward signal (win/lose, guesses used) without access to the entropy oracle used to train the supervised model.
84
-
85
- ---
86
-
87
- ## Inference
88
-
89
- The models are not used as raw classifiers β€” the backend combines model logits with constraint filtering:
90
-
91
- ```python
92
- # 1. Get top-20 model words
93
- logits = model(encode_board(history))
94
- model_words = [ALLOWED[i] for i in logits.topk(20).indices]
95
-
96
- # 2. Filter to words consistent with all previous guesses
97
- possible = filter_words(ANSWERS, history)
98
-
99
- # 3. Score by entropy against remaining possible set
100
- candidates = model_words + possible
101
- best = max(candidates, key=lambda w: entropy_score(w, possible))
102
- ```
103
-
104
- This hybrid approach is why the supervised model achieves 100% β€” the neural net narrows the search, entropy scoring picks the optimal move.
105
-
106
- ---
107
-
108
- ## Usage
109
-
110
- ```python
111
- import torch
112
- import torch.nn as nn
113
- from huggingface_hub import hf_hub_download
114
- import json
115
-
116
- REPO_ID = "sato2ru/wordle-solver"
117
-
118
- config = json.load(open(hf_hub_download(REPO_ID, "config.json")))
119
- ALLOWED = json.load(open(hf_hub_download(REPO_ID, "allowed.json")))
120
-
121
- class WordleNet(nn.Module):
122
- def __init__(self):
123
- super().__init__()
124
- h = config["hidden"]
125
- self.net = nn.Sequential(
126
- nn.Linear(390, h), nn.BatchNorm1d(h), nn.ReLU(), nn.Dropout(0.3),
127
- nn.Linear(h, h), nn.BatchNorm1d(h), nn.ReLU(), nn.Dropout(0.3),
128
- nn.Linear(h, 256), nn.BatchNorm1d(256), nn.ReLU(),
129
- nn.Linear(256, 12972)
130
- )
131
- def forward(self, x): return self.net(x)
132
-
133
- # Load supervised model
134
- model = WordleNet()
135
- model.load_state_dict(
136
- torch.load(hf_hub_download(REPO_ID, "model_weights.pt"), map_location="cpu")
137
- )
138
- model.eval()
139
- ```
140
-
141
- Or use the live API directly:
142
- ```bash
143
- curl -X POST "https://web-production-ea1d.up.railway.app/suggest?model=supervised" \
144
- -H "Content-Type: application/json" \
145
- -d '{"history": []}'
146
-
147
- curl -X POST "https://web-production-ea1d.up.railway.app/suggest?model=rl" \
148
- -H "Content-Type: application/json" \
149
- -d '{"history": []}'
150
- ```
151
-
152
- ---
153
-
154
- ## Results
155
-
156
- ### Supervised β€” all 2,315 answers (greedy + entropy filter)
157
- ```
158
- 1 guess : 1
159
- 2 guesses: 59 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
160
- 3 guesses: 1188 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
161
- 4 guesses: 1010 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
162
- 5 guesses: 56 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
163
- 6 guesses: 1
164
- FAILED : 0 βœ… 100% win rate
165
- ```
166
-
167
- ### RL β€” all 2,315 answers (greedy + entropy filter)
168
- ```
169
- 1 guess : 1
170
- 2 guesses: 141 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
171
- 3 guesses: 810 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
172
- 4 guesses: 893 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
173
- 5 guesses: 343 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
174
- 6 guesses: 86 β–ˆβ–ˆβ–ˆβ–ˆ
175
- FAILED : 41 βœ… 98.2% win rate
176
- ```
177
-
178
- ---
179
-
180
- ## Links
181
-
182
- - **Live App:** [wordle-solver-tan.vercel.app](https://wordle-solver-tan.vercel.app)
183
- - **GitHub:** [github.com/Jeanwrld/wordle-solver](https://github.com/Jeanwrld/wordle-solver)
184
- - **Backend:** [github.com/Jeanwrld/wordle-api](https://github.com/Jeanwrld/wordle-api)
185
- - **Gradio Demo:** [huggingface.co/spaces/sato2ru/wordle](https://huggingface.co/spaces/sato2ru/wordle)
186
-
187
- ---
188
-
189
- ## License
190
-
191
- MIT
192
-
 
1
  ---
2
+ title: wordle
3
+ sdk: docker
4
+ pinned: false
 
 
 
 
 
 
5
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,25 +1,34 @@
1
-
2
- import json, math, torch, gradio as gr
 
 
3
  from collections import Counter
4
- import numpy as np
5
- from huggingface_hub import hf_hub_download
6
  import torch.nn as nn
 
7
 
8
- REPO_ID = "sato2ru/wordle-solver" # ← update this
9
-
10
- # ── Load assets from HF Hub ──────────────────────────────────────
11
- config = json.load(open(hf_hub_download(REPO_ID, "config.json")))
12
- ANSWERS = json.load(open(hf_hub_download(REPO_ID, "answers.json")))
13
- ALLOWED = json.load(open(hf_hub_download(REPO_ID, "allowed.json")))
14
- WORD2IDX = {w: i for i, w in enumerate(ALLOWED)}
 
 
 
 
 
 
 
 
15
  LETTERS = "abcdefghijklmnopqrstuvwxyz"
16
- L2I = {c: i for i, c in enumerate(LETTERS)}
17
- INPUT_DIM = config["input_dim"]
18
- OUTPUT_DIM = config["output_dim"]
19
- OPENING = config["opening_guess"]
20
- WIN_PATTERN = (2,2,2,2,2)
21
 
22
- # ── Model ────────────────────────────────────────────────────────
23
  class WordleNet(nn.Module):
24
  def __init__(self):
25
  super().__init__()
@@ -33,107 +42,189 @@ class WordleNet(nn.Module):
33
  def forward(self, x): return self.net(x)
34
 
35
  model = WordleNet()
36
- model.load_state_dict(torch.load(hf_hub_download(REPO_ID, "model_weights.pt"), map_location="cpu"))
 
 
37
  model.eval()
 
38
 
39
- # ── Helpers ──────────────────────────────────────────────────────
40
  def get_pattern(guess, answer):
41
- pattern = [0]*5
42
- counts = Counter(answer)
43
  for i in range(5):
44
- if guess[i] == answer[i]: pattern[i] = 2; counts[guess[i]] -= 1
 
 
45
  for i in range(5):
46
- if pattern[i] == 0 and counts.get(guess[i],0) > 0:
47
- pattern[i] = 1; counts[guess[i]] -= 1
 
48
  return tuple(pattern)
49
 
50
  def filter_words(words, guess, pattern):
51
- return [w for w in words if get_pattern(guess, w) == pattern]
52
 
53
  def entropy_score(guess, possible):
54
  buckets = Counter(get_pattern(guess, w) for w in possible)
55
  n = len(possible)
56
- return sum(-(c/n)*math.log2(c/n) for c in buckets.values())
57
 
58
  def encode_board(history):
59
  vec = np.zeros(INPUT_DIM, dtype=np.float32)
60
  for word, pattern in history:
61
  for pos, (letter, state) in enumerate(zip(word, pattern)):
62
- vec[L2I[letter]*15 + pos*3 + state] = 1.0
63
  return vec
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  def model_suggest(history, possible):
 
66
  if len(possible) == 1: return possible[0]
67
- if not history: return OPENING
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  state = torch.tensor(encode_board(history)).unsqueeze(0)
69
  with torch.no_grad():
70
  logits = model(state)[0]
71
- top5 = [ALLOWED[i] for i in logits.topk(5).indices.tolist()]
72
- return max(top5, key=lambda w: entropy_score(w, possible))
73
 
74
- # ── State ─────────────────────────────────────────────────────────
75
- def init_state():
76
- return {"possible": list(ANSWERS), "history": [], "done": False}
77
-
78
- def render_board(history):
79
- colours = {0: "⬜", 1: "🟨", 2: "🟩"}
80
- rows = []
81
- for word, pattern in history:
82
- tiles = " ".join(f"{colours[s]}{c.upper()}" for c, s in zip(word, pattern))
83
- rows.append(tiles)
84
- return "
85
- ".join(rows) if rows else "(no guesses yet)"
86
-
87
- def process_guess(guess_input, pattern_input, state):
88
- if state["done"]:
89
- return render_board(state["history"]), "Game over β€” press Reset", state
90
-
91
- guess = guess_input.strip().lower()
92
- if len(guess) != 5:
93
- return render_board(state["history"]), "⚠️ Guess must be 5 letters", state
94
- if len(pattern_input) != 5 or not all(c in "012" for c in pattern_input):
95
- return render_board(state["history"]), "⚠️ Pattern must be 5 digits (0/1/2)", state
96
-
97
- pattern = tuple(int(c) for c in pattern_input)
98
- state["history"].append((guess, pattern))
99
-
100
- if pattern == WIN_PATTERN:
101
- state["done"] = True
102
- msg = f"πŸŽ‰ Solved in {len(state["history"])} turns!"
103
- return render_board(state["history"]), msg, state
104
-
105
- state["possible"] = filter_words(state["possible"], guess, pattern)
106
- if not state["possible"]:
107
- state["done"] = True
108
- return render_board(state["history"]), "❌ No words left. Check your input.", state
109
-
110
- suggestion = model_suggest(state["history"], state["possible"])
111
- msg = f"Try: **{suggestion.upper()}** | {len(state["possible"])} words left"
112
- return render_board(state["history"]), msg, state
113
-
114
- def reset(_state):
115
- s = init_state()
116
- return render_board([]), f"Try: **{OPENING.upper()}** to start", s
117
-
118
- # ── Gradio UI ─────────────────────────────────────────────────────
119
- with gr.Blocks(title="Wordle Solver", theme=gr.themes.Monochrome()) as demo:
120
- gr.Markdown("# 🟩 Wordle Solver
121
- Entropy-trained neural network. Enter each guess + the colour pattern.")
122
- gr.Markdown("**Pattern key:** `0` = ⬜ grey · `1` = 🟨 yellow · `2` = 🟩 green")
123
-
124
- state = gr.State(init_state())
125
- board_out = gr.Textbox(label="Board", lines=7, interactive=False)
126
- msg_out = gr.Markdown(f"Try: **{OPENING.upper()}** to start")
127
-
128
- with gr.Row():
129
- guess_in = gr.Textbox(label="Your guess", placeholder="crane", max_lines=1)
130
- pattern_in = gr.Textbox(label="Pattern (5 digits)", placeholder="02100", max_lines=1)
131
-
132
- with gr.Row():
133
- submit_btn = gr.Button("Submit", variant="primary")
134
- reset_btn = gr.Button("Reset")
135
-
136
- submit_btn.click(process_guess, [guess_in, pattern_in, state], [board_out, msg_out, state])
137
- reset_btn.click(reset, [state], [board_out, msg_out, state])
138
-
139
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from pydantic import BaseModel
4
+ import json, math, torch, numpy as np
5
  from collections import Counter
 
 
6
  import torch.nn as nn
7
+ from huggingface_hub import hf_hub_download
8
 
9
+ HF_REPO_ID = "sato2ru/wordle-solver"
10
+
11
+ app = FastAPI(title="Wordle Solver API")
12
+ app.add_middleware(
13
+ CORSMiddleware,
14
+ allow_origins=["*"],
15
+ allow_methods=["*"],
16
+ allow_headers=["*"],
17
+ )
18
+
19
+ # ── Load assets ───────────────────────────────────────────────────────────────
20
+ print("Loading model...")
21
+ config = json.load(open(hf_hub_download(HF_REPO_ID, "config.json")))
22
+ ANSWERS = json.load(open(hf_hub_download(HF_REPO_ID, "answers.json")))
23
+ ALLOWED = json.load(open(hf_hub_download(HF_REPO_ID, "allowed.json")))
24
  LETTERS = "abcdefghijklmnopqrstuvwxyz"
25
+ L2I = {c: i for i, c in enumerate(LETTERS)}
26
+ INPUT_DIM = config["input_dim"]
27
+ OUTPUT_DIM = config["output_dim"]
28
+ OPENING = config["opening_guess"]
29
+ WIN_PATTERN = (2, 2, 2, 2, 2)
30
 
31
+ # ── Model ─────────────────────────────────────────────────────────────────────
32
  class WordleNet(nn.Module):
33
  def __init__(self):
34
  super().__init__()
 
42
  def forward(self, x): return self.net(x)
43
 
44
  model = WordleNet()
45
+ model.load_state_dict(
46
+ torch.load(hf_hub_download(HF_REPO_ID, "model_weights.pt"), map_location="cpu")
47
+ )
48
  model.eval()
49
+ print("Model loaded βœ…")
50
 
51
+ # ── Helpers ───────────────────────────────────────────────────────────────────
52
  def get_pattern(guess, answer):
53
+ pattern = [0] * 5
54
+ counts = Counter(answer)
55
  for i in range(5):
56
+ if guess[i] == answer[i]:
57
+ pattern[i] = 2
58
+ counts[guess[i]] -= 1
59
  for i in range(5):
60
+ if pattern[i] == 0 and counts.get(guess[i], 0) > 0:
61
+ pattern[i] = 1
62
+ counts[guess[i]] -= 1
63
  return tuple(pattern)
64
 
65
  def filter_words(words, guess, pattern):
66
+ return [w for w in words if get_pattern(guess, w) == tuple(pattern)]
67
 
68
  def entropy_score(guess, possible):
69
  buckets = Counter(get_pattern(guess, w) for w in possible)
70
  n = len(possible)
71
+ return sum(-(c / n) * math.log2(c / n) for c in buckets.values())
72
 
73
  def encode_board(history):
74
  vec = np.zeros(INPUT_DIM, dtype=np.float32)
75
  for word, pattern in history:
76
  for pos, (letter, state) in enumerate(zip(word, pattern)):
77
+ vec[L2I[letter] * 15 + pos * 3 + state] = 1.0
78
  return vec
79
 
80
+ def is_consistent(word, history):
81
+ for guess, pattern in history:
82
+ green_letters = {letter for letter, state in zip(guess, pattern) if state == 2}
83
+ for pos, (letter, state) in enumerate(zip(guess, pattern)):
84
+ if state == 2:
85
+ if word[pos] != letter:
86
+ return False
87
+ elif state == 1:
88
+ if letter not in word or word[pos] == letter:
89
+ return False
90
+ else:
91
+ if letter not in green_letters and letter in word:
92
+ return False
93
+ return True
94
+
95
  def model_suggest(history, possible):
96
+ if not possible: return None
97
  if len(possible) == 1: return possible[0]
98
+ if not history: return OPENING
99
+
100
+ already_guessed = {w for w, _ in history}
101
+ possible_not_guessed = [w for w in possible if w not in already_guessed]
102
+
103
+ if len(possible) <= 6:
104
+ ambiguous = set()
105
+ for pos in range(5):
106
+ letters_at_pos = {w[pos] for w in possible}
107
+ if len(letters_at_pos) > 1:
108
+ ambiguous.update(letters_at_pos)
109
+
110
+ best_word, best_score = None, -1
111
+ for g in ALLOWED:
112
+ if g in already_guessed:
113
+ continue
114
+ if not is_consistent(g, history):
115
+ continue
116
+ if g in possible and len(possible) > 2:
117
+ continue
118
+ score = len(set(g) & ambiguous) * 2 + entropy_score(g, possible)
119
+ if score > best_score:
120
+ best_score, best_word = score, g
121
+
122
+ if not best_word:
123
+ best_word = possible_not_guessed[0] if possible_not_guessed else possible[0]
124
+ return best_word
125
+
126
  state = torch.tensor(encode_board(history)).unsqueeze(0)
127
  with torch.no_grad():
128
  logits = model(state)[0]
 
 
129
 
130
+ top50 = [ALLOWED[i] for i in logits.topk(50).indices.tolist()]
131
+ valid = [w for w in top50
132
+ if w not in already_guessed and is_consistent(w, history)]
133
+
134
+ if not valid:
135
+ return max(possible_not_guessed or possible,
136
+ key=lambda w: entropy_score(w, possible))
137
+
138
+ return max(valid[:10], key=lambda w: entropy_score(w, possible))
139
+
140
+
141
+ def top_suggestions(history, possible, n=5):
142
+ if not possible: return []
143
+
144
+ already_guessed = {w for w, _ in history}
145
+
146
+ if not history:
147
+ candidates = [OPENING] + [w for w in ALLOWED if w != OPENING][:30]
148
+ else:
149
+ state = torch.tensor(encode_board(history)).unsqueeze(0)
150
+ with torch.no_grad():
151
+ logits = model(state)[0]
152
+ candidates = [ALLOWED[i] for i in logits.topk(50).indices.tolist()]
153
+
154
+ candidates = [w for w in candidates
155
+ if w not in already_guessed and is_consistent(w, history)]
156
+
157
+ possible_set = set(possible)
158
+ scored = [
159
+ {
160
+ "word": w,
161
+ "entropy": round(entropy_score(w, possible), 3),
162
+ "is_possible": w in possible_set,
163
+ }
164
+ for w in candidates
165
+ ]
166
+ scored.sort(key=lambda x: (-x["entropy"], not x["is_possible"]))
167
+ return scored[:n]
168
+
169
+ # ── Models ────────────────────────────────────────────────────────────────────
170
+ class GuessEntry(BaseModel):
171
+ word: str
172
+ pattern: list[int]
173
+
174
+ class SuggestRequest(BaseModel):
175
+ history: list[GuessEntry] = []
176
+
177
+ class SuggestResponse(BaseModel):
178
+ suggestion: str
179
+ top_suggestions: list[dict]
180
+ possible_count: int
181
+ bits_remaining: float
182
+ solved: bool
183
+ message: str
184
+
185
+ # ── Routes ────────────────────────────────────────────────────────────────────
186
+ @app.get("/")
187
+ def root():
188
+ return {"status": "ok", "opener": OPENING}
189
+
190
+ @app.post("/suggest", response_model=SuggestResponse)
191
+ def suggest(req: SuggestRequest):
192
+ possible = list(ANSWERS)
193
+
194
+ for entry in req.history:
195
+ word = entry.word.lower().strip()
196
+ pattern = tuple(entry.pattern)
197
+ if len(word) != 5:
198
+ raise HTTPException(400, f"Word must be 5 letters: {word}")
199
+ if len(pattern) != 5 or not all(p in (0, 1, 2) for p in pattern):
200
+ raise HTTPException(400, "Pattern must be 5 values of 0, 1, or 2")
201
+ if pattern == WIN_PATTERN:
202
+ return SuggestResponse(
203
+ suggestion=word, top_suggestions=[], possible_count=1,
204
+ bits_remaining=0.0, solved=True,
205
+ message=f"Solved in {len(req.history)} guesses!"
206
+ )
207
+ possible = filter_words(possible, word, pattern)
208
+
209
+ if not possible:
210
+ raise HTTPException(422, "No possible words remaining. Check your pattern input.")
211
+
212
+ history_tuples = [(e.word.lower(), tuple(e.pattern)) for e in req.history]
213
+ suggestion = model_suggest(history_tuples, possible)
214
+ if not suggestion:
215
+ suggestion = possible[0]
216
+ top_suggs = top_suggestions(history_tuples, possible)
217
+ bits = math.log2(len(possible)) if len(possible) > 1 else 0.0
218
+
219
+ return SuggestResponse(
220
+ suggestion=suggestion,
221
+ top_suggestions=top_suggs,
222
+ possible_count=len(possible),
223
+ bits_remaining=round(bits, 2),
224
+ solved=False,
225
+ message=f"{len(possible)} words remaining β€” try {suggestion.upper()}"
226
+ )
227
+
228
+ @app.get("/opener")
229
+ def get_opener():
230
+ return {"word": OPENING}
requirements.txt CHANGED
@@ -1,4 +1,6 @@
1
- torch
2
- gradio
3
  huggingface_hub
4
  numpy
 
 
 
1
+ fastapi
2
+ uvicorn
3
  huggingface_hub
4
  numpy
5
+ --extra-index-url https://download.pytorch.org/whl/cpu
6
+ torch==2.10.0+cpu