glitchlings 0.4.4__cp313-cp313-manylinux_2_28_x86_64.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 +67 -0
- glitchlings/__main__.py +8 -0
- glitchlings/_zoo_rust.cpython-313-x86_64-linux-gnu.so +0 -0
- glitchlings/compat.py +284 -0
- glitchlings/config.py +388 -0
- glitchlings/config.toml +3 -0
- glitchlings/dlc/__init__.py +7 -0
- glitchlings/dlc/_shared.py +153 -0
- glitchlings/dlc/huggingface.py +81 -0
- glitchlings/dlc/prime.py +254 -0
- glitchlings/dlc/pytorch.py +166 -0
- glitchlings/dlc/pytorch_lightning.py +215 -0
- glitchlings/lexicon/__init__.py +192 -0
- glitchlings/lexicon/_cache.py +110 -0
- glitchlings/lexicon/data/default_vector_cache.json +82 -0
- glitchlings/lexicon/metrics.py +162 -0
- glitchlings/lexicon/vector.py +651 -0
- glitchlings/lexicon/wordnet.py +232 -0
- glitchlings/main.py +364 -0
- glitchlings/util/__init__.py +195 -0
- glitchlings/util/adapters.py +27 -0
- glitchlings/zoo/__init__.py +168 -0
- glitchlings/zoo/_ocr_confusions.py +32 -0
- glitchlings/zoo/_rate.py +131 -0
- glitchlings/zoo/_rust_extensions.py +143 -0
- glitchlings/zoo/_sampling.py +54 -0
- glitchlings/zoo/_text_utils.py +100 -0
- glitchlings/zoo/adjax.py +128 -0
- glitchlings/zoo/apostrofae.py +127 -0
- glitchlings/zoo/assets/__init__.py +0 -0
- glitchlings/zoo/assets/apostrofae_pairs.json +32 -0
- glitchlings/zoo/core.py +582 -0
- glitchlings/zoo/jargoyle.py +335 -0
- glitchlings/zoo/mim1c.py +109 -0
- glitchlings/zoo/ocr_confusions.tsv +30 -0
- glitchlings/zoo/redactyl.py +193 -0
- glitchlings/zoo/reduple.py +148 -0
- glitchlings/zoo/rushmore.py +153 -0
- glitchlings/zoo/scannequin.py +171 -0
- glitchlings/zoo/typogre.py +231 -0
- glitchlings/zoo/zeedub.py +185 -0
- glitchlings-0.4.4.dist-info/METADATA +627 -0
- glitchlings-0.4.4.dist-info/RECORD +47 -0
- glitchlings-0.4.4.dist-info/WHEEL +5 -0
- glitchlings-0.4.4.dist-info/entry_points.txt +2 -0
- glitchlings-0.4.4.dist-info/licenses/LICENSE +201 -0
- glitchlings-0.4.4.dist-info/top_level.txt +1 -0
glitchlings/zoo/core.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""Core data structures used to model glitchlings and their interactions."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from enum import IntEnum, auto
|
|
9
|
+
from hashlib import blake2s
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, Protocol, TypedDict, TypeGuard, Union, cast
|
|
11
|
+
|
|
12
|
+
from ..compat import get_datasets_dataset, require_datasets
|
|
13
|
+
from ._rust_extensions import get_rust_operation
|
|
14
|
+
|
|
15
|
+
_DatasetsDataset = get_datasets_dataset()
|
|
16
|
+
|
|
17
|
+
# Load Rust-accelerated orchestration operations if available
|
|
18
|
+
_compose_glitchlings_rust = get_rust_operation("compose_glitchlings")
|
|
19
|
+
_plan_glitchlings_rust = get_rust_operation("plan_glitchlings")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_PIPELINE_FEATURE_FLAG_ENV = "GLITCHLINGS_RUST_PIPELINE"
|
|
26
|
+
_PIPELINE_ENABLE_VALUES = {"1", "true", "yes", "on"}
|
|
27
|
+
_PIPELINE_DISABLE_VALUES = {"0", "false", "no", "off"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlanSpecification(TypedDict):
|
|
31
|
+
name: str
|
|
32
|
+
scope: int
|
|
33
|
+
order: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
TranscriptTurn = dict[str, Any]
|
|
37
|
+
Transcript = list[TranscriptTurn]
|
|
38
|
+
|
|
39
|
+
PlanEntry = Union["Glitchling", Mapping[str, Any]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pipeline_feature_flag_enabled() -> bool:
|
|
43
|
+
"""Return ``True`` when the environment does not explicitly disable the Rust pipeline."""
|
|
44
|
+
value = os.environ.get(_PIPELINE_FEATURE_FLAG_ENV)
|
|
45
|
+
if value is None:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
normalized = value.strip().lower()
|
|
49
|
+
if normalized in _PIPELINE_DISABLE_VALUES:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
if normalized in _PIPELINE_ENABLE_VALUES:
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pipeline_feature_flag_enabled() -> bool:
|
|
59
|
+
"""Compatibility shim for legacy callers."""
|
|
60
|
+
return pipeline_feature_flag_enabled()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_rust_pipeline_supported() -> bool:
|
|
64
|
+
"""Return ``True`` when the optional Rust extension is importable."""
|
|
65
|
+
return _compose_glitchlings_rust is not None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_rust_pipeline_enabled() -> bool:
|
|
69
|
+
"""Return ``True`` when the Rust pipeline is available and not explicitly disabled."""
|
|
70
|
+
return is_rust_pipeline_supported() and pipeline_feature_flag_enabled()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _spec_from_glitchling(glitchling: "Glitchling") -> PlanSpecification:
|
|
74
|
+
"""Create a plan specification mapping from a glitchling instance."""
|
|
75
|
+
return {
|
|
76
|
+
"name": glitchling.name,
|
|
77
|
+
"scope": int(glitchling.level),
|
|
78
|
+
"order": int(glitchling.order),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _normalize_plan_entry(entry: PlanEntry) -> PlanSpecification:
|
|
83
|
+
"""Convert a plan entry (glitchling or mapping) into a normalized specification."""
|
|
84
|
+
if isinstance(entry, Glitchling):
|
|
85
|
+
return _spec_from_glitchling(entry)
|
|
86
|
+
|
|
87
|
+
if not isinstance(entry, Mapping):
|
|
88
|
+
message = "plan_glitchlings expects Glitchling instances or mapping specifications"
|
|
89
|
+
raise TypeError(message)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
name = str(entry["name"])
|
|
93
|
+
scope_value = int(entry["scope"])
|
|
94
|
+
order_value = int(entry["order"])
|
|
95
|
+
except KeyError as exc: # pragma: no cover - defensive guard
|
|
96
|
+
raise ValueError(f"Plan specification missing required field: {exc.args[0]}") from exc
|
|
97
|
+
except (TypeError, ValueError) as exc:
|
|
98
|
+
raise ValueError("Plan specification fields must be coercible to integers") from exc
|
|
99
|
+
|
|
100
|
+
return {"name": name, "scope": scope_value, "order": order_value}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _normalize_plan_entries(entries: Sequence[PlanEntry]) -> list[PlanSpecification]:
|
|
104
|
+
"""Normalize a collection of orchestration plan entries."""
|
|
105
|
+
return [_normalize_plan_entry(entry) for entry in entries]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _plan_glitchlings_python(
|
|
109
|
+
specs: Sequence[Mapping[str, Any]],
|
|
110
|
+
master_seed: int,
|
|
111
|
+
) -> list[tuple[int, int]]:
|
|
112
|
+
"""Pure-Python fallback for orchestrating glitchlings in deterministic order."""
|
|
113
|
+
master_seed_int = int(master_seed)
|
|
114
|
+
planned: list[tuple[int, int, int, int, str]] = []
|
|
115
|
+
for index, spec in enumerate(specs):
|
|
116
|
+
name = str(spec["name"])
|
|
117
|
+
scope = int(spec["scope"])
|
|
118
|
+
order = int(spec["order"])
|
|
119
|
+
derived_seed = Gaggle.derive_seed(master_seed_int, name, index)
|
|
120
|
+
planned.append((index, derived_seed, scope, order, name))
|
|
121
|
+
|
|
122
|
+
planned.sort(key=lambda entry: (entry[2], entry[3], entry[4], entry[0]))
|
|
123
|
+
return [(index, seed) for index, seed, *_ in planned]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _plan_glitchlings_with_rust(
|
|
127
|
+
specs: Sequence[Mapping[str, Any]],
|
|
128
|
+
master_seed: int,
|
|
129
|
+
) -> list[tuple[int, int]] | None:
|
|
130
|
+
"""Attempt to obtain the orchestration plan from the compiled Rust module."""
|
|
131
|
+
if _plan_glitchlings_rust is None:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
plan = _plan_glitchlings_rust(specs, int(master_seed))
|
|
136
|
+
except (
|
|
137
|
+
TypeError,
|
|
138
|
+
ValueError,
|
|
139
|
+
RuntimeError,
|
|
140
|
+
AttributeError,
|
|
141
|
+
): # pragma: no cover - defer to Python fallback on failure
|
|
142
|
+
log.debug("Rust orchestration planning failed; falling back to Python plan", exc_info=True)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
return [(int(index), int(seed)) for index, seed in plan]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _resolve_orchestration_plan(
|
|
149
|
+
specs: Sequence[PlanSpecification],
|
|
150
|
+
master_seed: int,
|
|
151
|
+
prefer_rust: bool,
|
|
152
|
+
) -> list[tuple[int, int]]:
|
|
153
|
+
"""Dispatch to the Rust planner when available, otherwise fall back to Python."""
|
|
154
|
+
if prefer_rust:
|
|
155
|
+
plan = _plan_glitchlings_with_rust(list(specs), master_seed)
|
|
156
|
+
if plan is not None:
|
|
157
|
+
return plan
|
|
158
|
+
|
|
159
|
+
return _plan_glitchlings_python(list(specs), master_seed)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def plan_glitchling_specs(
|
|
163
|
+
specs: Sequence[Mapping[str, Any]],
|
|
164
|
+
master_seed: int | None,
|
|
165
|
+
*,
|
|
166
|
+
prefer_rust: bool = True,
|
|
167
|
+
) -> list[tuple[int, int]]:
|
|
168
|
+
"""Resolve orchestration order and seeds from glitchling specifications."""
|
|
169
|
+
if master_seed is None:
|
|
170
|
+
message = "Gaggle orchestration requires a master seed"
|
|
171
|
+
raise ValueError(message)
|
|
172
|
+
|
|
173
|
+
normalized_specs = [_normalize_plan_entry(spec) for spec in specs]
|
|
174
|
+
master_seed_int = int(master_seed)
|
|
175
|
+
return _resolve_orchestration_plan(normalized_specs, master_seed_int, prefer_rust)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def plan_glitchlings(
|
|
179
|
+
entries: Sequence[PlanEntry],
|
|
180
|
+
master_seed: int | None,
|
|
181
|
+
*,
|
|
182
|
+
prefer_rust: bool = True,
|
|
183
|
+
) -> list[tuple[int, int]]:
|
|
184
|
+
"""Normalize glitchling instances or specs and compute an orchestration plan."""
|
|
185
|
+
if master_seed is None:
|
|
186
|
+
message = "Gaggle orchestration requires a master seed"
|
|
187
|
+
raise ValueError(message)
|
|
188
|
+
|
|
189
|
+
normalized_specs = _normalize_plan_entries(entries)
|
|
190
|
+
master_seed_int = int(master_seed)
|
|
191
|
+
return _resolve_orchestration_plan(normalized_specs, master_seed_int, prefer_rust)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
195
|
+
from datasets import Dataset
|
|
196
|
+
elif _DatasetsDataset is not None:
|
|
197
|
+
Dataset = _DatasetsDataset
|
|
198
|
+
else:
|
|
199
|
+
|
|
200
|
+
class Dataset(Protocol): # type: ignore[no-redef]
|
|
201
|
+
"""Typed stub mirroring the Hugging Face dataset interface used here."""
|
|
202
|
+
|
|
203
|
+
def with_transform(self, function: Any) -> "Dataset": ...
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_transcript(
|
|
207
|
+
value: Any,
|
|
208
|
+
*,
|
|
209
|
+
allow_empty: bool = True,
|
|
210
|
+
require_all_content: bool = False,
|
|
211
|
+
) -> TypeGuard[Transcript]:
|
|
212
|
+
"""Return ``True`` when ``value`` appears to be a chat transcript."""
|
|
213
|
+
if not isinstance(value, list):
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
if not value:
|
|
217
|
+
return allow_empty
|
|
218
|
+
|
|
219
|
+
if not all(isinstance(turn, dict) for turn in value):
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
if require_all_content:
|
|
223
|
+
return all("content" in turn for turn in value)
|
|
224
|
+
|
|
225
|
+
return "content" in value[-1]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class CorruptionCallable(Protocol):
|
|
229
|
+
"""Protocol describing a callable capable of corrupting text."""
|
|
230
|
+
|
|
231
|
+
def __call__(self, text: str, *args: Any, **kwargs: Any) -> str: ...
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# Text levels for glitchlings, to enforce a sort order
|
|
235
|
+
# Work from highest level down, because e.g.
|
|
236
|
+
# duplicating a word then adding a typo is potentially different than
|
|
237
|
+
# adding a typo then duplicating a word
|
|
238
|
+
class AttackWave(IntEnum):
|
|
239
|
+
"""Granularity of text that a glitchling corrupts."""
|
|
240
|
+
|
|
241
|
+
DOCUMENT = auto()
|
|
242
|
+
PARAGRAPH = auto()
|
|
243
|
+
SENTENCE = auto()
|
|
244
|
+
WORD = auto()
|
|
245
|
+
CHARACTER = auto()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Modifier for within the same attack wave
|
|
249
|
+
class AttackOrder(IntEnum):
|
|
250
|
+
"""Relative execution order for glitchlings within the same wave."""
|
|
251
|
+
|
|
252
|
+
FIRST = auto()
|
|
253
|
+
EARLY = auto()
|
|
254
|
+
NORMAL = auto()
|
|
255
|
+
LATE = auto()
|
|
256
|
+
LAST = auto()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Glitchling:
|
|
260
|
+
"""A single text corruption agent with deterministic behaviour."""
|
|
261
|
+
|
|
262
|
+
def __init__(
|
|
263
|
+
self,
|
|
264
|
+
name: str,
|
|
265
|
+
corruption_function: CorruptionCallable,
|
|
266
|
+
scope: AttackWave,
|
|
267
|
+
order: AttackOrder = AttackOrder.NORMAL,
|
|
268
|
+
seed: int | None = None,
|
|
269
|
+
pipeline_operation: Callable[["Glitchling"], dict[str, Any] | None] | None = None,
|
|
270
|
+
**kwargs: Any,
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Initialize a glitchling.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
name: Human readable glitchling name.
|
|
276
|
+
corruption_function: Callable used to transform text.
|
|
277
|
+
scope: Text granularity on which the glitchling operates.
|
|
278
|
+
order: Relative ordering within the same scope.
|
|
279
|
+
seed: Optional seed for deterministic random behaviour.
|
|
280
|
+
**kwargs: Additional parameters forwarded to the corruption callable.
|
|
281
|
+
|
|
282
|
+
"""
|
|
283
|
+
# Each Glitchling maintains its own RNG for deterministic yet isolated behavior.
|
|
284
|
+
# If no seed is supplied, we fall back to Python's default entropy.
|
|
285
|
+
self.seed = seed
|
|
286
|
+
self.rng: random.Random = random.Random(seed)
|
|
287
|
+
self.name: str = name
|
|
288
|
+
self.corruption_function: CorruptionCallable = corruption_function
|
|
289
|
+
self.level: AttackWave = scope
|
|
290
|
+
self.order: AttackOrder = order
|
|
291
|
+
self._pipeline_descriptor_factory = pipeline_operation
|
|
292
|
+
self.kwargs: dict[str, Any] = {}
|
|
293
|
+
self._cached_rng_callable: CorruptionCallable | None = None
|
|
294
|
+
self._cached_rng_expectation: bool | None = None
|
|
295
|
+
for kw, val in kwargs.items():
|
|
296
|
+
self.set_param(kw, val)
|
|
297
|
+
|
|
298
|
+
def set_param(self, key: str, value: Any) -> None:
|
|
299
|
+
"""Persist a parameter for use by the corruption callable."""
|
|
300
|
+
aliases = getattr(self, "_param_aliases", {})
|
|
301
|
+
canonical = aliases.get(key, key)
|
|
302
|
+
|
|
303
|
+
# Drop stale alias keys so we only forward canonical kwargs.
|
|
304
|
+
self.kwargs.pop(key, None)
|
|
305
|
+
for alias, target in aliases.items():
|
|
306
|
+
if target == canonical:
|
|
307
|
+
self.kwargs.pop(alias, None)
|
|
308
|
+
|
|
309
|
+
self.kwargs[canonical] = value
|
|
310
|
+
setattr(self, canonical, value)
|
|
311
|
+
|
|
312
|
+
if canonical == "seed":
|
|
313
|
+
self.reset_rng(value)
|
|
314
|
+
|
|
315
|
+
for alias, target in aliases.items():
|
|
316
|
+
if target == canonical:
|
|
317
|
+
setattr(self, alias, value)
|
|
318
|
+
|
|
319
|
+
def pipeline_operation(self) -> dict[str, Any] | None:
|
|
320
|
+
"""Return the Rust pipeline operation descriptor for this glitchling."""
|
|
321
|
+
factory = self._pipeline_descriptor_factory
|
|
322
|
+
if factory is None:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
return factory(self)
|
|
326
|
+
|
|
327
|
+
def _corruption_expects_rng(self) -> bool:
|
|
328
|
+
"""Return `True` when the corruption function accepts an rng keyword."""
|
|
329
|
+
cached_callable = self._cached_rng_callable
|
|
330
|
+
cached_expectation = self._cached_rng_expectation
|
|
331
|
+
corruption_function = self.corruption_function
|
|
332
|
+
|
|
333
|
+
if cached_callable is corruption_function and cached_expectation is not None:
|
|
334
|
+
return cached_expectation
|
|
335
|
+
|
|
336
|
+
expects_rng = False
|
|
337
|
+
try:
|
|
338
|
+
signature = inspect.signature(corruption_function)
|
|
339
|
+
except (TypeError, ValueError):
|
|
340
|
+
signature = None
|
|
341
|
+
|
|
342
|
+
if signature is not None:
|
|
343
|
+
expects_rng = "rng" in signature.parameters
|
|
344
|
+
|
|
345
|
+
self._cached_rng_callable = corruption_function
|
|
346
|
+
self._cached_rng_expectation = expects_rng
|
|
347
|
+
return expects_rng
|
|
348
|
+
|
|
349
|
+
def __corrupt(self, text: str, *args: Any, **kwargs: Any) -> str:
|
|
350
|
+
"""Execute the corruption callable, injecting the RNG when required."""
|
|
351
|
+
# Pass rng to underlying corruption function if it expects it.
|
|
352
|
+
expects_rng = self._corruption_expects_rng()
|
|
353
|
+
|
|
354
|
+
if expects_rng:
|
|
355
|
+
corrupted = self.corruption_function(text, *args, rng=self.rng, **kwargs)
|
|
356
|
+
else:
|
|
357
|
+
corrupted = self.corruption_function(text, *args, **kwargs)
|
|
358
|
+
return corrupted
|
|
359
|
+
|
|
360
|
+
def corrupt(self, text: str | Transcript) -> str | Transcript:
|
|
361
|
+
"""Apply the corruption function to text or conversational transcripts."""
|
|
362
|
+
if _is_transcript(text):
|
|
363
|
+
transcript: Transcript = [dict(turn) for turn in text]
|
|
364
|
+
if transcript:
|
|
365
|
+
content = transcript[-1].get("content")
|
|
366
|
+
if isinstance(content, str):
|
|
367
|
+
transcript[-1]["content"] = self.__corrupt(content, **self.kwargs)
|
|
368
|
+
return transcript
|
|
369
|
+
|
|
370
|
+
return self.__corrupt(cast(str, text), **self.kwargs)
|
|
371
|
+
|
|
372
|
+
def corrupt_dataset(self, dataset: Dataset, columns: list[str]) -> Dataset:
|
|
373
|
+
"""Apply corruption lazily across dataset columns."""
|
|
374
|
+
require_datasets("datasets is not installed")
|
|
375
|
+
|
|
376
|
+
def __corrupt_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
377
|
+
row = dict(row)
|
|
378
|
+
for column in columns:
|
|
379
|
+
value = row[column]
|
|
380
|
+
if _is_transcript(
|
|
381
|
+
value,
|
|
382
|
+
allow_empty=False,
|
|
383
|
+
require_all_content=True,
|
|
384
|
+
):
|
|
385
|
+
row[column] = self.corrupt(value)
|
|
386
|
+
elif isinstance(value, list):
|
|
387
|
+
row[column] = [self.corrupt(item) for item in value]
|
|
388
|
+
else:
|
|
389
|
+
row[column] = self.corrupt(value)
|
|
390
|
+
return row
|
|
391
|
+
|
|
392
|
+
return dataset.with_transform(__corrupt_row)
|
|
393
|
+
|
|
394
|
+
def __call__(self, text: str, *args: Any, **kwds: Any) -> str | Transcript:
|
|
395
|
+
"""Allow a glitchling to be invoked directly like a callable."""
|
|
396
|
+
return self.corrupt(text, *args, **kwds)
|
|
397
|
+
|
|
398
|
+
def reset_rng(self, seed: int | None = None) -> None:
|
|
399
|
+
"""Reset the glitchling's RNG to its initial seed."""
|
|
400
|
+
if seed is not None:
|
|
401
|
+
self.seed = seed
|
|
402
|
+
if self.seed is not None:
|
|
403
|
+
self.rng = random.Random(self.seed)
|
|
404
|
+
|
|
405
|
+
def clone(self, seed: int | None = None) -> "Glitchling":
|
|
406
|
+
"""Create a copy of this glitchling, optionally with a new seed."""
|
|
407
|
+
cls = self.__class__
|
|
408
|
+
filtered_kwargs = {k: v for k, v in self.kwargs.items() if k != "seed"}
|
|
409
|
+
clone_seed = seed if seed is not None else self.seed
|
|
410
|
+
if clone_seed is not None:
|
|
411
|
+
filtered_kwargs["seed"] = clone_seed
|
|
412
|
+
|
|
413
|
+
if cls is Glitchling:
|
|
414
|
+
return Glitchling(
|
|
415
|
+
self.name,
|
|
416
|
+
self.corruption_function,
|
|
417
|
+
self.level,
|
|
418
|
+
self.order,
|
|
419
|
+
pipeline_operation=self._pipeline_descriptor_factory,
|
|
420
|
+
**filtered_kwargs,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
return cls(**filtered_kwargs)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class Gaggle(Glitchling):
|
|
427
|
+
"""A collection of glitchlings executed in a deterministic order."""
|
|
428
|
+
|
|
429
|
+
def __init__(self, glitchlings: list[Glitchling], seed: int = 151):
|
|
430
|
+
"""Initialize the gaggle and derive per-glitchling RNG seeds.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
glitchlings: Glitchlings to orchestrate.
|
|
434
|
+
seed: Master seed used to derive per-glitchling seeds.
|
|
435
|
+
|
|
436
|
+
"""
|
|
437
|
+
super().__init__("Gaggle", self._corrupt_text, AttackWave.DOCUMENT, seed=seed)
|
|
438
|
+
self._clones_by_index: list[Glitchling] = []
|
|
439
|
+
for idx, glitchling in enumerate(glitchlings):
|
|
440
|
+
clone = glitchling.clone()
|
|
441
|
+
setattr(clone, "_gaggle_index", idx)
|
|
442
|
+
self._clones_by_index.append(clone)
|
|
443
|
+
|
|
444
|
+
self.glitchlings: dict[AttackWave, list[Glitchling]] = {level: [] for level in AttackWave}
|
|
445
|
+
self.apply_order: list[Glitchling] = []
|
|
446
|
+
self._plan: list[tuple[int, int]] = []
|
|
447
|
+
self.sort_glitchlings()
|
|
448
|
+
|
|
449
|
+
@staticmethod
|
|
450
|
+
def derive_seed(master_seed: int, glitchling_name: str, index: int) -> int:
|
|
451
|
+
"""Derive a deterministic seed for a glitchling based on the master seed."""
|
|
452
|
+
|
|
453
|
+
def _int_to_bytes(value: int) -> bytes:
|
|
454
|
+
if value == 0:
|
|
455
|
+
return b"\x00"
|
|
456
|
+
|
|
457
|
+
abs_value = abs(value)
|
|
458
|
+
length = max(1, (abs_value.bit_length() + 7) // 8)
|
|
459
|
+
|
|
460
|
+
if value < 0:
|
|
461
|
+
while True:
|
|
462
|
+
try:
|
|
463
|
+
return value.to_bytes(length, "big", signed=True)
|
|
464
|
+
except OverflowError:
|
|
465
|
+
length += 1
|
|
466
|
+
|
|
467
|
+
return abs_value.to_bytes(length, "big", signed=False)
|
|
468
|
+
|
|
469
|
+
hasher = blake2s(digest_size=8)
|
|
470
|
+
hasher.update(_int_to_bytes(master_seed))
|
|
471
|
+
hasher.update(b"\x00")
|
|
472
|
+
hasher.update(glitchling_name.encode("utf-8"))
|
|
473
|
+
hasher.update(b"\x00")
|
|
474
|
+
hasher.update(_int_to_bytes(index))
|
|
475
|
+
return int.from_bytes(hasher.digest(), "big")
|
|
476
|
+
|
|
477
|
+
def sort_glitchlings(self) -> None:
|
|
478
|
+
"""Sort glitchlings by wave then order to produce application order."""
|
|
479
|
+
plan = plan_glitchlings(self._clones_by_index, self.seed)
|
|
480
|
+
self._plan = plan
|
|
481
|
+
|
|
482
|
+
self.glitchlings = {level: [] for level in AttackWave}
|
|
483
|
+
for clone in self._clones_by_index:
|
|
484
|
+
self.glitchlings[clone.level].append(clone)
|
|
485
|
+
|
|
486
|
+
missing = set(range(len(self._clones_by_index)))
|
|
487
|
+
apply_order: list[Glitchling] = []
|
|
488
|
+
for index, derived_seed in plan:
|
|
489
|
+
clone = self._clones_by_index[index]
|
|
490
|
+
clone.reset_rng(int(derived_seed))
|
|
491
|
+
apply_order.append(clone)
|
|
492
|
+
missing.discard(index)
|
|
493
|
+
|
|
494
|
+
if missing:
|
|
495
|
+
missing_indices = ", ".join(str(idx) for idx in sorted(missing))
|
|
496
|
+
message = f"Orchestration plan missing glitchlings at indices: {missing_indices}"
|
|
497
|
+
raise RuntimeError(message)
|
|
498
|
+
|
|
499
|
+
self.apply_order = apply_order
|
|
500
|
+
|
|
501
|
+
@staticmethod
|
|
502
|
+
def rust_pipeline_supported() -> bool:
|
|
503
|
+
"""Return ``True`` when the compiled Rust pipeline is importable."""
|
|
504
|
+
return is_rust_pipeline_supported()
|
|
505
|
+
|
|
506
|
+
@staticmethod
|
|
507
|
+
def rust_pipeline_enabled() -> bool:
|
|
508
|
+
"""Return ``True`` when the Rust pipeline is available and not explicitly disabled."""
|
|
509
|
+
return is_rust_pipeline_enabled()
|
|
510
|
+
|
|
511
|
+
def _pipeline_descriptors(self) -> list[dict[str, Any]] | None:
|
|
512
|
+
if not self.rust_pipeline_enabled():
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
descriptors: list[dict[str, Any]] = []
|
|
516
|
+
for glitchling in self.apply_order:
|
|
517
|
+
operation = glitchling.pipeline_operation()
|
|
518
|
+
if operation is None:
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
seed = glitchling.seed
|
|
522
|
+
if seed is None:
|
|
523
|
+
index = getattr(glitchling, "_gaggle_index", None)
|
|
524
|
+
master_seed = self.seed
|
|
525
|
+
if index is None or master_seed is None:
|
|
526
|
+
return None
|
|
527
|
+
seed = Gaggle.derive_seed(master_seed, glitchling.name, index)
|
|
528
|
+
|
|
529
|
+
descriptors.append(
|
|
530
|
+
{
|
|
531
|
+
"name": glitchling.name,
|
|
532
|
+
"operation": operation,
|
|
533
|
+
"seed": int(seed),
|
|
534
|
+
}
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
return descriptors
|
|
538
|
+
|
|
539
|
+
def _corrupt_text(self, text: str) -> str:
|
|
540
|
+
"""Apply each glitchling to string input sequentially."""
|
|
541
|
+
master_seed = self.seed
|
|
542
|
+
descriptors = self._pipeline_descriptors()
|
|
543
|
+
if (
|
|
544
|
+
master_seed is not None
|
|
545
|
+
and descriptors is not None
|
|
546
|
+
and _compose_glitchlings_rust is not None
|
|
547
|
+
):
|
|
548
|
+
try:
|
|
549
|
+
return cast(str, _compose_glitchlings_rust(text, descriptors, master_seed))
|
|
550
|
+
except (
|
|
551
|
+
TypeError,
|
|
552
|
+
ValueError,
|
|
553
|
+
RuntimeError,
|
|
554
|
+
AttributeError,
|
|
555
|
+
): # pragma: no cover - fall back to Python execution
|
|
556
|
+
log.debug("Rust pipeline failed; falling back", exc_info=True)
|
|
557
|
+
|
|
558
|
+
corrupted = text
|
|
559
|
+
for glitchling in self.apply_order:
|
|
560
|
+
next_value = glitchling.corrupt(corrupted)
|
|
561
|
+
if not isinstance(next_value, str):
|
|
562
|
+
message = "Glitchling pipeline produced non-string output for string input"
|
|
563
|
+
raise TypeError(message)
|
|
564
|
+
corrupted = next_value
|
|
565
|
+
|
|
566
|
+
return corrupted
|
|
567
|
+
|
|
568
|
+
def corrupt(self, text: str | Transcript) -> str | Transcript:
|
|
569
|
+
"""Apply each glitchling to the provided text sequentially."""
|
|
570
|
+
if isinstance(text, str):
|
|
571
|
+
return self._corrupt_text(text)
|
|
572
|
+
|
|
573
|
+
if _is_transcript(text):
|
|
574
|
+
transcript: Transcript = [dict(turn) for turn in text]
|
|
575
|
+
if transcript and "content" in transcript[-1]:
|
|
576
|
+
content = transcript[-1]["content"]
|
|
577
|
+
if isinstance(content, str):
|
|
578
|
+
transcript[-1]["content"] = self._corrupt_text(content)
|
|
579
|
+
return transcript
|
|
580
|
+
|
|
581
|
+
message = f"Unsupported text type for Gaggle corruption: {type(text)!r}"
|
|
582
|
+
raise TypeError(message)
|