first draft
This commit is contained in:
0
multiplayer_crosswords/__init__.py
Normal file
0
multiplayer_crosswords/__init__.py
Normal file
88
multiplayer_crosswords/crossword.py
Normal file
88
multiplayer_crosswords/crossword.py
Normal file
@ -0,0 +1,88 @@
|
||||
from enum import Enum
|
||||
from typing import List, Tuple, Optional
|
||||
from .dictionary import Dictionary, Word
|
||||
|
||||
class Orientation(Enum):
|
||||
HORIZONTAL = 1
|
||||
VERTICAL = 2
|
||||
|
||||
class PlacedWord:
|
||||
def __init__(self, word: Word, row: int, col: int, orientation: Orientation):
|
||||
self.word = word
|
||||
self.row = row
|
||||
self.col = col
|
||||
self.orientation = orientation
|
||||
|
||||
class Crossword:
|
||||
def __init__(self, rows: int, cols: int, dictionary: Dictionary):
|
||||
self.rows = rows
|
||||
self.cols = cols
|
||||
self.grid = [['' for _ in range(cols)] for _ in range(rows)]
|
||||
self.dictionary = dictionary
|
||||
self.placed_words: List[PlacedWord] = []
|
||||
|
||||
def _in_bounds(self, row: int, col: int) -> bool:
|
||||
return 0 <= row < self.rows and 0 <= col < self.cols
|
||||
|
||||
def can_place_word(self, word: Word, row: int, col: int, orientation: Orientation) -> bool:
|
||||
length = len(word.word)
|
||||
dr, dc = (0, 1) if orientation == Orientation.HORIZONTAL else (1, 0)
|
||||
|
||||
# Check start block
|
||||
start_r, start_c = row - dr, col - dc
|
||||
if self._in_bounds(start_r, start_c):
|
||||
if self.grid[start_r][start_c] not in ('', '#'):
|
||||
return False
|
||||
if self.grid[start_r][start_c] == '#':
|
||||
return False
|
||||
|
||||
# Check end block
|
||||
end_r, end_c = row + dr * length, col + dc * length
|
||||
if self._in_bounds(end_r, end_c):
|
||||
if self.grid[end_r][end_c] not in ('', '#'):
|
||||
return False
|
||||
if self.grid[end_r][end_c] == '#':
|
||||
return False
|
||||
|
||||
for i in range(length):
|
||||
r, c = row + dr * i, col + dc * i
|
||||
if not self._in_bounds(r, c):
|
||||
return False
|
||||
cell = self.grid[r][c]
|
||||
if cell == '#':
|
||||
return False
|
||||
if cell != '' and cell != word.word[i]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def add_word(self, word: Word, row: int, col: int, orientation: Orientation) -> bool:
|
||||
if not self.can_place_word(word, row, col, orientation):
|
||||
return False
|
||||
|
||||
length = len(word.word)
|
||||
dr, dc = (0, 1) if orientation == Orientation.HORIZONTAL else (1, 0)
|
||||
|
||||
# Place start block
|
||||
start_r, start_c = row - dr, col - dc
|
||||
if self._in_bounds(start_r, start_c):
|
||||
self.grid[start_r][start_c] = '#'
|
||||
|
||||
# Place word
|
||||
for i in range(length):
|
||||
r, c = row + dr * i, col + dc * i
|
||||
self.grid[r][c] = word.word[i]
|
||||
|
||||
# Place end block
|
||||
end_r, end_c = row + dr * length, col + dc * length
|
||||
if self._in_bounds(end_r, end_c):
|
||||
self.grid[end_r][end_c] = '#'
|
||||
|
||||
self.placed_words.append(PlacedWord(word, row, col, orientation))
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
return '\n'.join(
|
||||
''.join(cell if cell else '.' for cell in row)
|
||||
for row in self.grid
|
||||
)
|
||||
168
multiplayer_crosswords/dictionary.py
Normal file
168
multiplayer_crosswords/dictionary.py
Normal file
@ -0,0 +1,168 @@
|
||||
import numpy as np
|
||||
import json
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
from numba import njit
|
||||
import time
|
||||
import numpy as np
|
||||
import cProfile
|
||||
import pstats
|
||||
|
||||
|
||||
class Word:
|
||||
|
||||
def __init__(self, word: str, hints: list[str], difficulty: float):
|
||||
self.word = word
|
||||
self.hints = hints
|
||||
self.difficulty = difficulty
|
||||
|
||||
def to_dict(self) -> dict[str, any]:
|
||||
return {
|
||||
"word": self.word,
|
||||
"hints": self.hints,
|
||||
"difficulty": self.difficulty
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, any]) -> "Word":
|
||||
return Word(
|
||||
word=data["word"],
|
||||
hints=data["hints"],
|
||||
difficulty=data["difficulty"]
|
||||
)
|
||||
|
||||
class Dictionary:
|
||||
def __init__(self):
|
||||
self.words: List[Word] = []
|
||||
self._length_index: Dict[int, List[Word]] = {}
|
||||
self._max_word_length = 0
|
||||
self._pos_index_list = None # Will be built on demand
|
||||
self._index_built = False
|
||||
|
||||
def add_word(self, word: Word):
|
||||
self.words.append(word)
|
||||
l = len(word.word)
|
||||
if l not in self._length_index:
|
||||
self._length_index[l] = []
|
||||
self._length_index[l].append(word)
|
||||
if l > self._max_word_length:
|
||||
self._max_word_length = l
|
||||
self._index_built = False # Invalidate index
|
||||
elif self._index_built:
|
||||
# just add the word to the existing index
|
||||
if self._pos_index_list is not None:
|
||||
for i, c in enumerate(word.word):
|
||||
idx = ord(c) - ord('a')
|
||||
self._pos_index_list[l][i][idx].add(word)
|
||||
|
||||
def _build_pos_index_list(self):
|
||||
# pos_index_list[length][pos][char_index] = set(words)
|
||||
self._pos_index_list = [None] * (self._max_word_length + 1)
|
||||
for length in range(1, self._max_word_length + 1):
|
||||
pos_list = []
|
||||
for pos in range(length):
|
||||
char_list = [set() for _ in range(26)] # a-z
|
||||
pos_list.append(char_list)
|
||||
self._pos_index_list[length] = pos_list
|
||||
for word in self.words:
|
||||
l = len(word.word)
|
||||
for i, c in enumerate(word.word):
|
||||
idx = ord(c) - ord('a')
|
||||
self._pos_index_list[l][i][idx].add(word)
|
||||
self._index_built = True
|
||||
|
||||
def to_dict(self) -> dict[str, any]:
|
||||
return {
|
||||
"words": [w.to_dict() for w in self.words]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, any]) -> "Dictionary":
|
||||
d = Dictionary()
|
||||
for word_data in data.get("words", []):
|
||||
d.add_word(Word.from_dict(word_data))
|
||||
return d
|
||||
|
||||
def to_json(self) -> str:
|
||||
import json
|
||||
return json.dumps(self.to_dict(), indent=2)
|
||||
|
||||
@staticmethod
|
||||
def from_json(json_str: str) -> "Dictionary":
|
||||
import json
|
||||
data = json.loads(json_str)
|
||||
return Dictionary.from_dict(data)
|
||||
|
||||
def find_by_pattern(self, pattern: str) -> List[Word]:
|
||||
length = len(pattern)
|
||||
if not self._index_built:
|
||||
self._build_pos_index_list()
|
||||
if length > self._max_word_length or self._pos_index_list[length] is None:
|
||||
return []
|
||||
fixed_positions = [(i, c) for i, c in enumerate(pattern) if c != '*']
|
||||
if not fixed_positions:
|
||||
return [w for w in self._length_index.get(length, [])]
|
||||
sets = []
|
||||
for i, c in fixed_positions:
|
||||
idx = ord(c) - ord('a')
|
||||
sets.append(self._pos_index_list[length][i][idx])
|
||||
if not sets:
|
||||
return []
|
||||
sets.sort(key=len)
|
||||
candidates = set.intersection(*sets)
|
||||
return candidates
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
def generate_dummy_words(n, min_length=4, max_length=8):
|
||||
# Generates n random lowercase words of random length between min_length and max_length
|
||||
rng = np.random.default_rng(42)
|
||||
alphabet = np.array([ord(c) for c in "abcdefghijklmnopqrstuvwxyz"], dtype=np.uint8)
|
||||
words = []
|
||||
lengths = rng.integers(min_length, max_length + 1, size=n)
|
||||
for l in lengths:
|
||||
chars = rng.choice(alphabet, size=l)
|
||||
word = ''.join([chr(c) for c in chars])
|
||||
words.append(word)
|
||||
return words
|
||||
|
||||
N = 500_000
|
||||
MIN_WORD_LENGTH = 4
|
||||
MAX_WORD_LENGTH = 8
|
||||
dummy_words = generate_dummy_words(N, MIN_WORD_LENGTH, MAX_WORD_LENGTH)
|
||||
|
||||
# Fill Dictionary (position-indexed)
|
||||
dict_obj = Dictionary()
|
||||
for w in dummy_words:
|
||||
dict_obj.add_word(Word(w, hints=[], difficulty=1.0))
|
||||
|
||||
# Prepare 1000 patterns (repeat the same 10 patterns 100 times)
|
||||
base_patterns = ["**a*e", "b****", "c***d*", "*e***", "a****z", "****", "*****", "******", "*******", "********"]
|
||||
patterns = base_patterns * 100 # 10 * 100 = 1000
|
||||
|
||||
dict_obj._build_pos_index_list() # Ensure the index is built before testing
|
||||
|
||||
print(f"Testing {len(patterns)} patterns on {N} words with lengths {MIN_WORD_LENGTH}-{MAX_WORD_LENGTH}.")
|
||||
|
||||
# --- Profiling block ---
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
|
||||
t0 = time.time()
|
||||
total_matches_dict = 0
|
||||
for pattern in patterns:
|
||||
result1 = dict_obj.find_by_pattern(pattern)
|
||||
total_matches_dict += len(result1)
|
||||
t1 = time.time()
|
||||
|
||||
profiler.disable()
|
||||
print(f"Dictionary (position-indexed) total matches: {total_matches_dict} in {t1-t0:.6f} seconds for 1000 queries.")
|
||||
|
||||
# Print top 20 time-consuming functions
|
||||
stats = pstats.Stats(profiler).sort_stats('cumtime')
|
||||
stats.print_stats(20)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
@ -0,0 +1,29 @@
|
||||
[project]
|
||||
name = "multiplayer-crosswords"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Jonas Weinz",email = "jo.we93@gmx.de"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"numpy (>=2.2.6,<3.0.0)",
|
||||
"pandas (>=2.2.3,<3.0.0)",
|
||||
"numba (>=0.61.2,<1.0.0)",
|
||||
"bitarray (>=3.4.2,<4.0.0)",
|
||||
]
|
||||
|
||||
|
||||
|
||||
[tool.poetry]
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
jupyterlab = "^4.4.3"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
87
tests/test_crossword.py
Normal file
87
tests/test_crossword.py
Normal file
@ -0,0 +1,87 @@
|
||||
import pytest
|
||||
from multiplayer_crosswords.crossword import Crossword, Orientation, PlacedWord
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
class DummyWord:
|
||||
def __init__(self, word):
|
||||
self.word = word
|
||||
|
||||
class DummyDictionary:
|
||||
def __init__(self, words):
|
||||
self.words = words
|
||||
|
||||
def make_crossword(rows=5, cols=5):
|
||||
dictionary = DummyDictionary([DummyWord("HELLO"), DummyWord("WORLD")])
|
||||
return Crossword(rows, cols, dictionary)
|
||||
|
||||
def test_can_place_word_empty_grid_horizontal():
|
||||
cw = make_crossword()
|
||||
word = DummyWord("HELLO")
|
||||
assert cw.can_place_word(word, 0, 0, Orientation.HORIZONTAL)
|
||||
assert not cw.can_place_word(word, 0, 1, Orientation.HORIZONTAL) # would go out of bounds
|
||||
|
||||
def test_can_place_word_empty_grid_vertical():
|
||||
cw = make_crossword()
|
||||
word = DummyWord("HELLO")
|
||||
assert cw.can_place_word(word, 0, 0, Orientation.VERTICAL)
|
||||
assert not cw.can_place_word(word, 1, 0, Orientation.VERTICAL) # would go out of bounds
|
||||
|
||||
def test_add_word_and_grid_update():
|
||||
cw = make_crossword()
|
||||
word = DummyWord("HELLO")
|
||||
assert cw.add_word(word, 0, 0, Orientation.HORIZONTAL)
|
||||
# Check grid contents
|
||||
assert cw.grid[0][0] == "H"
|
||||
assert cw.grid[0][1] == "E"
|
||||
assert cw.grid[0][2] == "L"
|
||||
assert cw.grid[0][3] == "L"
|
||||
assert cw.grid[0][4] == "O"
|
||||
# Start and end blocks
|
||||
assert cw.grid[0][0-0][0-1] == "#" if cw._in_bounds(0, -1) else True
|
||||
assert cw.grid[0][5] == "#" if cw._in_bounds(0, 5) else True
|
||||
|
||||
def test_cannot_place_overlapping_different_letter():
|
||||
cw = make_crossword()
|
||||
word1 = DummyWord("HELLO")
|
||||
word2 = DummyWord("WORLD")
|
||||
assert cw.add_word(word1, 0, 0, Orientation.HORIZONTAL)
|
||||
# Try to place "WORLD" vertically overlapping 'E' with 'O'
|
||||
assert not cw.can_place_word(word2, 0, 1, Orientation.VERTICAL)
|
||||
|
||||
def test_can_place_overlapping_same_letter():
|
||||
cw = make_crossword()
|
||||
word1 = DummyWord("HELLO")
|
||||
word2 = DummyWord("EVE")
|
||||
assert cw.add_word(word1, 0, 0, Orientation.HORIZONTAL)
|
||||
# "EVE" vertical, E overlaps at (0,1)
|
||||
word2 = DummyWord("EVE")
|
||||
assert cw.can_place_word(word2, 0, 1, Orientation.VERTICAL)
|
||||
assert cw.add_word(word2, 0, 1, Orientation.VERTICAL)
|
||||
|
||||
def test_blocks_are_placed_correctly():
|
||||
cw = make_crossword()
|
||||
word = DummyWord("HI")
|
||||
assert cw.add_word(word, 2, 2, Orientation.HORIZONTAL)
|
||||
# Start block
|
||||
assert cw.grid[2][1] == "#"
|
||||
# End block
|
||||
assert cw.grid[2][4] == "#"
|
||||
|
||||
def test_str_representation():
|
||||
cw = make_crossword()
|
||||
word = DummyWord("HI")
|
||||
cw.add_word(word, 0, 0, Orientation.HORIZONTAL)
|
||||
s = str(cw)
|
||||
assert "HI" in s
|
||||
assert "." in s
|
||||
|
||||
def test_placed_words_tracking():
|
||||
cw = make_crossword()
|
||||
word = DummyWord("HI")
|
||||
cw.add_word(word, 1, 1, Orientation.VERTICAL)
|
||||
assert len(cw.placed_words) == 1
|
||||
placed = cw.placed_words[0]
|
||||
assert placed.word == word
|
||||
assert placed.row == 1
|
||||
assert placed.col == 1
|
||||
assert placed.orientation == Orientation.VERTICAL
|
||||
105
tests/test_dictionary.py
Normal file
105
tests/test_dictionary.py
Normal file
@ -0,0 +1,105 @@
|
||||
import pytest
|
||||
from multiplayer_crosswords.dictionary import Word, Dictionary
|
||||
|
||||
def test_add_word_and_to_dict():
|
||||
d = Dictionary()
|
||||
w = Word("apple", ["a red or green fruit"], 0.2)
|
||||
d.add_word(w)
|
||||
result = d.to_dict()
|
||||
assert "words" in result
|
||||
assert len(result["words"]) == 1
|
||||
assert result["words"][0]["word"] == "apple"
|
||||
assert result["words"][0]["hints"] == ["a red or green fruit"]
|
||||
assert result["words"][0]["difficulty"] == 0.2
|
||||
|
||||
def test_from_dict_and_to_dict_roundtrip():
|
||||
data = {
|
||||
"words": [
|
||||
{"word": "banana", "hints": ["a yellow fruit", "something monkeys like to eat"], "difficulty": 0.1},
|
||||
{"word": "car", "hints": ["vehicle", "four wheels"], "difficulty": 0.3}
|
||||
]
|
||||
}
|
||||
d = Dictionary.from_dict(data)
|
||||
assert len(d.words) == 2
|
||||
assert d.words[0].word == "banana"
|
||||
assert d.words[1].hints == ["vehicle", "four wheels"]
|
||||
assert d.words[1].difficulty == 0.3
|
||||
assert d.to_dict() == data
|
||||
|
||||
def test_to_json_and_from_json():
|
||||
d = Dictionary()
|
||||
d.add_word(Word("dog", ["animal", "barks"], 0.15))
|
||||
json_str = d.to_json()
|
||||
d2 = Dictionary.from_json(json_str)
|
||||
assert len(d2.words) == 1
|
||||
assert d2.words[0].word == "dog"
|
||||
assert d2.words[0].hints == ["animal", "barks"]
|
||||
assert d2.words[0].difficulty == 0.15
|
||||
|
||||
def test_empty_dictionary_to_dict_and_json():
|
||||
d = Dictionary()
|
||||
assert d.to_dict() == {"words": []}
|
||||
json_str = d.to_json()
|
||||
assert json_str.strip().startswith("{")
|
||||
d2 = Dictionary.from_json(json_str)
|
||||
assert isinstance(d2, Dictionary)
|
||||
assert d2.words == []
|
||||
|
||||
def test_word_to_dict_and_from_dict():
|
||||
w = Word("cat", ["animal", "meows"], 0.12)
|
||||
d = w.to_dict()
|
||||
assert d == {"word": "cat", "hints": ["animal", "meows"], "difficulty": 0.12}
|
||||
w2 = Word.from_dict(d)
|
||||
assert w2.word == "cat"
|
||||
assert w2.hints == ["animal", "meows"]
|
||||
assert w2.difficulty == 0.12
|
||||
|
||||
def test_find_by_pattern_exact_match():
|
||||
d = Dictionary()
|
||||
d.add_word(Word("apple", ["fruit"], 0.2))
|
||||
d.add_word(Word("apply", ["verb"], 0.3))
|
||||
d.add_word(Word("angle", ["geometry"], 0.4))
|
||||
result = d.find_by_pattern("apple")
|
||||
assert [r.word for r in result] == ["apple"]
|
||||
|
||||
def test_find_by_pattern_with_wildcards():
|
||||
d = Dictionary()
|
||||
d.add_word(Word("apple", ["fruit"], 0.2))
|
||||
d.add_word(Word("apply", ["verb"], 0.3))
|
||||
d.add_word(Word("angle", ["geometry"], 0.4))
|
||||
d.add_word(Word("ample", ["enough"], 0.5))
|
||||
# Pattern: a**le should match apple and angle and ample
|
||||
result = sorted([w.word for w in d.find_by_pattern("a**le")])
|
||||
assert set(result) == {"apple", "angle", "ample"}
|
||||
|
||||
def test_find_by_pattern_all_wildcards():
|
||||
d = Dictionary()
|
||||
d.add_word(Word("apple", ["fruit"], 0.2))
|
||||
d.add_word(Word("apply", ["verb"], 0.3))
|
||||
d.add_word(Word("angle", ["geometry"], 0.4))
|
||||
# Pattern: ***** should match all 5-letter words
|
||||
result = sorted([w.word for w in d.find_by_pattern("*****")])
|
||||
assert set(result) == {"apple", "apply", "angle"}
|
||||
|
||||
def test_find_by_pattern_no_match():
|
||||
d = Dictionary()
|
||||
d.add_word(Word("apple", ["fruit"], 0.2))
|
||||
d.add_word(Word("apply", ["verb"], 0.3))
|
||||
# Pattern: z**** should match nothing
|
||||
result = d.find_by_pattern("z****")
|
||||
assert list(result) == []
|
||||
|
||||
def test_find_by_pattern_duplicate_letters():
|
||||
d = Dictionary()
|
||||
d.add_word(Word("level", ["word"], 0.1))
|
||||
d.add_word(Word("lever", ["tool"], 0.2))
|
||||
# Pattern: l*vel should match "level" only
|
||||
result = d.find_by_pattern("l*vel")
|
||||
assert [r.word for r in result] == ["level"]
|
||||
|
||||
def test_find_by_pattern_length_not_in_index():
|
||||
d = Dictionary()
|
||||
d.add_word(Word("cat", ["animal"], 0.1))
|
||||
# Pattern: four letters, but only 3-letter word in dict
|
||||
result = d.find_by_pattern("****")
|
||||
assert [r.word for r in result] == []
|
||||
Reference in New Issue
Block a user