docs: add word dictionary mode implementation plan

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-23 09:39:36 -06:00
1 parent d868ce2de3
commit 689609884e
1 file changed
+891
@@ -0,0 +1,891 @@
# Word Dictionary Solve Mode — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a "word" solve mode that searches English nouns (single-word) and adjective/noun + noun phrases (two-word) against redaction pixel widths, with optional plural filtering.
**Architecture:** New `solve_word_dictionary()` generator in `dictionary.py` runs two phases — single-noun width matching, then two-word binary search. Word lists (nouns, adjectives, plurals) are generated from WordNet/Wiktionary via a build script and committed as static data files.
**Tech Stack:** Python (PIL for font metrics, bisect for binary search), NLTK WordNet for word list extraction, Wiktionary CSV for irregular plurals.
---
### Task 1: Word List Generation Script
**Files:**
- Create: `scripts/build_word_lists.py`
- Create: `unredact/data/nouns.txt`
- Create: `unredact/data/nouns_plural.txt`
- Create: `unredact/data/adjectives.txt`
**Step 1: Write the build script**
```python
# scripts/build_word_lists.py
"""Generate noun, adjective, and plural word lists from WordNet + Wiktionary.
Usage:
python scripts/build_word_lists.py [--data-dir PATH]
Requires: nltk (with wordnet corpus downloaded)
pip install nltk
python -c "import nltk; nltk.download('wordnet')"
For irregular plurals, downloads noun.csv from djstrong/nouns-with-plurals.
"""
import argparse
import csv
import io
import re
import urllib.request
from pathlib import Path
OUTPUT_DIR = Path(__file__).parent.parent / "unredact" / "data"
WIKTIONARY_CSV_URL = (
"https://raw.githubusercontent.com/djstrong/nouns-with-plurals/master/noun.csv"
)
# Pattern: only simple lowercase alpha words (no digits, hyphens, spaces, etc.)
WORD_RE = re.compile(r"^[a-z]+$")
def extract_wordnet_words(pos: str) -> list[str]:
"""Extract unique lemma names from WordNet for a given POS tag.
pos: 'n' for nouns, 'a' for adjectives (includes 's' satellite adjectives).
"""
from nltk.corpus import wordnet as wn
words = set()
pos_tags = [pos] if pos != "a" else ["a", "s"]
for tag in pos_tags:
for synset in wn.all_synsets(tag):
for lemma in synset.lemmas():
name = lemma.name().lower()
if WORD_RE.match(name):
words.add(name)
return sorted(words)
def download_wiktionary_plurals() -> dict[str, str]:
"""Download noun.csv from djstrong/nouns-with-plurals.
Returns dict mapping singular -> first plural form.
"""
response = urllib.request.urlopen(WIKTIONARY_CSV_URL)
text = response.read().decode("utf-8")
mapping = {}
reader = csv.reader(io.StringIO(text))
for row in reader:
if len(row) >= 2:
singular = row[0].strip().lower()
plural = row[1].strip().lower()
if WORD_RE.match(singular) and WORD_RE.match(plural):
mapping[singular] = plural
return mapping
def pluralize(word: str) -> str:
"""Apply standard English pluralization rules."""
if word.endswith(("s", "x", "z")):
return word + "es"
if word.endswith(("sh", "ch")):
return word + "es"
if word.endswith("y") and len(word) > 1 and word[-2] not in "aeiou":
return word[:-1] + "ies"
if word.endswith("f"):
return word[:-1] + "ves"
if word.endswith("fe"):
return word[:-2] + "ves"
return word + "s"
def main():
parser = argparse.ArgumentParser(description="Generate word lists from WordNet")
parser.add_argument("--data-dir", type=Path, default=OUTPUT_DIR)
args = parser.parse_args()
data_dir = args.data_dir
data_dir.mkdir(parents=True, exist_ok=True)
print("Extracting nouns from WordNet...")
nouns = extract_wordnet_words("n")
print(f" Found {len(nouns)} nouns")
print("Extracting adjectives from WordNet...")
adjectives = extract_wordnet_words("a")
print(f" Found {len(adjectives)} adjectives")
print("Downloading Wiktionary irregular plurals...")
wiktionary = download_wiktionary_plurals()
print(f" Found {len(wiktionary)} irregular plural mappings")
print("Generating plural forms...")
noun_set = set(nouns)
plurals = []
for noun in nouns:
if noun in wiktionary:
plurals.append(wiktionary[noun])
else:
plurals.append(pluralize(noun))
print(f" Generated {len(plurals)} plural forms")
nouns_path = data_dir / "nouns.txt"
nouns_path.write_text("\n".join(nouns) + "\n")
print(f"Wrote {nouns_path} ({len(nouns)} entries)")
plurals_path = data_dir / "nouns_plural.txt"
plurals_path.write_text("\n".join(plurals) + "\n")
print(f"Wrote {plurals_path} ({len(plurals)} entries)")
adj_path = data_dir / "adjectives.txt"
adj_path.write_text("\n".join(adjectives) + "\n")
print(f"Wrote {adj_path} ({len(adjectives)} entries)")
if __name__ == "__main__":
main()
```
**Step 2: Install NLTK and download WordNet data**
Run:
```bash
.venv/bin/pip install nltk
.venv/bin/python -c "import nltk; nltk.download('wordnet')"
```
**Step 3: Run the build script**
Run: `.venv/bin/python scripts/build_word_lists.py`
Expected: Three new files created in `unredact/data/` with counts printed.
**Step 4: Verify output**
Run:
```bash
wc -l unredact/data/nouns.txt unredact/data/nouns_plural.txt unredact/data/adjectives.txt
head -20 unredact/data/nouns.txt
head -20 unredact/data/nouns_plural.txt
head -20 unredact/data/adjectives.txt
```
Expected: ~40K nouns, ~40K plurals, ~20-30K adjectives. All lowercase alpha words.
**Step 5: Add Makefile target**
In `Makefile`, after the `build-emails` target (line 161), add:
```makefile
build-word-lists:
$(PYTHON) scripts/build_word_lists.py
```
Update the `.PHONY` line at top to include `build-word-lists`.
**Step 6: Commit**
```bash
git add scripts/build_word_lists.py unredact/data/nouns.txt unredact/data/nouns_plural.txt unredact/data/adjectives.txt Makefile
git commit -m "feat: add word list generation script (nouns, adjectives, plurals from WordNet)"
```
---
### Task 2: Word List Loaders
**Files:**
- Modify: `unredact/pipeline/word_filter.py`
- Test: `tests/test_word_lists.py`
**Step 1: Write the failing tests**
Create `tests/test_word_lists.py`:
```python
# tests/test_word_lists.py
"""Tests for word list loading (nouns, adjectives, plurals)."""
from unredact.pipeline.word_filter import (
_get_nouns,
_get_nouns_plural,
_get_adjectives,
)
class TestWordListLoaders:
def test_nouns_loads(self):
nouns = _get_nouns()
assert len(nouns) > 1000
assert isinstance(nouns, list)
assert "dog" in nouns
def test_nouns_plural_loads(self):
plurals = _get_nouns_plural()
assert len(plurals) > 1000
assert isinstance(plurals, list)
assert "dogs" in plurals
def test_adjectives_loads(self):
adjectives = _get_adjectives()
assert len(adjectives) > 1000
assert isinstance(adjectives, list)
assert "large" in adjectives
def test_nouns_and_plurals_same_length(self):
"""Each noun has exactly one plural form at the same index."""
nouns = _get_nouns()
plurals = _get_nouns_plural()
assert len(nouns) == len(plurals)
def test_all_words_lowercase_alpha(self):
"""All words should be lowercase alphabetic only."""
import re
pattern = re.compile(r"^[a-z]+$")
for word in _get_nouns()[:100]:
assert pattern.match(word), f"Non-alpha noun: {word}"
for word in _get_adjectives()[:100]:
assert pattern.match(word), f"Non-alpha adjective: {word}"
```
**Step 2: Run tests to verify they fail**
Run: `.venv/bin/python -m pytest tests/test_word_lists.py -v`
Expected: FAIL — `_get_nouns`, `_get_nouns_plural`, `_get_adjectives` don't exist yet.
**Step 3: Add loaders to word_filter.py**
In `unredact/pipeline/word_filter.py`, after the `_get_associate_variants` function (after line 92), add:
```python
_nouns: list[str] | None = None
_nouns_plural: list[str] | None = None
_adjectives: list[str] | None = None
def _load_list(filename: str) -> list[str]:
path = DATA_DIR / filename
if not path.exists():
return []
return [line.strip() for line in path.read_text().splitlines() if line.strip()]
def _get_nouns() -> list[str]:
global _nouns
if _nouns is None:
_nouns = _load_list("nouns.txt")
return _nouns
def _get_nouns_plural() -> list[str]:
global _nouns_plural
if _nouns_plural is None:
_nouns_plural = _load_list("nouns_plural.txt")
return _nouns_plural
def _get_adjectives() -> list[str]:
global _adjectives
if _adjectives is None:
_adjectives = _load_list("adjectives.txt")
return _adjectives
```
**Step 4: Run tests to verify they pass**
Run: `.venv/bin/python -m pytest tests/test_word_lists.py -v`
Expected: All 5 tests PASS.
**Step 5: Commit**
```bash
git add unredact/pipeline/word_filter.py tests/test_word_lists.py
git commit -m "feat: add noun/adjective/plural word list loaders"
```
---
### Task 3: Single-Word Noun Solver
**Files:**
- Modify: `unredact/pipeline/dictionary.py`
- Test: `tests/test_word_solve.py`
**Step 1: Write the failing tests**
Create `tests/test_word_solve.py`:
```python
# tests/test_word_solve.py
"""Tests for word dictionary solve mode."""
import subprocess
import pytest
from PIL import ImageFont
from unredact.pipeline.dictionary import solve_word_dictionary
def _get_test_font() -> ImageFont.FreeTypeFont:
result = subprocess.run(
["fc-match", "--format=%{file}", "Liberation Serif"],
capture_output=True, text=True,
)
path = result.stdout.strip()
return ImageFont.truetype(path, 40)
class TestSolveWordDictionarySingleWord:
def test_finds_exact_noun(self):
font = _get_test_font()
target = font.getlength("house")
results = list(solve_word_dictionary(
font, target, tolerance=0.5,
casing="lowercase",
))
texts = [r.text for r in results]
assert "house" in texts
def test_plural_mode_filters(self):
font = _get_test_font()
target = font.getlength("houses")
results = list(solve_word_dictionary(
font, target, tolerance=0.5,
casing="lowercase", ensure_plural=True,
))
texts = [r.text for r in results]
# "houses" should be found in plural mode
assert "houses" in texts
def test_casing_capitalized(self):
font = _get_test_font()
target = font.getlength("House")
results = list(solve_word_dictionary(
font, target, tolerance=0.5,
casing="capitalized",
))
texts = [r.text for r in results]
assert "House" in texts
def test_casing_uppercase(self):
font = _get_test_font()
target = font.getlength("HOUSE")
results = list(solve_word_dictionary(
font, target, tolerance=0.5,
casing="uppercase",
))
texts = [r.text for r in results]
assert "HOUSE" in texts
def test_known_start_filters(self):
font = _get_test_font()
target = font.getlength("house")
results = list(solve_word_dictionary(
font, target, tolerance=0.5,
casing="lowercase", known_start="ho",
))
texts = [r.text for r in results]
assert "house" in texts
# No results starting with other letters
assert all(t.lower().startswith("ho") for t in texts)
def test_known_end_filters(self):
font = _get_test_font()
target = font.getlength("house")
results = list(solve_word_dictionary(
font, target, tolerance=0.5,
casing="lowercase", known_end="se",
))
texts = [r.text for r in results]
assert "house" in texts
assert all(t.lower().endswith("se") for t in texts)
def test_returns_generator(self):
"""solve_word_dictionary should be a generator for SSE streaming."""
font = _get_test_font()
target = font.getlength("dog")
gen = solve_word_dictionary(font, target, tolerance=0.5, casing="lowercase")
# Should be a generator, not a list
import types
assert isinstance(gen, types.GeneratorType)
```
**Step 2: Run tests to verify they fail**
Run: `.venv/bin/python -m pytest tests/test_word_solve.py::TestSolveWordDictionarySingleWord -v`
Expected: FAIL — `solve_word_dictionary` doesn't exist yet.
**Step 3: Implement solve_word_dictionary (phase 1 only)**
In `unredact/pipeline/dictionary.py`, add at the bottom:
```python
from typing import Generator
def solve_word_dictionary(
font: ImageFont.FreeTypeFont,
target_width: float,
tolerance: float = 0.0,
left_context: str = "",
right_context: str = "",
casing: str = "lowercase",
known_start: str = "",
known_end: str = "",
ensure_plural: bool = False,
) -> Generator[SolveResult, None, None]:
"""Search English nouns (single-word) and adj+noun phrases (two-word).
Yields results as found for SSE streaming. Phase 1 searches single nouns,
phase 2 searches two-word combinations using binary search.
"""
from unredact.pipeline.word_filter import (
_get_nouns,
_get_nouns_plural,
)
# Phase 1: single-word noun search
nouns = _get_nouns()
plurals = _get_nouns_plural()
if ensure_plural:
word_list = plurals
else:
word_list = nouns
ks_lower = known_start.lower()
ke_lower = known_end.lower()
seen: set[str] = set()
for word in word_list:
if ks_lower and not word.startswith(ks_lower):
continue
if ke_lower and not word.endswith(ke_lower):
continue
display = _apply_casing(word, casing)
if display in seen:
continue
seen.add(display)
if left_context or right_context:
full = left_context + display + right_context
full_len = font.getlength(full)
left_len = font.getlength(left_context) if left_context else 0.0
right_len = font.getlength(right_context) if right_context else 0.0
width = full_len - left_len - right_len
else:
width = font.getlength(display)
error = abs(width - target_width)
if error <= tolerance:
yield SolveResult(text=display, width=float(width), error=float(error))
```
Note: `_apply_casing` already exists in `dictionary.py` (line 65).
**Step 4: Run tests to verify they pass**
Run: `.venv/bin/python -m pytest tests/test_word_solve.py::TestSolveWordDictionarySingleWord -v`
Expected: All 7 tests PASS.
**Step 5: Commit**
```bash
git add unredact/pipeline/dictionary.py tests/test_word_solve.py
git commit -m "feat: add single-word noun solver (phase 1 of word mode)"
```
---
### Task 4: Two-Word Binary Search Solver (Phase 2)
**Files:**
- Modify: `unredact/pipeline/dictionary.py`
- Modify: `tests/test_word_solve.py`
**Step 1: Write the failing tests**
Append to `tests/test_word_solve.py`:
```python
class TestSolveWordDictionaryTwoWord:
def test_finds_two_word_phrase(self):
font = _get_test_font()
target = font.getlength("large house")
results = list(solve_word_dictionary(
font, target, tolerance=1.0,
casing="lowercase",
))
texts = [r.text for r in results]
assert "large house" in texts
def test_two_word_capitalized(self):
font = _get_test_font()
target = font.getlength("Large House")
results = list(solve_word_dictionary(
font, target, tolerance=1.0,
casing="capitalized",
))
texts = [r.text for r in results]
assert "Large House" in texts
def test_two_word_plural(self):
font = _get_test_font()
target = font.getlength("large houses")
results = list(solve_word_dictionary(
font, target, tolerance=1.0,
casing="lowercase", ensure_plural=True,
))
texts = [r.text for r in results]
assert "large houses" in texts
def test_two_word_known_start(self):
font = _get_test_font()
target = font.getlength("large house")
results = list(solve_word_dictionary(
font, target, tolerance=1.0,
casing="lowercase", known_start="la",
))
texts = [r.text for r in results]
assert "large house" in texts
assert all(t.lower().startswith("la") for t in texts)
def test_two_word_known_end(self):
font = _get_test_font()
target = font.getlength("large house")
results = list(solve_word_dictionary(
font, target, tolerance=1.0,
casing="lowercase", known_end="se",
))
texts = [r.text for r in results]
assert "large house" in texts
assert all(t.lower().endswith("se") for t in texts)
def test_no_duplicates_across_phases(self):
"""Single-word and two-word results should not have duplicates."""
font = _get_test_font()
target = font.getlength("dog")
results = list(solve_word_dictionary(
font, target, tolerance=1.0,
casing="lowercase",
))
texts = [r.text for r in results]
assert len(texts) == len(set(texts))
```
**Step 2: Run tests to verify new ones fail**
Run: `.venv/bin/python -m pytest tests/test_word_solve.py::TestSolveWordDictionaryTwoWord -v`
Expected: FAIL — two-word phrases won't be found (only phase 1 implemented).
**Step 3: Add phase 2 to solve_word_dictionary**
At the end of the `solve_word_dictionary` function in `dictionary.py`, after the phase 1 loop, add the phase 2 code. The full function body after phase 1 continues:
```python
# Phase 2: two-word search (word1 + " " + noun)
import bisect
from unredact.pipeline.word_filter import _get_adjectives
adjectives = _get_adjectives()
# Word1 pool: adjectives + nouns (deduplicated)
word1_set = set(adjectives) | set(nouns)
word1_list = sorted(word1_set)
# Word2 pool: nouns or plural nouns
word2_list = list(plurals if ensure_plural else nouns)
# Pre-measure word2 widths and sort
word2_measured: list[tuple[float, str]] = []
for w2 in word2_list:
display2 = _apply_casing(w2, casing)
w = font.getlength(display2)
word2_measured.append((w, display2, w2))
word2_measured.sort(key=lambda x: x[0])
word2_widths = [x[0] for x in word2_measured]
KERNING_MARGIN = 3.0 # extra margin for kerning between words
for w1 in word1_list:
if ks_lower and not w1.startswith(ks_lower):
continue
display1 = _apply_casing(w1, casing)
# Measure word1 + space width (with left context kerning)
w1_with_space = display1 + " "
if left_context:
w1_portion = (
font.getlength(left_context + w1_with_space)
- font.getlength(left_context)
)
else:
w1_portion = font.getlength(w1_with_space)
remaining = target_width - w1_portion
if remaining < 0:
continue
# Binary search for word2 candidates
lo = bisect.bisect_left(word2_widths, remaining - tolerance - KERNING_MARGIN)
hi = bisect.bisect_right(word2_widths, remaining + tolerance + KERNING_MARGIN)
for i in range(lo, hi):
_, display2, w2_raw = word2_measured[i]
if ke_lower and not w2_raw.endswith(ke_lower):
continue
phrase = display1 + " " + display2
if phrase in seen:
continue
# Verify exact combined width with full kerning context
if left_context or right_context:
full = left_context + phrase + right_context
full_len = font.getlength(full)
left_len = font.getlength(left_context) if left_context else 0.0
right_len = font.getlength(right_context) if right_context else 0.0
exact_width = full_len - left_len - right_len
else:
exact_width = font.getlength(phrase)
error = abs(exact_width - target_width)
if error <= tolerance:
seen.add(phrase)
yield SolveResult(
text=phrase, width=float(exact_width), error=float(error)
)
```
**Step 4: Run tests to verify they pass**
Run: `.venv/bin/python -m pytest tests/test_word_solve.py -v`
Expected: All tests PASS (both single-word and two-word).
**Step 5: Commit**
```bash
git add unredact/pipeline/dictionary.py tests/test_word_solve.py
git commit -m "feat: add two-word binary search solver (phase 2 of word mode)"
```
---
### Task 5: API Wiring
**Files:**
- Modify: `unredact/app.py:277-289` (SolveRequest model)
- Modify: `unredact/app.py:354-489` (solve endpoint)
**Step 1: Add `ensure_plural` to SolveRequest**
In `unredact/app.py`, find the `SolveRequest` class (line 277-288). Add after line 288 (`known_end: str = ""`):
```python
ensure_plural: bool = False
```
**Step 2: Add word mode handler to the solve endpoint**
In the `event_generator` function inside `solve()` (around line 365-485), add a new block after the email mode block (after line 434) and before the enumerate mode block (line 437). Insert:
```python
# Word mode: English nouns + adjective/noun phrases
if req.mode == "word" and not _active_solves.get(solve_id):
from unredact.pipeline.dictionary import solve_word_dictionary
for r in solve_word_dictionary(
font, req.gap_width_px, req.tolerance_px,
req.left_context, req.right_context,
casing=charset_name,
known_start=req.known_start,
known_end=req.known_end,
ensure_plural=req.ensure_plural,
):
if _active_solves.get(solve_id):
break
if r.text in found_texts:
continue
found_texts.add(r.text)
yield json.dumps({
"status": "match",
"text": r.text,
"width_px": round(r.width, 2),
"error_px": round(r.error, 2),
"source": "words",
})
```
**Step 3: Run existing tests to check nothing broke**
Run: `.venv/bin/python -m pytest tests/test_dictionary.py tests/test_word_solve.py -v`
Expected: All PASS.
**Step 4: Commit**
```bash
git add unredact/app.py
git commit -m "feat: wire word mode and ensure_plural to /api/solve endpoint"
```
---
### Task 6: Frontend — HTML + DOM
**Files:**
- Modify: `unredact/static/index.html:98-103` (mode dropdown)
- Modify: `unredact/static/index.html:118-126` (add plural checkbox near filter label)
- Modify: `unredact/static/dom.js`
**Step 1: Add word option to mode dropdown**
In `index.html`, find the mode `<select>` (lines 98-103). Add a new option after `enumerate`:
```html
<option value="word">Word</option>
```
So it becomes:
```html
<select id="solve-mode">
<option value="name" selected>Name</option>
<option value="full_name">Full Name</option>
<option value="email">Email</option>
<option value="enumerate">Enumerate</option>
<option value="word">Word</option>
</select>
```
**Step 2: Add plural checkbox**
In `index.html`, after the filter label block (line 126), add:
```html
<label id="plural-label" hidden>
<input type="checkbox" id="solve-plural"> Ensure plural
</label>
```
**Step 3: Add DOM references**
In `unredact/static/dom.js`, add after the `filterLabel` export (line 31):
```javascript
export const pluralLabel = document.getElementById("plural-label");
export const solvePlural = /** @type {HTMLInputElement} */ (document.getElementById("solve-plural"));
```
**Step 4: Verify by inspection**
Open the app in the browser and confirm:
- "Word" appears in the mode dropdown
- Plural checkbox is hidden by default
**Step 5: Commit**
```bash
git add unredact/static/index.html unredact/static/dom.js
git commit -m "feat: add Word mode option and plural checkbox to UI"
```
---
### Task 7: Frontend — Popover Logic + Solver Integration
**Files:**
- Modify: `unredact/static/popover.js:198-200` (mode change handler)
- Modify: `unredact/static/solver.js:40-54` (request body)
**Step 1: Update popover mode change handler**
In `unredact/static/popover.js`, find the `solveMode` change handler (lines 198-200):
```javascript
solveMode.addEventListener("change", () => {
filterLabel.hidden = solveMode.value !== "enumerate";
});
```
Replace with:
```javascript
solveMode.addEventListener("change", () => {
filterLabel.hidden = solveMode.value !== "enumerate";
pluralLabel.hidden = solveMode.value !== "word";
});
```
Add `pluralLabel` to the import from `./dom.js` at the top of the file (line 14). Change:
```javascript
solveMode, filterLabel,
```
to:
```javascript
solveMode, filterLabel, pluralLabel, solvePlural,
```
**Step 2: Update solver.js to send ensure_plural**
In `unredact/static/solver.js`, find the import line (lines 6-10). Add `solvePlural` to the import from `./dom.js`. The imports should include `solvePlural`:
```javascript
import {
solveCharset, solveTolerance, solveMode, solveFilter,
solveKnownStart, solveKnownEnd, solvePlural,
solveResults, solveStatus, solveStart, solveStop,
solveAccept, redactionMarker, escapeHtml,
} from './dom.js';
```
Then in the `body` object (lines 40-54), add after `known_end`:
```javascript
ensure_plural: solvePlural.checked,
```
**Step 3: Verify end-to-end**
Start the app with `make run`. Open a document with a redaction. Select "Word" mode. Click Solve. Confirm:
- Single-word nouns appear as results.
- Two-word phrases (adjective + noun) appear.
- Toggling "Ensure plural" filters to plural forms.
**Step 4: Commit**
```bash
git add unredact/static/popover.js unredact/static/solver.js
git commit -m "feat: connect word mode UI controls to solver backend"
```
---
### Task 8: Add .gitignore Entry + Final Verification
**Files:**
- Modify: `.gitignore` (if needed — ensure data files aren't gitignored)
**Step 1: Verify data files are tracked**
Run:
```bash
git ls-files unredact/data/nouns.txt unredact/data/nouns_plural.txt unredact/data/adjectives.txt
```
If empty (files are gitignored), check `.gitignore` and add an exception if needed.
**Step 2: Run full test suite**
Run: `.venv/bin/python -m pytest tests/test_dictionary.py tests/test_word_lists.py tests/test_word_solve.py -v`
Expected: All tests PASS.
**Step 3: Final commit (if any changes)**
```bash
git add -A && git commit -m "chore: ensure word list data files are tracked"
```