glitchlings 0.1.2__tar.gz → 0.1.3__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.
Files changed (40) hide show
  1. {glitchlings-0.1.2 → glitchlings-0.1.3}/.gitignore +3 -0
  2. glitchlings-0.1.3/AGENTS.md +55 -0
  3. {glitchlings-0.1.2 → glitchlings-0.1.3}/PKG-INFO +1 -1
  4. {glitchlings-0.1.2 → glitchlings-0.1.3}/pyproject.toml +1 -1
  5. glitchlings-0.1.3/rust/typogre/Cargo.lock +295 -0
  6. glitchlings-0.1.3/rust/typogre/Cargo.toml +14 -0
  7. glitchlings-0.1.3/rust/typogre/src/lib.rs +260 -0
  8. glitchlings-0.1.3/rust/zoo/Cargo.lock +340 -0
  9. glitchlings-0.1.3/rust/zoo/Cargo.toml +15 -0
  10. glitchlings-0.1.3/rust/zoo/src/lib.rs +367 -0
  11. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/zoo/core.py +45 -5
  12. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/zoo/redactyl.py +46 -9
  13. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/zoo/reduple.py +35 -8
  14. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/zoo/rushmore.py +48 -25
  15. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/zoo/scannequin.py +33 -7
  16. glitchlings-0.1.3/src/glitchlings/zoo/typogre.py +184 -0
  17. glitchlings-0.1.3/tests/test_cli.py +132 -0
  18. glitchlings-0.1.3/tests/test_dataset_corruption.py +51 -0
  19. {glitchlings-0.1.2 → glitchlings-0.1.3}/tests/test_gaggle.py +6 -0
  20. {glitchlings-0.1.2 → glitchlings-0.1.3}/tests/test_glitchlings_determinism.py +18 -0
  21. {glitchlings-0.1.2 → glitchlings-0.1.3}/tests/test_parameter_effects.py +16 -0
  22. glitchlings-0.1.3/tests/test_rust_backed_glitchlings.py +110 -0
  23. glitchlings-0.1.2/AGENTS.md +0 -42
  24. glitchlings-0.1.2/src/glitchlings/zoo/typogre.py +0 -231
  25. {glitchlings-0.1.2 → glitchlings-0.1.3}/.github/workflows/publish.yml +0 -0
  26. {glitchlings-0.1.2 → glitchlings-0.1.3}/LICENSE +0 -0
  27. {glitchlings-0.1.2 → glitchlings-0.1.3}/MONSTER_MANUAL.md +0 -0
  28. {glitchlings-0.1.2 → glitchlings-0.1.3}/README.md +0 -0
  29. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/__init__.py +0 -0
  30. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/__main__.py +0 -0
  31. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/dlc/__init__.py +0 -0
  32. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/dlc/prime.py +0 -0
  33. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/main.py +0 -0
  34. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/util/__init__.py +0 -0
  35. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/zoo/__init__.py +0 -0
  36. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/zoo/jargoyle.py +0 -0
  37. {glitchlings-0.1.2 → glitchlings-0.1.3}/src/glitchlings/zoo/mim1c.py +0 -0
  38. {glitchlings-0.1.2 → glitchlings-0.1.3}/tests/conftest.py +0 -0
  39. {glitchlings-0.1.2 → glitchlings-0.1.3}/tests/test_jargoyle.py +0 -0
  40. {glitchlings-0.1.2 → glitchlings-0.1.3}/tests/test_keyboard_layouts.py +0 -0
@@ -11,4 +11,7 @@ wheels/
11
11
  .python-version
12
12
  uv.lock
13
13
 
14
+ # Rust build artifacts
15
+ /rust/typogre/target/
16
+
14
17
  RELEASE.md
@@ -0,0 +1,55 @@
1
+ # Glitchlings – Agent Handbook
2
+
3
+ Welcome! This repository corrals a roster of deterministic text-corruption "glitchlings" plus a CLI for orchestrating them.
4
+ Treat this handbook as the default guidance for any work in the repo.
5
+
6
+ ## Repository Tour
7
+ - **`src/glitchlings/`** – Installable Python package.
8
+ - `__init__.py` exposes the public API (glitchlings, `Gaggle`, `summon`, `SAMPLE_TEXT`).
9
+ - `__main__.py` wires `python -m glitchlings` to the CLI entry point in `main.py`.
10
+ - `main.py` implements the CLI: parser construction, text sourcing, glitchling summoning, and optional diff output.
11
+ - **`src/glitchlings/zoo/`** – Core glitchling implementations.
12
+ - `core.py` defines the `Glitchling` base class, `AttackWave`/`AttackOrder` enums, and the `Gaggle` orchestrator.
13
+ - `typogre.py`, `mim1c.py`, `reduple.py`, `rushmore.py`, `redactyl.py`, `jargoyle.py`, and `scannequin.py` provide concrete glitchlings.
14
+ Each module offers a pure-Python implementation and, when available, dispatches to an optional Rust acceleration layer.
15
+ - **`src/glitchlings/util/__init__.py`** – Shared helpers including `SAMPLE_TEXT`, keyboard-neighbour layouts, and diff utilities.
16
+ - **`src/glitchlings/dlc/prime.py`** – Optional DLC integration with the `verifiers` environments (install via `pip install -e .[prime]`).
17
+ - **`rust/`** – PyO3 crates backing the optional Rust extensions.
18
+ - `rust/zoo/` builds `glitchlings._zoo_rust` (used by Reduple, Rushmore, Redactyl, and Scannequin).
19
+ - `rust/typogre/` builds `glitchlings._typogre_rust` (Typogre's fast path).
20
+ - Use `maturin develop` (or `maturin build`) from each crate directory to compile the wheels when you need the acceleration paths.
21
+ - **`tests/`** – Pytest suite covering determinism, keyboard layouts, CLI behaviour, and parity between Python and Rust implementations.
22
+ - `test_glitchlings_determinism.py`, `test_parameter_effects.py`, and `test_gaggle.py` validate orchestration and RNG guarantees.
23
+ - `test_rust_backed_glitchlings.py` ensures Rust fast paths match the Python fallbacks.
24
+ - **Top-level docs** – `README.md` introduces the project and CLI, `MONSTER_MANUAL.md` serves as the glitchling bestiary.
25
+
26
+ ## Coding Conventions
27
+ - Target **Python 3.12+** (see `pyproject.toml`).
28
+ - Follow the import order used in the package: standard library, third-party, then local modules.
29
+ - Every new glitchling must:
30
+ - Subclass `Glitchling`, setting `scope` and `order` via `AttackWave` / `AttackOrder` from `core.py`.
31
+ - Accept keyword-only parameters in `__init__`, forwarding them through `super().__init__` so they are tracked by `set_param`.
32
+ - Drive all randomness through the instance's `rng` (do not rely on module-level RNG state) to keep `Gaggle` runs deterministic.
33
+ - Keep helper functions small and well-scoped; include docstrings that describe behaviour and note any determinism considerations.
34
+ - When mutating token sequences, preserve whitespace and punctuation via separator-preserving regex splits (see `reduple.py`, `rushmore.py`, `redactyl.py`).
35
+ - CLI work should continue the existing UX: validate inputs with `ArgumentParser.error`, keep deterministic output ordering, and gate optional behaviours behind explicit flags.
36
+ - Rust fast paths must remain optional: guard imports with `try`/`except ImportError`, surface identical signatures, and fall back to the Python implementation when the extension is absent.
37
+
38
+ ## Testing & Tooling
39
+ - Run the full suite with `pytest` from the repository root.
40
+ - Some tests rely on the NLTK WordNet corpus; if it is missing they skip automatically. Install it via `python -c "import nltk; nltk.download('wordnet')"` to exercise Jargoyle thoroughly.
41
+ - If you modify Rust-backed modules, rerun `pytest tests/test_rust_backed_glitchlings.py` with and without the compiled extensions to keep both code paths healthy.
42
+ - Optional extras (e.g., DLC) depend on `verifiers`. Install the `prime` extra (`pip install -e .[prime]`) when working in `src/glitchlings/dlc/`.
43
+
44
+ ## Determinism Checklist
45
+ - Expose configurable parameters via `set_param` so fixtures in `tests/test_glitchlings_determinism.py` can reset seeds predictably.
46
+ - Derive RNGs from the enclosing context (`Gaggle.derive_seed`) instead of using global state.
47
+ - When sampling subsets (e.g., replacements or deletions), stabilise candidate ordering before selecting to keep results reproducible.
48
+ - Preserve signature parity between Python and Rust implementations so switching paths does not alter behaviour.
49
+
50
+ ## Workflow Tips
51
+ - Use `summon([...], seed=...)` for programmatic orchestration when reproducing tests or crafting examples.
52
+ - The CLI lists built-in glitchlings (`glitchlings --list`) and can show diffs; update `BUILTIN_GLITCHLINGS` and help text when introducing new creatures.
53
+ - Keep documentation synchronized: update both `README.md` and `MONSTER_MANUAL.md` when adding or altering glitchlings or behaviours.
54
+ - When editing keyboard layouts or homoglyph mappings, ensure downstream consumers continue to work with lowercase keys (`util.KEYNEIGHBORS`).
55
+ - Rust builds are optional—keep the project functional when extensions are absent (e.g., in CI or user installs without `maturin`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glitchlings
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Monsters for your language games.
5
5
  Project-URL: Homepage, https://github.com/osoleve/glitchlings
6
6
  Project-URL: Repository, https://github.com/osoleve/glitchlings.git
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "glitchlings"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Monsters for your language games."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,295 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "autocfg"
7
+ version = "1.5.0"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
10
+
11
+ [[package]]
12
+ name = "bitflags"
13
+ version = "2.9.4"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
16
+
17
+ [[package]]
18
+ name = "cfg-if"
19
+ version = "1.0.3"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
22
+
23
+ [[package]]
24
+ name = "heck"
25
+ version = "0.4.1"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
28
+
29
+ [[package]]
30
+ name = "indoc"
31
+ version = "2.0.6"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
34
+
35
+ [[package]]
36
+ name = "libc"
37
+ version = "0.2.176"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
40
+
41
+ [[package]]
42
+ name = "lock_api"
43
+ version = "0.4.13"
44
+ source = "registry+https://github.com/rust-lang/crates.io-index"
45
+ checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
46
+ dependencies = [
47
+ "autocfg",
48
+ "scopeguard",
49
+ ]
50
+
51
+ [[package]]
52
+ name = "memoffset"
53
+ version = "0.9.1"
54
+ source = "registry+https://github.com/rust-lang/crates.io-index"
55
+ checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
56
+ dependencies = [
57
+ "autocfg",
58
+ ]
59
+
60
+ [[package]]
61
+ name = "once_cell"
62
+ version = "1.21.3"
63
+ source = "registry+https://github.com/rust-lang/crates.io-index"
64
+ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
65
+
66
+ [[package]]
67
+ name = "parking_lot"
68
+ version = "0.12.4"
69
+ source = "registry+https://github.com/rust-lang/crates.io-index"
70
+ checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
71
+ dependencies = [
72
+ "lock_api",
73
+ "parking_lot_core",
74
+ ]
75
+
76
+ [[package]]
77
+ name = "parking_lot_core"
78
+ version = "0.9.11"
79
+ source = "registry+https://github.com/rust-lang/crates.io-index"
80
+ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
81
+ dependencies = [
82
+ "cfg-if",
83
+ "libc",
84
+ "redox_syscall",
85
+ "smallvec",
86
+ "windows-targets",
87
+ ]
88
+
89
+ [[package]]
90
+ name = "portable-atomic"
91
+ version = "1.11.1"
92
+ source = "registry+https://github.com/rust-lang/crates.io-index"
93
+ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
94
+
95
+ [[package]]
96
+ name = "proc-macro2"
97
+ version = "1.0.101"
98
+ source = "registry+https://github.com/rust-lang/crates.io-index"
99
+ checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
100
+ dependencies = [
101
+ "unicode-ident",
102
+ ]
103
+
104
+ [[package]]
105
+ name = "pyo3"
106
+ version = "0.21.2"
107
+ source = "registry+https://github.com/rust-lang/crates.io-index"
108
+ checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8"
109
+ dependencies = [
110
+ "cfg-if",
111
+ "indoc",
112
+ "libc",
113
+ "memoffset",
114
+ "parking_lot",
115
+ "portable-atomic",
116
+ "pyo3-build-config",
117
+ "pyo3-ffi",
118
+ "pyo3-macros",
119
+ "unindent",
120
+ ]
121
+
122
+ [[package]]
123
+ name = "pyo3-build-config"
124
+ version = "0.21.2"
125
+ source = "registry+https://github.com/rust-lang/crates.io-index"
126
+ checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50"
127
+ dependencies = [
128
+ "once_cell",
129
+ "target-lexicon",
130
+ ]
131
+
132
+ [[package]]
133
+ name = "pyo3-ffi"
134
+ version = "0.21.2"
135
+ source = "registry+https://github.com/rust-lang/crates.io-index"
136
+ checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403"
137
+ dependencies = [
138
+ "libc",
139
+ "pyo3-build-config",
140
+ ]
141
+
142
+ [[package]]
143
+ name = "pyo3-macros"
144
+ version = "0.21.2"
145
+ source = "registry+https://github.com/rust-lang/crates.io-index"
146
+ checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c"
147
+ dependencies = [
148
+ "proc-macro2",
149
+ "pyo3-macros-backend",
150
+ "quote",
151
+ "syn",
152
+ ]
153
+
154
+ [[package]]
155
+ name = "pyo3-macros-backend"
156
+ version = "0.21.2"
157
+ source = "registry+https://github.com/rust-lang/crates.io-index"
158
+ checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c"
159
+ dependencies = [
160
+ "heck",
161
+ "proc-macro2",
162
+ "pyo3-build-config",
163
+ "quote",
164
+ "syn",
165
+ ]
166
+
167
+ [[package]]
168
+ name = "quote"
169
+ version = "1.0.41"
170
+ source = "registry+https://github.com/rust-lang/crates.io-index"
171
+ checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
172
+ dependencies = [
173
+ "proc-macro2",
174
+ ]
175
+
176
+ [[package]]
177
+ name = "redox_syscall"
178
+ version = "0.5.17"
179
+ source = "registry+https://github.com/rust-lang/crates.io-index"
180
+ checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
181
+ dependencies = [
182
+ "bitflags",
183
+ ]
184
+
185
+ [[package]]
186
+ name = "scopeguard"
187
+ version = "1.2.0"
188
+ source = "registry+https://github.com/rust-lang/crates.io-index"
189
+ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
190
+
191
+ [[package]]
192
+ name = "smallvec"
193
+ version = "1.15.1"
194
+ source = "registry+https://github.com/rust-lang/crates.io-index"
195
+ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
196
+
197
+ [[package]]
198
+ name = "syn"
199
+ version = "2.0.106"
200
+ source = "registry+https://github.com/rust-lang/crates.io-index"
201
+ checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
202
+ dependencies = [
203
+ "proc-macro2",
204
+ "quote",
205
+ "unicode-ident",
206
+ ]
207
+
208
+ [[package]]
209
+ name = "target-lexicon"
210
+ version = "0.12.16"
211
+ source = "registry+https://github.com/rust-lang/crates.io-index"
212
+ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
213
+
214
+ [[package]]
215
+ name = "typogre_rust"
216
+ version = "0.1.0"
217
+ dependencies = [
218
+ "pyo3",
219
+ ]
220
+
221
+ [[package]]
222
+ name = "unicode-ident"
223
+ version = "1.0.19"
224
+ source = "registry+https://github.com/rust-lang/crates.io-index"
225
+ checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
226
+
227
+ [[package]]
228
+ name = "unindent"
229
+ version = "0.2.4"
230
+ source = "registry+https://github.com/rust-lang/crates.io-index"
231
+ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
232
+
233
+ [[package]]
234
+ name = "windows-targets"
235
+ version = "0.52.6"
236
+ source = "registry+https://github.com/rust-lang/crates.io-index"
237
+ checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
238
+ dependencies = [
239
+ "windows_aarch64_gnullvm",
240
+ "windows_aarch64_msvc",
241
+ "windows_i686_gnu",
242
+ "windows_i686_gnullvm",
243
+ "windows_i686_msvc",
244
+ "windows_x86_64_gnu",
245
+ "windows_x86_64_gnullvm",
246
+ "windows_x86_64_msvc",
247
+ ]
248
+
249
+ [[package]]
250
+ name = "windows_aarch64_gnullvm"
251
+ version = "0.52.6"
252
+ source = "registry+https://github.com/rust-lang/crates.io-index"
253
+ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
254
+
255
+ [[package]]
256
+ name = "windows_aarch64_msvc"
257
+ version = "0.52.6"
258
+ source = "registry+https://github.com/rust-lang/crates.io-index"
259
+ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
260
+
261
+ [[package]]
262
+ name = "windows_i686_gnu"
263
+ version = "0.52.6"
264
+ source = "registry+https://github.com/rust-lang/crates.io-index"
265
+ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
266
+
267
+ [[package]]
268
+ name = "windows_i686_gnullvm"
269
+ version = "0.52.6"
270
+ source = "registry+https://github.com/rust-lang/crates.io-index"
271
+ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
272
+
273
+ [[package]]
274
+ name = "windows_i686_msvc"
275
+ version = "0.52.6"
276
+ source = "registry+https://github.com/rust-lang/crates.io-index"
277
+ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
278
+
279
+ [[package]]
280
+ name = "windows_x86_64_gnu"
281
+ version = "0.52.6"
282
+ source = "registry+https://github.com/rust-lang/crates.io-index"
283
+ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
284
+
285
+ [[package]]
286
+ name = "windows_x86_64_gnullvm"
287
+ version = "0.52.6"
288
+ source = "registry+https://github.com/rust-lang/crates.io-index"
289
+ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
290
+
291
+ [[package]]
292
+ name = "windows_x86_64_msvc"
293
+ version = "0.52.6"
294
+ source = "registry+https://github.com/rust-lang/crates.io-index"
295
+ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
@@ -0,0 +1,14 @@
1
+ [package]
2
+ name = "typogre_rust"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [lib]
7
+ name = "_typogre_rust"
8
+ crate-type = ["cdylib"]
9
+
10
+ [dependencies]
11
+ pyo3 = { version = "0.21", features = ["extension-module"] }
12
+
13
+ [package.metadata.maturin]
14
+ module-name = "glitchlings._typogre_rust"
@@ -0,0 +1,260 @@
1
+ use pyo3::prelude::*;
2
+ use pyo3::types::{PyAny, PyDict, PyList, PyModule};
3
+ use pyo3::Bound;
4
+ use std::collections::HashMap;
5
+
6
+ #[inline]
7
+ fn is_word_char(c: char) -> bool {
8
+ c.is_alphanumeric() || c == '_'
9
+ }
10
+
11
+ fn eligible_idx(chars: &[char], i: usize) -> bool {
12
+ if i >= chars.len() {
13
+ return false;
14
+ }
15
+ let c = chars[i];
16
+ if !is_word_char(c) {
17
+ return false;
18
+ }
19
+ if i == 0 || i + 1 >= chars.len() {
20
+ return false;
21
+ }
22
+ is_word_char(chars[i - 1]) && is_word_char(chars[i + 1])
23
+ }
24
+
25
+ fn draw_eligible_index(
26
+ rng: &Bound<'_, PyAny>,
27
+ chars: &[char],
28
+ max_tries: usize,
29
+ ) -> PyResult<Option<usize>> {
30
+ let n = chars.len();
31
+ if n == 0 {
32
+ return Ok(None);
33
+ }
34
+
35
+ for _ in 0..max_tries {
36
+ let idx: usize = rng.call_method1("randrange", (n,))?.extract()?;
37
+ if eligible_idx(chars, idx) {
38
+ return Ok(Some(idx));
39
+ }
40
+ }
41
+
42
+ let start: usize = rng.call_method1("randrange", (n,))?.extract()?;
43
+ if !eligible_idx(chars, start) {
44
+ let mut i = (start + 1) % n;
45
+ while i != start {
46
+ if eligible_idx(chars, i) {
47
+ return Ok(Some(i));
48
+ }
49
+ i = (i + 1) % n;
50
+ }
51
+ Ok(None)
52
+ } else {
53
+ Ok(Some(start))
54
+ }
55
+ }
56
+
57
+ fn neighbors_for_char(layout: &HashMap<String, Vec<String>>, ch: char) -> Vec<String> {
58
+ let lowered: String = ch.to_lowercase().collect();
59
+ layout.get(&lowered).cloned().unwrap_or_default()
60
+ }
61
+
62
+ fn python_choice<'py, T>(rng: &Bound<'py, PyAny>, candidates: &[T]) -> PyResult<Py<PyAny>>
63
+ where
64
+ T: ToPyObject,
65
+ {
66
+ let list = PyList::new_bound(rng.py(), candidates);
67
+ Ok(rng.call_method1("choice", (&list,))?.into())
68
+ }
69
+
70
+ fn remove_space(rng: &Bound<'_, PyAny>, chars: &mut Vec<char>) -> PyResult<()> {
71
+ let positions: Vec<usize> = chars
72
+ .iter()
73
+ .enumerate()
74
+ .filter_map(|(i, &c)| if c == ' ' { Some(i) } else { None })
75
+ .collect();
76
+ if positions.is_empty() {
77
+ return Ok(());
78
+ }
79
+ let idx_obj = python_choice(rng, &positions)?;
80
+ let idx: usize = idx_obj.extract(rng.py())?;
81
+ if idx < chars.len() {
82
+ chars.remove(idx);
83
+ }
84
+ Ok(())
85
+ }
86
+
87
+ fn insert_space(rng: &Bound<'_, PyAny>, chars: &mut Vec<char>) -> PyResult<()> {
88
+ if chars.len() < 2 {
89
+ return Ok(());
90
+ }
91
+ let stop = chars.len();
92
+ let idx: usize = rng.call_method1("randrange", (1, stop))?.extract()?;
93
+ if idx <= chars.len() {
94
+ chars.insert(idx, ' ');
95
+ }
96
+ Ok(())
97
+ }
98
+
99
+ fn repeat_char(rng: &Bound<'_, PyAny>, chars: &mut Vec<char>) -> PyResult<()> {
100
+ let positions: Vec<usize> = chars
101
+ .iter()
102
+ .enumerate()
103
+ .filter_map(|(i, &c)| if c.is_whitespace() { None } else { Some(i) })
104
+ .collect();
105
+ if positions.is_empty() {
106
+ return Ok(());
107
+ }
108
+ let idx_obj = python_choice(rng, &positions)?;
109
+ let idx: usize = idx_obj.extract(rng.py())?;
110
+ if idx < chars.len() {
111
+
112
+ let c = chars[idx];
113
+ chars.insert(idx, c);
114
+ }
115
+ Ok(())
116
+ }
117
+
118
+ fn collapse_duplicate(rng: &Bound<'_, PyAny>, chars: &mut Vec<char>) -> PyResult<()> {
119
+ if chars.len() < 3 {
120
+ return Ok(());
121
+ }
122
+ let mut matches: Vec<usize> = Vec::new();
123
+ let mut i = 0;
124
+ while i + 1 < chars.len() {
125
+ if chars[i] == chars[i + 1] && i + 2 < chars.len() && is_word_char(chars[i + 2]) {
126
+ matches.push(i);
127
+ i += 2;
128
+ } else {
129
+ i += 1;
130
+ }
131
+ }
132
+ if matches.is_empty() {
133
+ return Ok(());
134
+ }
135
+ let idx_obj = python_choice(rng, &matches)?;
136
+ let start: usize = idx_obj.extract(rng.py())?;
137
+ if start + 1 < chars.len() {
138
+ chars.remove(start + 1);
139
+ }
140
+ Ok(())
141
+ }
142
+
143
+ fn positional_action(
144
+ rng: &Bound<'_, PyAny>,
145
+ action: &str,
146
+ chars: &mut Vec<char>,
147
+ layout: &HashMap<String, Vec<String>>,
148
+ ) -> PyResult<()> {
149
+ let Some(idx) = draw_eligible_index(rng, chars, 16)? else {
150
+ return Ok(());
151
+ };
152
+
153
+ match action {
154
+ "char_swap" => {
155
+ if idx + 1 < chars.len() {
156
+ chars.swap(idx, idx + 1);
157
+ }
158
+ }
159
+ "missing_char" => {
160
+ if eligible_idx(chars, idx) {
161
+ chars.remove(idx);
162
+ }
163
+ }
164
+ "extra_char" => {
165
+ if idx < chars.len() {
166
+
167
+ let ch = chars[idx];
168
+ let mut neighbors = neighbors_for_char(layout, ch);
169
+ if neighbors.is_empty() {
170
+ neighbors.push(ch.to_string());
171
+ }
172
+ let choice = python_choice(rng, &neighbors)?;
173
+ let ins: String = choice.extract(rng.py())?;
174
+ let insert_chars: Vec<char> = ins.chars().collect();
175
+ chars.splice(idx..idx, insert_chars);
176
+ }
177
+ }
178
+ "nearby_char" => {
179
+ if idx < chars.len() {
180
+ let ch = chars[idx];
181
+ let neighbors = neighbors_for_char(layout, ch);
182
+ if !neighbors.is_empty() {
183
+ let choice = python_choice(rng, &neighbors)?;
184
+ let replacement: String = choice.extract(rng.py())?;
185
+ let rep_chars: Vec<char> = replacement.chars().collect();
186
+ chars.splice(idx..idx + 1, rep_chars);
187
+ }
188
+ }
189
+ }
190
+ _ => {}
191
+ }
192
+
193
+ Ok(())
194
+ }
195
+
196
+ fn global_action(rng: &Bound<'_, PyAny>, action: &str, chars: &mut Vec<char>) -> PyResult<()> {
197
+ match action {
198
+ "skipped_space" => remove_space(rng, chars)?,
199
+ "random_space" => insert_space(rng, chars)?,
200
+ "unichar" => collapse_duplicate(rng, chars)?,
201
+ "repeated_char" => repeat_char(rng, chars)?,
202
+ _ => {}
203
+ }
204
+ Ok(())
205
+ }
206
+
207
+ #[pyfunction]
208
+ fn fatfinger(
209
+ text: &str,
210
+ max_change_rate: f64,
211
+ layout: &Bound<'_, PyDict>,
212
+ rng: &Bound<'_, PyAny>,
213
+ ) -> PyResult<String> {
214
+ if text.is_empty() {
215
+ return Ok(String::new());
216
+ }
217
+
218
+ let mut chars: Vec<char> = text.chars().collect();
219
+ let mut layout_map: HashMap<String, Vec<String>> = HashMap::new();
220
+ for (key, value) in layout.iter() {
221
+ let key: String = key.extract()?;
222
+ let values: Vec<String> = value.extract()?;
223
+ layout_map.insert(key, values);
224
+ }
225
+
226
+ let length = chars.len();
227
+ let mut max_changes = (length as f64 * max_change_rate).floor() as usize;
228
+ if max_changes < 1 {
229
+ max_changes = 1;
230
+ }
231
+
232
+ let positional = ["char_swap", "missing_char", "extra_char", "nearby_char"];
233
+ let global = ["skipped_space", "random_space", "unichar", "repeated_char"];
234
+ let mut all_actions = Vec::with_capacity(positional.len() + global.len());
235
+ all_actions.extend_from_slice(&positional);
236
+ all_actions.extend_from_slice(&global);
237
+
238
+ let mut actions = Vec::with_capacity(max_changes);
239
+ for _ in 0..max_changes {
240
+ let action_obj = python_choice(rng, &all_actions)?;
241
+ let action: String = action_obj.extract(rng.py())?;
242
+ actions.push(action);
243
+ }
244
+
245
+ for action in actions {
246
+ if positional.contains(&action.as_str()) {
247
+ positional_action(rng, &action, &mut chars, &layout_map)?;
248
+ } else {
249
+ global_action(rng, &action, &mut chars)?;
250
+ }
251
+ }
252
+
253
+ Ok(chars.into_iter().collect())
254
+ }
255
+
256
+ #[pymodule]
257
+ fn _typogre_rust(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
258
+ m.add_function(wrap_pyfunction!(fatfinger, m)?)?;
259
+ Ok(())
260
+ }