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
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
"""Bounded rule, codon, and mutation-library design algorithms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import itertools
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import random
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
|
|
13
|
+
from dnakit.core import DNASequence, Issue, IssueSeverity
|
|
14
|
+
from dnakit.core._json import FrozenDict, freeze_mapping
|
|
15
|
+
from dnakit.exceptions import ConfigurationError
|
|
16
|
+
from dnakit.ops import translate
|
|
17
|
+
|
|
18
|
+
from ._shared import (
|
|
19
|
+
finite_fraction,
|
|
20
|
+
freeze_parameters,
|
|
21
|
+
iupac_compatible,
|
|
22
|
+
materialize_bounded,
|
|
23
|
+
native_provenance,
|
|
24
|
+
require_sequence,
|
|
25
|
+
reverse_complement_text,
|
|
26
|
+
validate_iupac_text,
|
|
27
|
+
validate_positive_int,
|
|
28
|
+
validate_text,
|
|
29
|
+
)
|
|
30
|
+
from .results import (
|
|
31
|
+
CodonOptimizationResult,
|
|
32
|
+
Mutation,
|
|
33
|
+
MutationLibraryResult,
|
|
34
|
+
RuleOptimizationResult,
|
|
35
|
+
SequenceChange,
|
|
36
|
+
SequenceVariant,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_BASES = "ACGT"
|
|
40
|
+
_CODONS = tuple("".join(parts) for parts in itertools.product(_BASES, repeat=3))
|
|
41
|
+
_CODON_TO_AA = {codon: translate(codon) for codon in _CODONS}
|
|
42
|
+
_AA_TO_CODONS: dict[str, tuple[str, ...]] = {
|
|
43
|
+
amino_acid: tuple(codon for codon in _CODONS if _CODON_TO_AA[codon] == amino_acid)
|
|
44
|
+
for amino_acid in sorted(set(_CODON_TO_AA.values()))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, init=False)
|
|
49
|
+
class RuleOptimizationConfig:
|
|
50
|
+
"""Deterministic local-search constraints and hard resource limits."""
|
|
51
|
+
|
|
52
|
+
target_gc_range: tuple[float, float] | None
|
|
53
|
+
forbidden_motifs: tuple[str, ...]
|
|
54
|
+
forbidden_motifs_both_strands: bool
|
|
55
|
+
max_homopolymer: int | None
|
|
56
|
+
allowed_positions: tuple[int, ...] | None
|
|
57
|
+
preserve_translation: bool
|
|
58
|
+
max_iterations: int
|
|
59
|
+
max_candidate_evaluations: int
|
|
60
|
+
max_total_scoring_cells: int
|
|
61
|
+
seed: int
|
|
62
|
+
gc_weight: float
|
|
63
|
+
motif_weight: float
|
|
64
|
+
homopolymer_weight: float
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
target_gc_range: tuple[float, float] | None = None,
|
|
70
|
+
forbidden_motifs: Iterable[str] = (),
|
|
71
|
+
forbidden_motifs_both_strands: bool = True,
|
|
72
|
+
max_homopolymer: int | None = None,
|
|
73
|
+
allowed_positions: Iterable[int] | None = None,
|
|
74
|
+
preserve_translation: bool = False,
|
|
75
|
+
max_iterations: int = 1_000,
|
|
76
|
+
max_candidate_evaluations: int = 100_000,
|
|
77
|
+
max_total_scoring_cells: int = 500_000_000,
|
|
78
|
+
seed: int = 0,
|
|
79
|
+
gc_weight: float = 1.0,
|
|
80
|
+
motif_weight: float = 100.0,
|
|
81
|
+
homopolymer_weight: float = 10.0,
|
|
82
|
+
) -> None:
|
|
83
|
+
resolved_gc: tuple[float, float] | None = None
|
|
84
|
+
if target_gc_range is not None:
|
|
85
|
+
if not isinstance(target_gc_range, tuple) or len(target_gc_range) != 2:
|
|
86
|
+
raise ConfigurationError("target_gc_range must contain two bounds.")
|
|
87
|
+
lower = finite_fraction(target_gc_range[0], "minimum GC")
|
|
88
|
+
upper = finite_fraction(target_gc_range[1], "maximum GC")
|
|
89
|
+
if lower > upper:
|
|
90
|
+
raise ConfigurationError("Minimum GC cannot exceed maximum GC.")
|
|
91
|
+
resolved_gc = (lower, upper)
|
|
92
|
+
if isinstance(forbidden_motifs, (str, bytes)):
|
|
93
|
+
raise ConfigurationError("forbidden_motifs must be an iterable of motifs.")
|
|
94
|
+
motif_inputs = materialize_bounded(
|
|
95
|
+
forbidden_motifs,
|
|
96
|
+
max_items=10_000,
|
|
97
|
+
name="forbidden motifs",
|
|
98
|
+
)
|
|
99
|
+
motifs = tuple(validate_iupac_text(item, "forbidden motif") for item in motif_inputs)
|
|
100
|
+
if not isinstance(forbidden_motifs_both_strands, bool) or not isinstance(
|
|
101
|
+
preserve_translation, bool
|
|
102
|
+
):
|
|
103
|
+
raise ConfigurationError("Rule optimization switches must be booleans.")
|
|
104
|
+
if max_homopolymer is not None:
|
|
105
|
+
validate_positive_int(max_homopolymer, "max_homopolymer", maximum=1_000_000)
|
|
106
|
+
positions: tuple[int, ...] | None = None
|
|
107
|
+
if allowed_positions is not None:
|
|
108
|
+
if isinstance(allowed_positions, (str, bytes)):
|
|
109
|
+
raise ConfigurationError("allowed_positions must be integer positions.")
|
|
110
|
+
copied = materialize_bounded(
|
|
111
|
+
allowed_positions,
|
|
112
|
+
max_items=1_000_000,
|
|
113
|
+
name="allowed positions",
|
|
114
|
+
)
|
|
115
|
+
if any(
|
|
116
|
+
isinstance(item, bool) or not isinstance(item, int) or item < 0 for item in copied
|
|
117
|
+
):
|
|
118
|
+
raise ConfigurationError("allowed_positions must be non-negative integers.")
|
|
119
|
+
if len(copied) != len(set(copied)):
|
|
120
|
+
raise ConfigurationError("allowed_positions cannot contain duplicates.")
|
|
121
|
+
positions = tuple(sorted(copied))
|
|
122
|
+
validate_positive_int(max_iterations, "max_iterations", maximum=1_000_000)
|
|
123
|
+
validate_positive_int(
|
|
124
|
+
max_candidate_evaluations,
|
|
125
|
+
"max_candidate_evaluations",
|
|
126
|
+
maximum=1_000_000_000,
|
|
127
|
+
)
|
|
128
|
+
validate_positive_int(
|
|
129
|
+
max_total_scoring_cells,
|
|
130
|
+
"max_total_scoring_cells",
|
|
131
|
+
maximum=10_000_000_000,
|
|
132
|
+
)
|
|
133
|
+
if isinstance(seed, bool) or not isinstance(seed, int):
|
|
134
|
+
raise ConfigurationError("seed must be an integer.")
|
|
135
|
+
weights = []
|
|
136
|
+
for value, name in (
|
|
137
|
+
(gc_weight, "gc_weight"),
|
|
138
|
+
(motif_weight, "motif_weight"),
|
|
139
|
+
(homopolymer_weight, "homopolymer_weight"),
|
|
140
|
+
):
|
|
141
|
+
if (
|
|
142
|
+
isinstance(value, bool)
|
|
143
|
+
or not isinstance(value, (int, float))
|
|
144
|
+
or not math.isfinite(value)
|
|
145
|
+
or value <= 0
|
|
146
|
+
):
|
|
147
|
+
raise ConfigurationError(f"{name} must be finite and positive.")
|
|
148
|
+
weights.append(float(value))
|
|
149
|
+
object.__setattr__(self, "target_gc_range", resolved_gc)
|
|
150
|
+
object.__setattr__(self, "forbidden_motifs", motifs)
|
|
151
|
+
object.__setattr__(self, "forbidden_motifs_both_strands", forbidden_motifs_both_strands)
|
|
152
|
+
object.__setattr__(self, "max_homopolymer", max_homopolymer)
|
|
153
|
+
object.__setattr__(self, "allowed_positions", positions)
|
|
154
|
+
object.__setattr__(self, "preserve_translation", preserve_translation)
|
|
155
|
+
object.__setattr__(self, "max_iterations", max_iterations)
|
|
156
|
+
object.__setattr__(self, "max_candidate_evaluations", max_candidate_evaluations)
|
|
157
|
+
object.__setattr__(self, "max_total_scoring_cells", max_total_scoring_cells)
|
|
158
|
+
object.__setattr__(self, "seed", seed)
|
|
159
|
+
object.__setattr__(self, "gc_weight", weights[0])
|
|
160
|
+
object.__setattr__(self, "motif_weight", weights[1])
|
|
161
|
+
object.__setattr__(self, "homopolymer_weight", weights[2])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _motif_occurrences(symbols: str, motifs: tuple[str, ...], both_strands: bool) -> int:
|
|
165
|
+
patterns: list[str] = list(motifs)
|
|
166
|
+
if both_strands:
|
|
167
|
+
patterns.extend(reverse_complement_text(motif) for motif in motifs)
|
|
168
|
+
unique = tuple(sorted(set(patterns)))
|
|
169
|
+
count = 0
|
|
170
|
+
for motif in unique:
|
|
171
|
+
for start in range(len(symbols) - len(motif) + 1):
|
|
172
|
+
if all(
|
|
173
|
+
iupac_compatible(symbols[start + offset], pattern)
|
|
174
|
+
for offset, pattern in enumerate(motif)
|
|
175
|
+
):
|
|
176
|
+
count += 1
|
|
177
|
+
return count
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _homopolymer_excess(symbols: str, maximum: int | None) -> int:
|
|
181
|
+
if maximum is None or not symbols:
|
|
182
|
+
return 0
|
|
183
|
+
excess = 0
|
|
184
|
+
run = 1
|
|
185
|
+
for previous, current in itertools.pairwise(symbols):
|
|
186
|
+
if previous == current:
|
|
187
|
+
run += 1
|
|
188
|
+
else:
|
|
189
|
+
excess += max(0, run - maximum)
|
|
190
|
+
run = 1
|
|
191
|
+
return excess + max(0, run - maximum)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _rule_components(symbols: str, config: RuleOptimizationConfig) -> tuple[float, int, int]:
|
|
195
|
+
gc_violation = 0.0
|
|
196
|
+
if config.target_gc_range is not None:
|
|
197
|
+
gc = sum(base in "GC" for base in symbols) / len(symbols)
|
|
198
|
+
lower, upper = config.target_gc_range
|
|
199
|
+
gc_violation = max(lower - gc, gc - upper, 0.0) * len(symbols)
|
|
200
|
+
return (
|
|
201
|
+
gc_violation,
|
|
202
|
+
_motif_occurrences(
|
|
203
|
+
symbols,
|
|
204
|
+
config.forbidden_motifs,
|
|
205
|
+
config.forbidden_motifs_both_strands,
|
|
206
|
+
),
|
|
207
|
+
_homopolymer_excess(symbols, config.max_homopolymer),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _rule_score(components: tuple[float, int, int], config: RuleOptimizationConfig) -> float:
|
|
212
|
+
return (
|
|
213
|
+
config.gc_weight * components[0]
|
|
214
|
+
+ config.motif_weight * components[1]
|
|
215
|
+
+ config.homopolymer_weight * components[2]
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def optimize_sequence_rules(
|
|
220
|
+
sequence: DNASequence,
|
|
221
|
+
config: RuleOptimizationConfig,
|
|
222
|
+
*,
|
|
223
|
+
max_sequence_length: int = 1_000_000,
|
|
224
|
+
) -> RuleOptimizationResult:
|
|
225
|
+
"""Greedily lower a documented rule score with deterministic seeded ties."""
|
|
226
|
+
|
|
227
|
+
symbols = require_sequence(
|
|
228
|
+
sequence,
|
|
229
|
+
operation="rule-based sequence optimization",
|
|
230
|
+
max_length=max_sequence_length,
|
|
231
|
+
canonical=True,
|
|
232
|
+
allow_circular=False,
|
|
233
|
+
)
|
|
234
|
+
if not isinstance(config, RuleOptimizationConfig):
|
|
235
|
+
raise ConfigurationError("config must be RuleOptimizationConfig.")
|
|
236
|
+
allowed = (
|
|
237
|
+
list(range(len(symbols)))
|
|
238
|
+
if config.allowed_positions is None
|
|
239
|
+
else list(config.allowed_positions)
|
|
240
|
+
)
|
|
241
|
+
if any(position >= len(symbols) for position in allowed):
|
|
242
|
+
raise ConfigurationError("allowed_positions contains a position outside the sequence.")
|
|
243
|
+
original_translation: str | None = None
|
|
244
|
+
if config.preserve_translation:
|
|
245
|
+
if len(symbols) % 3:
|
|
246
|
+
raise ConfigurationError(
|
|
247
|
+
"preserve_translation requires a complete frame-0 coding sequence."
|
|
248
|
+
)
|
|
249
|
+
original_translation = translate(symbols, incomplete_policy="error")
|
|
250
|
+
order = list(allowed)
|
|
251
|
+
random.Random(config.seed).shuffle(order)
|
|
252
|
+
current = symbols
|
|
253
|
+
initial_components = _rule_components(current, config)
|
|
254
|
+
current_score = _rule_score(initial_components, config)
|
|
255
|
+
selected_patterns = set(config.forbidden_motifs)
|
|
256
|
+
if config.forbidden_motifs_both_strands:
|
|
257
|
+
selected_patterns.update(
|
|
258
|
+
reverse_complement_text(motif) for motif in tuple(selected_patterns)
|
|
259
|
+
)
|
|
260
|
+
cells_per_score = (
|
|
261
|
+
(len(symbols) if config.target_gc_range is not None else 0)
|
|
262
|
+
+ (len(symbols) if config.max_homopolymer is not None else 0)
|
|
263
|
+
+ sum(max(0, len(symbols) - len(motif) + 1) * len(motif) for motif in selected_patterns)
|
|
264
|
+
+ (len(symbols) if config.preserve_translation else 0)
|
|
265
|
+
)
|
|
266
|
+
evaluation_bound = min(
|
|
267
|
+
config.max_candidate_evaluations,
|
|
268
|
+
config.max_iterations * len(order) * 3,
|
|
269
|
+
)
|
|
270
|
+
estimated_scoring_cells = cells_per_score * (evaluation_bound + 1)
|
|
271
|
+
if estimated_scoring_cells > config.max_total_scoring_cells:
|
|
272
|
+
raise ConfigurationError(
|
|
273
|
+
"Rule optimization exceeds max_total_scoring_cells.",
|
|
274
|
+
code="RULE_OPTIMIZATION_CELL_LIMIT_EXCEEDED",
|
|
275
|
+
context={
|
|
276
|
+
"estimated_scoring_cells": estimated_scoring_cells,
|
|
277
|
+
"max_total_scoring_cells": config.max_total_scoring_cells,
|
|
278
|
+
},
|
|
279
|
+
hint="Reduce sequence/constraint size, iterations, or candidate evaluations.",
|
|
280
|
+
)
|
|
281
|
+
changes: list[SequenceChange] = []
|
|
282
|
+
evaluations = 0
|
|
283
|
+
limit_reached = False
|
|
284
|
+
for _ in range(config.max_iterations):
|
|
285
|
+
best: tuple[float, int, str, str] | None = None
|
|
286
|
+
before_components = _rule_components(current, config)
|
|
287
|
+
for position in order:
|
|
288
|
+
for alternate in _BASES:
|
|
289
|
+
if alternate == current[position]:
|
|
290
|
+
continue
|
|
291
|
+
if evaluations >= config.max_candidate_evaluations:
|
|
292
|
+
limit_reached = True
|
|
293
|
+
break
|
|
294
|
+
evaluations += 1
|
|
295
|
+
candidate = current[:position] + alternate + current[position + 1 :]
|
|
296
|
+
if (
|
|
297
|
+
original_translation is not None
|
|
298
|
+
and translate(candidate, incomplete_policy="error") != original_translation
|
|
299
|
+
):
|
|
300
|
+
continue
|
|
301
|
+
score = _rule_score(_rule_components(candidate, config), config)
|
|
302
|
+
proposal = (score, position, alternate, candidate)
|
|
303
|
+
if score < current_score - 1e-12 and (best is None or score < best[0] - 1e-12):
|
|
304
|
+
best = proposal
|
|
305
|
+
if limit_reached:
|
|
306
|
+
break
|
|
307
|
+
if best is None:
|
|
308
|
+
break
|
|
309
|
+
score, position, alternate, candidate = best
|
|
310
|
+
after_components = _rule_components(candidate, config)
|
|
311
|
+
labels = ("gc_range", "forbidden_motif", "homopolymer")
|
|
312
|
+
reason = next(
|
|
313
|
+
(
|
|
314
|
+
label
|
|
315
|
+
for label, before, after in zip(
|
|
316
|
+
labels, before_components, after_components, strict=True
|
|
317
|
+
)
|
|
318
|
+
if after < before
|
|
319
|
+
),
|
|
320
|
+
"weighted_rule_score",
|
|
321
|
+
)
|
|
322
|
+
changes.append(SequenceChange(position, current[position], alternate, reason))
|
|
323
|
+
current = candidate
|
|
324
|
+
current_score = score
|
|
325
|
+
if all(value <= 1e-12 for value in after_components):
|
|
326
|
+
break
|
|
327
|
+
if limit_reached:
|
|
328
|
+
break
|
|
329
|
+
final_components = _rule_components(current, config)
|
|
330
|
+
satisfied = all(value <= 1e-12 for value in final_components)
|
|
331
|
+
issues: list[Issue] = []
|
|
332
|
+
if limit_reached:
|
|
333
|
+
issues.append(
|
|
334
|
+
Issue(
|
|
335
|
+
"RULE_OPTIMIZATION_EVALUATION_LIMIT",
|
|
336
|
+
IssueSeverity.WARNING,
|
|
337
|
+
"Optimization stopped at max_candidate_evaluations.",
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
if not satisfied:
|
|
341
|
+
issues.append(
|
|
342
|
+
Issue(
|
|
343
|
+
"RULE_OPTIMIZATION_INCOMPLETE",
|
|
344
|
+
IssueSeverity.WARNING,
|
|
345
|
+
"The bounded greedy search ended with unsatisfied constraints.",
|
|
346
|
+
details={
|
|
347
|
+
"gc_violation_bases": final_components[0],
|
|
348
|
+
"forbidden_motif_occurrences": final_components[1],
|
|
349
|
+
"homopolymer_excess_bases": final_components[2],
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
return RuleOptimizationResult(
|
|
354
|
+
original=sequence,
|
|
355
|
+
optimized=DNASequence(current, strandedness=sequence.strandedness),
|
|
356
|
+
changes=tuple(changes),
|
|
357
|
+
initial_score=_rule_score(initial_components, config),
|
|
358
|
+
final_score=current_score,
|
|
359
|
+
iterations=len(changes),
|
|
360
|
+
constraints_satisfied=satisfied,
|
|
361
|
+
method="seeded_deterministic_greedy_rule_search",
|
|
362
|
+
algorithm_version="dnakit-rule-optimization-v1",
|
|
363
|
+
parameters=freeze_parameters(
|
|
364
|
+
{
|
|
365
|
+
"target_gc_range": config.target_gc_range,
|
|
366
|
+
"forbidden_motifs": config.forbidden_motifs,
|
|
367
|
+
"forbidden_motifs_both_strands": config.forbidden_motifs_both_strands,
|
|
368
|
+
"max_homopolymer": config.max_homopolymer,
|
|
369
|
+
"allowed_positions": config.allowed_positions,
|
|
370
|
+
"preserve_translation": config.preserve_translation,
|
|
371
|
+
"max_iterations": config.max_iterations,
|
|
372
|
+
"max_candidate_evaluations": config.max_candidate_evaluations,
|
|
373
|
+
"max_total_scoring_cells": config.max_total_scoring_cells,
|
|
374
|
+
"candidate_evaluations": evaluations,
|
|
375
|
+
"estimated_scoring_cells": estimated_scoring_cells,
|
|
376
|
+
"seed": config.seed,
|
|
377
|
+
"weights": {
|
|
378
|
+
"gc": config.gc_weight,
|
|
379
|
+
"motif": config.motif_weight,
|
|
380
|
+
"homopolymer": config.homopolymer_weight,
|
|
381
|
+
},
|
|
382
|
+
"search_guarantees_global_optimum": False,
|
|
383
|
+
}
|
|
384
|
+
),
|
|
385
|
+
provenance=native_provenance(),
|
|
386
|
+
issues=tuple(issues),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@dataclass(frozen=True, init=False)
|
|
391
|
+
class CodonUsageTable:
|
|
392
|
+
"""User-supplied, versioned table of non-negative table-1 codon weights."""
|
|
393
|
+
|
|
394
|
+
name: str
|
|
395
|
+
version: str
|
|
396
|
+
frequencies: FrozenDict
|
|
397
|
+
checksum: str
|
|
398
|
+
|
|
399
|
+
def __init__(self, name: str, version: str, frequencies: Mapping[str, float]) -> None:
|
|
400
|
+
validate_text(name, "codon usage table name")
|
|
401
|
+
validate_text(version, "codon usage table version")
|
|
402
|
+
if not isinstance(frequencies, Mapping) or not frequencies:
|
|
403
|
+
raise ConfigurationError("frequencies must be a non-empty codon mapping.")
|
|
404
|
+
normalized: dict[str, float] = {}
|
|
405
|
+
for raw_codon, raw_value in frequencies.items():
|
|
406
|
+
codon = validate_iupac_text(raw_codon, "codon")
|
|
407
|
+
if len(codon) != 3 or set(codon) - set(_BASES):
|
|
408
|
+
raise ConfigurationError("Codon usage keys must be canonical DNA triplets.")
|
|
409
|
+
if (
|
|
410
|
+
isinstance(raw_value, bool)
|
|
411
|
+
or not isinstance(raw_value, (int, float))
|
|
412
|
+
or not math.isfinite(raw_value)
|
|
413
|
+
or raw_value < 0
|
|
414
|
+
):
|
|
415
|
+
raise ConfigurationError("Codon frequencies must be finite and non-negative.")
|
|
416
|
+
normalized[codon] = float(raw_value)
|
|
417
|
+
payload = json.dumps(
|
|
418
|
+
{"name": name, "version": version, "frequencies": normalized},
|
|
419
|
+
ensure_ascii=True,
|
|
420
|
+
sort_keys=True,
|
|
421
|
+
separators=(",", ":"),
|
|
422
|
+
).encode("utf-8")
|
|
423
|
+
object.__setattr__(self, "name", name)
|
|
424
|
+
object.__setattr__(self, "version", version)
|
|
425
|
+
object.__setattr__(self, "frequencies", freeze_mapping(normalized))
|
|
426
|
+
object.__setattr__(self, "checksum", hashlib.sha256(payload).hexdigest())
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _literal_locked_positions(symbols: str, motifs: tuple[str, ...]) -> frozenset[int]:
|
|
430
|
+
locked: set[int] = set()
|
|
431
|
+
for motif in motifs:
|
|
432
|
+
start = symbols.find(motif)
|
|
433
|
+
while start >= 0:
|
|
434
|
+
locked.update(range(start, start + len(motif)))
|
|
435
|
+
start = symbols.find(motif, start + 1)
|
|
436
|
+
return frozenset(locked)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _cai(codons: tuple[str, ...], table: CodonUsageTable) -> float:
|
|
440
|
+
logs: list[float] = []
|
|
441
|
+
for codon in codons:
|
|
442
|
+
amino_acid = _CODON_TO_AA[codon]
|
|
443
|
+
synonymous = _AA_TO_CODONS[amino_acid]
|
|
444
|
+
maximum = max(_codon_frequency(table, item) for item in synonymous)
|
|
445
|
+
weight = _codon_frequency(table, codon)
|
|
446
|
+
if maximum <= 0.0 or weight <= 0.0:
|
|
447
|
+
return 0.0
|
|
448
|
+
logs.append(math.log(weight / maximum))
|
|
449
|
+
return math.exp(math.fsum(logs) / len(logs)) if logs else 0.0
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _codon_frequency(table: CodonUsageTable, codon: str) -> float:
|
|
453
|
+
value = table.frequencies.get(codon, 0.0)
|
|
454
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
455
|
+
raise AssertionError("Validated codon frequency changed type.")
|
|
456
|
+
return float(value)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def optimize_codons(
|
|
460
|
+
sequence: DNASequence,
|
|
461
|
+
usage_table: CodonUsageTable,
|
|
462
|
+
*,
|
|
463
|
+
gc_range: tuple[float, float] | None = None,
|
|
464
|
+
forbidden_codons: Iterable[str] = (),
|
|
465
|
+
preserve_motifs: Iterable[str] = (),
|
|
466
|
+
max_codons: int = 2_000,
|
|
467
|
+
max_dp_cells: int = 2_000_000,
|
|
468
|
+
) -> CodonOptimizationResult:
|
|
469
|
+
"""Maximize relative codon weights under an optional global GC interval."""
|
|
470
|
+
|
|
471
|
+
validate_positive_int(max_codons, "max_codons", maximum=100_000)
|
|
472
|
+
validate_positive_int(max_dp_cells, "max_dp_cells", maximum=100_000_000)
|
|
473
|
+
symbols = require_sequence(
|
|
474
|
+
sequence,
|
|
475
|
+
operation="codon optimization",
|
|
476
|
+
max_length=max_codons * 3,
|
|
477
|
+
canonical=True,
|
|
478
|
+
allow_circular=False,
|
|
479
|
+
)
|
|
480
|
+
if len(symbols) % 3:
|
|
481
|
+
raise ConfigurationError("Codon optimization requires a complete frame-0 CDS.")
|
|
482
|
+
if not isinstance(usage_table, CodonUsageTable):
|
|
483
|
+
raise ConfigurationError("usage_table must be CodonUsageTable.")
|
|
484
|
+
if isinstance(forbidden_codons, (str, bytes)):
|
|
485
|
+
raise ConfigurationError("forbidden_codons must be an iterable of codons.")
|
|
486
|
+
forbidden_inputs = materialize_bounded(
|
|
487
|
+
forbidden_codons,
|
|
488
|
+
max_items=10_000,
|
|
489
|
+
name="forbidden codons",
|
|
490
|
+
)
|
|
491
|
+
forbidden = frozenset(validate_iupac_text(item, "forbidden codon") for item in forbidden_inputs)
|
|
492
|
+
if any(len(item) != 3 or set(item) - set(_BASES) for item in forbidden):
|
|
493
|
+
raise ConfigurationError("Forbidden codons must be canonical triplets.")
|
|
494
|
+
if isinstance(preserve_motifs, (str, bytes)):
|
|
495
|
+
raise ConfigurationError("preserve_motifs must be an iterable of motifs.")
|
|
496
|
+
motif_inputs = materialize_bounded(
|
|
497
|
+
preserve_motifs,
|
|
498
|
+
max_items=10_000,
|
|
499
|
+
name="preserved motifs",
|
|
500
|
+
)
|
|
501
|
+
motifs = tuple(validate_iupac_text(item, "preserved motif") for item in motif_inputs)
|
|
502
|
+
if any(set(item) - set(_BASES) for item in motifs):
|
|
503
|
+
raise ConfigurationError("Preserved motifs must be exact canonical DNA strings.")
|
|
504
|
+
locked = _literal_locked_positions(symbols, motifs)
|
|
505
|
+
lower_count, upper_count = 0, len(symbols)
|
|
506
|
+
resolved_gc: tuple[float, float] | None = None
|
|
507
|
+
if gc_range is not None:
|
|
508
|
+
if not isinstance(gc_range, tuple) or len(gc_range) != 2:
|
|
509
|
+
raise ConfigurationError("gc_range must contain two bounds.")
|
|
510
|
+
lower = finite_fraction(gc_range[0], "minimum GC")
|
|
511
|
+
upper = finite_fraction(gc_range[1], "maximum GC")
|
|
512
|
+
if lower > upper:
|
|
513
|
+
raise ConfigurationError("Minimum GC cannot exceed maximum GC.")
|
|
514
|
+
lower_count = math.ceil(lower * len(symbols) - 1e-12)
|
|
515
|
+
upper_count = math.floor(upper * len(symbols) + 1e-12)
|
|
516
|
+
if lower_count > upper_count:
|
|
517
|
+
raise ConfigurationError("GC range contains no attainable integer GC count.")
|
|
518
|
+
resolved_gc = (lower, upper)
|
|
519
|
+
original_codons = tuple(symbols[index : index + 3] for index in range(0, len(symbols), 3))
|
|
520
|
+
original_translation = translate(symbols, incomplete_policy="error")
|
|
521
|
+
|
|
522
|
+
# value = (log relative-weight score, previous GC count, chosen codon)
|
|
523
|
+
layers: list[dict[int, tuple[float, int | None, str]]] = [{0: (0.0, None, "")}]
|
|
524
|
+
total_cells = 1
|
|
525
|
+
for codon_index, original in enumerate(original_codons):
|
|
526
|
+
amino_acid = _CODON_TO_AA[original]
|
|
527
|
+
codon_positions = range(codon_index * 3, codon_index * 3 + 3)
|
|
528
|
+
is_locked = any(position in locked for position in codon_positions)
|
|
529
|
+
candidates = (original,) if is_locked else _AA_TO_CODONS[amino_acid]
|
|
530
|
+
candidates = tuple(
|
|
531
|
+
codon
|
|
532
|
+
for codon in candidates
|
|
533
|
+
if codon not in forbidden and _codon_frequency(usage_table, codon) > 0.0
|
|
534
|
+
)
|
|
535
|
+
if not candidates:
|
|
536
|
+
raise ConfigurationError(
|
|
537
|
+
"No positive-weight allowed synonymous codon is available.",
|
|
538
|
+
code="CODON_OPTIMIZATION_NO_OPTION",
|
|
539
|
+
context={"codon_index": codon_index, "amino_acid": amino_acid},
|
|
540
|
+
)
|
|
541
|
+
maximum = max(_codon_frequency(usage_table, item) for item in _AA_TO_CODONS[amino_acid])
|
|
542
|
+
current = layers[-1]
|
|
543
|
+
next_layer: dict[int, tuple[float, int | None, str]] = {}
|
|
544
|
+
for previous_gc in sorted(current):
|
|
545
|
+
previous_score = current[previous_gc][0]
|
|
546
|
+
for candidate in sorted(candidates):
|
|
547
|
+
gc_count = previous_gc + sum(base in "GC" for base in candidate)
|
|
548
|
+
score = previous_score + math.log(
|
|
549
|
+
_codon_frequency(usage_table, candidate) / maximum
|
|
550
|
+
)
|
|
551
|
+
existing = next_layer.get(gc_count)
|
|
552
|
+
if existing is None or score > existing[0] + 1e-15:
|
|
553
|
+
next_layer[gc_count] = (score, previous_gc, candidate)
|
|
554
|
+
total_cells += len(next_layer)
|
|
555
|
+
if total_cells > max_dp_cells:
|
|
556
|
+
raise ConfigurationError(
|
|
557
|
+
"Codon optimization exceeds max_dp_cells.",
|
|
558
|
+
code="CODON_DP_LIMIT_EXCEEDED",
|
|
559
|
+
context={"dp_cells": total_cells, "max_dp_cells": max_dp_cells},
|
|
560
|
+
)
|
|
561
|
+
layers.append(next_layer)
|
|
562
|
+
feasible = [gc_count for gc_count in layers[-1] if lower_count <= gc_count <= upper_count]
|
|
563
|
+
if not feasible:
|
|
564
|
+
raise ConfigurationError(
|
|
565
|
+
"No synonymous sequence satisfies the requested GC interval.",
|
|
566
|
+
code="CODON_GC_CONSTRAINT_INFEASIBLE",
|
|
567
|
+
)
|
|
568
|
+
final_gc = max(feasible, key=lambda count: (layers[-1][count][0], -count))
|
|
569
|
+
optimized_reversed: list[str] = []
|
|
570
|
+
cursor = final_gc
|
|
571
|
+
for layer_index in range(len(original_codons), 0, -1):
|
|
572
|
+
_, previous, chosen = layers[layer_index][cursor]
|
|
573
|
+
optimized_reversed.append(chosen)
|
|
574
|
+
if previous is None:
|
|
575
|
+
raise AssertionError("Codon DP backpointer is missing.")
|
|
576
|
+
cursor = previous
|
|
577
|
+
optimized_codons = tuple(reversed(optimized_reversed))
|
|
578
|
+
optimized_symbols = "".join(optimized_codons)
|
|
579
|
+
optimized_translation = translate(optimized_symbols, incomplete_policy="error")
|
|
580
|
+
if optimized_translation != original_translation:
|
|
581
|
+
raise AssertionError("Synonymous DP changed the translated protein.")
|
|
582
|
+
changes = tuple(
|
|
583
|
+
SequenceChange(index * 3, before, after, "synonymous_codon_weight")
|
|
584
|
+
for index, (before, after) in enumerate(zip(original_codons, optimized_codons, strict=True))
|
|
585
|
+
if before != after
|
|
586
|
+
)
|
|
587
|
+
return CodonOptimizationResult(
|
|
588
|
+
original=sequence,
|
|
589
|
+
optimized=DNASequence(optimized_symbols, strandedness=sequence.strandedness),
|
|
590
|
+
original_translation=original_translation,
|
|
591
|
+
optimized_translation=optimized_translation,
|
|
592
|
+
changes=changes,
|
|
593
|
+
original_cai=_cai(original_codons, usage_table),
|
|
594
|
+
optimized_cai=_cai(optimized_codons, usage_table),
|
|
595
|
+
original_gc_fraction=sum(base in "GC" for base in symbols) / len(symbols),
|
|
596
|
+
optimized_gc_fraction=sum(base in "GC" for base in optimized_symbols) / len(symbols),
|
|
597
|
+
usage_table_name=usage_table.name,
|
|
598
|
+
usage_table_version=usage_table.version,
|
|
599
|
+
usage_table_checksum=usage_table.checksum,
|
|
600
|
+
method="synonymous_codon_dynamic_programming",
|
|
601
|
+
algorithm_version="dnakit-codon-optimization-v1",
|
|
602
|
+
parameters=freeze_parameters(
|
|
603
|
+
{
|
|
604
|
+
"genetic_code": 1,
|
|
605
|
+
"gc_range": resolved_gc,
|
|
606
|
+
"forbidden_codons": sorted(forbidden),
|
|
607
|
+
"preserve_motifs": motifs,
|
|
608
|
+
"locked_position_count": len(locked),
|
|
609
|
+
"objective": "maximum_sum_log_relative_codon_weight",
|
|
610
|
+
"tie_break": "lowest_gc_then_first_sorted_dp_traversal_path",
|
|
611
|
+
"dp_cells": total_cells,
|
|
612
|
+
"max_dp_cells": max_dp_cells,
|
|
613
|
+
"max_codons": max_codons,
|
|
614
|
+
}
|
|
615
|
+
),
|
|
616
|
+
provenance=native_provenance(
|
|
617
|
+
reference_name=usage_table.name,
|
|
618
|
+
reference_version=usage_table.version,
|
|
619
|
+
reference_checksum=usage_table.checksum,
|
|
620
|
+
),
|
|
621
|
+
issues=(),
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def optimize_protein_codons(
|
|
626
|
+
protein: str,
|
|
627
|
+
usage_table: CodonUsageTable,
|
|
628
|
+
*,
|
|
629
|
+
gc_range: tuple[float, float] | None = None,
|
|
630
|
+
forbidden_codons: Iterable[str] = (),
|
|
631
|
+
max_amino_acids: int = 2_000,
|
|
632
|
+
max_dp_cells: int = 2_000_000,
|
|
633
|
+
) -> CodonOptimizationResult:
|
|
634
|
+
"""Reverse-translate a protein and optimize it with the same bounded DP."""
|
|
635
|
+
|
|
636
|
+
if not isinstance(protein, str) or not protein:
|
|
637
|
+
raise ConfigurationError("protein must be a non-empty uppercase amino-acid string.")
|
|
638
|
+
validate_positive_int(max_amino_acids, "max_amino_acids", maximum=100_000)
|
|
639
|
+
if len(protein) > max_amino_acids:
|
|
640
|
+
raise ConfigurationError("Protein exceeds max_amino_acids.")
|
|
641
|
+
invalid = sorted(set(protein) - set(_AA_TO_CODONS))
|
|
642
|
+
if invalid:
|
|
643
|
+
raise ConfigurationError(
|
|
644
|
+
"Protein contains residues unsupported by genetic code table 1.",
|
|
645
|
+
context={"invalid_residues": invalid},
|
|
646
|
+
)
|
|
647
|
+
if not isinstance(usage_table, CodonUsageTable):
|
|
648
|
+
raise ConfigurationError("usage_table must be CodonUsageTable.")
|
|
649
|
+
forbidden_inputs = materialize_bounded(
|
|
650
|
+
forbidden_codons,
|
|
651
|
+
max_items=10_000,
|
|
652
|
+
name="forbidden codons",
|
|
653
|
+
)
|
|
654
|
+
forbidden = frozenset(validate_iupac_text(item, "forbidden codon") for item in forbidden_inputs)
|
|
655
|
+
if any(len(item) != 3 or set(item) - set(_BASES) for item in forbidden):
|
|
656
|
+
raise ConfigurationError("Forbidden codons must be canonical triplets.")
|
|
657
|
+
baseline: list[str] = []
|
|
658
|
+
for residue_index, amino_acid in enumerate(protein):
|
|
659
|
+
candidates = tuple(
|
|
660
|
+
codon
|
|
661
|
+
for codon in _AA_TO_CODONS[amino_acid]
|
|
662
|
+
if codon not in forbidden and _codon_frequency(usage_table, codon) > 0.0
|
|
663
|
+
)
|
|
664
|
+
if not candidates:
|
|
665
|
+
raise ConfigurationError(
|
|
666
|
+
"No positive-weight allowed codon can encode a protein residue.",
|
|
667
|
+
code="PROTEIN_CODON_OPTIMIZATION_NO_OPTION",
|
|
668
|
+
context={"residue_index": residue_index, "amino_acid": amino_acid},
|
|
669
|
+
)
|
|
670
|
+
baseline.append(sorted(candidates)[0])
|
|
671
|
+
result = optimize_codons(
|
|
672
|
+
DNASequence("".join(baseline)),
|
|
673
|
+
usage_table,
|
|
674
|
+
gc_range=gc_range,
|
|
675
|
+
forbidden_codons=forbidden,
|
|
676
|
+
max_codons=max_amino_acids,
|
|
677
|
+
max_dp_cells=max_dp_cells,
|
|
678
|
+
)
|
|
679
|
+
parameters = dict(result.parameters)
|
|
680
|
+
parameters.update(
|
|
681
|
+
{
|
|
682
|
+
"input_type": "protein",
|
|
683
|
+
"source_protein": protein,
|
|
684
|
+
"baseline_reverse_translation": result.original.symbols,
|
|
685
|
+
}
|
|
686
|
+
)
|
|
687
|
+
return replace(
|
|
688
|
+
result,
|
|
689
|
+
method="protein_reverse_translation_and_synonymous_codon_dynamic_programming",
|
|
690
|
+
algorithm_version="dnakit-protein-codon-optimization-v1",
|
|
691
|
+
parameters=freeze_parameters(parameters),
|
|
692
|
+
issues=(
|
|
693
|
+
Issue(
|
|
694
|
+
"PROTEIN_REVERSE_TRANSLATION_BASELINE",
|
|
695
|
+
IssueSeverity.INFO,
|
|
696
|
+
"The result.original field is a deterministic synthetic DNA baseline.",
|
|
697
|
+
),
|
|
698
|
+
),
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _replacement_map(
|
|
703
|
+
symbols: str,
|
|
704
|
+
positions: Mapping[int, Iterable[str]] | Iterable[int],
|
|
705
|
+
allowed_bases: tuple[str, ...],
|
|
706
|
+
max_positions: int,
|
|
707
|
+
) -> dict[int, tuple[str, ...]]:
|
|
708
|
+
items: Iterable[tuple[int, Iterable[str]]]
|
|
709
|
+
if isinstance(positions, Mapping):
|
|
710
|
+
items = positions.items()
|
|
711
|
+
else:
|
|
712
|
+
if isinstance(positions, (str, bytes)):
|
|
713
|
+
raise ConfigurationError("positions must be integer positions or a mapping.")
|
|
714
|
+
items = ((position, allowed_bases) for position in positions)
|
|
715
|
+
result: dict[int, tuple[str, ...]] = {}
|
|
716
|
+
for position, replacements in items:
|
|
717
|
+
if len(result) >= max_positions:
|
|
718
|
+
raise ConfigurationError("Mutation target count exceeds max_positions.")
|
|
719
|
+
if (
|
|
720
|
+
isinstance(position, bool)
|
|
721
|
+
or not isinstance(position, int)
|
|
722
|
+
or not 0 <= position < len(symbols)
|
|
723
|
+
):
|
|
724
|
+
raise ConfigurationError("Mutation position lies outside the source sequence.")
|
|
725
|
+
copied = materialize_bounded(
|
|
726
|
+
replacements,
|
|
727
|
+
max_items=16,
|
|
728
|
+
name="replacement bases",
|
|
729
|
+
reject_text=False,
|
|
730
|
+
)
|
|
731
|
+
normalized = {validate_iupac_text(base, "replacement base") for base in copied}
|
|
732
|
+
normalized.discard(symbols[position])
|
|
733
|
+
resolved = tuple(sorted(normalized))
|
|
734
|
+
if any(len(base) != 1 or base not in allowed_bases for base in resolved):
|
|
735
|
+
raise ConfigurationError("Replacement bases must be members of allowed_bases.")
|
|
736
|
+
if not resolved:
|
|
737
|
+
raise ConfigurationError("Every mutation position needs at least one alternate base.")
|
|
738
|
+
if position in result:
|
|
739
|
+
raise ConfigurationError("Mutation positions cannot be repeated.")
|
|
740
|
+
result[position] = resolved
|
|
741
|
+
if not result:
|
|
742
|
+
raise ConfigurationError("At least one mutation position is required.")
|
|
743
|
+
return dict(sorted(result.items()))
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _variant_count(replacements: Mapping[int, tuple[str, ...]], max_order: int) -> int:
|
|
747
|
+
counts = [len(values) for values in replacements.values()]
|
|
748
|
+
totals = [0] * (max_order + 1)
|
|
749
|
+
totals[0] = 1
|
|
750
|
+
for count in counts:
|
|
751
|
+
for order in range(max_order, 0, -1):
|
|
752
|
+
totals[order] += totals[order - 1] * count
|
|
753
|
+
return sum(totals[1:])
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def generate_mutation_library(
|
|
757
|
+
sequence: DNASequence,
|
|
758
|
+
positions: Mapping[int, Iterable[str]] | Iterable[int],
|
|
759
|
+
*,
|
|
760
|
+
mode: str = "single",
|
|
761
|
+
allowed_bases: Iterable[str] = _BASES,
|
|
762
|
+
max_order: int = 1,
|
|
763
|
+
sample_size: int | None = None,
|
|
764
|
+
seed: int = 0,
|
|
765
|
+
max_positions: int = 1_000,
|
|
766
|
+
max_variants: int = 100_000,
|
|
767
|
+
max_enumerated_variants: int = 1_000_000,
|
|
768
|
+
max_output_bases: int = 100_000_000,
|
|
769
|
+
max_sequence_length: int = 1_000_000,
|
|
770
|
+
) -> MutationLibraryResult:
|
|
771
|
+
"""Generate bounded single, saturation, or combinatorial substitutions."""
|
|
772
|
+
|
|
773
|
+
symbols = require_sequence(
|
|
774
|
+
sequence,
|
|
775
|
+
operation="mutation library generation",
|
|
776
|
+
max_length=max_sequence_length,
|
|
777
|
+
canonical=True,
|
|
778
|
+
allow_circular=False,
|
|
779
|
+
)
|
|
780
|
+
if mode not in ("single", "saturation", "combinatorial"):
|
|
781
|
+
raise ConfigurationError("mode must be single, saturation, or combinatorial.")
|
|
782
|
+
base_values = materialize_bounded(
|
|
783
|
+
allowed_bases,
|
|
784
|
+
max_items=16,
|
|
785
|
+
name="allowed bases",
|
|
786
|
+
reject_text=False,
|
|
787
|
+
)
|
|
788
|
+
allowed = tuple(sorted({validate_iupac_text(base, "allowed base") for base in base_values}))
|
|
789
|
+
if any(len(base) != 1 or base not in _BASES for base in allowed):
|
|
790
|
+
raise ConfigurationError("allowed_bases must contain canonical single bases.")
|
|
791
|
+
validate_positive_int(max_positions, "max_positions", maximum=100_000)
|
|
792
|
+
validate_positive_int(max_variants, "max_variants", maximum=10_000_000)
|
|
793
|
+
validate_positive_int(max_enumerated_variants, "max_enumerated_variants", maximum=100_000_000)
|
|
794
|
+
validate_positive_int(max_output_bases, "max_output_bases", maximum=10_000_000_000)
|
|
795
|
+
if isinstance(seed, bool) or not isinstance(seed, int):
|
|
796
|
+
raise ConfigurationError("seed must be an integer.")
|
|
797
|
+
replacements = _replacement_map(symbols, positions, allowed, max_positions)
|
|
798
|
+
resolved_order = 1 if mode in ("single", "saturation") else max_order
|
|
799
|
+
validate_positive_int(resolved_order, "max_order", maximum=20)
|
|
800
|
+
resolved_order = min(resolved_order, len(replacements))
|
|
801
|
+
total = _variant_count(replacements, resolved_order)
|
|
802
|
+
if total > max_enumerated_variants:
|
|
803
|
+
raise ConfigurationError(
|
|
804
|
+
"Mutation search space exceeds max_enumerated_variants.",
|
|
805
|
+
code="MUTATION_ENUMERATION_LIMIT_EXCEEDED",
|
|
806
|
+
context={
|
|
807
|
+
"total_possible_variants": total,
|
|
808
|
+
"max_enumerated_variants": max_enumerated_variants,
|
|
809
|
+
},
|
|
810
|
+
)
|
|
811
|
+
if sample_size is not None:
|
|
812
|
+
validate_positive_int(sample_size, "sample_size", maximum=max_variants)
|
|
813
|
+
if sample_size > total:
|
|
814
|
+
raise ConfigurationError("sample_size cannot exceed total possible variants.")
|
|
815
|
+
output_count = sample_size
|
|
816
|
+
else:
|
|
817
|
+
output_count = total
|
|
818
|
+
if output_count > max_variants:
|
|
819
|
+
raise ConfigurationError(
|
|
820
|
+
"Mutation library exceeds max_variants; provide a bounded sample_size.",
|
|
821
|
+
code="MUTATION_OUTPUT_LIMIT_EXCEEDED",
|
|
822
|
+
)
|
|
823
|
+
if output_count * len(symbols) > max_output_bases:
|
|
824
|
+
raise ConfigurationError(
|
|
825
|
+
"Mutation library exceeds max_output_bases.",
|
|
826
|
+
code="MUTATION_OUTPUT_BASE_LIMIT_EXCEEDED",
|
|
827
|
+
context={
|
|
828
|
+
"output_bases": output_count * len(symbols),
|
|
829
|
+
"max_output_bases": max_output_bases,
|
|
830
|
+
},
|
|
831
|
+
)
|
|
832
|
+
selected_indices = (
|
|
833
|
+
None if sample_size is None else set(random.Random(seed).sample(range(total), sample_size))
|
|
834
|
+
)
|
|
835
|
+
generated: list[SequenceVariant] = []
|
|
836
|
+
positions_tuple = tuple(replacements)
|
|
837
|
+
enumeration_index = 0
|
|
838
|
+
for order in range(1, resolved_order + 1):
|
|
839
|
+
for selected_positions in itertools.combinations(positions_tuple, order):
|
|
840
|
+
options = tuple(replacements[position] for position in selected_positions)
|
|
841
|
+
for alternates in itertools.product(*options):
|
|
842
|
+
if selected_indices is not None and enumeration_index not in selected_indices:
|
|
843
|
+
enumeration_index += 1
|
|
844
|
+
continue
|
|
845
|
+
mutation_tuple = tuple(
|
|
846
|
+
Mutation(position, symbols[position], alternate)
|
|
847
|
+
for position, alternate in zip(selected_positions, alternates, strict=True)
|
|
848
|
+
)
|
|
849
|
+
mutated = list(symbols)
|
|
850
|
+
for mutation in mutation_tuple:
|
|
851
|
+
mutated[mutation.position] = mutation.alternate
|
|
852
|
+
generated.append(
|
|
853
|
+
SequenceVariant(
|
|
854
|
+
id=f"variant_{enumeration_index + 1}",
|
|
855
|
+
sequence=DNASequence("".join(mutated), strandedness=sequence.strandedness),
|
|
856
|
+
mutations=mutation_tuple,
|
|
857
|
+
)
|
|
858
|
+
)
|
|
859
|
+
enumeration_index += 1
|
|
860
|
+
if len(generated) != output_count:
|
|
861
|
+
raise AssertionError("Mutation enumeration did not produce the expected bounded output.")
|
|
862
|
+
return MutationLibraryResult(
|
|
863
|
+
source=sequence,
|
|
864
|
+
variants=tuple(generated),
|
|
865
|
+
total_possible_variants=total,
|
|
866
|
+
sampled=sample_size is not None,
|
|
867
|
+
seed=seed,
|
|
868
|
+
method="bounded_substitution_product_enumeration",
|
|
869
|
+
algorithm_version="dnakit-mutation-library-v1",
|
|
870
|
+
parameters=freeze_parameters(
|
|
871
|
+
{
|
|
872
|
+
"mode": mode,
|
|
873
|
+
"positions": {str(key): value for key, value in replacements.items()},
|
|
874
|
+
"allowed_bases": allowed,
|
|
875
|
+
"max_order": resolved_order,
|
|
876
|
+
"sample_size": sample_size,
|
|
877
|
+
"seed": seed,
|
|
878
|
+
"total_possible_variants": total,
|
|
879
|
+
"max_positions": max_positions,
|
|
880
|
+
"max_variants": max_variants,
|
|
881
|
+
"max_enumerated_variants": max_enumerated_variants,
|
|
882
|
+
"max_output_bases": max_output_bases,
|
|
883
|
+
}
|
|
884
|
+
),
|
|
885
|
+
provenance=native_provenance(),
|
|
886
|
+
issues=(),
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
__all__ = [
|
|
891
|
+
"CodonUsageTable",
|
|
892
|
+
"RuleOptimizationConfig",
|
|
893
|
+
"generate_mutation_library",
|
|
894
|
+
"optimize_codons",
|
|
895
|
+
"optimize_protein_codons",
|
|
896
|
+
"optimize_sequence_rules",
|
|
897
|
+
]
|