first draft

This commit is contained in:
2025-06-01 23:04:30 +02:00
parent e9b9930d4c
commit 175371ee00
6 changed files with 477 additions and 0 deletions

View File

View 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
)

View 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
View 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
View 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
View 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] == []