glitchlings 0.10.2__cp312-cp312-macosx_11_0_universal2.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.

Files changed (83) hide show
  1. glitchlings/__init__.py +99 -0
  2. glitchlings/__main__.py +8 -0
  3. glitchlings/_zoo_rust/__init__.py +12 -0
  4. glitchlings/_zoo_rust.cpython-312-darwin.so +0 -0
  5. glitchlings/assets/__init__.py +180 -0
  6. glitchlings/assets/apostrofae_pairs.json +32 -0
  7. glitchlings/assets/ekkokin_homophones.json +2014 -0
  8. glitchlings/assets/hokey_assets.json +193 -0
  9. glitchlings/assets/lexemes/academic.json +1049 -0
  10. glitchlings/assets/lexemes/colors.json +1333 -0
  11. glitchlings/assets/lexemes/corporate.json +716 -0
  12. glitchlings/assets/lexemes/cyberpunk.json +22 -0
  13. glitchlings/assets/lexemes/lovecraftian.json +23 -0
  14. glitchlings/assets/lexemes/synonyms.json +3354 -0
  15. glitchlings/assets/mim1c_homoglyphs.json.gz.b64 +1064 -0
  16. glitchlings/assets/ocr_confusions.tsv +30 -0
  17. glitchlings/assets/pipeline_assets.json +29 -0
  18. glitchlings/attack/__init__.py +147 -0
  19. glitchlings/attack/analysis.py +1321 -0
  20. glitchlings/attack/core.py +493 -0
  21. glitchlings/attack/core_execution.py +367 -0
  22. glitchlings/attack/core_planning.py +612 -0
  23. glitchlings/attack/encode.py +114 -0
  24. glitchlings/attack/metrics.py +218 -0
  25. glitchlings/attack/metrics_dispatch.py +70 -0
  26. glitchlings/attack/tokenization.py +227 -0
  27. glitchlings/auggie.py +284 -0
  28. glitchlings/compat/__init__.py +9 -0
  29. glitchlings/compat/loaders.py +355 -0
  30. glitchlings/compat/types.py +41 -0
  31. glitchlings/conf/__init__.py +41 -0
  32. glitchlings/conf/loaders.py +331 -0
  33. glitchlings/conf/schema.py +156 -0
  34. glitchlings/conf/types.py +72 -0
  35. glitchlings/config.toml +2 -0
  36. glitchlings/constants.py +59 -0
  37. glitchlings/dev/__init__.py +3 -0
  38. glitchlings/dev/docs.py +45 -0
  39. glitchlings/dlc/__init__.py +19 -0
  40. glitchlings/dlc/_shared.py +296 -0
  41. glitchlings/dlc/gutenberg.py +400 -0
  42. glitchlings/dlc/huggingface.py +68 -0
  43. glitchlings/dlc/prime.py +215 -0
  44. glitchlings/dlc/pytorch.py +98 -0
  45. glitchlings/dlc/pytorch_lightning.py +173 -0
  46. glitchlings/internal/__init__.py +16 -0
  47. glitchlings/internal/rust.py +159 -0
  48. glitchlings/internal/rust_ffi.py +490 -0
  49. glitchlings/main.py +426 -0
  50. glitchlings/protocols.py +91 -0
  51. glitchlings/runtime_config.py +24 -0
  52. glitchlings/util/__init__.py +27 -0
  53. glitchlings/util/adapters.py +65 -0
  54. glitchlings/util/keyboards.py +356 -0
  55. glitchlings/util/transcripts.py +108 -0
  56. glitchlings/zoo/__init__.py +161 -0
  57. glitchlings/zoo/assets/__init__.py +29 -0
  58. glitchlings/zoo/core.py +678 -0
  59. glitchlings/zoo/core_execution.py +154 -0
  60. glitchlings/zoo/core_planning.py +451 -0
  61. glitchlings/zoo/corrupt_dispatch.py +295 -0
  62. glitchlings/zoo/hokey.py +139 -0
  63. glitchlings/zoo/jargoyle.py +243 -0
  64. glitchlings/zoo/mim1c.py +148 -0
  65. glitchlings/zoo/pedant/__init__.py +109 -0
  66. glitchlings/zoo/pedant/core.py +105 -0
  67. glitchlings/zoo/pedant/forms.py +74 -0
  68. glitchlings/zoo/pedant/stones.py +74 -0
  69. glitchlings/zoo/redactyl.py +97 -0
  70. glitchlings/zoo/rng.py +259 -0
  71. glitchlings/zoo/rushmore.py +416 -0
  72. glitchlings/zoo/scannequin.py +66 -0
  73. glitchlings/zoo/transforms.py +346 -0
  74. glitchlings/zoo/typogre.py +128 -0
  75. glitchlings/zoo/validation.py +477 -0
  76. glitchlings/zoo/wherewolf.py +120 -0
  77. glitchlings/zoo/zeedub.py +93 -0
  78. glitchlings-0.10.2.dist-info/METADATA +337 -0
  79. glitchlings-0.10.2.dist-info/RECORD +83 -0
  80. glitchlings-0.10.2.dist-info/WHEEL +5 -0
  81. glitchlings-0.10.2.dist-info/entry_points.txt +3 -0
  82. glitchlings-0.10.2.dist-info/licenses/LICENSE +201 -0
  83. glitchlings-0.10.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,493 @@
1
+ """Attack orchestrator for measuring corruption impact.
2
+
3
+ This module provides the Attack class, a boundary layer that coordinates
4
+ glitchling corruption and metric computation. It follows the functional
5
+ purity architecture:
6
+
7
+ - **Pure planning**: Input analysis and result planning (core_planning.py)
8
+ - **Impure execution**: Corruption, tokenization, metrics (core_execution.py)
9
+ - **Boundary layer**: This module - validates inputs and delegates
10
+
11
+ See AGENTS.md "Functional Purity Architecture" for full details.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import inspect
17
+ from collections.abc import Callable, Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING, cast
20
+
21
+ if TYPE_CHECKING:
22
+ pass # For forward references in type hints
23
+
24
+ from ..conf import DEFAULT_ATTACK_SEED
25
+ from ..protocols import Corruptor
26
+ from ..util.transcripts import Transcript, TranscriptTarget
27
+ from .core_execution import (
28
+ execute_attack,
29
+ get_default_metrics,
30
+ resolve_glitchlings,
31
+ )
32
+ from .core_planning import (
33
+ plan_attack,
34
+ plan_result,
35
+ )
36
+ from .encode import describe_tokenizer
37
+ from .metrics import Metric
38
+ from .tokenization import Tokenizer, resolve_tokenizer
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Result Data Classes
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ @dataclass
46
+ class AttackResult:
47
+ """Result of an attack operation containing tokens and metrics.
48
+
49
+ Attributes:
50
+ original: Original input (string, transcript, or batch).
51
+ corrupted: Corrupted output (same type as original).
52
+ input_tokens: Tokenized original content.
53
+ output_tokens: Tokenized corrupted content.
54
+ input_token_ids: Token IDs for original.
55
+ output_token_ids: Token IDs for corrupted.
56
+ tokenizer_info: Description of the tokenizer used.
57
+ metrics: Computed metric values.
58
+ """
59
+
60
+ original: str | Transcript | Sequence[str]
61
+ corrupted: str | Transcript | Sequence[str]
62
+ input_tokens: list[str] | list[list[str]]
63
+ output_tokens: list[str] | list[list[str]]
64
+ input_token_ids: list[int] | list[list[int]]
65
+ output_token_ids: list[int] | list[list[int]]
66
+ tokenizer_info: str
67
+ metrics: dict[str, float | list[float]]
68
+
69
+ def _tokens_are_batched(self) -> bool:
70
+ """Check if tokens represent a batch."""
71
+ tokens = self.input_tokens
72
+ if tokens and isinstance(tokens[0], list):
73
+ return True
74
+ return isinstance(self.original, list) or isinstance(self.corrupted, list)
75
+
76
+ def _token_batches(self) -> tuple[list[list[str]], list[list[str]]]:
77
+ """Get tokens as batches (wrapping single sequences if needed)."""
78
+ if self._tokens_are_batched():
79
+ return (
80
+ cast(list[list[str]], self.input_tokens),
81
+ cast(list[list[str]], self.output_tokens),
82
+ )
83
+ return (
84
+ [cast(list[str], self.input_tokens)],
85
+ [cast(list[str], self.output_tokens)],
86
+ )
87
+
88
+ def _token_counts(self) -> tuple[list[int], list[int]]:
89
+ """Compute token counts per batch item."""
90
+ inputs, outputs = self._token_batches()
91
+ return [len(tokens) for tokens in inputs], [len(tokens) for tokens in outputs]
92
+
93
+ @staticmethod
94
+ def _format_metric_value(value: float | list[float]) -> str:
95
+ """Format a metric value for display."""
96
+ if isinstance(value, list):
97
+ if not value:
98
+ return "[]"
99
+ if len(value) <= 4:
100
+ rendered = ", ".join(f"{entry:.3f}" for entry in value)
101
+ return f"[{rendered}]"
102
+ total = sum(value)
103
+ minimum = min(value)
104
+ maximum = max(value)
105
+ mean = total / len(value)
106
+ return f"avg={mean:.3f} min={minimum:.3f} max={maximum:.3f}"
107
+ return f"{value:.3f}"
108
+
109
+ @staticmethod
110
+ def _format_token(token: str, *, max_length: int) -> str:
111
+ """Format a token for display, truncating if needed."""
112
+ clean = token.replace("\n", "\\n")
113
+ if len(clean) > max_length:
114
+ return clean[: max_length - 3] + "..."
115
+ return clean
116
+
117
+ def to_report(self) -> dict[str, object]:
118
+ """Convert to a JSON-serializable dictionary."""
119
+ input_counts, output_counts = self._token_counts()
120
+ return {
121
+ "tokenizer": self.tokenizer_info,
122
+ "original": self.original,
123
+ "corrupted": self.corrupted,
124
+ "input_tokens": self.input_tokens,
125
+ "output_tokens": self.output_tokens,
126
+ "input_token_ids": self.input_token_ids,
127
+ "output_token_ids": self.output_token_ids,
128
+ "token_counts": {
129
+ "input": {"per_sample": input_counts, "total": sum(input_counts)},
130
+ "output": {"per_sample": output_counts, "total": sum(output_counts)},
131
+ },
132
+ "metrics": self.metrics,
133
+ }
134
+
135
+ def summary(self, *, max_rows: int = 8, max_token_length: int = 24) -> str:
136
+ """Generate a human-readable summary.
137
+
138
+ Args:
139
+ max_rows: Maximum rows to display in token drift.
140
+ max_token_length: Maximum characters per token.
141
+
142
+ Returns:
143
+ Formatted multi-line summary string.
144
+ """
145
+ input_batches, output_batches = self._token_batches()
146
+ input_counts, output_counts = self._token_counts()
147
+ is_batch = self._tokens_are_batched()
148
+
149
+ lines: list[str] = [f"Tokenizer: {self.tokenizer_info}"]
150
+ if is_batch:
151
+ lines.append(f"Samples: {len(input_batches)}")
152
+
153
+ lines.append("Token counts:")
154
+ for index, (input_count, output_count) in enumerate(
155
+ zip(input_counts, output_counts), start=1
156
+ ):
157
+ prefix = f"#{index} " if is_batch else ""
158
+ delta = output_count - input_count
159
+ lines.append(f" {prefix}{input_count} -> {output_count} ({delta:+d})")
160
+ if index >= max_rows and len(input_batches) > max_rows:
161
+ remaining = len(input_batches) - max_rows
162
+ lines.append(f" ... {remaining} more samples")
163
+ break
164
+
165
+ lines.append("Metrics:")
166
+ for name, value in self.metrics.items():
167
+ lines.append(f" {name}: {self._format_metric_value(value)}")
168
+
169
+ if input_batches:
170
+ focus_index = 0
171
+ if is_batch and len(input_batches) > 1:
172
+ lines.append("Token drift (first sample):")
173
+ else:
174
+ lines.append("Token drift:")
175
+ input_tokens = input_batches[focus_index]
176
+ output_tokens = output_batches[focus_index]
177
+ rows = max(len(input_tokens), len(output_tokens))
178
+ display_rows = min(rows, max_rows)
179
+ for idx in range(display_rows):
180
+ left = (
181
+ self._format_token(input_tokens[idx], max_length=max_token_length)
182
+ if idx < len(input_tokens)
183
+ else ""
184
+ )
185
+ right = (
186
+ self._format_token(output_tokens[idx], max_length=max_token_length)
187
+ if idx < len(output_tokens)
188
+ else ""
189
+ )
190
+ if idx >= len(input_tokens):
191
+ marker = "+"
192
+ elif idx >= len(output_tokens):
193
+ marker = "-"
194
+ elif input_tokens[idx] == output_tokens[idx]:
195
+ marker = "="
196
+ else:
197
+ marker = "!"
198
+ lines.append(f" {idx + 1:>3}{marker} {left} -> {right}")
199
+ if rows > display_rows:
200
+ lines.append(f" ... {rows - display_rows} more tokens")
201
+ else:
202
+ lines.append("Token drift: (empty input)")
203
+
204
+ return "\n".join(lines)
205
+
206
+ # -------------------------------------------------------------------------
207
+ # Token-Level Analysis
208
+ # -------------------------------------------------------------------------
209
+
210
+ def get_metric(self, name: str) -> float | list[float] | None:
211
+ """Get a specific metric value by name.
212
+
213
+ Args:
214
+ name: Metric name (e.g., 'normalized_edit_distance').
215
+
216
+ Returns:
217
+ Metric value, or None if not found.
218
+ """
219
+ return self.metrics.get(name)
220
+
221
+ def get_changed_tokens(self, batch_index: int = 0) -> list[tuple[str, str]]:
222
+ """Get tokens that changed between original and corrupted.
223
+
224
+ Args:
225
+ batch_index: Which batch item to analyze (0 for single strings).
226
+
227
+ Returns:
228
+ List of (original_token, corrupted_token) pairs where they differ.
229
+ Only includes positions where both tokens exist and are different.
230
+ """
231
+ input_batches, output_batches = self._token_batches()
232
+ if batch_index >= len(input_batches):
233
+ return []
234
+
235
+ input_tokens = input_batches[batch_index]
236
+ output_tokens = output_batches[batch_index]
237
+
238
+ changes: list[tuple[str, str]] = []
239
+ for i in range(min(len(input_tokens), len(output_tokens))):
240
+ if input_tokens[i] != output_tokens[i]:
241
+ changes.append((input_tokens[i], output_tokens[i]))
242
+ return changes
243
+
244
+ def get_mutation_positions(self, batch_index: int = 0) -> list[int]:
245
+ """Get indices of tokens that were mutated.
246
+
247
+ Args:
248
+ batch_index: Which batch item to analyze (0 for single strings).
249
+
250
+ Returns:
251
+ List of token positions where original != corrupted.
252
+ Only includes positions where both tokens exist.
253
+ """
254
+ input_batches, output_batches = self._token_batches()
255
+ if batch_index >= len(input_batches):
256
+ return []
257
+
258
+ input_tokens = input_batches[batch_index]
259
+ output_tokens = output_batches[batch_index]
260
+
261
+ positions: list[int] = []
262
+ for i in range(min(len(input_tokens), len(output_tokens))):
263
+ if input_tokens[i] != output_tokens[i]:
264
+ positions.append(i)
265
+ return positions
266
+
267
+ def get_token_alignment(self, batch_index: int = 0) -> list[dict[str, object]]:
268
+ """Get detailed token-by-token comparison with alignment info.
269
+
270
+ Args:
271
+ batch_index: Which batch item to analyze (0 for single strings).
272
+
273
+ Returns:
274
+ List of alignment entries, each containing:
275
+ - index: Token position
276
+ - original: Original token (empty string if added)
277
+ - corrupted: Corrupted token (empty string if removed)
278
+ - changed: Whether the token changed
279
+ - op: Operation type ('=' unchanged, '!' modified, '+' added, '-' removed)
280
+ """
281
+ input_batches, output_batches = self._token_batches()
282
+ if batch_index >= len(input_batches):
283
+ return []
284
+
285
+ input_tokens = input_batches[batch_index]
286
+ output_tokens = output_batches[batch_index]
287
+
288
+ alignment: list[dict[str, object]] = []
289
+ max_len = max(len(input_tokens), len(output_tokens))
290
+
291
+ for i in range(max_len):
292
+ orig = input_tokens[i] if i < len(input_tokens) else ""
293
+ corr = output_tokens[i] if i < len(output_tokens) else ""
294
+
295
+ if i >= len(input_tokens):
296
+ op = "+"
297
+ changed = True
298
+ elif i >= len(output_tokens):
299
+ op = "-"
300
+ changed = True
301
+ elif orig == corr:
302
+ op = "="
303
+ changed = False
304
+ else:
305
+ op = "!"
306
+ changed = True
307
+
308
+ alignment.append(
309
+ {
310
+ "index": i,
311
+ "original": orig,
312
+ "corrupted": corr,
313
+ "changed": changed,
314
+ "op": op,
315
+ }
316
+ )
317
+
318
+ return alignment
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Attack Orchestrator
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ class Attack:
327
+ """Orchestrator for applying glitchling corruptions and measuring impact.
328
+
329
+ Attack is a thin boundary layer that:
330
+ 1. Validates inputs at construction time
331
+ 2. Delegates planning to pure functions (core_planning.py)
332
+ 3. Delegates execution to impure functions (core_execution.py)
333
+
334
+ Example:
335
+ >>> attack = Attack(Typogre(rate=0.05), tokenizer='cl100k_base')
336
+ >>> result = attack.run("Hello world")
337
+ >>> print(result.summary())
338
+ """
339
+
340
+ def __init__(
341
+ self,
342
+ glitchlings: Corruptor | str | Sequence[str | Corruptor],
343
+ tokenizer: str | Tokenizer | None = None,
344
+ metrics: Mapping[str, Metric] | None = None,
345
+ *,
346
+ seed: int | None = None,
347
+ transcript_target: TranscriptTarget | None = None,
348
+ ) -> None:
349
+ """Initialize an Attack.
350
+
351
+ Args:
352
+ glitchlings: Glitchling specification - a single Glitchling,
353
+ string spec (e.g. 'Typogre(rate=0.05)'), or iterable of these.
354
+ tokenizer: Tokenizer name (e.g. 'cl100k_base'), Tokenizer instance,
355
+ or None (defaults to whitespace tokenizer).
356
+ metrics: Dictionary of metric functions. If None, uses defaults
357
+ (jensen_shannon_divergence, normalized_edit_distance,
358
+ subsequence_retention).
359
+ seed: Master seed for the Gaggle. If None, uses DEFAULT_ATTACK_SEED.
360
+ transcript_target: Which transcript turns to corrupt. Accepts:
361
+ - "last": corrupt only the last turn (default)
362
+ - "all": corrupt all turns
363
+ - "assistant"/"user": corrupt only those roles
364
+ - int: corrupt a specific index
365
+ - Sequence[int]: corrupt specific indices
366
+ """
367
+ # Boundary: resolve seed
368
+ gaggle_seed = seed if seed is not None else DEFAULT_ATTACK_SEED
369
+
370
+ # Impure: resolve glitchlings (clones to avoid mutation)
371
+ self.glitchlings = resolve_glitchlings(
372
+ glitchlings,
373
+ seed=gaggle_seed,
374
+ transcript_target=transcript_target,
375
+ )
376
+
377
+ # Impure: resolve tokenizer
378
+ self.tokenizer = resolve_tokenizer(tokenizer)
379
+ self.tokenizer_info = describe_tokenizer(self.tokenizer, tokenizer)
380
+
381
+ # Setup metrics
382
+ if metrics is None:
383
+ self.metrics: dict[str, Metric] = get_default_metrics()
384
+ else:
385
+ self.metrics = dict(metrics)
386
+
387
+ # Validate custom metrics have correct signature
388
+ self._validate_metrics()
389
+
390
+ def _validate_metrics(self) -> None:
391
+ """Validate that metric functions have correct signatures.
392
+
393
+ Uses signature inspection to avoid executing metrics (which may have
394
+ side effects).
395
+
396
+ Raises:
397
+ ValueError: If a metric function has an invalid signature.
398
+ """
399
+ for name, func in self.metrics.items():
400
+ if not callable(func):
401
+ raise ValueError(f"Metric '{name}' is not callable")
402
+
403
+ try:
404
+ sig = inspect.signature(func)
405
+ params = list(sig.parameters.values())
406
+
407
+ # Count required positional parameters (no default, not *args/**kwargs)
408
+ positional_params = [
409
+ p
410
+ for p in params
411
+ if p.kind
412
+ in (
413
+ inspect.Parameter.POSITIONAL_ONLY,
414
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
415
+ )
416
+ and p.default is inspect.Parameter.empty
417
+ ]
418
+
419
+ if len(positional_params) < 2:
420
+ raise ValueError(
421
+ f"Metric '{name}' must accept at least 2 positional arguments "
422
+ f"(original_tokens, corrupted_tokens), found {len(positional_params)}"
423
+ )
424
+ except (ValueError, TypeError) as e:
425
+ if "Metric" in str(e):
426
+ raise
427
+ raise ValueError(f"Metric '{name}' has invalid signature: {e}") from e
428
+
429
+ def run(self, text: str | Transcript | Sequence[str]) -> AttackResult:
430
+ """Apply corruptions and calculate metrics.
431
+
432
+ Supports single strings, batches of strings, and chat transcripts.
433
+ For batched inputs, metrics are computed per entry and returned
434
+ as lists.
435
+
436
+ Args:
437
+ text: Input text, transcript, or batch of strings to corrupt.
438
+
439
+ Returns:
440
+ AttackResult containing original, corrupted, tokens, and metrics.
441
+
442
+ Raises:
443
+ TypeError: If input type is not recognized.
444
+ """
445
+ # Pure: plan the attack
446
+ attack_plan = plan_attack(text)
447
+ result_plan = plan_result(
448
+ attack_plan,
449
+ list(self.metrics.keys()),
450
+ self.tokenizer_info,
451
+ )
452
+
453
+ # Impure: execute the attack
454
+ fields = execute_attack(
455
+ self.glitchlings,
456
+ self.tokenizer,
457
+ self.metrics,
458
+ attack_plan,
459
+ result_plan,
460
+ text,
461
+ )
462
+
463
+ return AttackResult(**fields) # type: ignore[arg-type]
464
+
465
+ def run_batch(
466
+ self,
467
+ texts: Sequence[str | Transcript],
468
+ *,
469
+ progress_callback: Callable[[list[AttackResult]], None] | None = None,
470
+ ) -> list[AttackResult]:
471
+ """Run attack on multiple texts, returning results in order.
472
+
473
+ Args:
474
+ texts: List of inputs to process.
475
+ progress_callback: Optional callback called after each result,
476
+ receiving the list of results so far.
477
+
478
+ Returns:
479
+ List of AttackResult objects in input order.
480
+ """
481
+ results: list[AttackResult] = []
482
+ for text in texts:
483
+ result = self.run(text)
484
+ results.append(result)
485
+ if progress_callback is not None:
486
+ progress_callback(results)
487
+ return results
488
+
489
+
490
+ __all__ = [
491
+ "Attack",
492
+ "AttackResult",
493
+ ]