Skip to content

Commit 7377dce

Browse files
Denimworld12romanlutzCopilot
authored
FIX: mask words written with combining marks instead of skipping them (#3155)
Co-authored-by: Roman Lutz <romanlutz13@gmail.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bff8f168-7232-4586-9d79-d59da7d0ca0b
1 parent 04efcd6 commit 7377dce

7 files changed

Lines changed: 345 additions & 78 deletions

File tree

‎pyrit/converter/puzzled/_text.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Shared letter units for PUZZLED clues and puzzle encodings."""
5+
6+
import unicodedata
7+
8+
9+
def _is_combining_mark(char: str) -> bool:
10+
return unicodedata.category(char).startswith("M")
11+
12+
13+
def _letter_units(word: str) -> list[str]:
14+
"""
15+
Split a canonically composed word into letters with their combining marks attached.
16+
17+
Args:
18+
word (str): The word or puzzle sequence to split.
19+
20+
Returns:
21+
list[str]: One unit per letter, including any following combining marks.
22+
"""
23+
letters: list[str] = []
24+
for char in unicodedata.normalize("NFC", word):
25+
if letters and _is_combining_mark(char):
26+
letters[-1] += char
27+
else:
28+
letters.append(char)
29+
return letters

‎pyrit/converter/puzzled/keyword_masker.py‎

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,47 @@
1515
"""
1616

1717
import logging
18-
import re
1918
import threading
2019
import unicodedata
2120
from collections.abc import Iterator
2221
from dataclasses import dataclass
2322
from typing import Any
2423

24+
import regex
25+
26+
from pyrit.converter.puzzled._text import _is_combining_mark, _letter_units
27+
2528
logger = logging.getLogger(__name__)
2629

27-
# The one tokenizer used for candidate selection, placeholder ordering and replacement
28-
# (always through ``_iter_words``). Requiring word boundaries keeps candidates to whole
29-
# words, so a token embedded in an alphanumeric blob (the "h" of "h4ck3r") is never
30-
# selected. ``[^\W\d_]`` admits Unicode letters, so words such as "Schädlinge" or Cyrillic
31-
# words are candidates too; ``_iter_words`` then drops matches that are not all letters.
32-
_WORD_PATTERN = re.compile(r"\b[^\W\d_]+\b")
30+
# Scan complete tokens before rejecting numbers, underscores, joiners, or leading marks.
31+
# Rejecting a whole run prevents a mark from exposing a maskable prefix of an invalid token.
32+
_WORD_PATTERN = regex.compile(r"[\p{L}\p{M}\p{N}_\u200c\u200d]+")
33+
34+
35+
def _letter_count(word: str) -> int:
36+
"""
37+
Return the number of letter units used by the puzzle builders.
38+
39+
Combining marks stay attached to their letters, so pointed "שָׁלוֹם" has four units
40+
despite being written with seven code points. Canonically equivalent spellings use
41+
the same units.
42+
43+
Args:
44+
word (str): A word yielded by ``_iter_words``.
45+
46+
Returns:
47+
int: The count of letters with their attached combining marks.
48+
"""
49+
return len(_letter_units(word))
3350

3451

35-
def _is_word_continuation(char: str) -> bool:
36-
return char in ("\u200c", "\u200d") or unicodedata.category(char).startswith("M")
52+
@dataclass(frozen=True)
53+
class _Word:
54+
"""A whole word of the prompt and the span it occupies."""
55+
56+
text: str
57+
start: int
58+
end: int
3759

3860

3961
def _word_key(word: str) -> str:
@@ -44,43 +66,42 @@ def _word_key(word: str) -> str:
4466
so two spellings are either the same masked word everywhere or different words
4567
everywhere. For ASCII it equals ``str.lower``.
4668
69+
Case folding alone is not stable across normalization forms: composed "café" and
70+
decomposed "cafe\u0301" fold to different strings, which would make them two words and let
71+
the alphabetical tie-break in ``_rank_candidates`` choose differently for the same prompt.
72+
Normalize before folding to put marks in canonical order, and again afterward because
73+
folding can produce decomposed text. This also keeps Greek case variants equivalent.
74+
4775
Args:
4876
word (str): A word from the prompt or from the caller.
4977
5078
Returns:
51-
str: The case-folded word.
79+
str: The case-folded word in composed form.
5280
"""
53-
return word.casefold()
81+
return unicodedata.normalize("NFC", unicodedata.normalize("NFD", word).casefold())
5482

5583

56-
def _iter_words(prompt: str) -> Iterator[re.Match[str]]:
84+
def _iter_words(prompt: str) -> Iterator[_Word]:
5785
"""
5886
Yield the maskable words of ``prompt``.
5987
60-
``[^\\W\\d_]`` still admits characters that are numeric without being decimal digits,
61-
such as "²" or "¼", so a match is kept only if every character is a letter. "abc²" is
62-
therefore rejected whole, like "h4ck3r", rather than becoming a candidate.
63-
64-
``\\w`` does not cover combining marks (Devanagari vowel signs, Hebrew and Arabic vowel
65-
points, or the accents of NFD-decomposed text) or zero-width joiners and non-joiners.
66-
These characters can continue a word even though ``\\b`` treats them as boundaries.
67-
A match that touches one of them is only a fragment, so it is skipped as well.
88+
Scan maximal runs of letters, combining marks, numbers, underscores, and joiners.
89+
Keep only runs that start with a letter and contain only letters and attached marks.
90+
This accepts pointed scripts and NFD accents without exposing fragments of tokens
91+
such as "h4ck3r", "abc²", or a marked word followed by a digit or underscore.
92+
Words containing joiners remain unmaskable.
6893
6994
Args:
7095
prompt (str): The prompt to scan.
7196
7297
Yields:
73-
re.Match[str]: Each whole-word match, in order of appearance.
98+
_Word: Each whole word, in order of appearance.
7499
"""
75100
for match in _WORD_PATTERN.finditer(prompt):
76-
if not match.group().isalpha():
77-
continue
78-
start, end = match.span()
79-
if start > 0 and _is_word_continuation(prompt[start - 1]):
80-
continue
81-
if end < len(prompt) and _is_word_continuation(prompt[end]):
101+
word = match.group()
102+
if not word[0].isalpha() or not all(char.isalpha() or _is_combining_mark(char) for char in word):
82103
continue
83-
yield match
104+
yield _Word(text=word, start=match.start(), end=match.end())
84105

85106

86107
# Part-of-speech label used when spaCy is unavailable or the word was caller-supplied.
@@ -163,7 +184,7 @@ class MaskedWord:
163184
@property
164185
def clue(self) -> str:
165186
"""The deterministic clue for this word, e.g. ``"9-letter noun"``."""
166-
return f"{len(self.text)}-letter {self.pos}"
187+
return f"{_letter_count(self.text)}-letter {self.pos}"
167188

168189

169190
@dataclass(frozen=True)
@@ -269,7 +290,7 @@ def _rank_candidates(
269290
Returns:
270291
list[str]: Distinct candidate words (as they appear in the prompt) in priority order.
271292
"""
272-
tokens = [match.group() for match in _iter_words(prompt)]
293+
tokens = [word.text for word in _iter_words(prompt)]
273294
seen: set[str] = set()
274295
unique: list[str] = []
275296
for token in tokens:
@@ -292,7 +313,7 @@ def tier(word: str) -> int:
292313
return 3
293314
return 4
294315

295-
return sorted(unique, key=lambda w: (tier(w), -len(w), _word_key(w)))
316+
return sorted(unique, key=lambda w: (tier(w), -_letter_count(w), _word_key(w)))
296317

297318

298319
def mask_prompt(
@@ -336,8 +357,8 @@ def mask_prompt(
336357
# [WORD1] is the leftmost masked word. Candidates come from the same scan, so every
337358
# chosen word has an entry here.
338359
first_index: dict[str, int] = {}
339-
for match in _iter_words(prompt):
340-
first_index.setdefault(_word_key(match.group()), match.start())
360+
for word in _iter_words(prompt):
361+
first_index.setdefault(_word_key(word.text), word.start)
341362
ordered = sorted(chosen, key=lambda w: first_index[_word_key(w)])
342363

343364
masked_words: list[MaskedWord] = []
@@ -349,18 +370,17 @@ def mask_prompt(
349370
placeholders[_word_key(word)] = placeholder
350371

351372
# Replace the word spans of the same scan, looked up by the same key. Every occurrence of
352-
# a chosen word is masked whatever its casing, a fragment the scan skipped (next to a
353-
# combining mark or joiner) is never masked, and every replaced span has a placeholder.
373+
# a chosen word is masked whatever its casing, and every replaced span has a placeholder.
354374
# Building the result in one pass means an inserted placeholder can never be matched again.
355375
pieces: list[str] = []
356376
last_end = 0
357-
for match in _iter_words(prompt):
358-
placeholder = placeholders.get(_word_key(match.group()))
377+
for word in _iter_words(prompt):
378+
placeholder = placeholders.get(_word_key(word.text))
359379
if placeholder is None:
360380
continue
361-
pieces.append(prompt[last_end : match.start()])
381+
pieces.append(prompt[last_end : word.start])
362382
pieces.append(placeholder)
363-
last_end = match.end()
383+
last_end = word.end
364384
pieces.append(prompt[last_end:])
365385

366386
return MaskResult(masked_prompt="".join(pieces), masked_words=masked_words)

‎pyrit/converter/puzzled/puzzle_builders.py‎

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from collections import Counter
1616
from enum import Enum
1717

18+
from pyrit.converter.puzzled._text import _letter_units
19+
1820
# Eight placement directions for the word search: right, left, down, up, and the four
1921
# diagonals. Each is a (row_delta, col_delta) step.
2022
_DIRECTIONS = (
@@ -63,6 +65,7 @@ def _upper_same_length(word: str) -> str:
6365
``str.upper`` expands some letters ("ß" becomes "SS", "fi" becomes "FI"), which would make
6466
an encoded word longer than its clue. It also changes the case-folded spelling of dotless
6567
"ı" to "i", merging distinct words. Keep a character unchanged when either would happen.
68+
Combining marks are retained, even when uppercasing would turn one into a letter.
6669
6770
Args:
6871
word (str): The word to uppercase.
@@ -71,39 +74,42 @@ def _upper_same_length(word: str) -> str:
7174
str: The uppercased word, preserving its length and case-folded spelling.
7275
"""
7376
return "".join(
74-
upper if len(upper := char.upper()) == 1 and upper.casefold() == char.casefold() else char for char in word
77+
upper if char.isalpha() and len(upper := char.upper()) == 1 and upper.casefold() == char.casefold() else char
78+
for char in word
7579
)
7680

7781

78-
def _normalize(words: list[str]) -> list[str]:
82+
def _normalize(words: list[str]) -> list[list[str]]:
7983
"""
80-
Uppercase the words and strip surrounding whitespace.
84+
Uppercase the words and split them into canonically composed letter units.
8185
8286
Uppercasing keeps each word's length and case-folded spelling (see ``_upper_same_length``),
83-
so every puzzle agrees with both the masker's length clue and its word identity.
87+
and combining marks stay attached to their letters. Every puzzle uses the same units as
88+
the masker's length clue.
8489
8590
Args:
8691
words (list[str]): The masked keywords to encode.
8792
8893
Returns:
89-
list[str]: Cleaned, uppercased words.
94+
list[list[str]]: Cleaned words, each split into letters with attached marks.
9095
9196
Raises:
9297
ValueError: If ``words`` is empty or any word has no letters after cleaning.
9398
"""
9499
if not words:
95100
raise ValueError("At least one word is required to build a puzzle.")
96-
cleaned = [_upper_same_length(w.strip()) for w in words]
101+
cleaned = [[_upper_same_length(letter) for letter in _letter_units(w.strip())] for w in words]
97102
if any(not w for w in cleaned):
98103
raise ValueError("Words must contain at least one non-whitespace character.")
99104
return cleaned
100105

101106

102107
def build_anagram(words: list[str], rng: random.Random) -> str:
103108
"""
104-
Concatenate the masked words and shuffle every character into one sequence.
109+
Concatenate the masked words and shuffle their letter units into one sequence.
105110
106-
The model must both unscramble and re-segment the sequence into the original words.
111+
Combining marks move with their letters. The model must both unscramble and re-segment
112+
the sequence into the original words.
107113
108114
Args:
109115
words (list[str]): The masked keywords to encode.
@@ -115,17 +121,17 @@ def build_anagram(words: list[str], rng: random.Random) -> str:
115121
Raises:
116122
ValueError: If ``words`` is empty or any word has no letters after cleaning.
117123
"""
118-
source = "".join(_normalize(words))
119-
if len(source) <= 1 or len(set(source)) == 1:
124+
letters = [letter for word in _normalize(words) for letter in word]
125+
source = "".join(letters)
126+
if len(letters) <= 1 or len(set(letters)) == 1:
120127
return source
121128

122-
letters = list(source)
123129
for _ in range(_MAX_ANAGRAM_SHUFFLES):
124130
rng.shuffle(letters)
125131
result = "".join(letters)
126132
if result != source:
127133
return result
128-
return source[1:] + source[0]
134+
return "".join(letters[1:] + letters[:1])
129135

130136

131137
def crossword_symbol_map(words: list[str]) -> dict[str, str]:
@@ -151,7 +157,7 @@ def crossword_symbol_map(words: list[str]) -> dict[str, str]:
151157
for word in normalized:
152158
for letter in set(word):
153159
words_per_letter[letter] += 1
154-
total_frequency = Counter("".join(normalized))
160+
total_frequency = Counter(letter for word in normalized for letter in word)
155161

156162
shared = [letter for letter, count in words_per_letter.items() if count >= 2]
157163
shared.sort(key=lambda letter: (-words_per_letter[letter], -total_frequency[letter], letter))
@@ -177,20 +183,20 @@ def build_crossword(words: list[str]) -> str:
177183
ValueError: If ``words`` is empty or any word has no letters after cleaning.
178184
"""
179185
normalized = _normalize(words)
180-
mapping = crossword_symbol_map(normalized)
186+
mapping = crossword_symbol_map(words)
181187
lines = []
182188
for index, word in enumerate(normalized, start=1):
183189
masked = "".join(mapping.get(letter, letter) for letter in word)
184190
lines.append(f"{index}. {masked}")
185191
return "\n".join(lines)
186192

187193

188-
def _grid_size(words: list[str]) -> int:
194+
def _grid_size(words: list[list[str]]) -> int:
189195
"""
190196
Pick a square grid size large enough to place all words with room to spare.
191197
192198
Args:
193-
words (list[str]): The masked keywords to encode.
199+
words (list[list[str]]): The masked keywords split into letter units.
194200
195201
Returns:
196202
int: The side length of the square grid.
@@ -205,7 +211,7 @@ def _grid_size(words: list[str]) -> int:
205211

206212
def _try_place(
207213
grid: list[list[str]],
208-
word: str,
214+
word: list[str],
209215
rng: random.Random,
210216
) -> bool:
211217
"""
@@ -215,7 +221,7 @@ def _try_place(
215221
216222
Args:
217223
grid (list[list[str]]): The mutable grid; empty cells are the empty string.
218-
word (str): The word to place.
224+
word (list[str]): The letter units to place, one per cell.
219225
rng (random.Random): Randomness source.
220226
221227
Returns:
@@ -260,7 +266,8 @@ def build_word_search(words: list[str], rng: random.Random) -> str:
260266
rng (random.Random): Randomness source (injected for reproducibility).
261267
262268
Returns:
263-
str: The grid, one row per line with letters separated by spaces.
269+
str: The grid, one row per line with letter units separated by spaces. A cell may
270+
include combining marks attached to its letter.
264271
265272
Raises:
266273
ValueError: If ``words`` is empty, any word has no letters after cleaning, or the

‎pyrit/converter/puzzled/puzzled_converter.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ class PuzzledConverter(Converter):
6868
- ``crossword``: shared letters across the masked words are replaced by symbols the model
6969
must deduce.
7070
71+
Each puzzle uses the same letter units as its clues: canonically equivalent spellings
72+
are normalized, and combining marks stay attached to their letters during encoding.
73+
Alphanumeric tokens, underscores, and words containing joiners are not partially masked.
74+
7175
Keyword selection uses spaCy's ``en_core_web_sm`` model when it is installed and degrades to a
7276
length-based heuristic when it is not, so the converter has no hard dependency on spaCy. Run
7377
``python -m spacy download en_core_web_sm`` to enable the paper's part-of-speech-aware

0 commit comments

Comments
 (0)