glitchlings 0.2.6__cp312-cp312-win_amd64.whl → 0.4.0__cp312-cp312-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of glitchlings might be problematic. Click here for more details.
- glitchlings/__init__.py +8 -0
- glitchlings/_zoo_rust.cp312-win_amd64.pyd +0 -0
- glitchlings/config.py +258 -0
- glitchlings/config.toml +3 -0
- glitchlings/lexicon/__init__.py +191 -0
- glitchlings/lexicon/data/default_vector_cache.json +16 -0
- glitchlings/lexicon/graph.py +303 -0
- glitchlings/lexicon/metrics.py +169 -0
- glitchlings/lexicon/vector.py +610 -0
- glitchlings/lexicon/wordnet.py +182 -0
- glitchlings/main.py +145 -5
- glitchlings/zoo/__init__.py +20 -1
- glitchlings/zoo/_sampling.py +55 -0
- glitchlings/zoo/_text_utils.py +104 -0
- glitchlings/zoo/adjax.py +131 -0
- glitchlings/zoo/core.py +16 -14
- glitchlings/zoo/jargoyle.py +190 -200
- glitchlings/zoo/redactyl.py +32 -67
- glitchlings/zoo/reduple.py +13 -35
- glitchlings/zoo/rushmore.py +17 -28
- glitchlings/zoo/typogre.py +22 -1
- glitchlings/zoo/zeedub.py +40 -1
- {glitchlings-0.2.6.dist-info → glitchlings-0.4.0.dist-info}/METADATA +48 -11
- glitchlings-0.4.0.dist-info/RECORD +38 -0
- glitchlings-0.2.6.dist-info/RECORD +0 -27
- {glitchlings-0.2.6.dist-info → glitchlings-0.4.0.dist-info}/WHEEL +0 -0
- {glitchlings-0.2.6.dist-info → glitchlings-0.4.0.dist-info}/entry_points.txt +0 -0
- {glitchlings-0.2.6.dist-info → glitchlings-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {glitchlings-0.2.6.dist-info → glitchlings-0.4.0.dist-info}/top_level.txt +0 -0
glitchlings/zoo/adjax.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ._rate import resolve_rate
|
|
7
|
+
from ._text_utils import split_preserving_whitespace, split_token_edges
|
|
8
|
+
from .core import AttackWave, Glitchling
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from glitchlings._zoo_rust import swap_adjacent_words as _swap_adjacent_words_rust
|
|
12
|
+
except ImportError: # pragma: no cover - optional acceleration
|
|
13
|
+
_swap_adjacent_words_rust = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _python_swap_adjacent_words(
|
|
17
|
+
text: str,
|
|
18
|
+
*,
|
|
19
|
+
rate: float,
|
|
20
|
+
rng: random.Random,
|
|
21
|
+
) -> str:
|
|
22
|
+
"""Swap the cores of adjacent words while keeping affixes and spacing intact."""
|
|
23
|
+
|
|
24
|
+
tokens = split_preserving_whitespace(text)
|
|
25
|
+
if len(tokens) < 2:
|
|
26
|
+
return text
|
|
27
|
+
|
|
28
|
+
word_indices: list[int] = []
|
|
29
|
+
for index in range(len(tokens)):
|
|
30
|
+
token = tokens[index]
|
|
31
|
+
if not token or token.isspace():
|
|
32
|
+
continue
|
|
33
|
+
if index % 2 == 0:
|
|
34
|
+
word_indices.append(index)
|
|
35
|
+
|
|
36
|
+
if len(word_indices) < 2:
|
|
37
|
+
return text
|
|
38
|
+
|
|
39
|
+
clamped = max(0.0, min(rate, 1.0))
|
|
40
|
+
if clamped <= 0.0:
|
|
41
|
+
return text
|
|
42
|
+
|
|
43
|
+
for cursor in range(0, len(word_indices) - 1, 2):
|
|
44
|
+
left_index = word_indices[cursor]
|
|
45
|
+
right_index = word_indices[cursor + 1]
|
|
46
|
+
|
|
47
|
+
left_token = tokens[left_index]
|
|
48
|
+
right_token = tokens[right_index]
|
|
49
|
+
|
|
50
|
+
left_prefix, left_core, left_suffix = split_token_edges(left_token)
|
|
51
|
+
right_prefix, right_core, right_suffix = split_token_edges(right_token)
|
|
52
|
+
|
|
53
|
+
if not left_core or not right_core:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
should_swap = clamped >= 1.0 or rng.random() < clamped
|
|
57
|
+
if not should_swap:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
tokens[left_index] = f"{left_prefix}{right_core}{left_suffix}"
|
|
61
|
+
tokens[right_index] = f"{right_prefix}{left_core}{right_suffix}"
|
|
62
|
+
|
|
63
|
+
return "".join(tokens)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def swap_adjacent_words(
|
|
67
|
+
text: str,
|
|
68
|
+
rate: float | None = None,
|
|
69
|
+
seed: int | None = None,
|
|
70
|
+
rng: random.Random | None = None,
|
|
71
|
+
*,
|
|
72
|
+
swap_rate: float | None = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Swap adjacent word cores while preserving spacing and punctuation."""
|
|
75
|
+
|
|
76
|
+
effective_rate = resolve_rate(
|
|
77
|
+
rate=rate,
|
|
78
|
+
legacy_value=swap_rate,
|
|
79
|
+
default=0.5,
|
|
80
|
+
legacy_name="swap_rate",
|
|
81
|
+
)
|
|
82
|
+
clamped_rate = max(0.0, min(effective_rate, 1.0))
|
|
83
|
+
|
|
84
|
+
if rng is None:
|
|
85
|
+
rng = random.Random(seed)
|
|
86
|
+
|
|
87
|
+
if _swap_adjacent_words_rust is not None:
|
|
88
|
+
return _swap_adjacent_words_rust(text, clamped_rate, rng)
|
|
89
|
+
|
|
90
|
+
return _python_swap_adjacent_words(text, rate=clamped_rate, rng=rng)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Adjax(Glitchling):
|
|
94
|
+
"""Glitchling that swaps adjacent words to scramble local semantics."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
rate: float | None = None,
|
|
100
|
+
swap_rate: float | None = None,
|
|
101
|
+
seed: int | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._param_aliases = {"swap_rate": "rate"}
|
|
104
|
+
effective_rate = resolve_rate(
|
|
105
|
+
rate=rate,
|
|
106
|
+
legacy_value=swap_rate,
|
|
107
|
+
default=0.5,
|
|
108
|
+
legacy_name="swap_rate",
|
|
109
|
+
)
|
|
110
|
+
super().__init__(
|
|
111
|
+
name="Adjax",
|
|
112
|
+
corruption_function=swap_adjacent_words,
|
|
113
|
+
scope=AttackWave.WORD,
|
|
114
|
+
seed=seed,
|
|
115
|
+
rate=effective_rate,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def pipeline_operation(self) -> dict[str, Any] | None:
|
|
119
|
+
rate = self.kwargs.get("rate")
|
|
120
|
+
if rate is None:
|
|
121
|
+
return None
|
|
122
|
+
return {
|
|
123
|
+
"type": "swap_adjacent",
|
|
124
|
+
"swap_rate": float(rate),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
adjax = Adjax()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = ["Adjax", "adjax", "swap_adjacent_words"]
|
glitchlings/zoo/core.py
CHANGED
|
@@ -59,18 +59,26 @@ else:
|
|
|
59
59
|
def with_transform(self, function: Any) -> "Dataset": ...
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
def _is_transcript(
|
|
63
|
-
|
|
62
|
+
def _is_transcript(
|
|
63
|
+
value: Any,
|
|
64
|
+
*,
|
|
65
|
+
allow_empty: bool = True,
|
|
66
|
+
require_all_content: bool = False,
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""Return `True` when `value` appears to be a chat transcript."""
|
|
64
69
|
|
|
65
70
|
if not isinstance(value, list):
|
|
66
71
|
return False
|
|
67
72
|
|
|
68
73
|
if not value:
|
|
69
|
-
return
|
|
74
|
+
return allow_empty
|
|
70
75
|
|
|
71
76
|
if not all(isinstance(turn, dict) for turn in value):
|
|
72
77
|
return False
|
|
73
78
|
|
|
79
|
+
if require_all_content:
|
|
80
|
+
return all("content" in turn for turn in value)
|
|
81
|
+
|
|
74
82
|
return "content" in value[-1]
|
|
75
83
|
|
|
76
84
|
|
|
@@ -233,21 +241,15 @@ class Glitchling:
|
|
|
233
241
|
message = "datasets is not installed"
|
|
234
242
|
raise ModuleNotFoundError(message) from _datasets_error
|
|
235
243
|
|
|
236
|
-
def _is_transcript(value: Any) -> bool:
|
|
237
|
-
"""Return ``True`` when the value resembles a chat transcript."""
|
|
238
|
-
|
|
239
|
-
if not isinstance(value, list) or not value:
|
|
240
|
-
return False
|
|
241
|
-
|
|
242
|
-
return all(
|
|
243
|
-
isinstance(turn, dict) and "content" in turn for turn in value
|
|
244
|
-
)
|
|
245
|
-
|
|
246
244
|
def __corrupt_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
247
245
|
row = dict(row)
|
|
248
246
|
for column in columns:
|
|
249
247
|
value = row[column]
|
|
250
|
-
if _is_transcript(
|
|
248
|
+
if _is_transcript(
|
|
249
|
+
value,
|
|
250
|
+
allow_empty=False,
|
|
251
|
+
require_all_content=True,
|
|
252
|
+
):
|
|
251
253
|
row[column] = self.corrupt(value)
|
|
252
254
|
elif isinstance(value, list):
|
|
253
255
|
row[column] = [self.corrupt(item) for item in value]
|
glitchlings/zoo/jargoyle.py
CHANGED
|
@@ -2,121 +2,47 @@ import random
|
|
|
2
2
|
import re
|
|
3
3
|
from collections.abc import Iterable
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
-
from typing import
|
|
6
|
-
|
|
7
|
-
try: # pragma: no cover - exercised in environments with NLTK installed
|
|
8
|
-
import nltk # type: ignore[import]
|
|
9
|
-
except ModuleNotFoundError as exc: # pragma: no cover - triggered when NLTK missing
|
|
10
|
-
nltk = None # type: ignore[assignment]
|
|
11
|
-
find = None # type: ignore[assignment]
|
|
12
|
-
_NLTK_IMPORT_ERROR = exc
|
|
13
|
-
else: # pragma: no cover - executed when NLTK is available
|
|
14
|
-
from nltk.corpus.reader import WordNetCorpusReader as _WordNetCorpusReader # type: ignore[import]
|
|
15
|
-
from nltk.data import find as _nltk_find # type: ignore[import]
|
|
16
|
-
|
|
17
|
-
find = _nltk_find
|
|
18
|
-
_NLTK_IMPORT_ERROR = None
|
|
19
|
-
|
|
20
|
-
if TYPE_CHECKING: # pragma: no cover - typing aid only
|
|
21
|
-
from nltk.corpus.reader import WordNetCorpusReader # type: ignore[import]
|
|
22
|
-
else: # Use ``Any`` at runtime to avoid hard dependency when NLTK missing
|
|
23
|
-
WordNetCorpusReader = Any
|
|
24
|
-
|
|
25
|
-
if nltk is not None: # pragma: no cover - guarded by import success
|
|
26
|
-
try:
|
|
27
|
-
from nltk.corpus import wordnet as _WORDNET_MODULE # type: ignore[import]
|
|
28
|
-
except ModuleNotFoundError: # pragma: no cover - only hit on namespace packages
|
|
29
|
-
_WORDNET_MODULE = None
|
|
30
|
-
else:
|
|
31
|
-
WordNetCorpusReader = _WordNetCorpusReader # type: ignore[assignment]
|
|
32
|
-
else:
|
|
33
|
-
_WORDNET_MODULE = None
|
|
5
|
+
from typing import Any, Literal, cast
|
|
34
6
|
|
|
35
|
-
from .
|
|
36
|
-
from ._rate import resolve_rate
|
|
37
|
-
|
|
38
|
-
_WORDNET_HANDLE: WordNetCorpusReader | Any | None = _WORDNET_MODULE
|
|
39
|
-
|
|
40
|
-
_wordnet_ready = False
|
|
7
|
+
from glitchlings.lexicon import Lexicon, get_default_lexicon
|
|
41
8
|
|
|
9
|
+
try: # pragma: no cover - optional WordNet dependency
|
|
10
|
+
from glitchlings.lexicon.wordnet import (
|
|
11
|
+
WordNetLexicon,
|
|
12
|
+
dependencies_available as _lexicon_dependencies_available,
|
|
13
|
+
ensure_wordnet as _lexicon_ensure_wordnet,
|
|
14
|
+
)
|
|
15
|
+
except Exception: # pragma: no cover - triggered when nltk unavailable
|
|
16
|
+
WordNetLexicon = None # type: ignore[assignment]
|
|
42
17
|
|
|
43
|
-
def
|
|
44
|
-
|
|
18
|
+
def _lexicon_dependencies_available() -> bool:
|
|
19
|
+
return False
|
|
45
20
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"The
|
|
49
|
-
"
|
|
21
|
+
def _lexicon_ensure_wordnet() -> None:
|
|
22
|
+
raise RuntimeError(
|
|
23
|
+
"The WordNet backend is no longer bundled by default. Install NLTK "
|
|
24
|
+
"and download its WordNet corpus manually if you need legacy synonyms."
|
|
50
25
|
)
|
|
51
|
-
if '_NLTK_IMPORT_ERROR' in globals() and _NLTK_IMPORT_ERROR is not None:
|
|
52
|
-
raise RuntimeError(message) from _NLTK_IMPORT_ERROR
|
|
53
|
-
raise RuntimeError(message)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def dependencies_available() -> bool:
|
|
57
|
-
"""Return ``True`` when the runtime NLTK dependency is present."""
|
|
58
|
-
|
|
59
|
-
return nltk is not None and find is not None
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _load_wordnet_reader() -> WordNetCorpusReader:
|
|
63
|
-
"""Return a WordNet corpus reader from the downloaded corpus files."""
|
|
64
26
|
|
|
65
|
-
_require_nltk()
|
|
66
|
-
|
|
67
|
-
try:
|
|
68
|
-
root = find("corpora/wordnet")
|
|
69
|
-
except LookupError:
|
|
70
|
-
try:
|
|
71
|
-
zip_root = find("corpora/wordnet.zip")
|
|
72
|
-
except LookupError as exc:
|
|
73
|
-
raise RuntimeError(
|
|
74
|
-
"The NLTK WordNet corpus is not installed; run `nltk.download('wordnet')`."
|
|
75
|
-
) from exc
|
|
76
|
-
root = zip_root.join("wordnet/")
|
|
77
|
-
|
|
78
|
-
return WordNetCorpusReader(root, None)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _wordnet(force_refresh: bool = False) -> WordNetCorpusReader | Any:
|
|
82
|
-
"""Retrieve the active WordNet handle, rebuilding it on demand."""
|
|
83
|
-
|
|
84
|
-
global _WORDNET_HANDLE
|
|
85
|
-
|
|
86
|
-
if force_refresh:
|
|
87
|
-
_WORDNET_HANDLE = _WORDNET_MODULE
|
|
88
|
-
|
|
89
|
-
if _WORDNET_HANDLE is not None:
|
|
90
|
-
return _WORDNET_HANDLE
|
|
91
|
-
|
|
92
|
-
_WORDNET_HANDLE = _load_wordnet_reader()
|
|
93
|
-
return _WORDNET_HANDLE
|
|
94
27
|
|
|
28
|
+
from ._rate import resolve_rate
|
|
29
|
+
from .core import AttackWave, Glitchling
|
|
95
30
|
|
|
96
|
-
|
|
97
|
-
"""Ensure the WordNet corpus is available before use."""
|
|
31
|
+
ensure_wordnet = _lexicon_ensure_wordnet
|
|
98
32
|
|
|
99
|
-
global _wordnet_ready
|
|
100
|
-
if _wordnet_ready:
|
|
101
|
-
return
|
|
102
33
|
|
|
103
|
-
|
|
34
|
+
def dependencies_available() -> bool:
|
|
35
|
+
"""Return ``True`` when a synonym backend is accessible."""
|
|
104
36
|
|
|
105
|
-
|
|
37
|
+
if _lexicon_dependencies_available():
|
|
38
|
+
return True
|
|
106
39
|
|
|
107
40
|
try:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
resource.ensure_loaded()
|
|
114
|
-
except LookupError as exc: # pragma: no cover - only triggered when download fails
|
|
115
|
-
raise RuntimeError(
|
|
116
|
-
"Unable to load NLTK WordNet corpus for the jargoyle glitchling."
|
|
117
|
-
) from exc
|
|
118
|
-
|
|
119
|
-
_wordnet_ready = True
|
|
41
|
+
# Fall back to the configured default lexicon (typically the bundled vector cache).
|
|
42
|
+
get_default_lexicon(seed=None)
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
120
46
|
|
|
121
47
|
|
|
122
48
|
# Backwards compatibility for callers relying on the previous private helper name.
|
|
@@ -140,7 +66,9 @@ def _split_token(token: str) -> tuple[str, str, str]:
|
|
|
140
66
|
return prefix, core, suffix
|
|
141
67
|
|
|
142
68
|
|
|
143
|
-
def _normalize_parts_of_speech(
|
|
69
|
+
def _normalize_parts_of_speech(
|
|
70
|
+
part_of_speech: PartOfSpeechInput,
|
|
71
|
+
) -> NormalizedPartsOfSpeech:
|
|
144
72
|
"""Coerce user input into a tuple of valid WordNet POS tags."""
|
|
145
73
|
|
|
146
74
|
if isinstance(part_of_speech, str):
|
|
@@ -173,41 +101,8 @@ class CandidateInfo:
|
|
|
173
101
|
prefix: str
|
|
174
102
|
core_word: str
|
|
175
103
|
suffix: str
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _collect_synonyms(
|
|
180
|
-
word: str, parts_of_speech: NormalizedPartsOfSpeech
|
|
181
|
-
) -> list[str]:
|
|
182
|
-
"""Gather deterministic synonym candidates for the supplied word."""
|
|
183
|
-
|
|
184
|
-
normalized_word = word.lower()
|
|
185
|
-
wordnet = _wordnet()
|
|
186
|
-
synonyms: set[str] = set()
|
|
187
|
-
for pos_tag in parts_of_speech:
|
|
188
|
-
synsets = wordnet.synsets(word, pos=pos_tag)
|
|
189
|
-
if not synsets:
|
|
190
|
-
continue
|
|
191
|
-
|
|
192
|
-
for synset in synsets:
|
|
193
|
-
lemmas_list = [lemma.name() for lemma in cast(Any, synset).lemmas()]
|
|
194
|
-
if not lemmas_list:
|
|
195
|
-
continue
|
|
196
|
-
|
|
197
|
-
filtered = []
|
|
198
|
-
for lemma_str in lemmas_list:
|
|
199
|
-
cleaned = lemma_str.replace("_", " ")
|
|
200
|
-
if cleaned.lower() != normalized_word:
|
|
201
|
-
filtered.append(cleaned)
|
|
202
|
-
|
|
203
|
-
if filtered:
|
|
204
|
-
synonyms.update(filtered)
|
|
205
|
-
break
|
|
206
|
-
|
|
207
|
-
if synonyms:
|
|
208
|
-
break
|
|
209
|
-
|
|
210
|
-
return sorted(synonyms)
|
|
104
|
+
part_of_speech: str | None
|
|
105
|
+
synonyms: list[str]
|
|
211
106
|
|
|
212
107
|
|
|
213
108
|
def substitute_random_synonyms(
|
|
@@ -218,22 +113,27 @@ def substitute_random_synonyms(
|
|
|
218
113
|
rng: random.Random | None = None,
|
|
219
114
|
*,
|
|
220
115
|
replacement_rate: float | None = None,
|
|
116
|
+
lexicon: Lexicon | None = None,
|
|
221
117
|
) -> str:
|
|
222
|
-
"""Replace words with random
|
|
118
|
+
"""Replace words with random lexicon-driven synonyms.
|
|
223
119
|
|
|
224
120
|
Parameters
|
|
225
121
|
- text: Input text.
|
|
226
|
-
- rate: Max proportion of candidate words to replace (default 0.
|
|
122
|
+
- rate: Max proportion of candidate words to replace (default 0.01).
|
|
227
123
|
- part_of_speech: WordNet POS tag(s) to target. Accepts "n", "v", "a", "r",
|
|
228
|
-
any iterable of those tags, or "any" to include all four.
|
|
124
|
+
any iterable of those tags, or "any" to include all four. Backends that do
|
|
125
|
+
not differentiate parts of speech simply ignore the setting.
|
|
229
126
|
- rng: Optional RNG instance used for deterministic sampling.
|
|
230
127
|
- seed: Optional seed if `rng` not provided.
|
|
128
|
+
- lexicon: Optional :class:`~glitchlings.lexicon.Lexicon` implementation to
|
|
129
|
+
supply synonyms. Defaults to the configured lexicon priority, typically the
|
|
130
|
+
packaged vector cache.
|
|
231
131
|
|
|
232
132
|
Determinism
|
|
233
133
|
- Candidates collected in left-to-right order; no set() reordering.
|
|
234
134
|
- Replacement positions chosen via rng.sample.
|
|
235
|
-
- Synonyms
|
|
236
|
-
|
|
135
|
+
- Synonyms sourced through the lexicon; the default backend derives
|
|
136
|
+
deterministic subsets per word and part-of-speech using the active seed.
|
|
237
137
|
"""
|
|
238
138
|
effective_rate = resolve_rate(
|
|
239
139
|
rate=rate,
|
|
@@ -242,68 +142,106 @@ def substitute_random_synonyms(
|
|
|
242
142
|
legacy_name="replacement_rate",
|
|
243
143
|
)
|
|
244
144
|
|
|
245
|
-
ensure_wordnet()
|
|
246
|
-
wordnet = _wordnet()
|
|
247
|
-
|
|
248
145
|
active_rng: random.Random
|
|
249
146
|
if rng is not None:
|
|
250
147
|
active_rng = rng
|
|
251
148
|
else:
|
|
252
149
|
active_rng = random.Random(seed)
|
|
253
150
|
|
|
254
|
-
|
|
151
|
+
active_lexicon: Lexicon
|
|
152
|
+
restore_lexicon_seed = False
|
|
153
|
+
original_lexicon_seed: int | None = None
|
|
255
154
|
|
|
256
|
-
|
|
257
|
-
|
|
155
|
+
if lexicon is None:
|
|
156
|
+
active_lexicon = get_default_lexicon(seed=seed)
|
|
157
|
+
else:
|
|
158
|
+
active_lexicon = lexicon
|
|
159
|
+
if seed is not None:
|
|
160
|
+
original_lexicon_seed = active_lexicon.seed
|
|
161
|
+
if original_lexicon_seed != seed:
|
|
162
|
+
active_lexicon.reseed(seed)
|
|
163
|
+
restore_lexicon_seed = True
|
|
258
164
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
165
|
+
try:
|
|
166
|
+
target_pos = _normalize_parts_of_speech(part_of_speech)
|
|
167
|
+
|
|
168
|
+
# Split but keep whitespace separators so we can rebuild easily
|
|
169
|
+
tokens = re.split(r"(\s+)", text)
|
|
170
|
+
|
|
171
|
+
# Collect indices of candidate tokens (even positions 0,2,.. are words given our split design)
|
|
172
|
+
candidate_indices: list[int] = []
|
|
173
|
+
candidate_metadata: dict[int, CandidateInfo] = {}
|
|
174
|
+
for idx, tok in enumerate(tokens):
|
|
175
|
+
if idx % 2 == 0 and tok and not tok.isspace():
|
|
176
|
+
prefix, core_word, suffix = _split_token(tok)
|
|
177
|
+
if not core_word:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
chosen_pos: str | None = None
|
|
181
|
+
synonyms: list[str] = []
|
|
182
|
+
|
|
183
|
+
for pos in target_pos:
|
|
184
|
+
if not active_lexicon.supports_pos(pos):
|
|
185
|
+
continue
|
|
186
|
+
synonyms = active_lexicon.get_synonyms(core_word, pos=pos)
|
|
187
|
+
if synonyms:
|
|
188
|
+
chosen_pos = pos
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
if not synonyms and active_lexicon.supports_pos(None):
|
|
192
|
+
synonyms = active_lexicon.get_synonyms(core_word, pos=None)
|
|
193
|
+
|
|
194
|
+
if synonyms:
|
|
195
|
+
candidate_indices.append(idx)
|
|
196
|
+
candidate_metadata[idx] = CandidateInfo(
|
|
197
|
+
prefix=prefix,
|
|
198
|
+
core_word=core_word,
|
|
199
|
+
suffix=suffix,
|
|
200
|
+
part_of_speech=chosen_pos,
|
|
201
|
+
synonyms=synonyms,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if not candidate_indices:
|
|
205
|
+
return text
|
|
206
|
+
|
|
207
|
+
clamped_rate = max(0.0, effective_rate)
|
|
208
|
+
if clamped_rate == 0.0:
|
|
209
|
+
return text
|
|
210
|
+
|
|
211
|
+
population = len(candidate_indices)
|
|
212
|
+
effective_fraction = min(clamped_rate, 1.0)
|
|
213
|
+
expected_replacements = population * effective_fraction
|
|
214
|
+
max_replacements = int(expected_replacements)
|
|
215
|
+
remainder = expected_replacements - max_replacements
|
|
216
|
+
if remainder > 0.0 and active_rng.random() < remainder:
|
|
217
|
+
max_replacements += 1
|
|
218
|
+
if clamped_rate >= 1.0:
|
|
219
|
+
max_replacements = population
|
|
220
|
+
max_replacements = min(population, max_replacements)
|
|
221
|
+
if max_replacements <= 0:
|
|
222
|
+
return text
|
|
223
|
+
|
|
224
|
+
# Choose which positions to replace deterministically via rng.sample
|
|
225
|
+
replace_positions = active_rng.sample(candidate_indices, k=max_replacements)
|
|
226
|
+
# Process in ascending order to avoid affecting later indices
|
|
227
|
+
replace_positions.sort()
|
|
228
|
+
|
|
229
|
+
for pos in replace_positions:
|
|
230
|
+
metadata = candidate_metadata[pos]
|
|
231
|
+
if not metadata.synonyms:
|
|
266
232
|
continue
|
|
267
233
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
)
|
|
271
|
-
if available_pos:
|
|
272
|
-
candidate_indices.append(idx)
|
|
273
|
-
candidate_metadata[idx] = CandidateInfo(
|
|
274
|
-
prefix=prefix,
|
|
275
|
-
core_word=core_word,
|
|
276
|
-
suffix=suffix,
|
|
277
|
-
parts_of_speech=available_pos,
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
if not candidate_indices:
|
|
281
|
-
return text
|
|
282
|
-
|
|
283
|
-
clamped_rate = max(0.0, effective_rate)
|
|
284
|
-
max_replacements = int(len(candidate_indices) * clamped_rate)
|
|
285
|
-
if max_replacements <= 0:
|
|
286
|
-
return text
|
|
287
|
-
|
|
288
|
-
# Choose which positions to replace deterministically via rng.sample
|
|
289
|
-
replace_positions = active_rng.sample(candidate_indices, k=max_replacements)
|
|
290
|
-
# Process in ascending order to avoid affecting later indices
|
|
291
|
-
replace_positions.sort()
|
|
234
|
+
replacement = active_rng.choice(metadata.synonyms)
|
|
235
|
+
tokens[pos] = f"{metadata.prefix}{replacement}{metadata.suffix}"
|
|
292
236
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
continue
|
|
298
|
-
|
|
299
|
-
replacement = active_rng.choice(synonyms)
|
|
300
|
-
tokens[pos] = f"{metadata.prefix}{replacement}{metadata.suffix}"
|
|
301
|
-
|
|
302
|
-
return "".join(tokens)
|
|
237
|
+
return "".join(tokens)
|
|
238
|
+
finally:
|
|
239
|
+
if restore_lexicon_seed:
|
|
240
|
+
active_lexicon.reseed(original_lexicon_seed)
|
|
303
241
|
|
|
304
242
|
|
|
305
243
|
class Jargoyle(Glitchling):
|
|
306
|
-
"""Glitchling that swaps words with
|
|
244
|
+
"""Glitchling that swaps words with lexicon-driven synonyms."""
|
|
307
245
|
|
|
308
246
|
def __init__(
|
|
309
247
|
self,
|
|
@@ -312,22 +250,74 @@ class Jargoyle(Glitchling):
|
|
|
312
250
|
replacement_rate: float | None = None,
|
|
313
251
|
part_of_speech: PartOfSpeechInput = "n",
|
|
314
252
|
seed: int | None = None,
|
|
253
|
+
lexicon: Lexicon | None = None,
|
|
315
254
|
) -> None:
|
|
316
255
|
self._param_aliases = {"replacement_rate": "rate"}
|
|
256
|
+
self._owns_lexicon = lexicon is None
|
|
257
|
+
self._external_lexicon_original_seed = (
|
|
258
|
+
lexicon.seed if isinstance(lexicon, Lexicon) else None
|
|
259
|
+
)
|
|
260
|
+
self._initializing = True
|
|
317
261
|
effective_rate = resolve_rate(
|
|
318
262
|
rate=rate,
|
|
319
263
|
legacy_value=replacement_rate,
|
|
320
|
-
default=0.
|
|
264
|
+
default=0.01,
|
|
321
265
|
legacy_name="replacement_rate",
|
|
322
266
|
)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
267
|
+
prepared_lexicon = lexicon or get_default_lexicon(seed=seed)
|
|
268
|
+
if lexicon is not None and seed is not None:
|
|
269
|
+
prepared_lexicon.reseed(seed)
|
|
270
|
+
try:
|
|
271
|
+
super().__init__(
|
|
272
|
+
name="Jargoyle",
|
|
273
|
+
corruption_function=substitute_random_synonyms,
|
|
274
|
+
scope=AttackWave.WORD,
|
|
275
|
+
seed=seed,
|
|
276
|
+
rate=effective_rate,
|
|
277
|
+
part_of_speech=part_of_speech,
|
|
278
|
+
lexicon=prepared_lexicon,
|
|
279
|
+
)
|
|
280
|
+
finally:
|
|
281
|
+
self._initializing = False
|
|
282
|
+
|
|
283
|
+
def set_param(self, key: str, value: Any) -> None:
|
|
284
|
+
super().set_param(key, value)
|
|
285
|
+
|
|
286
|
+
aliases = getattr(self, "_param_aliases", {})
|
|
287
|
+
canonical = aliases.get(key, key)
|
|
288
|
+
|
|
289
|
+
if canonical == "seed":
|
|
290
|
+
current_lexicon = getattr(self, "lexicon", None)
|
|
291
|
+
if isinstance(current_lexicon, Lexicon):
|
|
292
|
+
if getattr(self, "_owns_lexicon", False):
|
|
293
|
+
current_lexicon.reseed(self.seed)
|
|
294
|
+
else:
|
|
295
|
+
if self.seed is not None:
|
|
296
|
+
current_lexicon.reseed(self.seed)
|
|
297
|
+
else:
|
|
298
|
+
if hasattr(self, "_external_lexicon_original_seed"):
|
|
299
|
+
original_seed = getattr(
|
|
300
|
+
self, "_external_lexicon_original_seed", None
|
|
301
|
+
)
|
|
302
|
+
current_lexicon.reseed(original_seed)
|
|
303
|
+
elif canonical == "lexicon" and isinstance(value, Lexicon):
|
|
304
|
+
if getattr(self, "_initializing", False):
|
|
305
|
+
if getattr(self, "_owns_lexicon", False):
|
|
306
|
+
if self.seed is not None:
|
|
307
|
+
value.reseed(self.seed)
|
|
308
|
+
else:
|
|
309
|
+
if getattr(self, "_external_lexicon_original_seed", None) is None:
|
|
310
|
+
self._external_lexicon_original_seed = value.seed
|
|
311
|
+
if self.seed is not None:
|
|
312
|
+
value.reseed(self.seed)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
self._owns_lexicon = False
|
|
316
|
+
self._external_lexicon_original_seed = value.seed
|
|
317
|
+
if self.seed is not None:
|
|
318
|
+
value.reseed(self.seed)
|
|
319
|
+
elif value.seed != self._external_lexicon_original_seed:
|
|
320
|
+
value.reseed(self._external_lexicon_original_seed)
|
|
331
321
|
|
|
332
322
|
|
|
333
323
|
jargoyle = Jargoyle()
|