1515"""
1616
1717import logging
18- import re
1918import threading
2019import unicodedata
2120from collections .abc import Iterator
2221from dataclasses import dataclass
2322from typing import Any
2423
24+ import regex
25+
26+ from pyrit .converter .puzzled ._text import _is_combining_mark , _letter_units
27+
2528logger = 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
3961def _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
298319def 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 )
0 commit comments