glitchlings 0.4.0__tar.gz → 0.4.2__tar.gz

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 (80) hide show
  1. {glitchlings-0.4.0/src/glitchlings.egg-info → glitchlings-0.4.2}/PKG-INFO +68 -4
  2. {glitchlings-0.4.0 → glitchlings-0.4.2}/README.md +57 -3
  3. {glitchlings-0.4.0 → glitchlings-0.4.2}/pyproject.toml +74 -1
  4. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/src/glitch_ops.rs +7 -2
  5. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/src/lib.rs +66 -0
  6. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/src/pipeline.rs +105 -1
  7. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/src/rng.rs +19 -0
  8. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/src/text_buffer.rs +39 -0
  9. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/__init__.py +26 -17
  10. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/__main__.py +0 -1
  11. glitchlings-0.4.2/src/glitchlings/compat.py +215 -0
  12. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/config.py +136 -19
  13. glitchlings-0.4.2/src/glitchlings/dlc/_shared.py +68 -0
  14. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/dlc/huggingface.py +26 -41
  15. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/dlc/prime.py +64 -101
  16. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/lexicon/__init__.py +26 -19
  17. glitchlings-0.4.2/src/glitchlings/lexicon/_cache.py +104 -0
  18. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/lexicon/graph.py +18 -39
  19. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/lexicon/metrics.py +1 -8
  20. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/lexicon/vector.py +29 -67
  21. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/lexicon/wordnet.py +39 -30
  22. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/main.py +9 -13
  23. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/util/__init__.py +18 -4
  24. glitchlings-0.4.2/src/glitchlings/util/adapters.py +27 -0
  25. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/__init__.py +21 -14
  26. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/_ocr_confusions.py +1 -3
  27. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/_rate.py +1 -4
  28. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/_sampling.py +0 -1
  29. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/_text_utils.py +1 -5
  30. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/adjax.py +0 -2
  31. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/core.py +185 -56
  32. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/jargoyle.py +9 -14
  33. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/mim1c.py +11 -10
  34. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/redactyl.py +5 -8
  35. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/reduple.py +3 -1
  36. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/rushmore.py +2 -8
  37. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/scannequin.py +5 -4
  38. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/typogre.py +3 -7
  39. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/zeedub.py +2 -2
  40. {glitchlings-0.4.0 → glitchlings-0.4.2/src/glitchlings.egg-info}/PKG-INFO +68 -4
  41. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings.egg-info/SOURCES.txt +5 -21
  42. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings.egg-info/requires.txt +10 -0
  43. glitchlings-0.4.0/tests/test_benchmarks.py +0 -88
  44. glitchlings-0.4.0/tests/test_cli.py +0 -306
  45. glitchlings-0.4.0/tests/test_config.py +0 -59
  46. glitchlings-0.4.0/tests/test_dataset_corruption.py +0 -128
  47. glitchlings-0.4.0/tests/test_gaggle.py +0 -46
  48. glitchlings-0.4.0/tests/test_glitchling_core.py +0 -68
  49. glitchlings-0.4.0/tests/test_glitchlings_determinism.py +0 -103
  50. glitchlings-0.4.0/tests/test_graph_lexicon.py +0 -70
  51. glitchlings-0.4.0/tests/test_huggingface_dlc.py +0 -58
  52. glitchlings-0.4.0/tests/test_jargoyle.py +0 -209
  53. glitchlings-0.4.0/tests/test_keyboard_layouts.py +0 -42
  54. glitchlings-0.4.0/tests/test_lexicon_config.py +0 -56
  55. glitchlings-0.4.0/tests/test_lexicon_metrics.py +0 -120
  56. glitchlings-0.4.0/tests/test_parameter_effects.py +0 -281
  57. glitchlings-0.4.0/tests/test_prime_echo_chamber.py +0 -308
  58. glitchlings-0.4.0/tests/test_property_based.py +0 -150
  59. glitchlings-0.4.0/tests/test_rust_backed_glitchlings.py +0 -679
  60. glitchlings-0.4.0/tests/test_text_utils.py +0 -37
  61. glitchlings-0.4.0/tests/test_util.py +0 -35
  62. glitchlings-0.4.0/tests/test_vector_lexicon.py +0 -193
  63. {glitchlings-0.4.0 → glitchlings-0.4.2}/LICENSE +0 -0
  64. {glitchlings-0.4.0 → glitchlings-0.4.2}/MANIFEST.in +0 -0
  65. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/Cargo.lock +0 -0
  66. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/Cargo.toml +0 -0
  67. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/Cargo.toml +0 -0
  68. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/assets/ocr_confusions.tsv +0 -0
  69. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/build.rs +0 -0
  70. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/src/resources.rs +0 -0
  71. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/src/typogre.rs +0 -0
  72. {glitchlings-0.4.0 → glitchlings-0.4.2}/rust/zoo/src/zeedub.rs +0 -0
  73. {glitchlings-0.4.0 → glitchlings-0.4.2}/setup.cfg +0 -0
  74. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/config.toml +0 -0
  75. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/dlc/__init__.py +0 -0
  76. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/lexicon/data/default_vector_cache.json +0 -0
  77. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings/zoo/ocr_confusions.tsv +0 -0
  78. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings.egg-info/dependency_links.txt +0 -0
  79. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings.egg-info/entry_points.txt +0 -0
  80. {glitchlings-0.4.0 → glitchlings-0.4.2}/src/glitchlings.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glitchlings
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Monsters for your language games.
5
5
  Author: osoleve
6
6
  License: Apache License
@@ -239,6 +239,16 @@ Provides-Extra: dev
239
239
  Requires-Dist: pytest>=8.0.0; extra == "dev"
240
240
  Requires-Dist: hypothesis>=6.140.0; extra == "dev"
241
241
  Requires-Dist: numpy<=2.0,>=1.24; extra == "dev"
242
+ Requires-Dist: mkdocs>=1.6.0; extra == "dev"
243
+ Requires-Dist: mkdocstrings[python]>=0.24.0; extra == "dev"
244
+ Requires-Dist: mkdocs-material>=9.5.0; extra == "dev"
245
+ Requires-Dist: mkdocstrings-python>=1.10.0; extra == "dev"
246
+ Requires-Dist: interrogate>=1.5.0; extra == "dev"
247
+ Requires-Dist: black>=24.4.0; extra == "dev"
248
+ Requires-Dist: isort>=5.13.0; extra == "dev"
249
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
250
+ Requires-Dist: mypy>=1.8.0; extra == "dev"
251
+ Requires-Dist: pre-commit>=3.8.0; extra == "dev"
242
252
  Dynamic: license-file
243
253
 
244
254
  #
@@ -338,10 +348,66 @@ They're horrible little gremlins, but they're not _unreasonable_.
338
348
 
339
349
  Keyboard warriors can challenge them directly via the `glitchlings` command:
340
350
 
351
+ <!-- BEGIN: CLI_USAGE -->
341
352
  ```bash
342
353
  # Discover which glitchlings are currently on the loose.
343
354
  glitchlings --list
355
+ ```
356
+
357
+ ```text
358
+ Typogre — scope: Character, order: early
359
+ Mim1c — scope: Character, order: last
360
+ Jargoyle — scope: Word, order: normal
361
+ Adjax — scope: Word, order: normal
362
+ Reduple — scope: Word, order: normal
363
+ Rushmore — scope: Word, order: normal
364
+ Redactyl — scope: Word, order: normal
365
+ Scannequin — scope: Character, order: late
366
+ Zeedub — scope: Character, order: last
367
+ ```
368
+
369
+ ```bash
370
+ # Review the full CLI contract.
371
+ glitchlings --help
372
+ ```
344
373
 
374
+ ```text
375
+ usage: glitchlings [-h] [-g SPEC] [-s SEED] [-f FILE] [--sample] [--diff]
376
+ [--list] [-c CONFIG]
377
+ [text]
378
+
379
+ Summon glitchlings to corrupt text. Provide input text as an argument, via
380
+ --file, or pipe it on stdin.
381
+
382
+ positional arguments:
383
+ text Text to corrupt. If omitted, stdin is used or --sample
384
+ provides fallback text.
385
+
386
+ options:
387
+ -h, --help show this help message and exit
388
+ -g SPEC, --glitchling SPEC
389
+ Glitchling to apply, optionally with parameters like
390
+ Typogre(rate=0.05). Repeat for multiples; defaults to
391
+ all built-ins.
392
+ -s SEED, --seed SEED Seed controlling deterministic corruption order
393
+ (default: 151).
394
+ -f FILE, --file FILE Read input text from a file instead of the command
395
+ line argument.
396
+ --sample Use the included SAMPLE_TEXT when no other input is
397
+ provided.
398
+ --diff Show a unified diff between the original and corrupted
399
+ text.
400
+ --list List available glitchlings and exit.
401
+ -c CONFIG, --config CONFIG
402
+ Load glitchlings from a YAML configuration file.
403
+ ```
404
+ <!-- END: CLI_USAGE -->
405
+
406
+ Run `python docs/build_cli_reference.py` whenever you tweak the CLI so the README stays in sync with the actual output. The script executes the commands above and replaces the block between the markers automatically.
407
+
408
+ Prefer inline tweaks? You can still configure glitchlings directly in the shell:
409
+
410
+ ```bash
345
411
  # Run Typogre against the contents of a file and inspect the diff.
346
412
  glitchlings -g typogre --file documents/report.txt --diff
347
413
 
@@ -355,8 +421,6 @@ echo "Beware LLM-written flavor-text" | glitchlings -g mim1c
355
421
  glitchlings --config experiments/chaos.yaml "Let slips the glitchlings of war"
356
422
  ```
357
423
 
358
- Use `--help` for a complete breakdown of available options, including support for parameterised glitchlings via `-g "Name(arg=value, ...)"` to mirror the Python API.
359
-
360
424
  Attack configurations live in plain YAML files so you can version-control experiments without touching code:
361
425
 
362
426
  ```yaml
@@ -420,7 +484,7 @@ _How can a computer need reading glasses?_
420
484
 
421
485
  ### Zeedub
422
486
 
423
- _A whispering glyph parasite that lives in the interstices of codepoints, marking territory with invisible traces._
487
+ _Watch your step around here._
424
488
 
425
489
  > _**Invisible Ink.**_ Zeedub slips zero-width codepoints between non-space character pairs, forcing models to reason about text whose visible form masks hidden glyphs.
426
490
  >
@@ -95,10 +95,66 @@ They're horrible little gremlins, but they're not _unreasonable_.
95
95
 
96
96
  Keyboard warriors can challenge them directly via the `glitchlings` command:
97
97
 
98
+ <!-- BEGIN: CLI_USAGE -->
98
99
  ```bash
99
100
  # Discover which glitchlings are currently on the loose.
100
101
  glitchlings --list
102
+ ```
103
+
104
+ ```text
105
+ Typogre — scope: Character, order: early
106
+ Mim1c — scope: Character, order: last
107
+ Jargoyle — scope: Word, order: normal
108
+ Adjax — scope: Word, order: normal
109
+ Reduple — scope: Word, order: normal
110
+ Rushmore — scope: Word, order: normal
111
+ Redactyl — scope: Word, order: normal
112
+ Scannequin — scope: Character, order: late
113
+ Zeedub — scope: Character, order: last
114
+ ```
115
+
116
+ ```bash
117
+ # Review the full CLI contract.
118
+ glitchlings --help
119
+ ```
101
120
 
121
+ ```text
122
+ usage: glitchlings [-h] [-g SPEC] [-s SEED] [-f FILE] [--sample] [--diff]
123
+ [--list] [-c CONFIG]
124
+ [text]
125
+
126
+ Summon glitchlings to corrupt text. Provide input text as an argument, via
127
+ --file, or pipe it on stdin.
128
+
129
+ positional arguments:
130
+ text Text to corrupt. If omitted, stdin is used or --sample
131
+ provides fallback text.
132
+
133
+ options:
134
+ -h, --help show this help message and exit
135
+ -g SPEC, --glitchling SPEC
136
+ Glitchling to apply, optionally with parameters like
137
+ Typogre(rate=0.05). Repeat for multiples; defaults to
138
+ all built-ins.
139
+ -s SEED, --seed SEED Seed controlling deterministic corruption order
140
+ (default: 151).
141
+ -f FILE, --file FILE Read input text from a file instead of the command
142
+ line argument.
143
+ --sample Use the included SAMPLE_TEXT when no other input is
144
+ provided.
145
+ --diff Show a unified diff between the original and corrupted
146
+ text.
147
+ --list List available glitchlings and exit.
148
+ -c CONFIG, --config CONFIG
149
+ Load glitchlings from a YAML configuration file.
150
+ ```
151
+ <!-- END: CLI_USAGE -->
152
+
153
+ Run `python docs/build_cli_reference.py` whenever you tweak the CLI so the README stays in sync with the actual output. The script executes the commands above and replaces the block between the markers automatically.
154
+
155
+ Prefer inline tweaks? You can still configure glitchlings directly in the shell:
156
+
157
+ ```bash
102
158
  # Run Typogre against the contents of a file and inspect the diff.
103
159
  glitchlings -g typogre --file documents/report.txt --diff
104
160
 
@@ -112,8 +168,6 @@ echo "Beware LLM-written flavor-text" | glitchlings -g mim1c
112
168
  glitchlings --config experiments/chaos.yaml "Let slips the glitchlings of war"
113
169
  ```
114
170
 
115
- Use `--help` for a complete breakdown of available options, including support for parameterised glitchlings via `-g "Name(arg=value, ...)"` to mirror the Python API.
116
-
117
171
  Attack configurations live in plain YAML files so you can version-control experiments without touching code:
118
172
 
119
173
  ```yaml
@@ -177,7 +231,7 @@ _How can a computer need reading glasses?_
177
231
 
178
232
  ### Zeedub
179
233
 
180
- _A whispering glyph parasite that lives in the interstices of codepoints, marking territory with invisible traces._
234
+ _Watch your step around here._
181
235
 
182
236
  > _**Invisible Ink.**_ Zeedub slips zero-width codepoints between non-space character pairs, forcing models to reason about text whose visible form masks hidden glyphs.
183
237
  >
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "glitchlings"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Monsters for your language games."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -56,6 +56,16 @@ dev = [
56
56
  "pytest>=8.0.0",
57
57
  "hypothesis>=6.140.0",
58
58
  "numpy>=1.24,<=2.0",
59
+ "mkdocs>=1.6.0",
60
+ "mkdocstrings[python]>=0.24.0",
61
+ "mkdocs-material>=9.5.0",
62
+ "mkdocstrings-python>=1.10.0",
63
+ "interrogate>=1.5.0",
64
+ "black>=24.4.0",
65
+ "isort>=5.13.0",
66
+ "ruff>=0.6.0",
67
+ "mypy>=1.8.0",
68
+ "pre-commit>=3.8.0",
59
69
  ]
60
70
 
61
71
  [build-system]
@@ -85,3 +95,66 @@ debug = false
85
95
  pythonpath = [
86
96
  "src",
87
97
  ]
98
+
99
+ [tool.interrogate]
100
+ config = true
101
+ fail-under = 80
102
+ ignore-init-module = true
103
+ ignore-module = true
104
+ ignore-nested-functions = true
105
+ ignore-private = true
106
+ ignore-semiprivate = true
107
+ ignore-magic = true
108
+ ignore-property-decorators = false
109
+ color = true
110
+ quiet = false
111
+ exclude = [
112
+ "tests",
113
+ "docs",
114
+ "rust",
115
+ "benchmarks",
116
+ ]
117
+
118
+ [tool.black]
119
+ line-length = 100
120
+ target-version = ["py310"]
121
+
122
+ [tool.isort]
123
+ profile = "black"
124
+ line_length = 100
125
+
126
+ [tool.ruff]
127
+ target-version = "py310"
128
+ line-length = 100
129
+
130
+ [tool.ruff.lint]
131
+ select = ["E", "F", "I"]
132
+
133
+ [tool.mypy]
134
+ python_version = "3.10"
135
+ follow_imports = "skip"
136
+ ignore_missing_imports = true
137
+ enable_error_code = ["ignore-without-code"]
138
+
139
+ [[tool.mypy.overrides]]
140
+ module = [
141
+ "glitchlings.util.adapters",
142
+ "glitchlings.dlc._shared",
143
+ "glitchlings.dlc.huggingface",
144
+ "glitchlings.dlc.prime",
145
+ ]
146
+ strict = true
147
+
148
+ [[tool.mypy.overrides]]
149
+ module = [
150
+ "glitchlings.compat",
151
+ "glitchlings.config",
152
+ "glitchlings.lexicon",
153
+ "glitchlings.lexicon.*",
154
+ "glitchlings.zoo",
155
+ "glitchlings.zoo.*",
156
+ "glitchlings.main",
157
+ "glitchlings.__main__",
158
+ "glitchlings.__init__",
159
+ ]
160
+ ignore_errors = true
@@ -398,6 +398,7 @@ impl GlitchOp for SwapAdjacentWordsOp {
398
398
  }
399
399
 
400
400
  let mut index = 0usize;
401
+ let mut replacements: SmallVec<[(usize, String); 8]> = SmallVec::new();
401
402
  while index + 1 < total_words {
402
403
  let left_segment = match buffer.word_segment(index) {
403
404
  Some(segment) => segment,
@@ -423,13 +424,17 @@ impl GlitchOp for SwapAdjacentWordsOp {
423
424
  if should_swap {
424
425
  let left_replacement = format!("{left_prefix}{right_core}{left_suffix}");
425
426
  let right_replacement = format!("{right_prefix}{left_core}{right_suffix}");
426
- buffer.replace_word(index, &left_replacement)?;
427
- buffer.replace_word(index + 1, &right_replacement)?;
427
+ replacements.push((index, left_replacement));
428
+ replacements.push((index + 1, right_replacement));
428
429
  }
429
430
 
430
431
  index += 2;
431
432
  }
432
433
 
434
+ if !replacements.is_empty() {
435
+ buffer.replace_words_bulk(replacements.into_iter())?;
436
+ }
437
+
433
438
  Ok(())
434
439
  }
435
440
  }
@@ -122,6 +122,47 @@ fn cached_layout_vec(layout_dict: &PyDict) -> PyResult<Arc<Vec<(String, Vec<Stri
122
122
  Ok(entry.clone())
123
123
  }
124
124
 
125
+ #[derive(Debug)]
126
+ struct PyGagglePlanInput {
127
+ name: String,
128
+ scope: i32,
129
+ order: i32,
130
+ }
131
+
132
+ impl<'py> FromPyObject<'py> for PyGagglePlanInput {
133
+ fn extract(obj: &'py PyAny) -> PyResult<Self> {
134
+ if let Ok(dict) = obj.downcast::<PyDict>() {
135
+ let name: String = dict
136
+ .get_item("name")?
137
+ .ok_or_else(|| PyValueError::new_err("plan input missing 'name' field"))?
138
+ .extract()?;
139
+ let scope: i32 = dict
140
+ .get_item("scope")?
141
+ .ok_or_else(|| PyValueError::new_err("plan input missing 'scope' field"))?
142
+ .extract()?;
143
+ let order: i32 = dict
144
+ .get_item("order")?
145
+ .ok_or_else(|| PyValueError::new_err("plan input missing 'order' field"))?
146
+ .extract()?;
147
+ return Ok(Self { name, scope, order });
148
+ }
149
+
150
+ let name = obj
151
+ .getattr("name")
152
+ .map_err(|_| PyValueError::new_err("plan input missing attribute 'name'"))?
153
+ .extract()?;
154
+ let scope = obj
155
+ .getattr("scope")
156
+ .map_err(|_| PyValueError::new_err("plan input missing attribute 'scope'"))?
157
+ .extract()?;
158
+ let order = obj
159
+ .getattr("order")
160
+ .map_err(|_| PyValueError::new_err("plan input missing attribute 'order'"))?
161
+ .extract()?;
162
+ Ok(Self { name, scope, order })
163
+ }
164
+ }
165
+
125
166
  #[derive(Debug)]
126
167
  enum PyGlitchOperation {
127
168
  Reduplicate {
@@ -346,6 +387,30 @@ fn redact_words(
346
387
  apply_operation(text, op, rng).map_err(glitch_ops::GlitchOpError::into_pyerr)
347
388
  }
348
389
 
390
+ #[pyfunction]
391
+ fn plan_glitchlings(
392
+ glitchlings: Vec<PyGagglePlanInput>,
393
+ master_seed: i128,
394
+ ) -> PyResult<Vec<(usize, u64)>> {
395
+ let plan = pipeline::plan_gaggle(
396
+ glitchlings
397
+ .into_iter()
398
+ .enumerate()
399
+ .map(|(index, input)| pipeline::GagglePlanInput {
400
+ index,
401
+ name: input.name,
402
+ scope: input.scope,
403
+ order: input.order,
404
+ })
405
+ .collect(),
406
+ master_seed,
407
+ );
408
+ Ok(plan
409
+ .into_iter()
410
+ .map(|entry| (entry.index, entry.seed))
411
+ .collect())
412
+ }
413
+
349
414
  #[pyfunction]
350
415
  fn compose_glitchlings(
351
416
  text: &str,
@@ -418,6 +483,7 @@ fn _zoo_rust(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
418
483
  m.add_function(wrap_pyfunction!(swap_adjacent_words, m)?)?;
419
484
  m.add_function(wrap_pyfunction!(ocr_artifacts, m)?)?;
420
485
  m.add_function(wrap_pyfunction!(redact_words, m)?)?;
486
+ m.add_function(wrap_pyfunction!(plan_glitchlings, m)?)?;
421
487
  m.add_function(wrap_pyfunction!(compose_glitchlings, m)?)?;
422
488
  m.add_function(wrap_pyfunction!(typogre::fatfinger, m)?)?;
423
489
  m.add_function(wrap_pyfunction!(zeedub::inject_zero_widths, m)?)?;
@@ -68,6 +68,57 @@ impl Pipeline {
68
68
  }
69
69
  }
70
70
 
71
+ #[derive(Debug, Clone, PartialEq, Eq)]
72
+ pub struct GagglePlanEntry {
73
+ pub index: usize,
74
+ pub seed: u64,
75
+ }
76
+
77
+ #[derive(Debug, Clone)]
78
+ pub struct GagglePlanInput {
79
+ pub index: usize,
80
+ pub name: String,
81
+ pub scope: i32,
82
+ pub order: i32,
83
+ }
84
+
85
+ struct PlannedGlitchling {
86
+ index: usize,
87
+ name: String,
88
+ scope: i32,
89
+ order: i32,
90
+ seed: u64,
91
+ }
92
+
93
+ pub fn plan_gaggle(inputs: Vec<GagglePlanInput>, master_seed: i128) -> Vec<GagglePlanEntry> {
94
+ let mut planned: Vec<PlannedGlitchling> = inputs
95
+ .into_iter()
96
+ .map(|input| PlannedGlitchling {
97
+ seed: derive_seed(master_seed, &input.name, input.index as i128),
98
+ index: input.index,
99
+ name: input.name,
100
+ scope: input.scope,
101
+ order: input.order,
102
+ })
103
+ .collect();
104
+
105
+ planned.sort_by(|left, right| {
106
+ left.scope
107
+ .cmp(&right.scope)
108
+ .then(left.order.cmp(&right.order))
109
+ .then(left.name.cmp(&right.name))
110
+ .then(left.index.cmp(&right.index))
111
+ });
112
+
113
+ planned
114
+ .into_iter()
115
+ .map(|item| GagglePlanEntry {
116
+ index: item.index,
117
+ seed: item.seed,
118
+ })
119
+ .collect()
120
+ }
121
+
71
122
  pub fn derive_seed(master_seed: i128, glitchling_name: &str, index: i128) -> u64 {
72
123
  let mut hasher = Blake2s::<U8>::new();
73
124
  Digest::update(&mut hasher, int_to_bytes(master_seed));
@@ -109,7 +160,9 @@ fn int_to_bytes(value: i128) -> Vec<u8> {
109
160
 
110
161
  #[cfg(test)]
111
162
  mod tests {
112
- use super::{derive_seed, GlitchDescriptor, Pipeline};
163
+ use super::{
164
+ derive_seed, plan_gaggle, GagglePlanEntry, GagglePlanInput, GlitchDescriptor, Pipeline,
165
+ };
113
166
  use crate::glitch_ops::{
114
167
  DeleteRandomWordsOp, GlitchOperation, OcrArtifactsOp, RedactWordsOp, ReduplicateWordsOp,
115
168
  SwapAdjacentWordsOp,
@@ -222,4 +275,55 @@ mod tests {
222
275
  .expect("pipeline succeeds");
223
276
  assert_eq!(output, "this Echo please line");
224
277
  }
278
+
279
+ #[test]
280
+ fn plan_gaggle_orders_by_scope_order_and_name() {
281
+ let master_seed = 5151i128;
282
+ let inputs = vec![
283
+ GagglePlanInput {
284
+ index: 0,
285
+ name: "Typogre".to_string(),
286
+ scope: 5,
287
+ order: 3,
288
+ },
289
+ GagglePlanInput {
290
+ index: 1,
291
+ name: "Reduple".to_string(),
292
+ scope: 4,
293
+ order: 3,
294
+ },
295
+ GagglePlanInput {
296
+ index: 2,
297
+ name: "Rushmore".to_string(),
298
+ scope: 4,
299
+ order: 2,
300
+ },
301
+ GagglePlanInput {
302
+ index: 3,
303
+ name: "Mim1c".to_string(),
304
+ scope: 5,
305
+ order: 2,
306
+ },
307
+ ];
308
+ let plan = plan_gaggle(inputs, master_seed);
309
+ let expected = vec![
310
+ GagglePlanEntry {
311
+ index: 2,
312
+ seed: derive_seed(master_seed, "Rushmore", 2),
313
+ },
314
+ GagglePlanEntry {
315
+ index: 1,
316
+ seed: derive_seed(master_seed, "Reduple", 1),
317
+ },
318
+ GagglePlanEntry {
319
+ index: 3,
320
+ seed: derive_seed(master_seed, "Mim1c", 3),
321
+ },
322
+ GagglePlanEntry {
323
+ index: 0,
324
+ seed: derive_seed(master_seed, "Typogre", 0),
325
+ },
326
+ ];
327
+ assert_eq!(plan, expected);
328
+ }
225
329
  }
@@ -323,6 +323,25 @@ mod tests {
323
323
  }
324
324
  }
325
325
 
326
+ #[test]
327
+ fn random_matches_python_for_additional_seed() {
328
+ let mut rng = PyRng::new(3815924951222172525);
329
+ let expected = [
330
+ 0.18518006574496737,
331
+ 0.5841689581060610,
332
+ 0.3699113163178772,
333
+ 0.7394349068470196,
334
+ 0.6855497906317899,
335
+ ];
336
+ for value in expected {
337
+ let actual = rng.random();
338
+ assert!(
339
+ (actual - value).abs() < 1e-15,
340
+ "expected {value}, got {actual}"
341
+ );
342
+ }
343
+ }
344
+
326
345
  #[test]
327
346
  fn randrange_supports_default_arguments() {
328
347
  let mut rng = PyRng::new(151);
@@ -171,6 +171,32 @@ impl TextBuffer {
171
171
  Ok(())
172
172
  }
173
173
 
174
+ /// Replace multiple words in a single pass, avoiding repeated reindexing.
175
+ pub fn replace_words_bulk<I>(&mut self, replacements: I) -> Result<(), TextBufferError>
176
+ where
177
+ I: IntoIterator<Item = (usize, String)>,
178
+ {
179
+ let mut applied_any = false;
180
+ for (word_index, replacement) in replacements {
181
+ let segment_index = self
182
+ .word_segment_indices
183
+ .get(word_index)
184
+ .copied()
185
+ .ok_or(TextBufferError::InvalidWordIndex { index: word_index })?;
186
+ let segment = self
187
+ .segments
188
+ .get_mut(segment_index)
189
+ .ok_or(TextBufferError::InvalidWordIndex { index: word_index })?;
190
+ segment.set_text(replacement, SegmentKind::Word);
191
+ applied_any = true;
192
+ }
193
+
194
+ if applied_any {
195
+ self.reindex();
196
+ }
197
+ Ok(())
198
+ }
199
+
174
200
  /// Deletes the word at the requested index.
175
201
  pub fn delete_word(&mut self, word_index: usize) -> Result<(), TextBufferError> {
176
202
  let segment_index = self
@@ -402,6 +428,19 @@ mod tests {
402
428
  assert_eq!(buffer.spans().len(), 5);
403
429
  }
404
430
 
431
+ #[test]
432
+ fn bulk_replace_words_updates_multiple_entries() {
433
+ let mut buffer = TextBuffer::from_str("alpha beta gamma delta");
434
+ buffer
435
+ .replace_words_bulk(vec![(0, "delta".to_string()), (3, "alpha".to_string())])
436
+ .expect("bulk replace succeeds");
437
+ assert_eq!(buffer.to_string(), "delta beta gamma alpha");
438
+ let spans = buffer.spans();
439
+ assert_eq!(spans[0].char_range, 0..5);
440
+ assert_eq!(spans.len(), 7);
441
+ assert_eq!(spans.last().unwrap().char_range, 17..22);
442
+ }
443
+
405
444
  #[test]
406
445
  fn replace_char_range_handles_multisegment_updates() {
407
446
  let mut buffer = TextBuffer::from_str("Hello world");
@@ -1,29 +1,33 @@
1
+ from .config import AttackConfig, build_gaggle, load_attack_config
2
+ from .util import SAMPLE_TEXT
1
3
  from .zoo import (
2
- Typogre,
3
- typogre,
4
- Mim1c,
5
- mim1c,
6
- Jargoyle,
7
- jargoyle,
8
4
  Adjax,
9
- adjax,
5
+ Gaggle,
6
+ Glitchling,
7
+ Jargoyle,
8
+ Mim1c,
10
9
  Redactyl,
11
- redactyl,
12
10
  Reduple,
13
- reduple,
14
11
  Rushmore,
15
- rushmore,
16
12
  Scannequin,
17
- scannequin,
13
+ Typogre,
18
14
  Zeedub,
19
- zeedub,
20
- Glitchling,
21
- Gaggle,
15
+ adjax,
16
+ is_rust_pipeline_enabled,
17
+ is_rust_pipeline_supported,
18
+ jargoyle,
19
+ mim1c,
20
+ pipeline_feature_flag_enabled,
21
+ plan_glitchling_specs,
22
+ plan_glitchlings,
23
+ redactyl,
24
+ reduple,
25
+ rushmore,
26
+ scannequin,
22
27
  summon,
28
+ typogre,
29
+ zeedub,
23
30
  )
24
- from .config import AttackConfig, build_gaggle, load_attack_config
25
- from .util import SAMPLE_TEXT
26
-
27
31
 
28
32
  __all__ = [
29
33
  "Typogre",
@@ -47,6 +51,11 @@ __all__ = [
47
51
  "summon",
48
52
  "Glitchling",
49
53
  "Gaggle",
54
+ "plan_glitchlings",
55
+ "plan_glitchling_specs",
56
+ "is_rust_pipeline_enabled",
57
+ "is_rust_pipeline_supported",
58
+ "pipeline_feature_flag_enabled",
50
59
  "SAMPLE_TEXT",
51
60
  "AttackConfig",
52
61
  "build_gaggle",
@@ -4,6 +4,5 @@ import sys
4
4
 
5
5
  from .main import main
6
6
 
7
-
8
7
  if __name__ == "__main__":
9
8
  sys.exit(main())