glitchlings 1.0.0__cp313-cp313-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. glitchlings/__init__.py +101 -0
  2. glitchlings/__main__.py +8 -0
  3. glitchlings/_corruption_engine/__init__.py +12 -0
  4. glitchlings/_corruption_engine.cp313-win_amd64.pyd +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 +184 -0
  19. glitchlings/attack/analysis.py +1321 -0
  20. glitchlings/attack/core.py +819 -0
  21. glitchlings/attack/core_execution.py +378 -0
  22. glitchlings/attack/core_planning.py +612 -0
  23. glitchlings/attack/encode.py +114 -0
  24. glitchlings/attack/metrics.py +211 -0
  25. glitchlings/attack/metrics_dispatch.py +70 -0
  26. glitchlings/attack/tokenization.py +338 -0
  27. glitchlings/attack/tokenizer_metrics.py +373 -0
  28. glitchlings/auggie.py +285 -0
  29. glitchlings/compat/__init__.py +9 -0
  30. glitchlings/compat/loaders.py +355 -0
  31. glitchlings/compat/types.py +41 -0
  32. glitchlings/conf/__init__.py +39 -0
  33. glitchlings/conf/loaders.py +331 -0
  34. glitchlings/conf/schema.py +156 -0
  35. glitchlings/conf/types.py +72 -0
  36. glitchlings/config.toml +2 -0
  37. glitchlings/constants.py +139 -0
  38. glitchlings/dev/__init__.py +3 -0
  39. glitchlings/dev/docs.py +45 -0
  40. glitchlings/dlc/__init__.py +21 -0
  41. glitchlings/dlc/_shared.py +300 -0
  42. glitchlings/dlc/gutenberg.py +400 -0
  43. glitchlings/dlc/huggingface.py +68 -0
  44. glitchlings/dlc/langchain.py +147 -0
  45. glitchlings/dlc/nemo.py +283 -0
  46. glitchlings/dlc/prime.py +215 -0
  47. glitchlings/dlc/pytorch.py +98 -0
  48. glitchlings/dlc/pytorch_lightning.py +173 -0
  49. glitchlings/internal/__init__.py +16 -0
  50. glitchlings/internal/rust.py +159 -0
  51. glitchlings/internal/rust_ffi.py +599 -0
  52. glitchlings/main.py +426 -0
  53. glitchlings/protocols.py +91 -0
  54. glitchlings/runtime_config.py +24 -0
  55. glitchlings/util/__init__.py +41 -0
  56. glitchlings/util/adapters.py +65 -0
  57. glitchlings/util/keyboards.py +508 -0
  58. glitchlings/util/transcripts.py +108 -0
  59. glitchlings/zoo/__init__.py +161 -0
  60. glitchlings/zoo/assets/__init__.py +29 -0
  61. glitchlings/zoo/core.py +852 -0
  62. glitchlings/zoo/core_execution.py +154 -0
  63. glitchlings/zoo/core_planning.py +451 -0
  64. glitchlings/zoo/corrupt_dispatch.py +291 -0
  65. glitchlings/zoo/hokey.py +139 -0
  66. glitchlings/zoo/jargoyle.py +301 -0
  67. glitchlings/zoo/mim1c.py +269 -0
  68. glitchlings/zoo/pedant/__init__.py +109 -0
  69. glitchlings/zoo/pedant/core.py +99 -0
  70. glitchlings/zoo/pedant/forms.py +50 -0
  71. glitchlings/zoo/pedant/stones.py +83 -0
  72. glitchlings/zoo/redactyl.py +94 -0
  73. glitchlings/zoo/rng.py +280 -0
  74. glitchlings/zoo/rushmore.py +416 -0
  75. glitchlings/zoo/scannequin.py +370 -0
  76. glitchlings/zoo/transforms.py +331 -0
  77. glitchlings/zoo/typogre.py +194 -0
  78. glitchlings/zoo/validation.py +643 -0
  79. glitchlings/zoo/wherewolf.py +120 -0
  80. glitchlings/zoo/zeedub.py +165 -0
  81. glitchlings-1.0.0.dist-info/METADATA +404 -0
  82. glitchlings-1.0.0.dist-info/RECORD +86 -0
  83. glitchlings-1.0.0.dist-info/WHEEL +5 -0
  84. glitchlings-1.0.0.dist-info/entry_points.txt +3 -0
  85. glitchlings-1.0.0.dist-info/licenses/LICENSE +201 -0
  86. glitchlings-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,643 @@
1
+ """Boundary validation layer for glitchling parameters.
2
+
3
+ This module centralizes all input validation, type coercion, and defensive checks
4
+ for glitchling parameters. Functions here are called at module boundaries (CLI,
5
+ public API entry points, configuration loaders) to ensure that invalid data is
6
+ rejected early.
7
+
8
+ **Design Philosophy:**
9
+
10
+ All functions in this module are *pure* - they perform validation and coercion
11
+ based solely on their inputs, without side effects. They are intended to be
12
+ called once at the boundary where untrusted input enters the system. Core
13
+ transformation functions that call these validation helpers can then trust
14
+ their inputs without re-validating.
15
+
16
+ See AGENTS.md "Functional Purity Architecture" for full details.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import re
23
+ from collections.abc import Collection, Iterable, Mapping, Sequence
24
+ from dataclasses import dataclass
25
+ from typing import Literal, TypeVar, cast
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Rate Validation (universal)
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def clamp_rate(value: float, *, allow_nan: bool = False) -> float:
33
+ """Clamp a rate value to [0.0, infinity), optionally treating NaN as 0.0.
34
+
35
+ Args:
36
+ value: The rate to clamp.
37
+ allow_nan: If False (default), NaN values become 0.0.
38
+
39
+ Returns:
40
+ The clamped rate value.
41
+ """
42
+ if math.isnan(value):
43
+ return 0.0 if not allow_nan else value
44
+ return max(0.0, value)
45
+
46
+
47
+ def clamp_rate_unit(value: float, *, allow_nan: bool = False) -> float:
48
+ """Clamp a rate value to [0.0, 1.0], optionally treating NaN as 0.0.
49
+
50
+ Args:
51
+ value: The rate to clamp.
52
+ allow_nan: If False (default), NaN values become 0.0.
53
+
54
+ Returns:
55
+ The clamped rate value in range [0.0, 1.0].
56
+ """
57
+ if math.isnan(value):
58
+ return 0.0 if not allow_nan else value
59
+ return max(0.0, min(1.0, value))
60
+
61
+
62
+ def resolve_rate(
63
+ value: float | None,
64
+ default: float,
65
+ *,
66
+ clamp: bool = True,
67
+ unit_interval: bool = False,
68
+ ) -> float:
69
+ """Resolve a rate parameter, applying defaults and optional clamping.
70
+
71
+ Args:
72
+ value: The user-provided rate, or None for default.
73
+ default: The default rate to use when value is None.
74
+ clamp: Whether to clamp the result to non-negative.
75
+ unit_interval: If True, clamp to [0.0, 1.0] instead of [0.0, inf).
76
+
77
+ Returns:
78
+ The resolved, optionally clamped rate.
79
+ """
80
+ effective = default if value is None else value
81
+ if not clamp:
82
+ return effective
83
+ return clamp_rate_unit(effective) if unit_interval else clamp_rate(effective)
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Mim1c Validation
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def normalise_mim1c_classes(
92
+ value: object,
93
+ ) -> tuple[str, ...] | Literal["all"] | None:
94
+ """Normalize Mim1c homoglyph class specification.
95
+
96
+ Args:
97
+ value: User input - None, "all", a single class name, or an iterable.
98
+
99
+ Returns:
100
+ Normalized tuple of class names, literal "all", or None.
101
+
102
+ Raises:
103
+ TypeError: If value is not None, string, or iterable.
104
+ """
105
+ if value is None:
106
+ return None
107
+ if isinstance(value, str):
108
+ if value.lower() == "all":
109
+ return "all"
110
+ return (value,)
111
+ if isinstance(value, Iterable):
112
+ return tuple(str(item) for item in value)
113
+ raise TypeError("classes must be an iterable of strings or 'all'")
114
+
115
+
116
+ def normalise_mim1c_banned(value: object) -> tuple[str, ...] | None:
117
+ """Normalize Mim1c banned character specification.
118
+
119
+ Args:
120
+ value: User input - None, a string of characters, or an iterable.
121
+
122
+ Returns:
123
+ Normalized tuple of banned characters, or None.
124
+
125
+ Raises:
126
+ TypeError: If value is not None, string, or iterable.
127
+ """
128
+ if value is None:
129
+ return None
130
+ if isinstance(value, str):
131
+ return tuple(value)
132
+ if isinstance(value, Iterable):
133
+ return tuple(str(item) for item in value)
134
+ raise TypeError("banned_characters must be an iterable of strings")
135
+
136
+
137
+ # Valid Mim1c homoglyph mode values
138
+ _MIM1C_MODE_VALUES: frozenset[str] = frozenset(
139
+ {
140
+ "single_script",
141
+ "mixed_script",
142
+ "compatibility",
143
+ "aggressive",
144
+ }
145
+ )
146
+
147
+ # Mode aliases for user convenience
148
+ _MIM1C_MODE_ALIASES: dict[str, str] = {
149
+ "single": "single_script",
150
+ "singlescript": "single_script",
151
+ "single_script": "single_script",
152
+ "mixed": "mixed_script",
153
+ "mixedscript": "mixed_script",
154
+ "mixed_script": "mixed_script",
155
+ "compat": "compatibility",
156
+ "compatibility": "compatibility",
157
+ "all": "aggressive",
158
+ "aggressive": "aggressive",
159
+ }
160
+
161
+
162
+ def normalize_mim1c_mode(
163
+ mode: str | None,
164
+ default: str = "mixed_script",
165
+ ) -> str:
166
+ """Normalize Mim1c homoglyph mode.
167
+
168
+ Args:
169
+ mode: User-provided mode string, or None for default.
170
+ default: Default mode when input is None.
171
+
172
+ Returns:
173
+ Normalized mode string.
174
+
175
+ Raises:
176
+ ValueError: If mode is not recognized.
177
+ """
178
+ if mode is None:
179
+ return default
180
+ normalized = mode.lower().replace("-", "_").replace(" ", "_")
181
+ canonical = _MIM1C_MODE_ALIASES.get(normalized)
182
+ if canonical is None:
183
+ raise ValueError(
184
+ f"Invalid homoglyph mode '{mode}'. "
185
+ f"Expected one of: {', '.join(sorted(_MIM1C_MODE_VALUES))}"
186
+ )
187
+ return canonical
188
+
189
+
190
+ def normalize_mim1c_max_consecutive(
191
+ max_consecutive: int | None,
192
+ default: int = 3,
193
+ ) -> int:
194
+ """Normalize Mim1c max_consecutive constraint.
195
+
196
+ Args:
197
+ max_consecutive: User-provided limit, or None for default.
198
+ default: Default max_consecutive value.
199
+
200
+ Returns:
201
+ Normalized max_consecutive value (non-negative).
202
+ """
203
+ if max_consecutive is None:
204
+ return default
205
+ return max(0, int(max_consecutive))
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Wherewolf Validation
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ def normalise_homophone_group(group: Sequence[str]) -> tuple[str, ...]:
214
+ """Return a tuple of lowercase homophones preserving original order.
215
+
216
+ Uses dict.fromkeys to preserve ordering while de-duplicating.
217
+
218
+ Args:
219
+ group: Sequence of homophone words.
220
+
221
+ Returns:
222
+ De-duplicated tuple of lowercase words.
223
+ """
224
+ return tuple(dict.fromkeys(word.lower() for word in group if word))
225
+
226
+
227
+ def build_homophone_lookup(
228
+ groups: Iterable[Sequence[str]],
229
+ ) -> Mapping[str, tuple[str, ...]]:
230
+ """Return a mapping from word -> homophone group.
231
+
232
+ Args:
233
+ groups: Iterable of homophone word groups.
234
+
235
+ Returns:
236
+ Dictionary mapping each word to its normalized group.
237
+ """
238
+ lookup: dict[str, tuple[str, ...]] = {}
239
+ for group in groups:
240
+ normalised = normalise_homophone_group(group)
241
+ if len(normalised) < 2:
242
+ continue
243
+ for word in normalised:
244
+ lookup[word] = normalised
245
+ return lookup
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Rushmore Validation
250
+ # ---------------------------------------------------------------------------
251
+
252
+ # Import enum locally to avoid circular dependencies at module level
253
+ # The RushmoreMode enum is defined in rushmore.py but we need its values here
254
+ # for mode validation. We use string-based validation to avoid the import cycle.
255
+
256
+ _RUSHMORE_MODE_ALIASES: dict[str, str] = {
257
+ "delete": "delete",
258
+ "drop": "delete",
259
+ "rushmore": "delete",
260
+ "duplicate": "duplicate",
261
+ "reduplicate": "duplicate",
262
+ "repeat": "duplicate",
263
+ "swap": "swap",
264
+ "adjacent": "swap",
265
+ }
266
+
267
+ _RUSHMORE_EXECUTION_ORDER: tuple[str, ...] = ("delete", "duplicate", "swap")
268
+
269
+
270
+ def normalize_rushmore_mode_item(value: str) -> list[str]:
271
+ """Parse a single Rushmore mode specification into canonical mode names.
272
+
273
+ Args:
274
+ value: A mode name, alias, or compound expression like "delete+duplicate".
275
+
276
+ Returns:
277
+ List of canonical mode names ("delete", "duplicate", "swap").
278
+
279
+ Raises:
280
+ ValueError: If the mode name is not recognized.
281
+ """
282
+ text = str(value).strip().lower()
283
+ if not text:
284
+ return []
285
+
286
+ if text in {"all", "any", "full"}:
287
+ return list(_RUSHMORE_EXECUTION_ORDER)
288
+
289
+ tokens = [token for token in re.split(r"[+,\s]+", text) if token]
290
+ if not tokens:
291
+ return []
292
+
293
+ modes: list[str] = []
294
+ for token in tokens:
295
+ mode = _RUSHMORE_MODE_ALIASES.get(token)
296
+ if mode is None:
297
+ raise ValueError(f"Unsupported Rushmore mode '{value}'")
298
+ modes.append(mode)
299
+ return modes
300
+
301
+
302
+ def normalize_rushmore_modes(
303
+ modes: str | Iterable[str] | None,
304
+ *,
305
+ default: str = "delete",
306
+ ) -> tuple[str, ...]:
307
+ """Normalize Rushmore mode specification to canonical tuple.
308
+
309
+ Args:
310
+ modes: User input - None, single mode string, or iterable of modes.
311
+ default: Default mode when input is None or empty.
312
+
313
+ Returns:
314
+ Tuple of unique canonical mode names in insertion order.
315
+ """
316
+ if modes is None:
317
+ candidates: Sequence[str] = (default,)
318
+ elif isinstance(modes, str):
319
+ candidates = (modes,)
320
+ else:
321
+ collected = tuple(modes)
322
+ candidates = collected if collected else (default,)
323
+
324
+ resolved: list[str] = []
325
+ seen: set[str] = set()
326
+ for candidate in candidates:
327
+ for mode in normalize_rushmore_mode_item(candidate):
328
+ if mode not in seen:
329
+ seen.add(mode)
330
+ resolved.append(mode)
331
+
332
+ if not resolved:
333
+ return (default,)
334
+ return tuple(resolved)
335
+
336
+
337
+ @dataclass(frozen=True)
338
+ class RushmoreRateConfig:
339
+ """Resolved rate configuration for a single Rushmore mode."""
340
+
341
+ mode: str
342
+ rate: float
343
+ is_default: bool = False
344
+
345
+
346
+ def resolve_rushmore_mode_rate(
347
+ *,
348
+ mode: str,
349
+ global_rate: float | None,
350
+ specific_rate: float | None,
351
+ default_rates: Mapping[str, float],
352
+ allow_default: bool,
353
+ ) -> float | None:
354
+ """Resolve the effective rate for a single Rushmore mode.
355
+
356
+ Args:
357
+ mode: The canonical mode name ("delete", "duplicate", "swap").
358
+ global_rate: User-provided global rate, or None.
359
+ specific_rate: User-provided mode-specific rate, or None.
360
+ default_rates: Mapping of mode names to default rates.
361
+ allow_default: Whether to fall back to defaults when no rate provided.
362
+
363
+ Returns:
364
+ The resolved rate, or None if no rate available and defaults disallowed.
365
+ """
366
+ baseline = specific_rate if specific_rate is not None else global_rate
367
+ if baseline is None:
368
+ if not allow_default:
369
+ return None
370
+ baseline = default_rates.get(mode)
371
+ if baseline is None:
372
+ return None
373
+
374
+ value = float(baseline)
375
+ value = max(0.0, value)
376
+ if mode == "swap":
377
+ value = min(1.0, value)
378
+ return value
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Keyboard Layout Validation
383
+ # ---------------------------------------------------------------------------
384
+
385
+ T = TypeVar("T")
386
+
387
+
388
+ def validate_keyboard_layout(
389
+ keyboard: str,
390
+ layouts: object,
391
+ *,
392
+ context: str = "keyboard layout",
393
+ ) -> Mapping[str, Sequence[str]]:
394
+ """Validate that a keyboard layout name exists and return its mapping.
395
+
396
+ Args:
397
+ keyboard: The layout name to look up.
398
+ layouts: Object with layout names as attributes (e.g., KEYNEIGHBORS).
399
+ context: Description for error messages.
400
+
401
+ Returns:
402
+ The keyboard neighbor mapping.
403
+
404
+ Raises:
405
+ RuntimeError: If the layout name is not found.
406
+ """
407
+ layout = getattr(layouts, keyboard, None)
408
+ if layout is None:
409
+ raise RuntimeError(f"Unknown {context} '{keyboard}'")
410
+ return cast(Mapping[str, Sequence[str]], layout)
411
+
412
+
413
+ def get_keyboard_layout_or_default(
414
+ keyboard: str,
415
+ layouts: object,
416
+ *,
417
+ default: Mapping[str, Sequence[str]] | None = None,
418
+ ) -> Mapping[str, Sequence[str]] | None:
419
+ """Look up a keyboard layout, returning None or default if not found.
420
+
421
+ Args:
422
+ keyboard: The layout name to look up.
423
+ layouts: Object with layout names as attributes.
424
+ default: Value to return if layout not found.
425
+
426
+ Returns:
427
+ The keyboard neighbor mapping, or default if not found.
428
+ """
429
+ layout = getattr(layouts, keyboard, None)
430
+ if layout is None:
431
+ return default
432
+ return cast(Mapping[str, Sequence[str]], layout)
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # Zeedub Validation
437
+ # ---------------------------------------------------------------------------
438
+
439
+ # Valid visibility mode values
440
+ _ZEEDUB_VISIBILITY_MODES: frozenset[str] = frozenset(
441
+ {
442
+ "glyphless",
443
+ "with_joiners",
444
+ "semi_visible",
445
+ }
446
+ )
447
+
448
+ # Valid placement mode values
449
+ _ZEEDUB_PLACEMENT_MODES: frozenset[str] = frozenset(
450
+ {
451
+ "random",
452
+ "grapheme_boundary",
453
+ "script_aware",
454
+ }
455
+ )
456
+
457
+
458
+ def normalize_zero_width_palette(
459
+ characters: Sequence[str] | None,
460
+ default: tuple[str, ...],
461
+ ) -> tuple[str, ...]:
462
+ """Normalize zero-width character palette, filtering empty entries.
463
+
464
+ Args:
465
+ characters: User-provided character sequence, or None for default.
466
+ default: Default character palette.
467
+
468
+ Returns:
469
+ Tuple of non-empty characters.
470
+ """
471
+ palette: Sequence[str] = tuple(characters) if characters is not None else default
472
+ return tuple(char for char in palette if char)
473
+
474
+
475
+ def normalize_zeedub_visibility(
476
+ visibility: str | None,
477
+ default: str = "glyphless",
478
+ ) -> str:
479
+ """Normalize Zeedub visibility mode.
480
+
481
+ Args:
482
+ visibility: User-provided visibility mode, or None for default.
483
+ default: Default visibility mode.
484
+
485
+ Returns:
486
+ Normalized visibility mode string.
487
+
488
+ Raises:
489
+ ValueError: If visibility mode is not recognized.
490
+ """
491
+ if visibility is None:
492
+ return default
493
+ mode = visibility.lower()
494
+ if mode not in _ZEEDUB_VISIBILITY_MODES:
495
+ raise ValueError(
496
+ f"Invalid visibility mode '{visibility}'. "
497
+ f"Expected one of: {', '.join(sorted(_ZEEDUB_VISIBILITY_MODES))}"
498
+ )
499
+ return mode
500
+
501
+
502
+ def normalize_zeedub_placement(
503
+ placement: str | None,
504
+ default: str = "random",
505
+ ) -> str:
506
+ """Normalize Zeedub placement mode.
507
+
508
+ Args:
509
+ placement: User-provided placement mode, or None for default.
510
+ default: Default placement mode.
511
+
512
+ Returns:
513
+ Normalized placement mode string.
514
+
515
+ Raises:
516
+ ValueError: If placement mode is not recognized.
517
+ """
518
+ if placement is None:
519
+ return default
520
+ mode = placement.lower()
521
+ if mode not in _ZEEDUB_PLACEMENT_MODES:
522
+ raise ValueError(
523
+ f"Invalid placement mode '{placement}'. "
524
+ f"Expected one of: {', '.join(sorted(_ZEEDUB_PLACEMENT_MODES))}"
525
+ )
526
+ return mode
527
+
528
+
529
+ def normalize_zeedub_max_consecutive(
530
+ max_consecutive: int | None,
531
+ default: int = 4,
532
+ ) -> int:
533
+ """Normalize Zeedub max_consecutive constraint.
534
+
535
+ Args:
536
+ max_consecutive: User-provided limit, or None for default.
537
+ default: Default max_consecutive value.
538
+
539
+ Returns:
540
+ Normalized max_consecutive value (non-negative).
541
+ """
542
+ if max_consecutive is None:
543
+ return default
544
+ return max(0, int(max_consecutive))
545
+
546
+
547
+ # ---------------------------------------------------------------------------
548
+ # Redactyl Validation
549
+ # ---------------------------------------------------------------------------
550
+
551
+
552
+ def normalize_replacement_char(
553
+ replacement_char: str | None,
554
+ default: str,
555
+ ) -> str:
556
+ """Normalize redaction replacement character.
557
+
558
+ Args:
559
+ replacement_char: User-provided character, or None for default.
560
+ default: Default replacement character.
561
+
562
+ Returns:
563
+ The replacement character as a string.
564
+ """
565
+ return default if replacement_char is None else str(replacement_char)
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Boolean Flag Helpers
570
+ # ---------------------------------------------------------------------------
571
+
572
+
573
+ def resolve_bool_flag(
574
+ specific: bool | None,
575
+ global_default: bool,
576
+ ) -> bool:
577
+ """Resolve a boolean flag with specific/global precedence.
578
+
579
+ Args:
580
+ specific: Specific override value, or None to use global.
581
+ global_default: Global default when specific is None.
582
+
583
+ Returns:
584
+ The resolved boolean flag.
585
+ """
586
+ return bool(specific if specific is not None else global_default)
587
+
588
+
589
+ # ---------------------------------------------------------------------------
590
+ # Collection Helpers
591
+ # ---------------------------------------------------------------------------
592
+
593
+
594
+ def normalize_string_collection(
595
+ value: str | Collection[str] | None,
596
+ ) -> tuple[str, ...] | None:
597
+ """Normalize a string or collection of strings to a tuple.
598
+
599
+ Args:
600
+ value: Single string, collection of strings, or None.
601
+
602
+ Returns:
603
+ Tuple of strings, or None if input is None.
604
+ """
605
+ if value is None:
606
+ return None
607
+ if isinstance(value, str):
608
+ return (value,)
609
+ return tuple(value)
610
+
611
+
612
+ __all__ = [
613
+ # Rate validation
614
+ "clamp_rate",
615
+ "clamp_rate_unit",
616
+ "resolve_rate",
617
+ # Mim1c
618
+ "normalise_mim1c_classes",
619
+ "normalise_mim1c_banned",
620
+ "normalize_mim1c_mode",
621
+ "normalize_mim1c_max_consecutive",
622
+ # Wherewolf
623
+ "normalise_homophone_group",
624
+ "build_homophone_lookup",
625
+ # Rushmore
626
+ "normalize_rushmore_mode_item",
627
+ "normalize_rushmore_modes",
628
+ "resolve_rushmore_mode_rate",
629
+ "RushmoreRateConfig",
630
+ # Keyboard
631
+ "validate_keyboard_layout",
632
+ "get_keyboard_layout_or_default",
633
+ # Zeedub
634
+ "normalize_zero_width_palette",
635
+ "normalize_zeedub_visibility",
636
+ "normalize_zeedub_placement",
637
+ "normalize_zeedub_max_consecutive",
638
+ # Redactyl
639
+ "normalize_replacement_char",
640
+ # Flags and helpers
641
+ "resolve_bool_flag",
642
+ "normalize_string_collection",
643
+ ]