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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/ops/evolution.py
ADDED
|
@@ -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
|
+
]
|