dnakit 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ ]