glitchlings 0.2.1__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.
@@ -0,0 +1,275 @@
1
+ import random
2
+ import re
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal, cast
6
+
7
+ import nltk
8
+ from nltk.corpus.reader import WordNetCorpusReader
9
+ from nltk.data import find
10
+
11
+ from .core import AttackWave, Glitchling
12
+
13
+ try: # pragma: no cover - exercised when the namespace package is present
14
+ from nltk.corpus import wordnet as _WORDNET_MODULE
15
+ except ModuleNotFoundError: # pragma: no cover - triggered on modern NLTK installs
16
+ _WORDNET_MODULE = None
17
+
18
+ _WORDNET_HANDLE: WordNetCorpusReader | Any | None = _WORDNET_MODULE
19
+
20
+ _wordnet_ready = False
21
+
22
+
23
+ def _load_wordnet_reader() -> WordNetCorpusReader:
24
+ """Return a WordNet corpus reader from the downloaded corpus files."""
25
+
26
+ try:
27
+ root = find("corpora/wordnet")
28
+ except LookupError:
29
+ try:
30
+ zip_root = find("corpora/wordnet.zip")
31
+ except LookupError as exc:
32
+ raise RuntimeError(
33
+ "The NLTK WordNet corpus is not installed; run `nltk.download('wordnet')`."
34
+ ) from exc
35
+ root = zip_root.join("wordnet/")
36
+
37
+ return WordNetCorpusReader(root, None)
38
+
39
+
40
+ def _wordnet(force_refresh: bool = False) -> WordNetCorpusReader | Any:
41
+ """Retrieve the active WordNet handle, rebuilding it on demand."""
42
+
43
+ global _WORDNET_HANDLE
44
+
45
+ if force_refresh:
46
+ _WORDNET_HANDLE = _WORDNET_MODULE
47
+
48
+ if _WORDNET_HANDLE is not None:
49
+ return _WORDNET_HANDLE
50
+
51
+ _WORDNET_HANDLE = _load_wordnet_reader()
52
+ return _WORDNET_HANDLE
53
+
54
+
55
+ def ensure_wordnet() -> None:
56
+ """Ensure the WordNet corpus is available before use."""
57
+
58
+ global _wordnet_ready
59
+ if _wordnet_ready:
60
+ return
61
+
62
+ resource = _wordnet()
63
+
64
+ try:
65
+ resource.ensure_loaded()
66
+ except LookupError:
67
+ nltk.download("wordnet", quiet=True)
68
+ try:
69
+ resource = _wordnet(force_refresh=True)
70
+ resource.ensure_loaded()
71
+ except LookupError as exc: # pragma: no cover - only triggered when download fails
72
+ raise RuntimeError(
73
+ "Unable to load NLTK WordNet corpus for the jargoyle glitchling."
74
+ ) from exc
75
+
76
+ _wordnet_ready = True
77
+
78
+
79
+ # Backwards compatibility for callers relying on the previous private helper name.
80
+ _ensure_wordnet = ensure_wordnet
81
+
82
+
83
+ PartOfSpeech = Literal["n", "v", "a", "r"]
84
+ PartOfSpeechInput = PartOfSpeech | Iterable[PartOfSpeech] | Literal["any"]
85
+ NormalizedPartsOfSpeech = tuple[PartOfSpeech, ...]
86
+
87
+ _VALID_POS: tuple[PartOfSpeech, ...] = ("n", "v", "a", "r")
88
+
89
+
90
+ def _split_token(token: str) -> tuple[str, str, str]:
91
+ """Split a token into leading punctuation, core word, and trailing punctuation."""
92
+
93
+ match = re.match(r"^(\W*)(.*?)(\W*)$", token)
94
+ if not match:
95
+ return "", token, ""
96
+ prefix, core, suffix = match.groups()
97
+ return prefix, core, suffix
98
+
99
+
100
+ def _normalize_parts_of_speech(part_of_speech: PartOfSpeechInput) -> NormalizedPartsOfSpeech:
101
+ """Coerce user input into a tuple of valid WordNet POS tags."""
102
+
103
+ if isinstance(part_of_speech, str):
104
+ lowered = part_of_speech.lower()
105
+ if lowered == "any":
106
+ return _VALID_POS
107
+ if lowered not in _VALID_POS:
108
+ raise ValueError(
109
+ "part_of_speech must be one of 'n', 'v', 'a', 'r', or 'any'"
110
+ )
111
+ return (cast(PartOfSpeech, lowered),)
112
+
113
+ normalized: list[PartOfSpeech] = []
114
+ for pos in part_of_speech:
115
+ if pos not in _VALID_POS:
116
+ raise ValueError(
117
+ "part_of_speech entries must be one of 'n', 'v', 'a', or 'r'"
118
+ )
119
+ if pos not in normalized:
120
+ normalized.append(pos)
121
+ if not normalized:
122
+ raise ValueError("part_of_speech iterable may not be empty")
123
+ return tuple(normalized)
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class CandidateInfo:
128
+ """Metadata for a candidate token that may be replaced."""
129
+
130
+ prefix: str
131
+ core_word: str
132
+ suffix: str
133
+ parts_of_speech: NormalizedPartsOfSpeech
134
+
135
+
136
+ def _collect_synonyms(
137
+ word: str, parts_of_speech: NormalizedPartsOfSpeech
138
+ ) -> list[str]:
139
+ """Gather deterministic synonym candidates for the supplied word."""
140
+
141
+ normalized_word = word.lower()
142
+ wordnet = _wordnet()
143
+ synonyms: set[str] = set()
144
+ for pos_tag in parts_of_speech:
145
+ synsets = wordnet.synsets(word, pos=pos_tag)
146
+ if not synsets:
147
+ continue
148
+
149
+ for synset in synsets:
150
+ lemmas_list = [lemma.name() for lemma in cast(Any, synset).lemmas()]
151
+ if not lemmas_list:
152
+ continue
153
+
154
+ filtered = []
155
+ for lemma_str in lemmas_list:
156
+ cleaned = lemma_str.replace("_", " ")
157
+ if cleaned.lower() != normalized_word:
158
+ filtered.append(cleaned)
159
+
160
+ if filtered:
161
+ synonyms.update(filtered)
162
+ break
163
+
164
+ if synonyms:
165
+ break
166
+
167
+ return sorted(synonyms)
168
+
169
+
170
+ def substitute_random_synonyms(
171
+ text: str,
172
+ replacement_rate: float = 0.1,
173
+ part_of_speech: PartOfSpeechInput = "n",
174
+ seed: int | None = None,
175
+ rng: random.Random | None = None,
176
+ ) -> str:
177
+ """Replace words with random WordNet synonyms.
178
+
179
+ Parameters
180
+ - text: Input text.
181
+ - replacement_rate: Max proportion of candidate words to replace (default 0.1).
182
+ - part_of_speech: WordNet POS tag(s) to target. Accepts "n", "v", "a", "r",
183
+ any iterable of those tags, or "any" to include all four.
184
+ - rng: Optional RNG instance used for deterministic sampling.
185
+ - seed: Optional seed if `rng` not provided.
186
+
187
+ Determinism
188
+ - Candidates collected in left-to-right order; no set() reordering.
189
+ - Replacement positions chosen via rng.sample.
190
+ - Synonyms sorted before rng.choice to fix ordering.
191
+ - For each POS, the first synset containing alternate lemmas is used for stability.
192
+ """
193
+ ensure_wordnet()
194
+ wordnet = _wordnet()
195
+
196
+ active_rng: random.Random
197
+ if rng is not None:
198
+ active_rng = rng
199
+ else:
200
+ active_rng = random.Random(seed)
201
+
202
+ target_pos = _normalize_parts_of_speech(part_of_speech)
203
+
204
+ # Split but keep whitespace separators so we can rebuild easily
205
+ tokens = re.split(r"(\s+)", text)
206
+
207
+ # Collect indices of candidate tokens (even positions 0,2,.. are words given our split design)
208
+ candidate_indices: list[int] = []
209
+ candidate_metadata: dict[int, CandidateInfo] = {}
210
+ for idx, tok in enumerate(tokens):
211
+ if idx % 2 == 0 and tok and not tok.isspace():
212
+ prefix, core_word, suffix = _split_token(tok)
213
+ if not core_word:
214
+ continue
215
+
216
+ available_pos: NormalizedPartsOfSpeech = tuple(
217
+ pos for pos in target_pos if wordnet.synsets(core_word, pos=pos)
218
+ )
219
+ if available_pos:
220
+ candidate_indices.append(idx)
221
+ candidate_metadata[idx] = CandidateInfo(
222
+ prefix=prefix,
223
+ core_word=core_word,
224
+ suffix=suffix,
225
+ parts_of_speech=available_pos,
226
+ )
227
+
228
+ if not candidate_indices:
229
+ return text
230
+
231
+ max_replacements = int(len(candidate_indices) * replacement_rate)
232
+ if max_replacements <= 0:
233
+ return text
234
+
235
+ # Choose which positions to replace deterministically via rng.sample
236
+ replace_positions = active_rng.sample(candidate_indices, k=max_replacements)
237
+ # Process in ascending order to avoid affecting later indices
238
+ replace_positions.sort()
239
+
240
+ for pos in replace_positions:
241
+ metadata = candidate_metadata[pos]
242
+ synonyms = _collect_synonyms(metadata.core_word, metadata.parts_of_speech)
243
+ if not synonyms:
244
+ continue
245
+
246
+ replacement = active_rng.choice(synonyms)
247
+ tokens[pos] = f"{metadata.prefix}{replacement}{metadata.suffix}"
248
+
249
+ return "".join(tokens)
250
+
251
+
252
+ class Jargoyle(Glitchling):
253
+ """Glitchling that swaps words with random WordNet synonyms."""
254
+
255
+ def __init__(
256
+ self,
257
+ *,
258
+ replacement_rate: float = 0.1,
259
+ part_of_speech: PartOfSpeechInput = "n",
260
+ seed: int | None = None,
261
+ ) -> None:
262
+ super().__init__(
263
+ name="Jargoyle",
264
+ corruption_function=substitute_random_synonyms,
265
+ scope=AttackWave.WORD,
266
+ seed=seed,
267
+ replacement_rate=replacement_rate,
268
+ part_of_speech=part_of_speech,
269
+ )
270
+
271
+
272
+ jargoyle = Jargoyle()
273
+
274
+
275
+ __all__ = ["Jargoyle", "ensure_wordnet", "jargoyle"]
@@ -0,0 +1,89 @@
1
+ from collections.abc import Collection
2
+ import random
3
+ from typing import Literal
4
+
5
+ from confusable_homoglyphs import confusables
6
+
7
+ from .core import AttackOrder, AttackWave, Glitchling
8
+
9
+
10
+ def swap_homoglyphs(
11
+ text: str,
12
+ replacement_rate: float = 0.02,
13
+ classes: list[str] | Literal["all"] | None = None,
14
+ banned_characters: Collection[str] | None = None,
15
+ seed: int | None = None,
16
+ rng: random.Random | None = None,
17
+ ) -> str:
18
+ """Replace characters with visually confusable homoglyphs.
19
+
20
+ Parameters
21
+ - text: Input text.
22
+ - replacement_rate: Max proportion of eligible characters to replace (default 0.02).
23
+ - classes: Restrict replacements to these Unicode script classes (default ["LATIN","GREEK","CYRILLIC"]). Use "all" to allow any.
24
+ - banned_characters: Characters that must never appear as replacements.
25
+ - seed: Optional seed if `rng` not provided.
26
+ - rng: Optional RNG; overrides seed.
27
+
28
+ Notes
29
+ - Only replaces characters present in confusables.confusables_data with single-codepoint alternatives.
30
+ - Maintains determinism by shuffling candidates and sampling via the provided RNG.
31
+ """
32
+ if rng is None:
33
+ rng = random.Random(seed)
34
+
35
+ if classes is None:
36
+ classes = ["LATIN", "GREEK", "CYRILLIC"]
37
+
38
+ target_chars = [char for char in text if char.isalnum()]
39
+ confusable_chars = [
40
+ char for char in target_chars if char in confusables.confusables_data
41
+ ]
42
+ num_replacements = int(len(confusable_chars) * replacement_rate)
43
+ done = 0
44
+ rng.shuffle(confusable_chars)
45
+ banned_set = set(banned_characters or ())
46
+ for char in confusable_chars:
47
+ if done >= num_replacements:
48
+ break
49
+ options = [
50
+ o["c"] for o in confusables.confusables_data[char] if len(o["c"]) == 1
51
+ ]
52
+ if classes != "all":
53
+ options = [opt for opt in options if confusables.alias(opt) in classes]
54
+ if banned_set:
55
+ options = [opt for opt in options if opt not in banned_set]
56
+ if not options:
57
+ continue
58
+ text = text.replace(char, rng.choice(options), 1)
59
+ done += 1
60
+ return text
61
+
62
+
63
+ class Mim1c(Glitchling):
64
+ """Glitchling that swaps characters for visually similar homoglyphs."""
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ replacement_rate: float = 0.02,
70
+ classes: list[str] | Literal["all"] | None = None,
71
+ banned_characters: Collection[str] | None = None,
72
+ seed: int | None = None,
73
+ ) -> None:
74
+ super().__init__(
75
+ name="Mim1c",
76
+ corruption_function=swap_homoglyphs,
77
+ scope=AttackWave.CHARACTER,
78
+ order=AttackOrder.LAST,
79
+ seed=seed,
80
+ replacement_rate=replacement_rate,
81
+ classes=classes,
82
+ banned_characters=banned_characters,
83
+ )
84
+
85
+
86
+ mim1c = Mim1c()
87
+
88
+
89
+ __all__ = ["Mim1c", "mim1c"]
@@ -0,0 +1,128 @@
1
+ import re
2
+ import random
3
+
4
+ from .core import Glitchling, AttackWave
5
+
6
+ FULL_BLOCK = "█"
7
+
8
+
9
+ try:
10
+ from glitchlings._zoo_rust import redact_words as _redact_words_rust
11
+ except ImportError: # pragma: no cover - compiled extension not present
12
+ _redact_words_rust = None
13
+
14
+
15
+ def _python_redact_words(
16
+ text: str,
17
+ *,
18
+ replacement_char: str,
19
+ redaction_rate: float,
20
+ merge_adjacent: bool,
21
+ rng: random.Random,
22
+ ) -> str:
23
+ """Redact random words by replacing their characters.
24
+
25
+ Parameters
26
+ - text: Input text.
27
+ - replacement_char: The character to use for redaction (default FULL_BLOCK).
28
+ - redaction_rate: Max proportion of words to redact (default 0.05).
29
+ - merge_adjacent: If True, merges adjacent redactions across intervening non-word chars.
30
+ - seed: Seed used if `rng` not provided (default 151).
31
+ - rng: Optional RNG; overrides seed.
32
+ """
33
+ # Preserve exact spacing and punctuation by using regex
34
+ tokens = re.split(r"(\s+)", text)
35
+ word_indices = [i for i, token in enumerate(tokens) if i % 2 == 0 and token.strip()]
36
+ if not word_indices:
37
+ raise ValueError("Cannot redact words because the input text contains no redactable words.")
38
+ num_to_redact = max(1, int(len(word_indices) * redaction_rate))
39
+
40
+ # Sample from the indices of actual words
41
+ indices_to_redact = rng.sample(word_indices, k=num_to_redact)
42
+ indices_to_redact.sort()
43
+
44
+ for i in indices_to_redact:
45
+ if i >= len(tokens):
46
+ break
47
+
48
+ word = tokens[i]
49
+ if not word or word.isspace(): # Skip empty or whitespace
50
+ continue
51
+
52
+ # Check if word has trailing punctuation
53
+ match = re.match(r"^(\W*)(.*?)(\W*)$", word)
54
+ if match:
55
+ prefix, core, suffix = match.groups()
56
+ tokens[i] = f"{prefix}{replacement_char * len(core)}{suffix}"
57
+ else:
58
+ tokens[i] = f"{replacement_char * len(word)}"
59
+
60
+ text = "".join(tokens)
61
+
62
+ if merge_adjacent:
63
+ text = re.sub(
64
+ rf"{replacement_char}\W+{replacement_char}",
65
+ lambda m: replacement_char * (len(m.group(0)) - 1),
66
+ text,
67
+ )
68
+
69
+ return text
70
+
71
+
72
+ def redact_words(
73
+ text: str,
74
+ replacement_char: str = FULL_BLOCK,
75
+ redaction_rate: float = 0.05,
76
+ merge_adjacent: bool = False,
77
+ seed: int = 151,
78
+ rng: random.Random | None = None,
79
+ ) -> str:
80
+ """Redact random words by replacing their characters."""
81
+
82
+ if rng is None:
83
+ rng = random.Random(seed)
84
+
85
+ if _redact_words_rust is not None:
86
+ return _redact_words_rust(
87
+ text,
88
+ replacement_char,
89
+ redaction_rate,
90
+ merge_adjacent,
91
+ rng,
92
+ )
93
+
94
+ return _python_redact_words(
95
+ text,
96
+ replacement_char=replacement_char,
97
+ redaction_rate=redaction_rate,
98
+ merge_adjacent=merge_adjacent,
99
+ rng=rng,
100
+ )
101
+
102
+
103
+ class Redactyl(Glitchling):
104
+ """Glitchling that redacts words with block characters."""
105
+
106
+ def __init__(
107
+ self,
108
+ *,
109
+ replacement_char: str = FULL_BLOCK,
110
+ redaction_rate: float = 0.05,
111
+ merge_adjacent: bool = False,
112
+ seed: int = 151,
113
+ ) -> None:
114
+ super().__init__(
115
+ name="Redactyl",
116
+ corruption_function=redact_words,
117
+ scope=AttackWave.WORD,
118
+ seed=seed,
119
+ replacement_char=replacement_char,
120
+ redaction_rate=redaction_rate,
121
+ merge_adjacent=merge_adjacent,
122
+ )
123
+
124
+
125
+ redactyl = Redactyl()
126
+
127
+
128
+ __all__ = ["Redactyl", "redactyl"]
@@ -0,0 +1,100 @@
1
+ import re
2
+ import random
3
+
4
+ from .core import Glitchling, AttackWave
5
+
6
+ try:
7
+ from glitchlings._zoo_rust import reduplicate_words as _reduplicate_words_rust
8
+ except ImportError: # pragma: no cover - compiled extension not present
9
+ _reduplicate_words_rust = None
10
+
11
+
12
+ def _python_reduplicate_words(
13
+ text: str,
14
+ *,
15
+ reduplication_rate: float,
16
+ rng: random.Random,
17
+ ) -> str:
18
+ """Randomly reduplicate words in the text.
19
+
20
+ Parameters
21
+ - text: Input text.
22
+ - reduplication_rate: Max proportion of words to reduplicate (default 0.05).
23
+ - seed: Optional seed if `rng` not provided.
24
+ - rng: Optional RNG; overrides seed.
25
+
26
+ Notes
27
+ - Preserves spacing and punctuation by tokenizing with separators.
28
+ - Deterministic when run with a fixed seed or via Gaggle.
29
+ """
30
+ # Preserve exact spacing and punctuation by using regex
31
+ tokens = re.split(r"(\s+)", text) # Split but keep separators
32
+
33
+ for i in range(0, len(tokens), 2): # Every other token is a word
34
+ if i >= len(tokens):
35
+ break
36
+
37
+ word = tokens[i]
38
+ if not word or word.isspace(): # Skip empty or whitespace
39
+ continue
40
+
41
+ # Only consider actual words for reduplication
42
+ if rng.random() < reduplication_rate:
43
+ # Check if word has trailing punctuation
44
+ match = re.match(r"^(\W*)(.*?)(\W*)$", word)
45
+ if match:
46
+ prefix, core, suffix = match.groups()
47
+ # Reduplicate with a space: "word" -> "word word"
48
+ tokens[i] = f"{prefix}{core} {core}{suffix}"
49
+ else:
50
+ tokens[i] = f"{word} {word}"
51
+ return "".join(tokens)
52
+
53
+
54
+ def reduplicate_words(
55
+ text: str,
56
+ reduplication_rate: float = 0.05,
57
+ seed: int | None = None,
58
+ rng: random.Random | None = None,
59
+ ) -> str:
60
+ """Randomly reduplicate words in the text.
61
+
62
+ Falls back to the Python implementation when the optional Rust
63
+ extension is unavailable.
64
+ """
65
+
66
+ if rng is None:
67
+ rng = random.Random(seed)
68
+
69
+ if _reduplicate_words_rust is not None:
70
+ return _reduplicate_words_rust(text, reduplication_rate, rng)
71
+
72
+ return _python_reduplicate_words(
73
+ text,
74
+ reduplication_rate=reduplication_rate,
75
+ rng=rng,
76
+ )
77
+
78
+
79
+ class Reduple(Glitchling):
80
+ """Glitchling that repeats words to simulate stuttering speech."""
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ reduplication_rate: float = 0.05,
86
+ seed: int | None = None,
87
+ ) -> None:
88
+ super().__init__(
89
+ name="Reduple",
90
+ corruption_function=reduplicate_words,
91
+ scope=AttackWave.WORD,
92
+ seed=seed,
93
+ reduplication_rate=reduplication_rate,
94
+ )
95
+
96
+
97
+ reduple = Reduple()
98
+
99
+
100
+ __all__ = ["Reduple", "reduple"]
@@ -0,0 +1,104 @@
1
+ import math
2
+ import random
3
+ import re
4
+
5
+ from .core import Glitchling, AttackWave
6
+
7
+ try:
8
+ from glitchlings._zoo_rust import delete_random_words as _delete_random_words_rust
9
+ except ImportError: # pragma: no cover - compiled extension not present
10
+ _delete_random_words_rust = None
11
+
12
+
13
+ def _python_delete_random_words(
14
+ text: str,
15
+ *,
16
+ max_deletion_rate: float,
17
+ rng: random.Random,
18
+ ) -> str:
19
+ """Delete random words from the input text while preserving whitespace."""
20
+
21
+ tokens = re.split(r"(\s+)", text) # Split but keep separators for later rejoin
22
+
23
+ candidate_indices: list[int] = []
24
+ for i in range(2, len(tokens), 2): # Every other token is a word, skip the first word
25
+ word = tokens[i]
26
+ if not word or word.isspace():
27
+ continue
28
+
29
+ candidate_indices.append(i)
30
+
31
+ allowed_deletions = min(
32
+ len(candidate_indices), math.floor(len(candidate_indices) * max_deletion_rate)
33
+ )
34
+ if allowed_deletions <= 0:
35
+ return text
36
+
37
+ deletions = 0
38
+ for i in candidate_indices:
39
+ if rng.random() < max_deletion_rate:
40
+ word = tokens[i]
41
+ match = re.match(r"^(\W*)(.*?)(\W*)$", word)
42
+ if match:
43
+ prefix, _, suffix = match.groups()
44
+ tokens[i] = f"{prefix.strip()}{suffix.strip()}"
45
+ else:
46
+ tokens[i] = ""
47
+
48
+ deletions += 1
49
+ if deletions >= allowed_deletions:
50
+ break
51
+
52
+ text = "".join(tokens)
53
+ text = re.sub(r"\s+([.,;:])", r"\1", text)
54
+ text = re.sub(r"\s{2,}", " ", text).strip()
55
+
56
+ return text
57
+
58
+
59
+ def delete_random_words(
60
+ text: str,
61
+ max_deletion_rate: float = 0.01,
62
+ seed: int | None = None,
63
+ rng: random.Random | None = None,
64
+ ) -> str:
65
+ """Delete random words from the input text.
66
+
67
+ Uses the optional Rust implementation when available.
68
+ """
69
+
70
+ if rng is None:
71
+ rng = random.Random(seed)
72
+
73
+ if _delete_random_words_rust is not None:
74
+ return _delete_random_words_rust(text, max_deletion_rate, rng)
75
+
76
+ return _python_delete_random_words(
77
+ text,
78
+ max_deletion_rate=max_deletion_rate,
79
+ rng=rng,
80
+ )
81
+
82
+
83
+ class Rushmore(Glitchling):
84
+ """Glitchling that deletes words to simulate missing information."""
85
+
86
+ def __init__(
87
+ self,
88
+ *,
89
+ max_deletion_rate: float = 0.01,
90
+ seed: int | None = None,
91
+ ) -> None:
92
+ super().__init__(
93
+ name="Rushmore",
94
+ corruption_function=delete_random_words,
95
+ scope=AttackWave.WORD,
96
+ seed=seed,
97
+ max_deletion_rate=max_deletion_rate,
98
+ )
99
+
100
+
101
+ rushmore = Rushmore()
102
+
103
+
104
+ __all__ = ["Rushmore", "rushmore"]