Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wordlist functions #24

Merged
merged 9 commits into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,68 @@ ScoredWord(word='MACDUFF', score=25.387070408819042)


Custom word lists are supported and can be passed into the `Crossword` constructor or any of the solving methods. The default word list used is the [Crossword Nexus Collaborative Word List](https://github.com/Crossword-Nexus/collaborative-word-list).

## Example: full symmetry puzzles
As an example of how blacksquare's abstractions allow for non-trivial crossword construction, consider the [June 6 2023 NYT puzzle](https://www.xwordinfo.com/Crossword?date=6/6/2023), which displays not only a rotationaly symmetric grid but a rotationally symmetric *fill*. While this might seem daunting to build, all we have to do is override the `set_word` method of `Crossword` to fill two words at once, and then restrict our wordlist to emordnilaps (words that are also a word when reversed).
```python
class SymmetricCrossword(Crossword):
def set_word(self, word_index: WordIndex, value: str) -> None:
super().set_word(word_index, value)
super().set_word(self.get_symmetric_word_index(word_index), value[::-1])

emordilaps = {}
for word, score in tqdm(bs.DEFAULT_WORDLIST):
reverse_score = bs.DEFAULT_WORDLIST.get_score(word[::-1])
if reverse_score:
emordilaps[word] = min(score, reverse_score)
emordilaps_wordlist = bs.WordList(emordilaps)

# Now just construct the puzzle and fill!
xw = SymmetricCrossword(15)
filled = [
(0, 3), (0, 4), (0, 5), (0, 11), (1, 4), (1, 5), (1, 11),
(2, 4), (2, 11), (3, 4), (3, 9), (4, 0), (4, 1), (4, 2),
(4, 7), (4, 8), (4, 14), (5, 6), (5, 12), (5, 13), (5, 14),
(6, 5), (6, 10), (7, 3),
]
for i in filled:
xw[i] = bs.BLACK
xw.fill(emordnilap_wordlist, temperature=0.3)

┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│^F │^E │^N │███│███│███│^S │^N │^I │^P │^S │███│^E │^D │^A │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^L │ I │ A │^R │███│███│^P │ O │ S │ E │ A │███│^V │ E │ R │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^O │ K │ I │ E │███│^R │ E │ W │ A │ R │ D │███│^A │ L │ B │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^G │ O │ R │ T │███│^A │ T │ I │ N │███│^D │^E │ L │ I │ A │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│███│███│███│^R │^A │ P │ S │███│███│^R │ E │ E │ S │ A │███│
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^S │^T │^R │ O │ P │ S │███│^S │^P │ A │ N │ K │███│███│███│
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^R │ E │ E │ S │ A │███│^S │ T │ O │ M │███│^S │^E │^P │^S │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^A │ R │ M │███│^R │^O │ T │ A │ T │ O │^R │███│^M │ R │ A │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^S │ P │ E │^S │███│^M │ O │ T │ S │███│^A │^S │ E │ E │ R │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│███│███│███│^K │^N │ A │ P │ S │███│^S │ P │ O │ R │ T │ S │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│███│^A │^S │ E │ E │ R │███│███│^S │ P │ A │ R │███│███│███│
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^A │ I │ L │ E │ D │███│^N │^I │ T │ A │███│^T │^R │^O │^G │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^B │ L │ A │███│^D │^R │ A │ W │ E │ R │███│^E │ I │ K │ O │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^R │ E │ V │███│^A │ E │ S │ O │ P │███│███│^R │ A │ I │ L │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│^A │ D │ E │███│^S │ P │ I │ N │ S │███│███│███│^N │ E │ F │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
```
There's clearly some extra curation that could be done to improve the word list, and we'd need a little more logic to avoid repeat fills and using true palindromes outside of the center. But not bad for a few lines of code!

## Installation
`pip install blacksquare`

Expand Down
4 changes: 2 additions & 2 deletions src/blacksquare/crossword.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,8 +768,8 @@ def _grid_html(self, size_px: Optional[int] = None) -> str:
table.xw{suffix} {{table-layout:fixed; background-color:white;width:{width}px;height:{height}px;}}
td.xw{suffix} {{outline: 2px solid black;outline-offset: -1px;position: relative;font-family: Arial, Helvetica, sans-serif;}}
tr.xw{suffix} {{background-color: white !important;}}
.number{suffix} {{position: absolute;top: 2px;left: 2px;font-size: {num_font}px;font-weight: normal;user-select: none;}}
.value{suffix} {{position: absolute;bottom:0;left: 50%;font-weight: bold;font-size: {val_font}px; transform: translate(-50%, 0%);}}
.number{suffix} {{position: absolute;top: 2px;left: 2px;font-size: {num_font}px;font-weight: normal;user-select: none; color: black;}}
.value{suffix} {{position: absolute;bottom:0;left: 50%;font-weight: bold;font-size: {val_font}px; transform: translate(-50%, 0%); color: black;}}
.black{suffix} {{background-color: black;}}
.gray{suffix} {{background-color: lightgrey;}}
.circle{suffix} {{position: absolute; border-radius: 50%; border: 1px solid black; right: 0px; left: 0px; top: 0px; bottom: 0px;}}
Expand Down
39 changes: 22 additions & 17 deletions src/blacksquare/word_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import io
import re
from collections import defaultdict
from functools import lru_cache
from functools import lru_cache, cached_property
from importlib.resources import files
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Union
Expand Down Expand Up @@ -118,13 +118,13 @@ def __init__(
self._words, self._scores = norm_words, norm_scores

word_scores_by_length = defaultdict(lambda: ([], []))
for w, s in zip(norm_words, norm_scores):
word_scores_by_length[len(w)][0].append(w)
word_scores_by_length[len(w)][1].append(s)
for word, score in zip(norm_words, norm_scores):
word_scores_by_length[len(word)][0].append(word)
word_scores_by_length[len(word)][1].append(score)

self._word_scores_by_length = {
l: (np.array(words, dtype=str), np.array(scores))
for l, (words, scores) in word_scores_by_length.items()
length: (np.array(words, dtype=str), np.array(scores))
for length, (words, scores) in word_scores_by_length.items()
}

def find_matches(self, word: Word) -> MatchWordList:
Expand Down Expand Up @@ -170,12 +170,16 @@ def find_matches_str(self, query: str) -> MatchWordList:
len(query_array), np.empty((0,), dtype=str), np.empty((0,), dtype=float)
)

@property
def words(self) -> List[str]:
@cached_property
def words(self) -> list[str]:
return list(self._words)

@cached_property
def _words_dict(self) -> dict[str, float]:
return dict(zip(self._words, self._scores))

@property
def scores(self) -> List[float]:
@cached_property
def scores(self) -> list[float]:
return list(self._scores)

def get_score(self, word: str) -> Optional[float]:
Expand All @@ -187,14 +191,10 @@ def get_score(self, word: str) -> Optional[float]:
Returns:
Optional[float]: The score. None if word is not in word list.
"""
words, scores = self._word_scores_by_length[len(word)]
score = scores[np.where(words == word)]
if len(score) == 1:
return score[0]
else:
return None
return self._words_dict.get(word)


@property
@cached_property
def frame(self) -> pd.DataFrame:
return pd.DataFrame({"word": self._words, "score": self._scores})

Expand All @@ -209,6 +209,9 @@ def score_filter(self, threshold: float) -> WordList:
"""
score_mask = self._scores >= threshold
return WordList(dict(zip(self._words[score_mask], self._scores[score_mask])))

def filter(self, filter_fn: Callable[[ScoredWord], bool]) -> WordList:
return WordList(dict([w for w in self if filter_fn(w)]))

def __len__(self):
return len(self._words)
Expand Down Expand Up @@ -239,6 +242,8 @@ def __add__(self, other):
{w: s for w, s in zip(self.words + other.words, self.scores + other.scores)}
)

def __contains__(self, item):
return item in self._words_set

class MatchWordList(WordList):
"""An object representing a WordList matching an open word. This class should not be
Expand Down