glitchlings 0.2.5__cp312-cp312-win_amd64.whl → 0.9.3__cp312-cp312-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 (85) hide show
  1. glitchlings/__init__.py +36 -17
  2. glitchlings/__main__.py +0 -1
  3. glitchlings/_zoo_rust/__init__.py +12 -0
  4. glitchlings/_zoo_rust.cp312-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/pipeline_assets.json +29 -0
  17. glitchlings/attack/__init__.py +53 -0
  18. glitchlings/attack/compose.py +299 -0
  19. glitchlings/attack/core.py +465 -0
  20. glitchlings/attack/encode.py +114 -0
  21. glitchlings/attack/metrics.py +104 -0
  22. glitchlings/attack/metrics_dispatch.py +70 -0
  23. glitchlings/attack/tokenization.py +157 -0
  24. glitchlings/auggie.py +283 -0
  25. glitchlings/compat/__init__.py +9 -0
  26. glitchlings/compat/loaders.py +355 -0
  27. glitchlings/compat/types.py +41 -0
  28. glitchlings/conf/__init__.py +41 -0
  29. glitchlings/conf/loaders.py +331 -0
  30. glitchlings/conf/schema.py +156 -0
  31. glitchlings/conf/types.py +72 -0
  32. glitchlings/config.toml +2 -0
  33. glitchlings/constants.py +59 -0
  34. glitchlings/dev/__init__.py +3 -0
  35. glitchlings/dev/docs.py +45 -0
  36. glitchlings/dlc/__init__.py +17 -3
  37. glitchlings/dlc/_shared.py +296 -0
  38. glitchlings/dlc/gutenberg.py +400 -0
  39. glitchlings/dlc/huggingface.py +37 -65
  40. glitchlings/dlc/prime.py +55 -114
  41. glitchlings/dlc/pytorch.py +98 -0
  42. glitchlings/dlc/pytorch_lightning.py +173 -0
  43. glitchlings/internal/__init__.py +16 -0
  44. glitchlings/internal/rust.py +159 -0
  45. glitchlings/internal/rust_ffi.py +432 -0
  46. glitchlings/main.py +123 -32
  47. glitchlings/runtime_config.py +24 -0
  48. glitchlings/util/__init__.py +29 -176
  49. glitchlings/util/adapters.py +65 -0
  50. glitchlings/util/keyboards.py +311 -0
  51. glitchlings/util/transcripts.py +108 -0
  52. glitchlings/zoo/__init__.py +47 -24
  53. glitchlings/zoo/assets/__init__.py +29 -0
  54. glitchlings/zoo/core.py +301 -167
  55. glitchlings/zoo/core_execution.py +98 -0
  56. glitchlings/zoo/core_planning.py +451 -0
  57. glitchlings/zoo/corrupt_dispatch.py +295 -0
  58. glitchlings/zoo/ekkokin.py +118 -0
  59. glitchlings/zoo/hokey.py +137 -0
  60. glitchlings/zoo/jargoyle.py +179 -274
  61. glitchlings/zoo/mim1c.py +106 -68
  62. glitchlings/zoo/pedant/__init__.py +107 -0
  63. glitchlings/zoo/pedant/core.py +105 -0
  64. glitchlings/zoo/pedant/forms.py +74 -0
  65. glitchlings/zoo/pedant/stones.py +74 -0
  66. glitchlings/zoo/redactyl.py +44 -175
  67. glitchlings/zoo/rng.py +259 -0
  68. glitchlings/zoo/rushmore.py +359 -116
  69. glitchlings/zoo/scannequin.py +18 -125
  70. glitchlings/zoo/transforms.py +386 -0
  71. glitchlings/zoo/typogre.py +76 -162
  72. glitchlings/zoo/validation.py +477 -0
  73. glitchlings/zoo/zeedub.py +33 -86
  74. glitchlings-0.9.3.dist-info/METADATA +334 -0
  75. glitchlings-0.9.3.dist-info/RECORD +80 -0
  76. {glitchlings-0.2.5.dist-info → glitchlings-0.9.3.dist-info}/entry_points.txt +1 -0
  77. glitchlings/zoo/_ocr_confusions.py +0 -34
  78. glitchlings/zoo/_rate.py +0 -21
  79. glitchlings/zoo/reduple.py +0 -169
  80. glitchlings-0.2.5.dist-info/METADATA +0 -490
  81. glitchlings-0.2.5.dist-info/RECORD +0 -27
  82. /glitchlings/{zoo → assets}/ocr_confusions.tsv +0 -0
  83. {glitchlings-0.2.5.dist-info → glitchlings-0.9.3.dist-info}/WHEEL +0 -0
  84. {glitchlings-0.2.5.dist-info → glitchlings-0.9.3.dist-info}/licenses/LICENSE +0 -0
  85. {glitchlings-0.2.5.dist-info → glitchlings-0.9.3.dist-info}/top_level.txt +0 -0
glitchlings/zoo/mim1c.py CHANGED
@@ -1,11 +1,55 @@
1
- from collections.abc import Collection
1
+ """Rust-backed Mim1c glitchling that swaps characters for homoglyphs."""
2
+
3
+ from __future__ import annotations
4
+
2
5
  import random
3
- from typing import Literal
6
+ from collections.abc import Collection, Iterable
7
+ from typing import Literal, cast
8
+
9
+ from glitchlings.constants import DEFAULT_MIM1C_RATE, MIM1C_DEFAULT_CLASSES
10
+ from glitchlings.internal.rust_ffi import mim1c_rust, resolve_seed
11
+
12
+ from .core import AttackOrder, AttackWave, Glitchling, PipelineOperationPayload
13
+
14
+
15
+ def _normalise_classes(
16
+ value: object,
17
+ ) -> tuple[str, ...] | Literal["all"] | None:
18
+ if value is None:
19
+ return None
20
+ if isinstance(value, str):
21
+ if value.lower() == "all":
22
+ return "all"
23
+ return (value,)
24
+ if isinstance(value, Iterable):
25
+ return tuple(str(item) for item in value)
26
+ raise TypeError("classes must be an iterable of strings or 'all'")
4
27
 
5
- from confusable_homoglyphs import confusables
6
28
 
7
- from .core import AttackOrder, AttackWave, Glitchling
8
- from ._rate import resolve_rate
29
+ def _normalise_banned(value: object) -> tuple[str, ...] | None:
30
+ if value is None:
31
+ return None
32
+ if isinstance(value, str):
33
+ return tuple(value)
34
+ if isinstance(value, Iterable):
35
+ return tuple(str(item) for item in value)
36
+ raise TypeError("banned_characters must be an iterable of strings")
37
+
38
+
39
+ def _serialise_classes(
40
+ value: tuple[str, ...] | Literal["all"] | None,
41
+ ) -> list[str] | Literal["all"] | None:
42
+ if value is None:
43
+ return None
44
+ if value == "all":
45
+ return "all"
46
+ return list(value)
47
+
48
+
49
+ def _serialise_banned(value: tuple[str, ...] | None) -> list[str] | None:
50
+ if value is None:
51
+ return None
52
+ return list(value)
9
53
 
10
54
 
11
55
  def swap_homoglyphs(
@@ -15,81 +59,48 @@ def swap_homoglyphs(
15
59
  banned_characters: Collection[str] | None = None,
16
60
  seed: int | None = None,
17
61
  rng: random.Random | None = None,
18
- *,
19
- replacement_rate: float | None = None,
20
62
  ) -> str:
21
- """Replace characters with visually confusable homoglyphs.
22
-
23
- Parameters
24
- - text: Input text.
25
- - rate: Max proportion of eligible characters to replace (default 0.02).
26
- - classes: Restrict replacements to these Unicode script classes (default ["LATIN","GREEK","CYRILLIC"]). Use "all" to allow any.
27
- - banned_characters: Characters that must never appear as replacements.
28
- - seed: Optional seed if `rng` not provided.
29
- - rng: Optional RNG; overrides seed.
30
-
31
- Notes
32
- - Only replaces characters present in confusables.confusables_data with single-codepoint alternatives.
33
- - Maintains determinism by shuffling candidates and sampling via the provided RNG.
34
- """
35
- effective_rate = resolve_rate(
36
- rate=rate,
37
- legacy_value=replacement_rate,
38
- default=0.02,
39
- legacy_name="replacement_rate",
40
- )
63
+ """Replace characters with visually confusable homoglyphs via the Rust engine."""
64
+
65
+ effective_rate = DEFAULT_MIM1C_RATE if rate is None else rate
41
66
 
42
- if rng is None:
43
- rng = random.Random(seed)
44
-
45
- if classes is None:
46
- classes = ["LATIN", "GREEK", "CYRILLIC"]
47
-
48
- target_chars = [char for char in text if char.isalnum()]
49
- confusable_chars = [
50
- char for char in target_chars if char in confusables.confusables_data
51
- ]
52
- clamped_rate = max(0.0, effective_rate)
53
- num_replacements = int(len(confusable_chars) * clamped_rate)
54
- done = 0
55
- rng.shuffle(confusable_chars)
56
- banned_set = set(banned_characters or ())
57
- for char in confusable_chars:
58
- if done >= num_replacements:
59
- break
60
- options = [
61
- o["c"] for o in confusables.confusables_data[char] if len(o["c"]) == 1
62
- ]
63
- if classes != "all":
64
- options = [opt for opt in options if confusables.alias(opt) in classes]
65
- if banned_set:
66
- options = [opt for opt in options if opt not in banned_set]
67
- if not options:
68
- continue
69
- text = text.replace(char, rng.choice(options), 1)
70
- done += 1
71
- return text
67
+ normalised_classes = _normalise_classes(classes)
68
+ normalised_banned = _normalise_banned(banned_characters)
69
+
70
+ if normalised_classes is None:
71
+ payload_classes: list[str] | Literal["all"] | None = list(MIM1C_DEFAULT_CLASSES)
72
+ else:
73
+ payload_classes = _serialise_classes(normalised_classes)
74
+ payload_banned = _serialise_banned(normalised_banned)
75
+
76
+ return mim1c_rust(
77
+ text,
78
+ effective_rate,
79
+ payload_classes,
80
+ payload_banned,
81
+ resolve_seed(seed, rng),
82
+ )
72
83
 
73
84
 
74
85
  class Mim1c(Glitchling):
75
86
  """Glitchling that swaps characters for visually similar homoglyphs."""
76
87
 
88
+ flavor = (
89
+ "Breaks your parser by replacing some characters in strings with "
90
+ "doppelgangers. Don't worry, this text is clean. ;)"
91
+ )
92
+
77
93
  def __init__(
78
94
  self,
79
95
  *,
80
96
  rate: float | None = None,
81
- replacement_rate: float | None = None,
82
97
  classes: list[str] | Literal["all"] | None = None,
83
98
  banned_characters: Collection[str] | None = None,
84
99
  seed: int | None = None,
85
100
  ) -> None:
86
- self._param_aliases = {"replacement_rate": "rate"}
87
- effective_rate = resolve_rate(
88
- rate=rate,
89
- legacy_value=replacement_rate,
90
- default=0.02,
91
- legacy_name="replacement_rate",
92
- )
101
+ effective_rate = DEFAULT_MIM1C_RATE if rate is None else rate
102
+ normalised_classes = _normalise_classes(classes)
103
+ normalised_banned = _normalise_banned(banned_characters)
93
104
  super().__init__(
94
105
  name="Mim1c",
95
106
  corruption_function=swap_homoglyphs,
@@ -97,12 +108,39 @@ class Mim1c(Glitchling):
97
108
  order=AttackOrder.LAST,
98
109
  seed=seed,
99
110
  rate=effective_rate,
100
- classes=classes,
101
- banned_characters=banned_characters,
111
+ classes=normalised_classes,
112
+ banned_characters=normalised_banned,
102
113
  )
103
114
 
115
+ def pipeline_operation(self) -> PipelineOperationPayload:
116
+ rate_value = self.kwargs.get("rate")
117
+ rate = DEFAULT_MIM1C_RATE if rate_value is None else float(rate_value)
118
+
119
+ descriptor: dict[str, object] = {"type": "mimic", "rate": rate}
120
+
121
+ classes = self.kwargs.get("classes")
122
+ serialised_classes = _serialise_classes(classes)
123
+ if serialised_classes is not None:
124
+ descriptor["classes"] = serialised_classes
125
+
126
+ banned = self.kwargs.get("banned_characters")
127
+ serialised_banned = _serialise_banned(banned)
128
+ if serialised_banned:
129
+ descriptor["banned_characters"] = serialised_banned
130
+
131
+ return cast(PipelineOperationPayload, descriptor)
132
+
133
+ def set_param(self, key: str, value: object) -> None:
134
+ if key == "classes":
135
+ super().set_param(key, _normalise_classes(value))
136
+ return
137
+ if key == "banned_characters":
138
+ super().set_param(key, _normalise_banned(value))
139
+ return
140
+ super().set_param(key, value)
141
+
104
142
 
105
143
  mim1c = Mim1c()
106
144
 
107
145
 
108
- __all__ = ["Mim1c", "mim1c"]
146
+ __all__ = ["Mim1c", "mim1c", "swap_homoglyphs"]
@@ -0,0 +1,107 @@
1
+ """Pedant glitchling integrating grammar evolutions with Rust acceleration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from typing import Any, cast
7
+
8
+ from glitchlings.internal.rust_ffi import resolve_seed
9
+
10
+ from ..core import AttackOrder, AttackWave, Glitchling, PipelineOperationPayload
11
+ from .core import EVOLUTIONS, PedantBase, apply_pedant
12
+ from .stones import STONES, PedantStone
13
+
14
+
15
+ def _coerce_stone(value: Any) -> PedantStone:
16
+ """Return a :class:`PedantStone` enum member for ``value``."""
17
+
18
+ return PedantStone.from_value(value)
19
+
20
+
21
+ def pedant_transform(
22
+ text: str,
23
+ *,
24
+ stone: PedantStone | str = PedantStone.COEURITE,
25
+ seed: int | None = None,
26
+ rng: random.Random | None = None,
27
+ ) -> str:
28
+ """Apply a pedant evolution to text."""
29
+
30
+ pedant_stone = _coerce_stone(stone)
31
+ if pedant_stone not in EVOLUTIONS:
32
+ raise ValueError(f"Unknown pedant stone: {stone!r}")
33
+
34
+ effective_seed = resolve_seed(seed, rng)
35
+
36
+ return apply_pedant(
37
+ text,
38
+ stone=pedant_stone,
39
+ seed=effective_seed,
40
+ )
41
+
42
+
43
+ def _build_pipeline_descriptor(glitch: Glitchling) -> PipelineOperationPayload:
44
+ stone_value = glitch.kwargs.get("stone")
45
+ if stone_value is None:
46
+ message = "Pedant requires a stone to build the pipeline descriptor"
47
+ raise RuntimeError(message)
48
+
49
+ pedant_stone = _coerce_stone(stone_value)
50
+
51
+ return cast(
52
+ PipelineOperationPayload,
53
+ {"type": "pedant", "stone": pedant_stone.label},
54
+ )
55
+
56
+
57
+ class Pedant(Glitchling):
58
+ """Glitchling that deterministically applies pedant evolutions."""
59
+
60
+ _param_aliases = {
61
+ "form": "stone",
62
+ "stone_name": "stone",
63
+ }
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ stone: PedantStone | str = PedantStone.COEURITE,
69
+ seed: int | None = None,
70
+ ) -> None:
71
+ super().__init__(
72
+ name="Pedant",
73
+ corruption_function=pedant_transform,
74
+ scope=AttackWave.WORD,
75
+ order=AttackOrder.LATE,
76
+ seed=seed,
77
+ pipeline_operation=_build_pipeline_descriptor,
78
+ stone=_coerce_stone(stone),
79
+ )
80
+ if seed is not None:
81
+ self.set_param("seed", int(seed))
82
+
83
+ def set_param(self, key: str, value: object) -> None:
84
+ if key in {"stone", "form", "stone_name"}:
85
+ super().set_param(key, _coerce_stone(value))
86
+ return
87
+ super().set_param(key, value)
88
+
89
+ def reset_rng(self, seed: int | None = None) -> None:
90
+ super().reset_rng(seed)
91
+ if self.seed is None:
92
+ self.kwargs.pop("seed", None)
93
+ return
94
+ self.kwargs["seed"] = int(self.seed)
95
+
96
+
97
+ pedant = Pedant()
98
+
99
+ __all__ = [
100
+ "PedantBase",
101
+ "Pedant",
102
+ "pedant",
103
+ "pedant_transform",
104
+ "EVOLUTIONS",
105
+ "STONES",
106
+ "PedantStone",
107
+ ]
@@ -0,0 +1,105 @@
1
+ """Core classes for the pedant evolution chain backed by the Rust pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Type
6
+
7
+ from glitchlings.internal.rust_ffi import pedant_rust
8
+
9
+ from ..core import Gaggle
10
+ from .stones import PedantStone
11
+
12
+
13
+ def apply_pedant(
14
+ text: str,
15
+ *,
16
+ stone: PedantStone,
17
+ seed: int,
18
+ ) -> str:
19
+ """Apply a pedant transformation via the Rust extension."""
20
+
21
+ return pedant_rust(
22
+ text,
23
+ stone=stone.label,
24
+ seed=int(seed),
25
+ )
26
+
27
+
28
+ class PedantEvolution:
29
+ """Concrete pedant form that delegates to the Rust implementation."""
30
+
31
+ stone: PedantStone
32
+
33
+ def __init__(
34
+ self,
35
+ seed: int,
36
+ *,
37
+ stone: PedantStone | None = None,
38
+ ) -> None:
39
+ resolved_stone = stone or getattr(self, "stone", None)
40
+ if resolved_stone is None: # pragma: no cover - defensive guard
41
+ raise ValueError("PedantEvolution requires a PedantStone")
42
+ self.seed = int(seed)
43
+ self.stone = resolved_stone
44
+
45
+ def move(self, text: str) -> str:
46
+ result = apply_pedant(text, stone=self.stone, seed=self.seed)
47
+ return result
48
+
49
+
50
+ class PedantBase:
51
+ """Base pedant capable of evolving into specialised grammar forms."""
52
+
53
+ name: str = "Pedant"
54
+ type: str = "Normal"
55
+ flavor: str = "A novice grammarian waiting to evolve."
56
+
57
+ def __init__(self, seed: int, *, root_seed: int | None = None) -> None:
58
+ self.seed = int(seed)
59
+ self.root_seed = int(seed if root_seed is None else root_seed)
60
+
61
+ def evolve(self, stone: PedantStone | str) -> PedantEvolution:
62
+ pedant_stone = PedantStone.from_value(stone)
63
+ form_cls = EVOLUTIONS.get(pedant_stone)
64
+ if form_cls is None: # pragma: no cover - sanity guard
65
+ raise KeyError(f"Unknown stone: {stone}")
66
+ derived_seed = Gaggle.derive_seed(self.root_seed, pedant_stone.label, 0)
67
+ return form_cls(seed=int(derived_seed))
68
+
69
+ def move(self, text: str) -> str:
70
+ return text
71
+
72
+ def __repr__(self) -> str: # pragma: no cover - debugging helper
73
+ return f"<{self.__class__.__name__} seed={self.seed} type={self.type}>"
74
+
75
+
76
+ EVOLUTIONS: Dict[PedantStone, Type[PedantEvolution]] = {}
77
+
78
+
79
+ try: # pragma: no cover - import resolution occurs at runtime
80
+ from .forms import (
81
+ Aetheria,
82
+ Apostrofae,
83
+ Commama,
84
+ Correctopus,
85
+ Fewerling,
86
+ Kiloa,
87
+ Subjunic,
88
+ Whomst,
89
+ )
90
+ except ImportError: # pragma: no cover - partial imports during type checking
91
+ pass
92
+ else:
93
+ EVOLUTIONS = {
94
+ PedantStone.WHOM: Whomst,
95
+ PedantStone.FEWERITE: Fewerling,
96
+ PedantStone.COEURITE: Aetheria,
97
+ PedantStone.CURLITE: Apostrofae,
98
+ PedantStone.SUBJUNCTITE: Subjunic,
99
+ PedantStone.OXFORDIUM: Commama,
100
+ PedantStone.ORTHOGONITE: Correctopus,
101
+ PedantStone.METRICITE: Kiloa,
102
+ }
103
+
104
+
105
+ __all__ = ["PedantBase", "PedantEvolution", "EVOLUTIONS", "apply_pedant"]
@@ -0,0 +1,74 @@
1
+ """Pedant evolution forms delegating to the Rust-backed core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .core import PedantEvolution
6
+ from .stones import PedantStone
7
+
8
+
9
+ class Whomst(PedantEvolution):
10
+ stone = PedantStone.WHOM
11
+ name = "Whomst"
12
+ type = "Ghost"
13
+ flavor = "Insists upon objective-case precision."
14
+
15
+
16
+ class Fewerling(PedantEvolution):
17
+ stone = PedantStone.FEWERITE
18
+ name = "Fewerling"
19
+ type = "Fairy"
20
+ flavor = "Counts only countable nouns."
21
+
22
+
23
+ class Aetheria(PedantEvolution):
24
+ stone = PedantStone.COEURITE
25
+ name = "Aetheria"
26
+ type = "Psychic"
27
+ flavor = "Resurrects archaic ligatures and diacritics."
28
+
29
+
30
+ class Apostrofae(PedantEvolution):
31
+ stone = PedantStone.CURLITE
32
+ name = "Apostrofae"
33
+ type = "Fairy"
34
+ flavor = "Curves quotes into typeset perfection."
35
+
36
+
37
+ class Subjunic(PedantEvolution):
38
+ stone = PedantStone.SUBJUNCTITE
39
+ name = "Subjunic"
40
+ type = "Psychic"
41
+ flavor = "Corrects the subjunctive wherever it can."
42
+
43
+
44
+ class Commama(PedantEvolution):
45
+ stone = PedantStone.OXFORDIUM
46
+ name = "Commama"
47
+ type = "Steel"
48
+ flavor = "Oxonian hero of the list."
49
+
50
+
51
+ class Kiloa(PedantEvolution):
52
+ stone = PedantStone.METRICITE
53
+ name = "Kiloa"
54
+ type = "Electric"
55
+ flavor = "Measures the world in rational units."
56
+
57
+
58
+ class Correctopus(PedantEvolution):
59
+ stone = PedantStone.ORTHOGONITE
60
+ name = "Correctopus"
61
+ type = "Dragon"
62
+ flavor = "The final editor, breathing blue ink."
63
+
64
+
65
+ __all__ = [
66
+ "Whomst",
67
+ "Fewerling",
68
+ "Aetheria",
69
+ "Apostrofae",
70
+ "Subjunic",
71
+ "Commama",
72
+ "Kiloa",
73
+ "Correctopus",
74
+ ]
@@ -0,0 +1,74 @@
1
+ """Evolution stones recognised by the pedant."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Stone:
11
+ """Descriptor for an evolution stone."""
12
+
13
+ name: str
14
+ type: str
15
+ effect: str
16
+
17
+
18
+ class PedantStone(Enum):
19
+ """Enumeration of evolution stones available to the pedant."""
20
+
21
+ WHOM = Stone("Whom Stone", "Ghost", "Encourages object-pronoun precision.")
22
+ FEWERITE = Stone("Fewerite", "Fairy", "Obsesses over countable quantities.")
23
+ COEURITE = Stone("Coeurite", "Psychic", "Restores archaic ligatures to modern words.")
24
+ CURLITE = Stone(
25
+ "Curlite",
26
+ "Fairy",
27
+ "Coaches punctuation to embrace typographic curls.",
28
+ )
29
+ SUBJUNCTITE = Stone("Subjunctite", "Psychic", "Demands contrary-to-fact phrasing.")
30
+ OXFORDIUM = Stone("Oxfordium", "Steel", "Polishes serial comma usage.")
31
+ ORTHOGONITE = Stone("Orthogonite", "Dragon", "Unlocks the legendary Correctopus.")
32
+ METRICITE = Stone("Metricite", "Electric", "Compels metrication of measures.")
33
+
34
+ @property
35
+ def descriptor(self) -> Stone:
36
+ """Return the metadata describing this stone."""
37
+
38
+ return self.value
39
+
40
+ @property
41
+ def label(self) -> str:
42
+ """Return the display name for this stone."""
43
+
44
+ return self.value.name
45
+
46
+ def __str__(self) -> str: # pragma: no cover - convenience for reprs/CLI echo
47
+ return self.label
48
+
49
+ @classmethod
50
+ def from_value(cls, value: object) -> "PedantStone":
51
+ """Normalise ``value`` into a :class:`PedantStone` member."""
52
+
53
+ if isinstance(value, cls):
54
+ return value
55
+ if isinstance(value, Stone):
56
+ for member in cls:
57
+ if member.value == value:
58
+ return member
59
+ msg = f"Unknown pedant stone descriptor: {value!r}"
60
+ raise ValueError(msg)
61
+
62
+ try:
63
+ return _STONE_BY_NAME[str(value)]
64
+ except KeyError as exc:
65
+ raise ValueError(f"Unknown pedant stone: {value!r}") from exc
66
+
67
+
68
+ _STONE_BY_NAME: dict[str, PedantStone] = {stone.value.name: stone for stone in PedantStone}
69
+
70
+
71
+ STONES: dict[str, Stone] = {stone.label: stone.descriptor for stone in PedantStone}
72
+
73
+
74
+ __all__ = ["Stone", "PedantStone", "STONES"]