force reindex
Browse files- Dockerfile +12 -0
- README.md +3 -190
- app.py +187 -96
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
LETTERS = "abcdefghijklmnopqrstuvwxyz"
|
| 16 |
-
L2I
|
| 17 |
-
INPUT_DIM
|
| 18 |
-
OUTPUT_DIM
|
| 19 |
-
OPENING
|
| 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(
|
|
|
|
|
|
|
| 37 |
model.eval()
|
|
|
|
| 38 |
|
| 39 |
-
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
def get_pattern(guess, answer):
|
| 41 |
-
pattern = [0]*5
|
| 42 |
-
counts
|
| 43 |
for i in range(5):
|
| 44 |
-
if guess[i] == answer[i]:
|
|
|
|
|
|
|
| 45 |
for i in range(5):
|
| 46 |
-
if pattern[i] == 0 and counts.get(guess[i],0) > 0:
|
| 47 |
-
pattern[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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 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
|