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,531 @@
1
+ """Validated configuration for deterministic dataset operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from typing import Literal, TypeAlias
9
+
10
+ from dnakit.core._json import FrozenDict
11
+ from dnakit.exceptions import ConfigurationError
12
+ from dnakit.representations.models import RepresentationConfig
13
+
14
+ DedupEquivalence: TypeAlias = Literal[
15
+ "exact",
16
+ "reverse_complement",
17
+ "circular",
18
+ "circular_reverse_complement",
19
+ ]
20
+ RepresentativePolicy: TypeAlias = Literal["first", "last", "best_quality"]
21
+ ConflictPolicy: TypeAlias = Literal["error", "drop_group", "keep_representative", "keep_all"]
22
+ SplitMethod: TypeAlias = Literal["random", "hash", "stratified", "group", "similarity"]
23
+ MissingMetadataPolicy: TypeAlias = Literal["error", "separate"]
24
+ SimilarityAmbiguityPolicy: TypeAlias = Literal["error", "ignore"]
25
+ SimilarityGapPolicy: TypeAlias = Literal["error", "split"]
26
+ ClusterMethod: TypeAlias = Literal["identity", "edit", "kmer", "fingerprint"]
27
+ LinkageMethod: TypeAlias = Literal["single", "complete", "average"]
28
+ AdvancedRepresentativePolicy: TypeAlias = Literal[
29
+ "first", "shortest", "longest", "best_quality", "medoid"
30
+ ]
31
+ InfeasiblePolicy: TypeAlias = Literal["error", "relax"]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class DeduplicationConfig:
36
+ """Control representative selection and duplicate-label conflicts."""
37
+
38
+ representative_policy: RepresentativePolicy = "first"
39
+ conflict_field: str | None = None
40
+ conflict_policy: ConflictPolicy = "error"
41
+ merge_metadata: bool = False
42
+
43
+ def __post_init__(self) -> None:
44
+ if self.representative_policy not in {"first", "last", "best_quality"}:
45
+ raise ConfigurationError(
46
+ "Unknown representative_policy.",
47
+ code="INVALID_REPRESENTATIVE_POLICY",
48
+ context={"representative_policy": self.representative_policy},
49
+ )
50
+ if self.conflict_policy not in {
51
+ "error",
52
+ "drop_group",
53
+ "keep_representative",
54
+ "keep_all",
55
+ }:
56
+ raise ConfigurationError(
57
+ "Unknown conflict_policy.",
58
+ code="INVALID_CONFLICT_POLICY",
59
+ context={"conflict_policy": self.conflict_policy},
60
+ )
61
+ if self.conflict_field is not None and (
62
+ not isinstance(self.conflict_field, str) or not self.conflict_field.strip()
63
+ ):
64
+ raise ConfigurationError(
65
+ "conflict_field must be None or a non-empty metadata key.",
66
+ code="INVALID_CONFLICT_FIELD",
67
+ )
68
+ if not isinstance(self.merge_metadata, bool):
69
+ raise ConfigurationError(
70
+ "merge_metadata must be a boolean.",
71
+ code="INVALID_DEDUPLICATION_CONFIG",
72
+ )
73
+
74
+
75
+ def _default_ratios() -> dict[str, float]:
76
+ return {"train": 0.8, "valid": 0.1, "test": 0.1}
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class SplitConfig:
81
+ """Control random, stable-hash, stratified, group-aware, or k-mer similarity splitting."""
82
+
83
+ method: SplitMethod = "random"
84
+ ratios: Mapping[str, float] = field(default_factory=_default_ratios)
85
+ seed: int = 0
86
+ shuffle: bool = True
87
+ preserve_order: bool = True
88
+ metadata_key: str | None = None
89
+ missing_metadata_policy: MissingMetadataPolicy = "error"
90
+ similarity_k: int = 3
91
+ similarity_threshold: float = 0.8
92
+ similarity_ambiguity_policy: SimilarityAmbiguityPolicy = "error"
93
+ similarity_gap_policy: SimilarityGapPolicy = "error"
94
+ max_pairwise_records: int = 5_000
95
+
96
+ def __post_init__(self) -> None:
97
+ if self.method not in {"random", "hash", "stratified", "group", "similarity"}:
98
+ raise ConfigurationError(
99
+ "Unknown dataset split method.",
100
+ code="INVALID_SPLIT_METHOD",
101
+ context={"method": self.method},
102
+ )
103
+ if not isinstance(self.ratios, Mapping):
104
+ raise ConfigurationError(
105
+ "ratios must be a mapping of split names to fractions.",
106
+ code="INVALID_SPLIT_RATIOS",
107
+ )
108
+ ratio_items = tuple(self.ratios.items())
109
+ if len(ratio_items) < 2:
110
+ raise ConfigurationError(
111
+ "At least two split ratios are required.",
112
+ code="INVALID_SPLIT_RATIOS",
113
+ )
114
+ normalized_ratios: dict[str, float] = {}
115
+ for name, value in ratio_items:
116
+ if not isinstance(name, str) or not name.strip():
117
+ raise ConfigurationError(
118
+ "Split names must be non-empty strings.",
119
+ code="INVALID_SPLIT_RATIOS",
120
+ )
121
+ if (
122
+ isinstance(value, bool)
123
+ or not isinstance(value, (int, float))
124
+ or not math.isfinite(value)
125
+ or value <= 0
126
+ ):
127
+ raise ConfigurationError(
128
+ "Every split ratio must be a finite positive number.",
129
+ code="INVALID_SPLIT_RATIOS",
130
+ context={"split": name, "ratio": value},
131
+ )
132
+ normalized_ratios[name] = float(value)
133
+ ratio_sum = sum(normalized_ratios.values())
134
+ if not math.isclose(ratio_sum, 1.0, rel_tol=0.0, abs_tol=1e-9):
135
+ raise ConfigurationError(
136
+ "Split ratios must sum to 1.0.",
137
+ code="INVALID_SPLIT_RATIOS",
138
+ context={"sum": ratio_sum},
139
+ )
140
+ if isinstance(self.seed, bool) or not isinstance(self.seed, int):
141
+ raise ConfigurationError(
142
+ "seed must be an integer.",
143
+ code="INVALID_SPLIT_SEED",
144
+ )
145
+ for field_name in ("shuffle", "preserve_order"):
146
+ if not isinstance(getattr(self, field_name), bool):
147
+ raise ConfigurationError(
148
+ f"{field_name} must be a boolean.",
149
+ code="INVALID_SPLIT_CONFIG",
150
+ )
151
+ if self.method in {"stratified", "group"} and (
152
+ not isinstance(self.metadata_key, str) or not self.metadata_key.strip()
153
+ ):
154
+ raise ConfigurationError(
155
+ f"metadata_key is required for method={self.method!r}.",
156
+ code="SPLIT_METADATA_KEY_REQUIRED",
157
+ )
158
+ if self.metadata_key is not None and (
159
+ not isinstance(self.metadata_key, str) or not self.metadata_key.strip()
160
+ ):
161
+ raise ConfigurationError(
162
+ "metadata_key must be None or a non-empty string.",
163
+ code="INVALID_SPLIT_METADATA_KEY",
164
+ )
165
+ if self.missing_metadata_policy not in {"error", "separate"}:
166
+ raise ConfigurationError(
167
+ "missing_metadata_policy must be 'error' or 'separate'.",
168
+ code="INVALID_MISSING_METADATA_POLICY",
169
+ )
170
+ if (
171
+ isinstance(self.similarity_k, bool)
172
+ or not isinstance(self.similarity_k, int)
173
+ or self.similarity_k <= 0
174
+ ):
175
+ raise ConfigurationError(
176
+ "similarity_k must be a positive integer.",
177
+ code="INVALID_SIMILARITY_SPLIT_CONFIG",
178
+ )
179
+ if (
180
+ isinstance(self.similarity_threshold, bool)
181
+ or not isinstance(self.similarity_threshold, (int, float))
182
+ or not math.isfinite(self.similarity_threshold)
183
+ or not 0 <= self.similarity_threshold <= 1
184
+ ):
185
+ raise ConfigurationError(
186
+ "similarity_threshold must be finite and within [0, 1].",
187
+ code="INVALID_SIMILARITY_SPLIT_CONFIG",
188
+ )
189
+ if self.similarity_ambiguity_policy not in {"error", "ignore"}:
190
+ raise ConfigurationError(
191
+ "similarity_ambiguity_policy must be 'error' or 'ignore'.",
192
+ code="INVALID_SIMILARITY_SPLIT_CONFIG",
193
+ )
194
+ if self.similarity_gap_policy not in {"error", "split"}:
195
+ raise ConfigurationError(
196
+ "similarity_gap_policy must be 'error' or 'split'.",
197
+ code="INVALID_SIMILARITY_SPLIT_CONFIG",
198
+ )
199
+ if (
200
+ isinstance(self.max_pairwise_records, bool)
201
+ or not isinstance(self.max_pairwise_records, int)
202
+ or self.max_pairwise_records <= 0
203
+ ):
204
+ raise ConfigurationError(
205
+ "max_pairwise_records must be a positive integer.",
206
+ code="INVALID_SIMILARITY_SPLIT_CONFIG",
207
+ )
208
+ object.__setattr__(self, "ratios", FrozenDict(normalized_ratios))
209
+
210
+
211
+ def _validate_probability(value: object, name: str) -> float:
212
+ if (
213
+ isinstance(value, bool)
214
+ or not isinstance(value, (int, float))
215
+ or not math.isfinite(value)
216
+ or not 0.0 <= value <= 1.0
217
+ ):
218
+ raise ConfigurationError(
219
+ f"{name} must be finite and within [0, 1].",
220
+ code="INVALID_ADVANCED_DATASET_CONFIG",
221
+ )
222
+ return float(value)
223
+
224
+
225
+ def _validate_bounded_positive_int(value: object, name: str, maximum: int) -> int:
226
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0 or value > maximum:
227
+ raise ConfigurationError(
228
+ f"{name} must be an integer in [1, {maximum}].",
229
+ code="INVALID_ADVANCED_DATASET_LIMIT",
230
+ )
231
+ return value
232
+
233
+
234
+ @dataclass(frozen=True, slots=True)
235
+ class IUPACDeduplicationConfig:
236
+ """Bound pairwise work for all-pairs IUPAC compatibility grouping."""
237
+
238
+ representative_policy: AdvancedRepresentativePolicy = "first"
239
+ max_records: int = 2_000
240
+ max_pairwise_comparisons: int = 1_000_000
241
+
242
+ def __post_init__(self) -> None:
243
+ if self.representative_policy not in {
244
+ "first",
245
+ "shortest",
246
+ "longest",
247
+ "best_quality",
248
+ "medoid",
249
+ }:
250
+ raise ConfigurationError("Unknown advanced representative policy.")
251
+ if self.representative_policy == "medoid":
252
+ raise ConfigurationError(
253
+ "IUPAC compatibility groups do not define a numeric medoid distance.",
254
+ code="IUPAC_MEDOID_UNDEFINED",
255
+ )
256
+ _validate_bounded_positive_int(self.max_records, "max_records", 10_000)
257
+ _validate_bounded_positive_int(
258
+ self.max_pairwise_comparisons,
259
+ "max_pairwise_comparisons",
260
+ 50_000_000,
261
+ )
262
+
263
+
264
+ @dataclass(frozen=True, slots=True)
265
+ class ClusterConfig:
266
+ """Configuration for exhaustive bounded threshold-graph clustering."""
267
+
268
+ method: ClusterMethod = "identity"
269
+ threshold: float = 0.9
270
+ k: int = 3
271
+ canonical: bool = False
272
+ representative_policy: AdvancedRepresentativePolicy = "first"
273
+ seed: int = 0
274
+ max_records: int = 1_000
275
+ max_pairwise_comparisons: int = 500_000
276
+ max_alignment_cells: int = 5_000_000
277
+
278
+ def __post_init__(self) -> None:
279
+ if self.method not in {"identity", "edit", "kmer", "fingerprint"}:
280
+ raise ConfigurationError("Unknown clustering method.", code="INVALID_CLUSTER_METHOD")
281
+ object.__setattr__(self, "threshold", _validate_probability(self.threshold, "threshold"))
282
+ _validate_bounded_positive_int(self.k, "k", 12)
283
+ if not isinstance(self.canonical, bool):
284
+ raise ConfigurationError("canonical must be boolean.")
285
+ if self.representative_policy not in {
286
+ "first",
287
+ "shortest",
288
+ "longest",
289
+ "best_quality",
290
+ "medoid",
291
+ }:
292
+ raise ConfigurationError("Unknown advanced representative policy.")
293
+ if isinstance(self.seed, bool) or not isinstance(self.seed, int):
294
+ raise ConfigurationError("seed must be an integer.")
295
+ _validate_bounded_positive_int(self.max_records, "max_records", 5_000)
296
+ _validate_bounded_positive_int(
297
+ self.max_pairwise_comparisons,
298
+ "max_pairwise_comparisons",
299
+ 10_000_000,
300
+ )
301
+ _validate_bounded_positive_int(
302
+ self.max_alignment_cells,
303
+ "max_alignment_cells",
304
+ 100_000_000,
305
+ )
306
+
307
+
308
+ @dataclass(frozen=True, slots=True)
309
+ class HierarchicalClusteringConfig:
310
+ """Configuration for exact agglomerative clustering over a bounded matrix."""
311
+
312
+ method: ClusterMethod = "identity"
313
+ linkage: LinkageMethod = "average"
314
+ k: int = 3
315
+ canonical: bool = False
316
+ max_records: int = 500
317
+ max_pairwise_comparisons: int = 124_750
318
+ max_alignment_cells: int = 5_000_000
319
+
320
+ def __post_init__(self) -> None:
321
+ if self.method not in {"identity", "edit", "kmer", "fingerprint"}:
322
+ raise ConfigurationError("Unknown clustering method.", code="INVALID_CLUSTER_METHOD")
323
+ if self.linkage not in {"single", "complete", "average"}:
324
+ raise ConfigurationError("Unknown linkage method.", code="INVALID_LINKAGE_METHOD")
325
+ _validate_bounded_positive_int(self.k, "k", 12)
326
+ if not isinstance(self.canonical, bool):
327
+ raise ConfigurationError("canonical must be boolean.")
328
+ _validate_bounded_positive_int(self.max_records, "max_records", 1_000)
329
+ _validate_bounded_positive_int(
330
+ self.max_pairwise_comparisons,
331
+ "max_pairwise_comparisons",
332
+ 499_500,
333
+ )
334
+ _validate_bounded_positive_int(
335
+ self.max_alignment_cells,
336
+ "max_alignment_cells",
337
+ 100_000_000,
338
+ )
339
+
340
+
341
+ @dataclass(frozen=True, slots=True)
342
+ class NeuralClusteringConfig:
343
+ """Configure DNA model representations followed by seeded k-means."""
344
+
345
+ representation: RepresentationConfig = field(default_factory=RepresentationConfig)
346
+ n_clusters: int = 2
347
+ n_init: int = 10
348
+ max_iter: int = 300
349
+ tolerance: float = 1e-4
350
+ normalize: bool = True
351
+ pca_components: int | None = None
352
+ silhouette_sample_size: int = 5_000
353
+ seed: int = 0
354
+ max_records: int = 10_000
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_NEURAL_CLUSTER_CONFIG",
361
+ )
362
+ _validate_bounded_positive_int(self.n_clusters, "n_clusters", 100_000)
363
+ _validate_bounded_positive_int(self.n_init, "n_init", 1_000)
364
+ _validate_bounded_positive_int(self.max_iter, "max_iter", 100_000)
365
+ if (
366
+ isinstance(self.tolerance, bool)
367
+ or not isinstance(self.tolerance, (int, float))
368
+ or not math.isfinite(float(self.tolerance))
369
+ or not 0 < float(self.tolerance) <= 1.0
370
+ ):
371
+ raise ConfigurationError(
372
+ "tolerance must be finite and in (0, 1].",
373
+ code="INVALID_NEURAL_CLUSTER_CONFIG",
374
+ )
375
+ object.__setattr__(self, "tolerance", float(self.tolerance))
376
+ if not isinstance(self.normalize, bool):
377
+ raise ConfigurationError(
378
+ "normalize must be boolean.",
379
+ code="INVALID_NEURAL_CLUSTER_CONFIG",
380
+ )
381
+ if self.pca_components is not None:
382
+ _validate_bounded_positive_int(self.pca_components, "pca_components", 100_000)
383
+ _validate_bounded_positive_int(
384
+ self.silhouette_sample_size,
385
+ "silhouette_sample_size",
386
+ 1_000_000,
387
+ )
388
+ if isinstance(self.seed, bool) or not isinstance(self.seed, int):
389
+ raise ConfigurationError(
390
+ "seed must be an integer.",
391
+ code="INVALID_NEURAL_CLUSTER_CONFIG",
392
+ )
393
+ _validate_bounded_positive_int(self.max_records, "max_records", 1_000_000)
394
+
395
+
396
+ @dataclass(frozen=True, slots=True)
397
+ class TemporalSplitConfig:
398
+ """Chronological split configuration using ISO-8601 metadata strings."""
399
+
400
+ metadata_key: str = "date"
401
+ ratios: Mapping[str, float] = field(default_factory=lambda: {"train": 0.8, "test": 0.2})
402
+ cutoffs: tuple[str, ...] | None = None
403
+ preserve_order: bool = True
404
+ max_records: int = 1_000_000
405
+
406
+ def __post_init__(self) -> None:
407
+ if not isinstance(self.metadata_key, str) or not self.metadata_key.strip():
408
+ raise ConfigurationError("metadata_key must be a non-empty string.")
409
+ validated = SplitConfig(ratios=self.ratios, preserve_order=self.preserve_order)
410
+ object.__setattr__(self, "ratios", validated.ratios)
411
+ _validate_bounded_positive_int(self.max_records, "max_records", 10_000_000)
412
+ if self.cutoffs is not None:
413
+ cutoffs = tuple(self.cutoffs)
414
+ if len(cutoffs) != len(self.ratios) - 1 or any(
415
+ not isinstance(value, str) or not value.strip() for value in cutoffs
416
+ ):
417
+ raise ConfigurationError(
418
+ "cutoffs must contain one non-empty ISO timestamp per split boundary.",
419
+ code="INVALID_TEMPORAL_CUTOFFS",
420
+ )
421
+ object.__setattr__(self, "cutoffs", cutoffs)
422
+
423
+
424
+ @dataclass(frozen=True, slots=True)
425
+ class JointSplitConfig:
426
+ """Greedy multi-constraint split with explicit infeasibility handling."""
427
+
428
+ ratios: Mapping[str, float] = field(default_factory=_default_ratios)
429
+ group_keys: tuple[str, ...] = ()
430
+ label_key: str | None = None
431
+ similarity_threshold: float | None = None
432
+ similarity_k: int = 3
433
+ similarity_canonical: bool = True
434
+ seed: int = 0
435
+ ratio_tolerance: float = 0.05
436
+ infeasible_policy: InfeasiblePolicy = "error"
437
+ max_records: int = 2_000
438
+ max_pairwise_comparisons: int = 1_000_000
439
+
440
+ def __post_init__(self) -> None:
441
+ validated = SplitConfig(ratios=self.ratios, seed=self.seed)
442
+ object.__setattr__(self, "ratios", validated.ratios)
443
+ keys = tuple(self.group_keys)
444
+ if any(not isinstance(key, str) or not key.strip() for key in keys):
445
+ raise ConfigurationError("group_keys must contain non-empty strings.")
446
+ if len(keys) != len(set(keys)):
447
+ raise ConfigurationError("group_keys must be unique.")
448
+ object.__setattr__(self, "group_keys", keys)
449
+ if self.label_key is not None and (
450
+ not isinstance(self.label_key, str) or not self.label_key.strip()
451
+ ):
452
+ raise ConfigurationError("label_key must be non-empty or None.")
453
+ if self.similarity_threshold is not None:
454
+ object.__setattr__(
455
+ self,
456
+ "similarity_threshold",
457
+ _validate_probability(self.similarity_threshold, "similarity_threshold"),
458
+ )
459
+ if not keys and self.similarity_threshold is None:
460
+ raise ConfigurationError(
461
+ "At least one group key or similarity constraint is required.",
462
+ code="JOINT_SPLIT_CONSTRAINT_REQUIRED",
463
+ )
464
+ _validate_bounded_positive_int(self.similarity_k, "similarity_k", 12)
465
+ if not isinstance(self.similarity_canonical, bool):
466
+ raise ConfigurationError("similarity_canonical must be boolean.")
467
+ object.__setattr__(
468
+ self, "ratio_tolerance", _validate_probability(self.ratio_tolerance, "ratio_tolerance")
469
+ )
470
+ if self.infeasible_policy not in {"error", "relax"}:
471
+ raise ConfigurationError("infeasible_policy must be 'error' or 'relax'.")
472
+ _validate_bounded_positive_int(self.max_records, "max_records", 5_000)
473
+ _validate_bounded_positive_int(
474
+ self.max_pairwise_comparisons,
475
+ "max_pairwise_comparisons",
476
+ 10_000_000,
477
+ )
478
+
479
+
480
+ @dataclass(frozen=True, slots=True)
481
+ class LeakageConfig:
482
+ """Cross-split exhaustive comparison controls."""
483
+
484
+ method: ClusterMethod = "identity"
485
+ threshold: float = 0.9
486
+ k: int = 3
487
+ canonical: bool = False
488
+ max_records: int = 5_000
489
+ max_cross_pairs: int = 1_000_000
490
+ max_events: int = 100_000
491
+ max_alignment_cells: int = 5_000_000
492
+
493
+ def __post_init__(self) -> None:
494
+ if self.method not in {"identity", "edit", "kmer", "fingerprint"}:
495
+ raise ConfigurationError("Unknown leakage method.")
496
+ object.__setattr__(self, "threshold", _validate_probability(self.threshold, "threshold"))
497
+ _validate_bounded_positive_int(self.k, "k", 12)
498
+ if not isinstance(self.canonical, bool):
499
+ raise ConfigurationError("canonical must be boolean.")
500
+ _validate_bounded_positive_int(self.max_records, "max_records", 20_000)
501
+ _validate_bounded_positive_int(self.max_cross_pairs, "max_cross_pairs", 50_000_000)
502
+ _validate_bounded_positive_int(self.max_events, "max_events", 5_000_000)
503
+ _validate_bounded_positive_int(
504
+ self.max_alignment_cells,
505
+ "max_alignment_cells",
506
+ 100_000_000,
507
+ )
508
+
509
+
510
+ __all__ = [
511
+ "AdvancedRepresentativePolicy",
512
+ "ClusterConfig",
513
+ "ClusterMethod",
514
+ "ConflictPolicy",
515
+ "DedupEquivalence",
516
+ "DeduplicationConfig",
517
+ "HierarchicalClusteringConfig",
518
+ "IUPACDeduplicationConfig",
519
+ "InfeasiblePolicy",
520
+ "JointSplitConfig",
521
+ "LeakageConfig",
522
+ "LinkageMethod",
523
+ "MissingMetadataPolicy",
524
+ "NeuralClusteringConfig",
525
+ "RepresentativePolicy",
526
+ "SimilarityAmbiguityPolicy",
527
+ "SimilarityGapPolicy",
528
+ "SplitConfig",
529
+ "SplitMethod",
530
+ "TemporalSplitConfig",
531
+ ]