dnakit 0.1.0.dev0__py3-none-any.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 (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,991 @@
1
+ """Reproducible DNA sequence generation and structural variants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+ from collections import Counter
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from itertools import pairwise
11
+ from typing import Literal, TypeAlias, cast
12
+
13
+ from dnakit.core import DNASequence, Topology
14
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
15
+ from dnakit.ops._common import require_sequence
16
+ from dnakit.ops.mutation import RandomSource, RandomState
17
+
18
+ EvolutionAugmentation: TypeAlias = Literal[
19
+ "mutation",
20
+ "deletion",
21
+ "translocation",
22
+ "insertion",
23
+ "inversion",
24
+ "reverse_complement",
25
+ ]
26
+ IndelOperation: TypeAlias = Literal["insertion", "deletion"]
27
+ RearrangementOperation: TypeAlias = Literal["exchange", "inversion", "duplication"]
28
+
29
+ DEFAULT_EVOLUTION_AUGMENTATIONS: tuple[EvolutionAugmentation, ...] = (
30
+ "deletion",
31
+ "insertion",
32
+ "translocation",
33
+ "inversion",
34
+ "reverse_complement",
35
+ "mutation",
36
+ )
37
+
38
+ _EVOLUTION_PRIORITY: tuple[EvolutionAugmentation, ...] = (
39
+ "inversion",
40
+ "deletion",
41
+ "translocation",
42
+ "insertion",
43
+ "reverse_complement",
44
+ "mutation",
45
+ )
46
+ _EVOLUTION_PRIORITY_INDEX = {name: index for index, name in enumerate(_EVOLUTION_PRIORITY)}
47
+ _CANONICAL_BASES = "ACGT"
48
+ _MUTATION_ALTERNATIVES = {
49
+ base: tuple(candidate for candidate in _CANONICAL_BASES if candidate != base)
50
+ for base in _CANONICAL_BASES
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class EvolutionStep:
56
+ """Audit details for one selected EvoAug operation."""
57
+
58
+ augmentation: EvolutionAugmentation
59
+ applied: bool
60
+ start: int | None = None
61
+ end: int | None = None
62
+ length: int | None = None
63
+ shift: int | None = None
64
+ mutation_attempts: int | None = None
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class EvolutionGenerationResult:
69
+ """Generated sequence, selected operations, and reproducibility metadata."""
70
+
71
+ sequence: DNASequence
72
+ steps: tuple[EvolutionStep, ...]
73
+ seed: int | None
74
+ random_source: RandomSource
75
+ rng_algorithm_version: int
76
+ rng_state_before: RandomState
77
+ rng_state_after: RandomState
78
+ algorithm_version: str = "dnakit-evoaug-v3"
79
+
80
+ @property
81
+ def augmentations(self) -> tuple[EvolutionAugmentation, ...]:
82
+ """Return the selected operations in their applied priority order."""
83
+
84
+ return tuple(step.augmentation for step in self.steps)
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class RearrangementResult:
89
+ """A rearranged sequence plus the generated segment layout audit."""
90
+
91
+ sequence: DNASequence
92
+ operation: RearrangementOperation
93
+ breakpoints: tuple[int, ...]
94
+ permutation: tuple[int, ...] | None
95
+ selected_segment: int | None
96
+ seed: int | None
97
+ random_source: RandomSource
98
+ rng_algorithm_version: int
99
+ rng_state_before: RandomState
100
+ rng_state_after: RandomState
101
+ algorithm_version: str = "dnakit-rearrangement-v1"
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class KmerShuffleResult:
106
+ """A k-mer-count-preserving shuffle plus its reproducibility audit."""
107
+
108
+ sequence: DNASequence
109
+ k: int
110
+ kmer_counts: tuple[tuple[str, int], ...]
111
+ attempts: int
112
+ seed: int | None
113
+ random_source: RandomSource
114
+ rng_algorithm_version: int
115
+ rng_state_before: RandomState
116
+ rng_state_after: RandomState
117
+ algorithm_version: str = "dnakit-kmer-shuffle-v1"
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class CrossoverResult:
122
+ """A one-point crossover child plus its parent-coordinate audit."""
123
+
124
+ sequence: DNASequence
125
+ position: int
126
+ first_parent_length: int
127
+ second_parent_length: int
128
+ seed: int | None
129
+ random_source: RandomSource = "none"
130
+ rng_algorithm_version: int | None = None
131
+ rng_state_before: RandomState | None = None
132
+ rng_state_after: RandomState | None = None
133
+ algorithm_version: str = "dnakit-crossover-v1"
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class _EvolutionParameters:
138
+ mut_frac: float
139
+ insert_frac: float
140
+ delete_frac: float
141
+ insert_min: int
142
+ insert_max: int
143
+ shift_min: int
144
+ shift_max: int
145
+ invert_min: int
146
+ invert_max: int
147
+ rc_prob: float
148
+
149
+
150
+ def _validate_nonnegative_int(name: str, value: int) -> int:
151
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
152
+ raise ConfigurationError(
153
+ f"{name} must be a non-negative integer.",
154
+ code="INVALID_EVOLUTION_PARAMETER",
155
+ context={name: value},
156
+ )
157
+ return value
158
+
159
+
160
+ def _validate_range(name: str, minimum: int, maximum: int) -> tuple[int, int]:
161
+ resolved_minimum = _validate_nonnegative_int(f"{name}_min", minimum)
162
+ resolved_maximum = _validate_nonnegative_int(f"{name}_max", maximum)
163
+ if resolved_minimum > resolved_maximum:
164
+ raise ConfigurationError(
165
+ f"{name}_min cannot exceed {name}_max.",
166
+ code="INVALID_EVOLUTION_PARAMETER_RANGE",
167
+ context={f"{name}_min": minimum, f"{name}_max": maximum},
168
+ )
169
+ return resolved_minimum, resolved_maximum
170
+
171
+
172
+ def _validate_probability(name: str, value: float) -> float:
173
+ if (
174
+ isinstance(value, bool)
175
+ or not isinstance(value, (int, float))
176
+ or not math.isfinite(float(value))
177
+ or not 0 <= value <= 1
178
+ ):
179
+ raise ConfigurationError(
180
+ f"{name} must be a finite probability between 0 and 1.",
181
+ code="INVALID_EVOLUTION_PROBABILITY",
182
+ context={name: value},
183
+ )
184
+ return float(value)
185
+
186
+
187
+ def _resolve_parameters(
188
+ *,
189
+ mut_frac: float,
190
+ insert_frac: float,
191
+ delete_frac: float,
192
+ insert_min: int,
193
+ insert_max: int,
194
+ shift_min: int,
195
+ shift_max: int,
196
+ invert_min: int,
197
+ invert_max: int,
198
+ rc_prob: float,
199
+ ) -> _EvolutionParameters:
200
+ insert_bounds = _validate_range("insert", insert_min, insert_max)
201
+ if insert_bounds[0] == 0:
202
+ raise ConfigurationError(
203
+ "insert_min must be at least 1.",
204
+ code="INVALID_EVOLUTION_PARAMETER",
205
+ context={"insert_min": insert_min},
206
+ )
207
+ shift_bounds = _validate_range("shift", shift_min, shift_max)
208
+ invert_bounds = _validate_range("invert", invert_min, invert_max)
209
+ return _EvolutionParameters(
210
+ mut_frac=_validate_probability("mut_frac", mut_frac),
211
+ insert_frac=_validate_probability("insert_frac", insert_frac),
212
+ delete_frac=_validate_probability("delete_frac", delete_frac),
213
+ insert_min=insert_bounds[0],
214
+ insert_max=insert_bounds[1],
215
+ shift_min=shift_bounds[0],
216
+ shift_max=shift_bounds[1],
217
+ invert_min=invert_bounds[0],
218
+ invert_max=invert_bounds[1],
219
+ rc_prob=_validate_probability("rc_prob", rc_prob),
220
+ )
221
+
222
+
223
+ def _normalize_augmentations(
224
+ augmentations: Sequence[EvolutionAugmentation] | str,
225
+ ) -> tuple[EvolutionAugmentation, ...]:
226
+ raw: tuple[str, ...]
227
+ if isinstance(augmentations, str):
228
+ raw = (augmentations,)
229
+ else:
230
+ try:
231
+ raw = tuple(augmentations)
232
+ except TypeError as exc:
233
+ raise ConfigurationError(
234
+ "augmentations must be an iterable of supported operation names.",
235
+ code="INVALID_EVOLUTION_AUGMENTATIONS",
236
+ ) from exc
237
+ resolved: list[EvolutionAugmentation] = []
238
+ for name in raw:
239
+ if not isinstance(name, str) or name not in _EVOLUTION_PRIORITY_INDEX:
240
+ raise ConfigurationError(
241
+ "Unsupported EvoAug operation.",
242
+ code="INVALID_EVOLUTION_AUGMENTATION",
243
+ context={"augmentation": name},
244
+ hint="Use mutation, deletion, insertion, translocation, inversion, "
245
+ "or reverse_complement.",
246
+ )
247
+ resolved.append(name)
248
+ if len(set(resolved)) != len(resolved):
249
+ raise ConfigurationError(
250
+ "augmentations cannot contain duplicate operation names.",
251
+ code="DUPLICATE_EVOLUTION_AUGMENTATION",
252
+ )
253
+ return tuple(resolved)
254
+
255
+
256
+ def _resolve_random_source(
257
+ seed: int | None,
258
+ rng: random.Random | None,
259
+ ) -> tuple[random.Random, int | None, RandomSource, RandomState]:
260
+ if (seed is None) == (rng is None):
261
+ raise ConfigurationError(
262
+ "Evolution generation requires exactly one of seed or rng.",
263
+ code="EVOLUTION_RANDOM_SOURCE_REQUIRED",
264
+ )
265
+ if seed is not None and (isinstance(seed, bool) or not isinstance(seed, int)):
266
+ raise ConfigurationError(
267
+ "seed must be an integer.",
268
+ code="INVALID_RANDOM_SEED",
269
+ context={"seed": seed},
270
+ )
271
+ if rng is not None and not isinstance(rng, random.Random):
272
+ raise ConfigurationError(
273
+ "rng must be random.Random.",
274
+ code="INVALID_RANDOM_GENERATOR",
275
+ context={"type": type(rng).__name__},
276
+ )
277
+ generator = random.Random(seed) if rng is None else rng
278
+ return (
279
+ generator,
280
+ seed,
281
+ "seed" if rng is None else "rng",
282
+ cast(RandomState, generator.getstate()),
283
+ )
284
+
285
+
286
+ def _validate_input(sequence: DNASequence) -> tuple[DNASequence, str]:
287
+ source = require_sequence(sequence)
288
+ if source.topology is not Topology.LINEAR:
289
+ raise ConfigurationError(
290
+ "Evolution generation requires a linear DNASequence.",
291
+ code="EVOLUTION_CIRCULAR_NOT_SUPPORTED",
292
+ )
293
+ if source.is_gapped:
294
+ raise UnsupportedGapOperationError(
295
+ "Evolution generation does not operate across explicit Gaps.",
296
+ code="EVOLUTION_GAPPED_SEQUENCE_NOT_SUPPORTED",
297
+ hint="Resolve or split Gap parts before applying EvoAug operations.",
298
+ )
299
+ symbols = source.symbols
300
+ if not symbols:
301
+ raise ConfigurationError(
302
+ "Evolution generation requires at least one nucleotide.",
303
+ code="EVOLUTION_EMPTY_SEQUENCE",
304
+ )
305
+ if any(symbol not in _CANONICAL_BASES for symbol in symbols):
306
+ raise ConfigurationError(
307
+ "Evolution generation requires canonical A, C, G, and T symbols.",
308
+ code="EVOLUTION_CANONICAL_DNA_REQUIRED",
309
+ hint="Resolve IUPAC ambiguity before using the one-hot EvoAug operations.",
310
+ )
311
+ return source, symbols
312
+
313
+
314
+ def _random_dna(generator: random.Random, length: int) -> str:
315
+ return "".join(generator.choice(_CANONICAL_BASES) for _ in range(length))
316
+
317
+
318
+ def _reverse_complement_text(text: str, source: DNASequence) -> str:
319
+ return DNASequence(text, alphabet=source.alphabet).reverse_complement().symbols
320
+
321
+
322
+ def _require_segment_maximum(name: str, maximum: int, sequence_length: int) -> None:
323
+ if maximum > sequence_length:
324
+ raise ConfigurationError(
325
+ f"{name}_max cannot exceed the current sequence length.",
326
+ code="EVOLUTION_SEGMENT_TOO_LONG",
327
+ context={
328
+ "operation": name,
329
+ f"{name}_max": maximum,
330
+ "sequence_length": sequence_length,
331
+ },
332
+ hint=f"Set {name}_max to at most {sequence_length} for this sequence.",
333
+ )
334
+
335
+
336
+ def _apply_mutation(
337
+ symbols: str,
338
+ generator: random.Random,
339
+ mut_frac: float,
340
+ ) -> tuple[str, EvolutionStep]:
341
+ positions = tuple(position for position in range(len(symbols)) if generator.random() < mut_frac)
342
+ chars = list(symbols)
343
+ for position in positions:
344
+ chars[position] = generator.choice(_MUTATION_ALTERNATIVES[chars[position]])
345
+ mutation_count = len(positions)
346
+ return (
347
+ "".join(chars),
348
+ EvolutionStep(
349
+ "mutation",
350
+ mutation_count > 0,
351
+ length=mutation_count,
352
+ mutation_attempts=mutation_count,
353
+ ),
354
+ )
355
+
356
+
357
+ def _apply_deletion(
358
+ symbols: str,
359
+ generator: random.Random,
360
+ delete_frac: float,
361
+ ) -> tuple[str, EvolutionStep]:
362
+ retained = tuple(symbol for symbol in symbols if generator.random() >= delete_frac)
363
+ deletion_count = len(symbols) - len(retained)
364
+ return (
365
+ "".join(retained),
366
+ EvolutionStep("deletion", deletion_count > 0, length=deletion_count),
367
+ )
368
+
369
+
370
+ def _apply_insertion(
371
+ symbols: str,
372
+ generator: random.Random,
373
+ parameters: _EvolutionParameters,
374
+ ) -> tuple[str, EvolutionStep]:
375
+ parts: list[str] = []
376
+ inserted_length = 0
377
+ for symbol in symbols:
378
+ parts.append(symbol)
379
+ if generator.random() < parameters.insert_frac:
380
+ length = generator.randint(parameters.insert_min, parameters.insert_max)
381
+ parts.append(_random_dna(generator, length))
382
+ inserted_length += length
383
+ return (
384
+ "".join(parts),
385
+ EvolutionStep("insertion", inserted_length > 0, length=inserted_length),
386
+ )
387
+
388
+
389
+ def _apply_translocation(
390
+ symbols: str,
391
+ generator: random.Random,
392
+ parameters: _EvolutionParameters,
393
+ ) -> tuple[str, EvolutionStep]:
394
+ if not symbols:
395
+ return symbols, EvolutionStep("translocation", False, shift=0)
396
+ magnitude = generator.randint(parameters.shift_min, parameters.shift_max)
397
+ shift = -magnitude if generator.random() < 0.5 else magnitude
398
+ normalized = shift % len(symbols)
399
+ generated = symbols if normalized == 0 else symbols[-normalized:] + symbols[:-normalized]
400
+ return generated, EvolutionStep("translocation", normalized != 0, shift=shift)
401
+
402
+
403
+ def _apply_inversion(
404
+ symbols: str,
405
+ source: DNASequence,
406
+ generator: random.Random,
407
+ parameters: _EvolutionParameters,
408
+ ) -> tuple[str, EvolutionStep]:
409
+ _require_segment_maximum("invert", parameters.invert_max, len(symbols))
410
+ length = generator.randint(parameters.invert_min, parameters.invert_max)
411
+ start = generator.randint(0, len(symbols) - parameters.invert_max)
412
+ segment = _reverse_complement_text(symbols[start : start + length], source)
413
+ generated = symbols[:start] + segment + symbols[start + length :]
414
+ return (
415
+ generated,
416
+ EvolutionStep("inversion", length > 0, start=start, end=start + length, length=length),
417
+ )
418
+
419
+
420
+ def _apply_reverse_complement(
421
+ symbols: str,
422
+ source: DNASequence,
423
+ generator: random.Random,
424
+ probability: float,
425
+ ) -> tuple[str, EvolutionStep]:
426
+ applied = generator.random() < probability
427
+ generated = _reverse_complement_text(symbols, source) if applied else symbols
428
+ return generated, EvolutionStep("reverse_complement", applied)
429
+
430
+
431
+ def _apply_augmentation(
432
+ symbols: str,
433
+ source: DNASequence,
434
+ generator: random.Random,
435
+ augmentation: EvolutionAugmentation,
436
+ parameters: _EvolutionParameters,
437
+ ) -> tuple[str, EvolutionStep]:
438
+ if augmentation == "mutation":
439
+ return _apply_mutation(symbols, generator, parameters.mut_frac)
440
+ if augmentation == "deletion":
441
+ return _apply_deletion(symbols, generator, parameters.delete_frac)
442
+ if augmentation == "insertion":
443
+ return _apply_insertion(symbols, generator, parameters)
444
+ if augmentation == "translocation":
445
+ return _apply_translocation(symbols, generator, parameters)
446
+ if augmentation == "inversion":
447
+ return _apply_inversion(symbols, source, generator, parameters)
448
+ return _apply_reverse_complement(symbols, source, generator, parameters.rc_prob)
449
+
450
+
451
+ def evolution_generate(
452
+ sequence: DNASequence,
453
+ *,
454
+ augmentations: Sequence[EvolutionAugmentation] = DEFAULT_EVOLUTION_AUGMENTATIONS,
455
+ max_augmentations: int = 1,
456
+ hard_aug: bool = True,
457
+ seed: int | None = None,
458
+ rng: random.Random | None = None,
459
+ mut_frac: float = 0.05,
460
+ insert_frac: float = 0.05,
461
+ delete_frac: float = 0.05,
462
+ insert_min: int = 1,
463
+ insert_max: int = 1,
464
+ shift_min: int = 0,
465
+ shift_max: int = 20,
466
+ invert_min: int = 0,
467
+ invert_max: int = 20,
468
+ rc_prob: float = 0.5,
469
+ ) -> EvolutionGenerationResult:
470
+ """Generate one EvoAug-inspired DNA sequence variant.
471
+
472
+ The candidate operations are sampled without replacement and applied in
473
+ EvoAug priority order: inversion, deletion, translocation, insertion,
474
+ reverse-complement, then mutation. ``hard_aug=True`` applies exactly
475
+ ``max_augmentations`` selected operations; ``False`` samples a count from
476
+ one through that maximum. Exactly one of ``seed`` and ``rng`` is required.
477
+ ``mut_frac``, ``insert_frac``, and ``delete_frac`` are independent
478
+ per-nucleotide probabilities for their respective selected operations.
479
+ A mutation always changes the selected base. An insertion is placed after
480
+ the selected base and has a random inclusive length from ``insert_min`` to
481
+ ``insert_max``. A deletion removes the selected base.
482
+
483
+ The official EvoAug package operates on fixed-shape one-hot tensors and
484
+ includes Gaussian noise. This sequence-level API keeps only operations
485
+ that produce valid DNA symbols. Use ``indel_generate()`` when exactly one
486
+ random contiguous insertion or deletion is required.
487
+ """
488
+
489
+ source, symbols = _validate_input(sequence)
490
+ names = _normalize_augmentations(augmentations)
491
+ if isinstance(max_augmentations, bool) or not isinstance(max_augmentations, int):
492
+ raise ConfigurationError(
493
+ "max_augmentations must be a non-negative integer.",
494
+ code="INVALID_MAX_AUGMENTATIONS",
495
+ context={"max_augmentations": max_augmentations},
496
+ )
497
+ if max_augmentations < 0 or max_augmentations > len(names):
498
+ raise ConfigurationError(
499
+ "max_augmentations must be between zero and the number of augmentations.",
500
+ code="INVALID_MAX_AUGMENTATIONS",
501
+ context={"max_augmentations": max_augmentations, "augmentation_count": len(names)},
502
+ )
503
+ if not isinstance(hard_aug, bool):
504
+ raise ConfigurationError(
505
+ "hard_aug must be boolean.",
506
+ code="INVALID_HARD_AUGMENTATION_MODE",
507
+ context={"hard_aug": hard_aug},
508
+ )
509
+ parameters = _resolve_parameters(
510
+ mut_frac=mut_frac,
511
+ insert_frac=insert_frac,
512
+ delete_frac=delete_frac,
513
+ insert_min=insert_min,
514
+ insert_max=insert_max,
515
+ shift_min=shift_min,
516
+ shift_max=shift_max,
517
+ invert_min=invert_min,
518
+ invert_max=invert_max,
519
+ rc_prob=rc_prob,
520
+ )
521
+ generator, resolved_seed, random_source, state_before = _resolve_random_source(seed, rng)
522
+ if max_augmentations == 0:
523
+ selected: tuple[EvolutionAugmentation, ...] = ()
524
+ else:
525
+ selected_count = max_augmentations if hard_aug else generator.randint(1, max_augmentations)
526
+ selected = tuple(
527
+ sorted(
528
+ generator.sample(names, selected_count),
529
+ key=lambda name: _EVOLUTION_PRIORITY_INDEX[name],
530
+ )
531
+ )
532
+
533
+ generated = symbols
534
+ steps: list[EvolutionStep] = []
535
+ for augmentation in selected:
536
+ generated, step = _apply_augmentation(
537
+ generated,
538
+ source,
539
+ generator,
540
+ augmentation,
541
+ parameters,
542
+ )
543
+ steps.append(step)
544
+ result = DNASequence(
545
+ generated,
546
+ alphabet=source.alphabet,
547
+ topology=Topology.LINEAR,
548
+ strandedness=source.strandedness,
549
+ )
550
+ return EvolutionGenerationResult(
551
+ sequence=result,
552
+ steps=tuple(steps),
553
+ seed=resolved_seed,
554
+ random_source=random_source,
555
+ rng_algorithm_version=random.Random.VERSION,
556
+ rng_state_before=state_before,
557
+ rng_state_after=cast(RandomState, generator.getstate()),
558
+ )
559
+
560
+
561
+ def _validate_positive_int(name: str, value: int) -> int:
562
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
563
+ raise ConfigurationError(
564
+ f"{name} must be a positive integer.",
565
+ code="INVALID_SEQUENCE_GENERATION_PARAMETER",
566
+ context={name: value},
567
+ )
568
+ return value
569
+
570
+
571
+ def _build_linear_sequence(source: DNASequence, symbols: str) -> DNASequence:
572
+ return DNASequence(
573
+ symbols,
574
+ alphabet=source.alphabet,
575
+ topology=Topology.LINEAR,
576
+ strandedness=source.strandedness,
577
+ )
578
+
579
+
580
+ def _apply_contiguous_deletion(
581
+ symbols: str,
582
+ generator: random.Random,
583
+ minimum: int,
584
+ maximum: int,
585
+ *,
586
+ pad_indels: bool,
587
+ ) -> tuple[str, EvolutionStep]:
588
+ _require_segment_maximum("delete", maximum, len(symbols))
589
+ length = generator.randint(minimum, maximum)
590
+ start = generator.randint(0, len(symbols) - maximum)
591
+ generated = symbols[:start] + symbols[start + length :]
592
+ if pad_indels:
593
+ padding = _random_dna(generator, maximum)
594
+ pad_begin = length // 2
595
+ pad_end = length - pad_begin
596
+ generated = padding[:pad_begin] + generated + padding[maximum - pad_end :]
597
+ return (
598
+ generated,
599
+ EvolutionStep("deletion", length > 0, start=start, end=start + length, length=length),
600
+ )
601
+
602
+
603
+ def _apply_contiguous_insertion(
604
+ symbols: str,
605
+ generator: random.Random,
606
+ minimum: int,
607
+ maximum: int,
608
+ *,
609
+ pad_indels: bool,
610
+ ) -> tuple[str, EvolutionStep]:
611
+ length = generator.randint(minimum, maximum)
612
+ start = generator.randrange(len(symbols) + 1)
613
+ if pad_indels:
614
+ padding = _random_dna(generator, maximum)
615
+ pad_begin = (maximum - length) // 2
616
+ generated = (
617
+ padding[:pad_begin]
618
+ + symbols[:start]
619
+ + padding[pad_begin : pad_begin + length]
620
+ + symbols[start:]
621
+ + padding[pad_begin + length :]
622
+ )
623
+ else:
624
+ generated = symbols[:start] + _random_dna(generator, length) + symbols[start:]
625
+ return (
626
+ generated,
627
+ EvolutionStep("insertion", length > 0, start=start, end=start, length=length),
628
+ )
629
+
630
+
631
+ def indel_generate(
632
+ sequence: DNASequence,
633
+ *,
634
+ operation: IndelOperation,
635
+ min_length: int = 1,
636
+ max_length: int = 20,
637
+ seed: int | None = None,
638
+ rng: random.Random | None = None,
639
+ pad_indels: bool = False,
640
+ ) -> EvolutionGenerationResult:
641
+ """Generate one explicit random insertion or deletion.
642
+
643
+ ``pad_indels=False`` returns a naturally variable-length sequence. Set it
644
+ to ``True`` to use the fixed-shape random-DNA padding used by EvoAug.
645
+ """
646
+
647
+ if not isinstance(operation, str) or operation not in {"insertion", "deletion"}:
648
+ raise ConfigurationError(
649
+ "operation must be 'insertion' or 'deletion'.",
650
+ code="INVALID_INDEL_OPERATION",
651
+ context={"operation": operation},
652
+ )
653
+ if not isinstance(pad_indels, bool):
654
+ raise ConfigurationError(
655
+ "pad_indels must be boolean.",
656
+ code="INVALID_EVOLUTION_PARAMETER",
657
+ context={"pad_indels": pad_indels},
658
+ )
659
+ source, symbols = _validate_input(sequence)
660
+ minimum, maximum = _validate_range("indel", min_length, max_length)
661
+ generator, resolved_seed, random_source, state_before = _resolve_random_source(seed, rng)
662
+ if operation == "deletion":
663
+ generated, step = _apply_contiguous_deletion(
664
+ symbols,
665
+ generator,
666
+ minimum,
667
+ maximum,
668
+ pad_indels=pad_indels,
669
+ )
670
+ else:
671
+ generated, step = _apply_contiguous_insertion(
672
+ symbols,
673
+ generator,
674
+ minimum,
675
+ maximum,
676
+ pad_indels=pad_indels,
677
+ )
678
+ return EvolutionGenerationResult(
679
+ sequence=_build_linear_sequence(source, generated),
680
+ steps=(step,),
681
+ seed=resolved_seed,
682
+ random_source=random_source,
683
+ rng_algorithm_version=random.Random.VERSION,
684
+ rng_state_before=state_before,
685
+ rng_state_after=cast(RandomState, generator.getstate()),
686
+ )
687
+
688
+
689
+ def _random_segment_boundaries(
690
+ sequence_length: int,
691
+ segment_count: int,
692
+ generator: random.Random,
693
+ ) -> tuple[int, ...]:
694
+ if segment_count == 1:
695
+ return (0, sequence_length)
696
+ internal = sorted(generator.sample(range(1, sequence_length), segment_count - 1))
697
+ return (0, *internal, sequence_length)
698
+
699
+
700
+ def rearrange_generate(
701
+ sequence: DNASequence,
702
+ *,
703
+ operation: RearrangementOperation = "exchange",
704
+ segment_count: int = 3,
705
+ seed: int | None = None,
706
+ rng: random.Random | None = None,
707
+ ) -> RearrangementResult:
708
+ """Rearrange random contiguous segments by exchange, inversion, or duplication.
709
+
710
+ ``exchange`` randomly permutes the segments. ``inversion`` replaces one
711
+ segment with its reverse complement, and ``duplication`` inserts a copy of
712
+ one segment immediately after itself.
713
+ """
714
+
715
+ source, symbols = _validate_input(sequence)
716
+ if not isinstance(operation, str) or operation not in {
717
+ "exchange",
718
+ "inversion",
719
+ "duplication",
720
+ }:
721
+ raise ConfigurationError(
722
+ "operation must be 'exchange', 'inversion', or 'duplication'.",
723
+ code="INVALID_REARRANGEMENT_OPERATION",
724
+ context={"operation": operation},
725
+ )
726
+ resolved_segment_count = _validate_positive_int("segment_count", segment_count)
727
+ if resolved_segment_count > len(symbols):
728
+ raise ConfigurationError(
729
+ "segment_count cannot exceed the sequence length.",
730
+ code="INVALID_REARRANGEMENT_SEGMENT_COUNT",
731
+ context={"segment_count": segment_count, "sequence_length": len(symbols)},
732
+ )
733
+ generator, resolved_seed, random_source, state_before = _resolve_random_source(seed, rng)
734
+ boundaries = _random_segment_boundaries(len(symbols), resolved_segment_count, generator)
735
+ segments = [symbols[start:end] for start, end in pairwise(boundaries)]
736
+ permutation: tuple[int, ...] | None = None
737
+ selected_segment: int | None = None
738
+
739
+ if operation == "exchange":
740
+ order = list(range(resolved_segment_count))
741
+ generator.shuffle(order)
742
+ if resolved_segment_count > 1 and order == list(range(resolved_segment_count)):
743
+ order[0], order[1] = order[1], order[0]
744
+ permutation = tuple(order)
745
+ generated = "".join(segments[index] for index in order)
746
+ else:
747
+ selected_index = generator.randrange(resolved_segment_count)
748
+ selected_segment = selected_index
749
+ if operation == "inversion":
750
+ selected = _reverse_complement_text(segments[selected_index], source)
751
+ generated_segments = segments.copy()
752
+ generated_segments[selected_index] = selected
753
+ generated = "".join(generated_segments)
754
+ else:
755
+ generated = "".join(
756
+ (
757
+ *segments[: selected_index + 1],
758
+ segments[selected_index],
759
+ *segments[selected_index + 1 :],
760
+ )
761
+ )
762
+
763
+ return RearrangementResult(
764
+ sequence=_build_linear_sequence(source, generated),
765
+ operation=operation,
766
+ breakpoints=boundaries[1:-1],
767
+ permutation=permutation,
768
+ selected_segment=selected_segment,
769
+ seed=resolved_seed,
770
+ random_source=random_source,
771
+ rng_algorithm_version=random.Random.VERSION,
772
+ rng_state_before=state_before,
773
+ rng_state_after=cast(RandomState, generator.getstate()),
774
+ )
775
+
776
+
777
+ def _kmer_counts(symbols: str, k: int) -> tuple[tuple[str, int], ...]:
778
+ return tuple(
779
+ sorted(Counter(symbols[index : index + k] for index in range(len(symbols) - k + 1)).items())
780
+ )
781
+
782
+
783
+ def _random_eulerian_shuffle(
784
+ symbols: str,
785
+ k: int,
786
+ generator: random.Random,
787
+ ) -> str:
788
+ adjacency: dict[str, list[str]] = {}
789
+ for index in range(len(symbols) - k + 1):
790
+ kmer = symbols[index : index + k]
791
+ adjacency.setdefault(kmer[:-1], []).append(kmer[-1])
792
+ for edges in adjacency.values():
793
+ generator.shuffle(edges)
794
+
795
+ stack_nodes = [symbols[: k - 1]]
796
+ stack_edges: list[str] = []
797
+ trail_edges: list[str] = []
798
+ while stack_nodes:
799
+ node = stack_nodes[-1]
800
+ outgoing = adjacency.get(node)
801
+ if outgoing:
802
+ next_base = outgoing.pop()
803
+ stack_nodes.append(node[1:] + next_base)
804
+ stack_edges.append(next_base)
805
+ else:
806
+ stack_nodes.pop()
807
+ if stack_edges:
808
+ trail_edges.append(stack_edges.pop())
809
+
810
+ expected_edge_count = len(symbols) - k + 1
811
+ if len(trail_edges) != expected_edge_count:
812
+ raise ConfigurationError(
813
+ "The source sequence did not yield a complete Eulerian path.",
814
+ code="KMER_SHUFFLE_GRAPH_FAILURE",
815
+ context={"k": k, "edge_count": expected_edge_count},
816
+ )
817
+ return symbols[: k - 1] + "".join(reversed(trail_edges))
818
+
819
+
820
+ def kmer_shuffle(
821
+ sequence: DNASequence,
822
+ *,
823
+ k: int = 2,
824
+ seed: int | None = None,
825
+ rng: random.Random | None = None,
826
+ ensure_different: bool = True,
827
+ max_attempts: int = 100,
828
+ ) -> KmerShuffleResult:
829
+ """Shuffle a sequence while exactly preserving overlapping k-mer counts.
830
+
831
+ For ``k >= 2`` this samples randomized Eulerian paths in the sequence's
832
+ de Bruijn multigraph. ``ensure_different=True`` retries until the output
833
+ differs from the source, then raises if no alternative was found.
834
+ """
835
+
836
+ source, symbols = _validate_input(sequence)
837
+ resolved_k = _validate_positive_int("k", k)
838
+ if resolved_k > len(symbols):
839
+ raise ConfigurationError(
840
+ "k cannot exceed the sequence length.",
841
+ code="INVALID_KMER_LENGTH",
842
+ context={"k": k, "sequence_length": len(symbols)},
843
+ )
844
+ if not isinstance(ensure_different, bool):
845
+ raise ConfigurationError(
846
+ "ensure_different must be boolean.",
847
+ code="INVALID_KMER_SHUFFLE_PARAMETER",
848
+ context={"ensure_different": ensure_different},
849
+ )
850
+ resolved_attempts = _validate_positive_int("max_attempts", max_attempts)
851
+ generator, resolved_seed, random_source, state_before = _resolve_random_source(seed, rng)
852
+ expected_counts = _kmer_counts(symbols, resolved_k)
853
+ generated = symbols
854
+ attempts = 0
855
+
856
+ for attempt in range(1, resolved_attempts + 1):
857
+ if resolved_k == 1:
858
+ chars = list(symbols)
859
+ generator.shuffle(chars)
860
+ candidate = "".join(chars)
861
+ else:
862
+ candidate = _random_eulerian_shuffle(symbols, resolved_k, generator)
863
+ if _kmer_counts(candidate, resolved_k) != expected_counts:
864
+ raise ConfigurationError(
865
+ "The generated sequence changed the requested k-mer counts.",
866
+ code="KMER_SHUFFLE_COUNT_FAILURE",
867
+ context={"k": resolved_k},
868
+ )
869
+ generated = candidate
870
+ attempts = attempt
871
+ if not ensure_different or candidate != symbols:
872
+ break
873
+ else:
874
+ raise ConfigurationError(
875
+ "No different sequence was found with the same k-mer counts.",
876
+ code="KMER_SHUFFLE_NO_ALTERNATIVE",
877
+ context={"k": resolved_k, "max_attempts": resolved_attempts},
878
+ hint="Set ensure_different=False when the source has a unique reconstruction.",
879
+ )
880
+
881
+ return KmerShuffleResult(
882
+ sequence=_build_linear_sequence(source, generated),
883
+ k=resolved_k,
884
+ kmer_counts=expected_counts,
885
+ attempts=attempts,
886
+ seed=resolved_seed,
887
+ random_source=random_source,
888
+ rng_algorithm_version=random.Random.VERSION,
889
+ rng_state_before=state_before,
890
+ rng_state_after=cast(RandomState, generator.getstate()),
891
+ )
892
+
893
+
894
+ def crossover(
895
+ first: DNASequence,
896
+ second: DNASequence,
897
+ *,
898
+ position: int | None = None,
899
+ seed: int | None = None,
900
+ rng: random.Random | None = None,
901
+ ) -> CrossoverResult:
902
+ """Create a child by one-point crossover of two equal-length DNA sequences.
903
+
904
+ ``position`` is a zero-based boundary and must leave at least one base from
905
+ each parent. If it is omitted, exactly one of ``seed`` or ``rng`` chooses
906
+ the boundary reproducibly.
907
+ """
908
+
909
+ first_source, first_symbols = _validate_input(first)
910
+ second_source, second_symbols = _validate_input(second)
911
+ if first_source.strandedness is not second_source.strandedness:
912
+ raise ConfigurationError(
913
+ "Crossover parents must have matching strandedness.",
914
+ code="CROSSOVER_STRANDEDNESS_MISMATCH",
915
+ )
916
+ if len(first_symbols) != len(second_symbols):
917
+ raise ConfigurationError(
918
+ "Crossover parents must have equal sequence lengths.",
919
+ code="CROSSOVER_LENGTH_MISMATCH",
920
+ context={
921
+ "first_length": len(first_symbols),
922
+ "second_length": len(second_symbols),
923
+ },
924
+ )
925
+ if len(first_symbols) < 2:
926
+ raise ConfigurationError(
927
+ "Crossover requires parents with at least two bases.",
928
+ code="CROSSOVER_SEQUENCE_TOO_SHORT",
929
+ )
930
+
931
+ if position is None:
932
+ generator, resolved_seed, random_source, state_before = _resolve_random_source(seed, rng)
933
+ resolved_position = generator.randint(1, len(first_symbols) - 1)
934
+ state_after: RandomState | None = cast(RandomState, generator.getstate())
935
+ rng_version: int | None = random.Random.VERSION
936
+ else:
937
+ if seed is not None or rng is not None:
938
+ raise ConfigurationError(
939
+ "seed and rng are only valid when position is omitted.",
940
+ code="UNEXPECTED_RANDOM_SOURCE",
941
+ )
942
+ if isinstance(position, bool) or not isinstance(position, int):
943
+ raise ConfigurationError(
944
+ "position must be an integer crossover boundary.",
945
+ code="INVALID_CROSSOVER_POSITION",
946
+ context={"position": position},
947
+ )
948
+ if not 1 <= position < len(first_symbols):
949
+ raise ConfigurationError(
950
+ "position must leave at least one base from each parent.",
951
+ code="INVALID_CROSSOVER_POSITION",
952
+ context={"position": position, "sequence_length": len(first_symbols)},
953
+ )
954
+ generator = None
955
+ resolved_seed = None
956
+ resolved_position = position
957
+ random_source = "none"
958
+ state_before = None
959
+ state_after = None
960
+ rng_version = None
961
+
962
+ child = first_symbols[:resolved_position] + second_symbols[resolved_position:]
963
+ return CrossoverResult(
964
+ sequence=_build_linear_sequence(first_source, child),
965
+ position=resolved_position,
966
+ first_parent_length=len(first_symbols),
967
+ second_parent_length=len(second_symbols),
968
+ seed=resolved_seed,
969
+ random_source=random_source,
970
+ rng_algorithm_version=rng_version,
971
+ rng_state_before=state_before,
972
+ rng_state_after=state_after,
973
+ )
974
+
975
+
976
+ __all__ = [
977
+ "DEFAULT_EVOLUTION_AUGMENTATIONS",
978
+ "CrossoverResult",
979
+ "EvolutionAugmentation",
980
+ "EvolutionGenerationResult",
981
+ "EvolutionStep",
982
+ "IndelOperation",
983
+ "KmerShuffleResult",
984
+ "RearrangementOperation",
985
+ "RearrangementResult",
986
+ "crossover",
987
+ "evolution_generate",
988
+ "indel_generate",
989
+ "kmer_shuffle",
990
+ "rearrange_generate",
991
+ ]