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,591 @@
1
+ """Validated configuration for transparent DNA evaluation methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from types import MappingProxyType
9
+ from typing import Literal, TypeAlias
10
+
11
+ from dnakit.core._json import FrozenDict
12
+ from dnakit.exceptions import ConfigurationError
13
+ from dnakit.representations import RepresentationConfig
14
+
15
+ GapDenominatorPolicy: TypeAlias = Literal["exclude", "include_known", "error"]
16
+ PairSimilarityMethod: TypeAlias = Literal["exact", "identity", "edit", "kmer"]
17
+ UniquenessEquivalence: TypeAlias = Literal[
18
+ "exact",
19
+ "reverse_complement",
20
+ "circular",
21
+ "circular_reverse_complement",
22
+ "iupac",
23
+ "approximate",
24
+ ]
25
+ ScoreDirection: TypeAlias = Literal["higher_is_better", "lower_is_better"]
26
+ MissingScorePolicy: TypeAlias = Literal["error", "omit", "zero"]
27
+ KmerAmbiguityPolicy: TypeAlias = Literal["error", "ignore"]
28
+
29
+
30
+ def _finite(value: object, name: str) -> float:
31
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
32
+ raise ConfigurationError(f"{name} must be a finite number.")
33
+ return float(value)
34
+
35
+
36
+ def _probability(value: object, name: str) -> float:
37
+ resolved = _finite(value, name)
38
+ if not 0.0 <= resolved <= 1.0:
39
+ raise ConfigurationError(f"{name} must be within [0, 1].")
40
+ return resolved
41
+
42
+
43
+ def _positive_int(value: object, name: str, maximum: int) -> int:
44
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 < value <= maximum:
45
+ raise ConfigurationError(f"{name} must be an integer within [1, {maximum}].")
46
+ return value
47
+
48
+
49
+ def _non_negative_int(value: object, name: str, maximum: int) -> int:
50
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= maximum:
51
+ raise ConfigurationError(f"{name} must be an integer within [0, {maximum}].")
52
+ return value
53
+
54
+
55
+ def _freeze_weights(
56
+ values: Mapping[str, float],
57
+ *,
58
+ name: str,
59
+ allowed: frozenset[str] | None = None,
60
+ ) -> FrozenDict:
61
+ if not isinstance(values, Mapping) or not values:
62
+ raise ConfigurationError(f"{name} must be a non-empty mapping.")
63
+ resolved: dict[str, float] = {}
64
+ for key, value in values.items():
65
+ if not isinstance(key, str) or not key.strip():
66
+ raise ConfigurationError(f"{name} keys must be non-empty strings.")
67
+ if allowed is not None and key not in allowed:
68
+ raise ConfigurationError(f"{name} contains an unknown component: {key!r}.")
69
+ weight = _finite(value, f"{name}[{key!r}]")
70
+ if weight < 0:
71
+ raise ConfigurationError(f"{name} values must be non-negative.")
72
+ resolved[key] = weight
73
+ if math.fsum(resolved.values()) <= 0:
74
+ raise ConfigurationError(f"{name} must contain at least one positive weight.")
75
+ return FrozenDict(resolved)
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class EvaluationLimits:
80
+ """Shared caps for materialization and exhaustive calculations."""
81
+
82
+ max_records: int = 1_000
83
+ max_total_symbols: int = 10_000_000
84
+ max_pairwise_comparisons: int = 500_000
85
+ max_alignment_cells: int = 5_000_000
86
+
87
+ def __post_init__(self) -> None:
88
+ _positive_int(self.max_records, "max_records", 1_000_000)
89
+ _positive_int(self.max_total_symbols, "max_total_symbols", 1_000_000_000)
90
+ _positive_int(self.max_pairwise_comparisons, "max_pairwise_comparisons", 50_000_000)
91
+ _positive_int(self.max_alignment_cells, "max_alignment_cells", 100_000_000)
92
+
93
+
94
+ def _ambiguity_weights() -> dict[str, float]:
95
+ return {
96
+ "R": 0.5,
97
+ "Y": 0.5,
98
+ "S": 0.5,
99
+ "W": 0.5,
100
+ "K": 0.5,
101
+ "M": 0.5,
102
+ "B": 0.75,
103
+ "D": 0.75,
104
+ "H": 0.75,
105
+ "V": 0.75,
106
+ "N": 1.0,
107
+ }
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class AmbiguityEvaluationConfig:
112
+ max_fraction: float = 0.05
113
+ symbol_weights: Mapping[str, float] = field(default_factory=_ambiguity_weights)
114
+ gap_denominator_policy: GapDenominatorPolicy = "exclude"
115
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
116
+
117
+ def __post_init__(self) -> None:
118
+ object.__setattr__(self, "max_fraction", _probability(self.max_fraction, "max_fraction"))
119
+ if self.gap_denominator_policy not in {"exclude", "include_known", "error"}:
120
+ raise ConfigurationError("Unknown gap_denominator_policy.")
121
+ allowed = frozenset("RYSWKMBDHVN")
122
+ weights = _freeze_weights(self.symbol_weights, name="symbol_weights", allowed=allowed)
123
+ if set(weights) != allowed:
124
+ raise ConfigurationError("symbol_weights must define every ambiguous IUPAC symbol.")
125
+ if any(
126
+ isinstance(value, (int, float)) and not isinstance(value, bool) and value > 1.0
127
+ for value in weights.values()
128
+ ):
129
+ raise ConfigurationError("Ambiguity symbol weights must be within [0, 1].")
130
+ object.__setattr__(self, "symbol_weights", weights)
131
+ if not isinstance(self.limits, EvaluationLimits):
132
+ raise ConfigurationError("limits must be EvaluationLimits.")
133
+
134
+
135
+ def _complexity_weights() -> dict[str, float]:
136
+ return {
137
+ "entropy": 1.0,
138
+ "linguistic": 1.0,
139
+ "repeat_cleanliness": 1.0,
140
+ "homopolymer_cleanliness": 1.0,
141
+ }
142
+
143
+
144
+ @dataclass(frozen=True, slots=True)
145
+ class ComplexityEvaluationConfig:
146
+ max_word_size: int = 6
147
+ max_repeat_unit: int = 20
148
+ min_repeat_count: int = 2
149
+ acceptable_homopolymer_length: int = 6
150
+ weights: Mapping[str, float] = field(default_factory=_complexity_weights)
151
+ max_observations_per_sequence: int = 10_000_000
152
+ max_comparisons_per_sequence: int = 5_000_000
153
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
154
+
155
+ def __post_init__(self) -> None:
156
+ _positive_int(self.max_word_size, "max_word_size", 16)
157
+ _positive_int(self.max_repeat_unit, "max_repeat_unit", 100)
158
+ if self.min_repeat_count < 2:
159
+ raise ConfigurationError("min_repeat_count must be at least 2.")
160
+ _positive_int(self.min_repeat_count, "min_repeat_count", 1_000_000)
161
+ _positive_int(
162
+ self.acceptable_homopolymer_length, "acceptable_homopolymer_length", 1_000_000
163
+ )
164
+ _positive_int(
165
+ self.max_observations_per_sequence, "max_observations_per_sequence", 100_000_000
166
+ )
167
+ _positive_int(
168
+ self.max_comparisons_per_sequence, "max_comparisons_per_sequence", 100_000_000
169
+ )
170
+ object.__setattr__(
171
+ self,
172
+ "weights",
173
+ _freeze_weights(
174
+ self.weights,
175
+ name="weights",
176
+ allowed=frozenset(_complexity_weights()),
177
+ ),
178
+ )
179
+ if not isinstance(self.limits, EvaluationLimits):
180
+ raise ConfigurationError("limits must be EvaluationLimits.")
181
+
182
+
183
+ def _quality_weights() -> dict[str, float]:
184
+ return {
185
+ "validity": 2.0,
186
+ "ambiguity": 1.0,
187
+ "complexity": 1.0,
188
+ "length": 1.0,
189
+ "completeness": 1.0,
190
+ }
191
+
192
+
193
+ @dataclass(frozen=True, slots=True)
194
+ class QualityEvaluationConfig:
195
+ min_length: int = 1
196
+ max_length: int | None = None
197
+ ambiguity: AmbiguityEvaluationConfig = field(default_factory=AmbiguityEvaluationConfig)
198
+ complexity: ComplexityEvaluationConfig = field(default_factory=ComplexityEvaluationConfig)
199
+ weights: Mapping[str, float] = field(default_factory=_quality_weights)
200
+ warning_score: float = 0.75
201
+ pass_score: float = 0.9
202
+
203
+ def __post_init__(self) -> None:
204
+ _non_negative_int(self.min_length, "min_length", 1_000_000_000)
205
+ if self.max_length is not None:
206
+ _positive_int(self.max_length, "max_length", 1_000_000_000)
207
+ if self.max_length < self.min_length:
208
+ raise ConfigurationError("max_length cannot be smaller than min_length.")
209
+ if not isinstance(self.ambiguity, AmbiguityEvaluationConfig):
210
+ raise ConfigurationError("ambiguity must be AmbiguityEvaluationConfig.")
211
+ if not isinstance(self.complexity, ComplexityEvaluationConfig):
212
+ raise ConfigurationError("complexity must be ComplexityEvaluationConfig.")
213
+ if self.ambiguity.limits != self.complexity.limits:
214
+ raise ConfigurationError(
215
+ "Quality ambiguity and complexity configs must use identical limits.",
216
+ code="QUALITY_LIMIT_MISMATCH",
217
+ )
218
+ object.__setattr__(
219
+ self,
220
+ "weights",
221
+ _freeze_weights(self.weights, name="weights", allowed=frozenset(_quality_weights())),
222
+ )
223
+ warning = _probability(self.warning_score, "warning_score")
224
+ passing = _probability(self.pass_score, "pass_score")
225
+ if warning > passing:
226
+ raise ConfigurationError("warning_score cannot exceed pass_score.")
227
+ object.__setattr__(self, "warning_score", warning)
228
+ object.__setattr__(self, "pass_score", passing)
229
+
230
+
231
+ @dataclass(frozen=True, slots=True)
232
+ class UniquenessEvaluationConfig:
233
+ equivalence: UniquenessEquivalence = "exact"
234
+ approximate_method: PairSimilarityMethod = "kmer"
235
+ threshold: float = 0.9
236
+ k: int = 5
237
+ canonical: bool = True
238
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
239
+
240
+ def __post_init__(self) -> None:
241
+ if self.equivalence not in {
242
+ "exact",
243
+ "reverse_complement",
244
+ "circular",
245
+ "circular_reverse_complement",
246
+ "iupac",
247
+ "approximate",
248
+ }:
249
+ raise ConfigurationError("Unknown uniqueness equivalence.")
250
+ if self.approximate_method not in {"exact", "identity", "edit", "kmer"}:
251
+ raise ConfigurationError("Unknown approximate_method.")
252
+ object.__setattr__(self, "threshold", _probability(self.threshold, "threshold"))
253
+ _positive_int(self.k, "k", 32)
254
+ if not isinstance(self.canonical, bool):
255
+ raise ConfigurationError("canonical must be boolean.")
256
+ if not isinstance(self.limits, EvaluationLimits):
257
+ raise ConfigurationError("limits must be EvaluationLimits.")
258
+
259
+
260
+ @dataclass(frozen=True, slots=True)
261
+ class DiversityEvaluationConfig:
262
+ method: PairSimilarityMethod = "kmer"
263
+ k: int = 5
264
+ canonical: bool = True
265
+ cluster_threshold: float = 0.9
266
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
267
+
268
+ def __post_init__(self) -> None:
269
+ if self.method not in {"exact", "identity", "edit", "kmer"}:
270
+ raise ConfigurationError("Unknown diversity similarity method.")
271
+ _positive_int(self.k, "k", 32)
272
+ if not isinstance(self.canonical, bool):
273
+ raise ConfigurationError("canonical must be boolean.")
274
+ object.__setattr__(
275
+ self, "cluster_threshold", _probability(self.cluster_threshold, "cluster_threshold")
276
+ )
277
+ if not isinstance(self.limits, EvaluationLimits):
278
+ raise ConfigurationError("limits must be EvaluationLimits.")
279
+
280
+
281
+ @dataclass(frozen=True, slots=True)
282
+ class ReferenceSearchConfig:
283
+ method: PairSimilarityMethod = "kmer"
284
+ k: int = 7
285
+ canonical: bool = True
286
+ top_k: int = 1
287
+ min_similarity: float = 0.0
288
+ min_coverage: float = 0.0
289
+ copy_threshold: float = 0.9
290
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
291
+
292
+ def __post_init__(self) -> None:
293
+ if self.method not in {"exact", "identity", "edit", "kmer"}:
294
+ raise ConfigurationError("Unknown reference similarity method.")
295
+ _positive_int(self.k, "k", 32)
296
+ _positive_int(self.top_k, "top_k", 100_000)
297
+ if not isinstance(self.canonical, bool):
298
+ raise ConfigurationError("canonical must be boolean.")
299
+ object.__setattr__(
300
+ self, "min_similarity", _probability(self.min_similarity, "min_similarity")
301
+ )
302
+ object.__setattr__(self, "min_coverage", _probability(self.min_coverage, "min_coverage"))
303
+ object.__setattr__(
304
+ self, "copy_threshold", _probability(self.copy_threshold, "copy_threshold")
305
+ )
306
+ if not isinstance(self.limits, EvaluationLimits):
307
+ raise ConfigurationError("limits must be EvaluationLimits.")
308
+
309
+
310
+ @dataclass(frozen=True, slots=True)
311
+ class DistributionEvaluationConfig:
312
+ features: tuple[str, ...] = ("length", "gc", "kmer", "motif", "repeat")
313
+ k: int = 3
314
+ canonical: bool = True
315
+ motifs: tuple[str, ...] = ("CG",)
316
+ max_kmer_observations: int = 10_000_000
317
+ max_repeat_comparisons_per_sequence: int = 1_000_000
318
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
319
+
320
+ def __post_init__(self) -> None:
321
+ allowed = {"length", "gc", "kmer", "motif", "repeat"}
322
+ features = tuple(self.features)
323
+ if not features or len(set(features)) != len(features) or set(features) - allowed:
324
+ raise ConfigurationError("features must be unique supported distribution features.")
325
+ object.__setattr__(self, "features", features)
326
+ _positive_int(self.k, "k", 12)
327
+ if not isinstance(self.canonical, bool):
328
+ raise ConfigurationError("canonical must be boolean.")
329
+ motifs = tuple(self.motifs)
330
+ if len(set(motifs)) != len(motifs) or any(
331
+ not isinstance(motif, str) or not motif or set(motif) - set("ACGT") for motif in motifs
332
+ ):
333
+ raise ConfigurationError("motifs must contain unique, non-empty A/C/G/T strings.")
334
+ if "motif" in features and not motifs:
335
+ raise ConfigurationError("At least one motif is required for motif distribution.")
336
+ object.__setattr__(self, "motifs", motifs)
337
+ _positive_int(self.max_kmer_observations, "max_kmer_observations", 100_000_000)
338
+ _positive_int(
339
+ self.max_repeat_comparisons_per_sequence,
340
+ "max_repeat_comparisons_per_sequence",
341
+ 100_000_000,
342
+ )
343
+ if not isinstance(self.limits, EvaluationLimits):
344
+ raise ConfigurationError("limits must be EvaluationLimits.")
345
+
346
+
347
+ @dataclass(frozen=True, slots=True)
348
+ class FrechetDistanceConfig:
349
+ """Configure a Fréchet distance over DNA foundation-model representations."""
350
+
351
+ representation: RepresentationConfig = field(default_factory=RepresentationConfig)
352
+ normalize: bool = True
353
+ max_cross_gram_elements: int = 1_000_000
354
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
355
+
356
+ def __post_init__(self) -> None:
357
+ if not isinstance(self.representation, RepresentationConfig):
358
+ raise ConfigurationError(
359
+ "representation must be RepresentationConfig.",
360
+ code="INVALID_FRECHET_DISTANCE_CONFIG",
361
+ )
362
+ if not isinstance(self.normalize, bool):
363
+ raise ConfigurationError(
364
+ "normalize must be boolean.",
365
+ code="INVALID_FRECHET_DISTANCE_CONFIG",
366
+ )
367
+ _positive_int(
368
+ self.max_cross_gram_elements,
369
+ "max_cross_gram_elements",
370
+ 100_000_000,
371
+ )
372
+ if not isinstance(self.limits, EvaluationLimits):
373
+ raise ConfigurationError(
374
+ "limits must be EvaluationLimits.",
375
+ code="INVALID_FRECHET_DISTANCE_CONFIG",
376
+ )
377
+
378
+
379
+ @dataclass(frozen=True, slots=True)
380
+ class FragmentSimilarityConfig:
381
+ """Configure the DNA adaptation of the MOSES Frag metric."""
382
+
383
+ k: int = 3
384
+ canonical: bool = True
385
+ ambiguity_policy: KmerAmbiguityPolicy = "ignore"
386
+ max_kmer_observations: int = 10_000_000
387
+ show_progress: bool = True
388
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
389
+
390
+ def __post_init__(self) -> None:
391
+ _positive_int(self.k, "k", 32)
392
+ if not isinstance(self.canonical, bool):
393
+ raise ConfigurationError(
394
+ "canonical must be boolean.",
395
+ code="INVALID_FRAGMENT_SIMILARITY_CONFIG",
396
+ )
397
+ if self.ambiguity_policy not in {"error", "ignore"}:
398
+ raise ConfigurationError(
399
+ "ambiguity_policy must be 'error' or 'ignore'.",
400
+ code="INVALID_FRAGMENT_SIMILARITY_CONFIG",
401
+ )
402
+ _positive_int(
403
+ self.max_kmer_observations,
404
+ "max_kmer_observations",
405
+ 100_000_000,
406
+ )
407
+ if not isinstance(self.show_progress, bool):
408
+ raise ConfigurationError(
409
+ "show_progress must be boolean.",
410
+ code="INVALID_FRAGMENT_SIMILARITY_CONFIG",
411
+ )
412
+ if not isinstance(self.limits, EvaluationLimits):
413
+ raise ConfigurationError(
414
+ "limits must be EvaluationLimits.",
415
+ code="INVALID_FRAGMENT_SIMILARITY_CONFIG",
416
+ )
417
+
418
+
419
+ @dataclass(frozen=True, slots=True)
420
+ class SNNConfig:
421
+ """Configure similarity to nearest neighbor over DNA bit fingerprints."""
422
+
423
+ k: int = 7
424
+ n_bits: int = 1_024
425
+ canonical: bool = True
426
+ seed: int = 0
427
+ ambiguity_policy: KmerAmbiguityPolicy = "ignore"
428
+ max_fingerprint_elements: int = 10_000_000
429
+ show_progress: bool = True
430
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
431
+
432
+ def __post_init__(self) -> None:
433
+ _positive_int(self.k, "k", 32)
434
+ _positive_int(self.n_bits, "n_bits", 1_000_000)
435
+ if not isinstance(self.canonical, bool):
436
+ raise ConfigurationError(
437
+ "canonical must be boolean.",
438
+ code="INVALID_SNN_CONFIG",
439
+ )
440
+ if (
441
+ isinstance(self.seed, bool)
442
+ or not isinstance(self.seed, int)
443
+ or not 0 <= self.seed < 2**64
444
+ ):
445
+ raise ConfigurationError(
446
+ "seed must be an integer in [0, 2**64).",
447
+ code="INVALID_SNN_CONFIG",
448
+ )
449
+ if self.ambiguity_policy not in {"error", "ignore"}:
450
+ raise ConfigurationError(
451
+ "ambiguity_policy must be 'error' or 'ignore'.",
452
+ code="INVALID_SNN_CONFIG",
453
+ )
454
+ _positive_int(
455
+ self.max_fingerprint_elements,
456
+ "max_fingerprint_elements",
457
+ 100_000_000,
458
+ )
459
+ if not isinstance(self.show_progress, bool):
460
+ raise ConfigurationError(
461
+ "show_progress must be boolean.",
462
+ code="INVALID_SNN_CONFIG",
463
+ )
464
+ if not isinstance(self.limits, EvaluationLimits):
465
+ raise ConfigurationError(
466
+ "limits must be EvaluationLimits.",
467
+ code="INVALID_SNN_CONFIG",
468
+ )
469
+
470
+
471
+ @dataclass(frozen=True, slots=True)
472
+ class SynthesisRiskConfig:
473
+ global_gc_min: float = 0.30
474
+ global_gc_max: float = 0.70
475
+ local_gc_min: float = 0.20
476
+ local_gc_max: float = 0.80
477
+ window_size: int = 50
478
+ window_step: int = 10
479
+ homopolymer_threshold: int = 8
480
+ tandem_min_unit: int = 2
481
+ tandem_max_unit: int = 20
482
+ tandem_min_repeats: int = 4
483
+ inverted_min_arm: int = 10
484
+ inverted_max_arm: int = 30
485
+ inverted_max_loop: int = 80
486
+ max_windows_per_sequence: int = 1_000_000
487
+ max_pattern_comparisons_per_sequence: int = 2_000_000
488
+ max_matches_per_sequence: int = 100_000
489
+ limits: EvaluationLimits = field(default_factory=EvaluationLimits)
490
+
491
+ def __post_init__(self) -> None:
492
+ for name in ("global_gc_min", "global_gc_max", "local_gc_min", "local_gc_max"):
493
+ object.__setattr__(self, name, _probability(getattr(self, name), name))
494
+ if self.global_gc_min > self.global_gc_max or self.local_gc_min > self.local_gc_max:
495
+ raise ConfigurationError("Minimum GC thresholds cannot exceed maximum thresholds.")
496
+ for name, maximum in (
497
+ ("window_size", 1_000_000),
498
+ ("window_step", 1_000_000),
499
+ ("homopolymer_threshold", 1_000_000),
500
+ ("tandem_min_unit", 100),
501
+ ("tandem_max_unit", 100),
502
+ ("tandem_min_repeats", 1_000_000),
503
+ ("inverted_min_arm", 1_000),
504
+ ("inverted_max_arm", 1_000),
505
+ ("max_windows_per_sequence", 10_000_000),
506
+ ("max_pattern_comparisons_per_sequence", 100_000_000),
507
+ ("max_matches_per_sequence", 1_000_000),
508
+ ):
509
+ _positive_int(getattr(self, name), name, maximum)
510
+ _non_negative_int(self.inverted_max_loop, "inverted_max_loop", 1_000_000)
511
+ if self.tandem_min_unit > self.tandem_max_unit:
512
+ raise ConfigurationError("tandem_min_unit cannot exceed tandem_max_unit.")
513
+ if self.tandem_min_repeats < 2:
514
+ raise ConfigurationError("tandem_min_repeats must be at least 2.")
515
+ if self.inverted_min_arm > self.inverted_max_arm:
516
+ raise ConfigurationError("inverted_min_arm cannot exceed inverted_max_arm.")
517
+ if not isinstance(self.limits, EvaluationLimits):
518
+ raise ConfigurationError("limits must be EvaluationLimits.")
519
+
520
+
521
+ @dataclass(frozen=True, slots=True)
522
+ class ScoreRule:
523
+ """One explicit min-max normalization rule for a scorecard component."""
524
+
525
+ direction: ScoreDirection = "higher_is_better"
526
+ weight: float = 1.0
527
+ minimum: float = 0.0
528
+ maximum: float = 1.0
529
+ metric: str | None = None
530
+
531
+ def __post_init__(self) -> None:
532
+ if self.direction not in {"higher_is_better", "lower_is_better"}:
533
+ raise ConfigurationError("Unknown score direction.")
534
+ weight = _finite(self.weight, "weight")
535
+ minimum = _finite(self.minimum, "minimum")
536
+ maximum = _finite(self.maximum, "maximum")
537
+ if weight <= 0 or minimum >= maximum:
538
+ raise ConfigurationError("ScoreRule requires weight > 0 and minimum < maximum.")
539
+ if self.metric is not None and (
540
+ not isinstance(self.metric, str) or not self.metric.strip()
541
+ ):
542
+ raise ConfigurationError("ScoreRule metric must be non-empty or None.")
543
+ object.__setattr__(self, "weight", weight)
544
+ object.__setattr__(self, "minimum", minimum)
545
+ object.__setattr__(self, "maximum", maximum)
546
+
547
+
548
+ @dataclass(frozen=True, slots=True)
549
+ class ScorecardConfig:
550
+ rules: Mapping[str, ScoreRule]
551
+ missing_policy: MissingScorePolicy = "error"
552
+ warning_score: float = 0.5
553
+ pass_score: float = 0.8
554
+
555
+ def __post_init__(self) -> None:
556
+ if not isinstance(self.rules, Mapping) or not self.rules:
557
+ raise ConfigurationError("rules must be a non-empty mapping.")
558
+ rules: dict[str, ScoreRule] = {}
559
+ for name, rule in self.rules.items():
560
+ if not isinstance(name, str) or not name.strip() or not isinstance(rule, ScoreRule):
561
+ raise ConfigurationError(
562
+ "Scorecard rules require non-empty names and ScoreRule values."
563
+ )
564
+ rules[name] = rule
565
+ object.__setattr__(self, "rules", MappingProxyType(rules))
566
+ if self.missing_policy not in {"error", "omit", "zero"}:
567
+ raise ConfigurationError("Unknown missing_policy.")
568
+ warning = _probability(self.warning_score, "warning_score")
569
+ passing = _probability(self.pass_score, "pass_score")
570
+ if warning > passing:
571
+ raise ConfigurationError("warning_score cannot exceed pass_score.")
572
+ object.__setattr__(self, "warning_score", warning)
573
+ object.__setattr__(self, "pass_score", passing)
574
+
575
+
576
+ __all__ = [
577
+ "AmbiguityEvaluationConfig",
578
+ "ComplexityEvaluationConfig",
579
+ "DistributionEvaluationConfig",
580
+ "DiversityEvaluationConfig",
581
+ "EvaluationLimits",
582
+ "FragmentSimilarityConfig",
583
+ "FrechetDistanceConfig",
584
+ "QualityEvaluationConfig",
585
+ "ReferenceSearchConfig",
586
+ "SNNConfig",
587
+ "ScoreRule",
588
+ "ScorecardConfig",
589
+ "SynthesisRiskConfig",
590
+ "UniquenessEvaluationConfig",
591
+ ]