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,450 @@
1
+ """Reproducible random, stable-hash, stratified, group, and similarity splits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import random
8
+ from collections import deque
9
+ from collections.abc import Hashable, Iterable, Mapping, Sequence
10
+ from typing import cast
11
+
12
+ from dnakit.core import DNARecord, DNASet, Topology
13
+ from dnakit.core._json import FrozenDict
14
+ from dnakit.descriptors import kmer_statistics
15
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
16
+
17
+ from ._metadata import metadata_value_key
18
+ from .config import SplitConfig
19
+ from .results import SplitAssignment, SplitResult, SplitSubset
20
+
21
+
22
+ def _materialize(records: Iterable[DNARecord]) -> tuple[DNARecord, ...]:
23
+ try:
24
+ materialized = tuple(records)
25
+ except TypeError as exc:
26
+ raise ConfigurationError(
27
+ "records must be an iterable of DNARecord objects.",
28
+ code="INVALID_DATASET_RECORDS",
29
+ ) from exc
30
+ for index, record in enumerate(materialized):
31
+ if not isinstance(record, DNARecord):
32
+ raise ConfigurationError(
33
+ "Every dataset item must be a DNARecord.",
34
+ code="INVALID_DATASET_RECORD",
35
+ context={"input_index": index, "type": type(record).__name__},
36
+ )
37
+ return materialized
38
+
39
+
40
+ def _materialize_limited(records: Iterable[DNARecord], *, limit: int) -> tuple[DNARecord, ...]:
41
+ """Materialize no more than ``limit + 1`` items from a pairwise workload."""
42
+
43
+ try:
44
+ iterator = iter(records)
45
+ except TypeError as exc:
46
+ raise ConfigurationError(
47
+ "records must be an iterable of DNARecord objects.",
48
+ code="INVALID_DATASET_RECORDS",
49
+ ) from exc
50
+ materialized: list[DNARecord] = []
51
+ for index, record in enumerate(iterator):
52
+ if not isinstance(record, DNARecord):
53
+ raise ConfigurationError(
54
+ "Every dataset item must be a DNARecord.",
55
+ code="INVALID_DATASET_RECORD",
56
+ context={"input_index": index, "type": type(record).__name__},
57
+ )
58
+ materialized.append(record)
59
+ if len(materialized) > limit:
60
+ raise ConfigurationError(
61
+ "Similarity split input exceeds max_pairwise_records.",
62
+ code="SIMILARITY_SPLIT_SIZE_LIMIT",
63
+ context={
64
+ "consumed_record_count": len(materialized),
65
+ "limit": limit,
66
+ "record_count_is_lower_bound": True,
67
+ },
68
+ hint="Pre-cluster records externally or explicitly raise the safety limit.",
69
+ )
70
+ return tuple(materialized)
71
+
72
+
73
+ def _allocate_counts(size: int, ratios: Mapping[str, float]) -> dict[str, int]:
74
+ exact = {name: size * ratio for name, ratio in ratios.items()}
75
+ allocated = {name: math.floor(value) for name, value in exact.items()}
76
+ remainder = size - sum(allocated.values())
77
+ order = sorted(ratios, key=lambda name: (-(exact[name] - allocated[name]), name))
78
+ for name in order[:remainder]:
79
+ allocated[name] += 1
80
+ return allocated
81
+
82
+
83
+ def _assign_individuals(
84
+ indices: Sequence[int],
85
+ ratios: Mapping[str, float],
86
+ *,
87
+ generator: random.Random,
88
+ shuffle: bool,
89
+ ) -> dict[int, str]:
90
+ ordered = list(indices)
91
+ if shuffle:
92
+ generator.shuffle(ordered)
93
+ return _assign_ordered_individuals(ordered, ratios)
94
+
95
+
96
+ def _assign_ordered_individuals(
97
+ ordered: Sequence[int], ratios: Mapping[str, float]
98
+ ) -> dict[int, str]:
99
+ counts = _allocate_counts(len(ordered), ratios)
100
+ assigned: dict[int, str] = {}
101
+ cursor = 0
102
+ for name in ratios:
103
+ end = cursor + counts[name]
104
+ for index in ordered[cursor:end]:
105
+ assigned[index] = name
106
+ cursor = end
107
+ return assigned
108
+
109
+
110
+ def _stable_hash_digest(seed: int, record_id: str) -> bytes:
111
+ """Return the versioned, cross-process hash key for one record ID."""
112
+
113
+ payload = (
114
+ b"dnakit.split.hash.v1\0" + str(seed).encode("ascii") + b"\0" + record_id.encode("utf-8")
115
+ )
116
+ return hashlib.sha256(payload).digest()
117
+
118
+
119
+ def _assign_hashed_individuals(
120
+ records: Sequence[DNARecord],
121
+ ratios: Mapping[str, float],
122
+ *,
123
+ seed: int,
124
+ shuffle: bool,
125
+ ) -> dict[int, str]:
126
+ """Assign records by stable ID hash, independently of input order."""
127
+
128
+ seen: set[str] = set()
129
+ duplicate_ids: set[str] = set()
130
+ for record in records:
131
+ if record.id in seen:
132
+ duplicate_ids.add(record.id)
133
+ seen.add(record.id)
134
+ if duplicate_ids:
135
+ raise ConfigurationError(
136
+ "Hash split requires unique record IDs.",
137
+ code="HASH_SPLIT_DUPLICATE_RECORD_ID",
138
+ context={"duplicate_record_ids": tuple(sorted(duplicate_ids))},
139
+ hint="Assign a stable unique ID to every record before using method='hash'.",
140
+ )
141
+
142
+ if shuffle:
143
+ ordered = sorted(
144
+ range(len(records)),
145
+ key=lambda index: (
146
+ _stable_hash_digest(seed, records[index].id),
147
+ records[index].id.encode("utf-8"),
148
+ ),
149
+ )
150
+ else:
151
+ ordered = sorted(range(len(records)), key=lambda index: records[index].id.encode("utf-8"))
152
+ return _assign_ordered_individuals(ordered, ratios)
153
+
154
+
155
+ def _assign_strata(
156
+ strata: Sequence[tuple[int, ...]],
157
+ ratios: Mapping[str, float],
158
+ *,
159
+ total_size: int,
160
+ generator: random.Random,
161
+ shuffle: bool,
162
+ ) -> dict[int, str]:
163
+ """Allocate strata against global quotas instead of rounding each stratum alone."""
164
+
165
+ members = [list(stratum) for stratum in strata]
166
+ if shuffle:
167
+ generator.shuffle(members)
168
+ for stratum in members:
169
+ generator.shuffle(stratum)
170
+ targets = _allocate_counts(total_size, ratios)
171
+ remaining = dict(targets)
172
+ split_order = {name: index for index, name in enumerate(ratios)}
173
+ assigned_per_stratum = [{name: 0 for name in ratios} for _ in members]
174
+ offsets = [0 for _ in members]
175
+ active = deque(index for index, stratum in enumerate(members) if stratum)
176
+ assignment: dict[int, str] = {}
177
+
178
+ while active:
179
+ stratum_index = active.popleft()
180
+ stratum = members[stratum_index]
181
+ local_counts = assigned_per_stratum[stratum_index]
182
+ candidates = [name for name in ratios if remaining[name] > 0]
183
+ if not candidates:
184
+ raise AssertionError("Global split quotas were exhausted before assignment completed.")
185
+ stratum_size = len(stratum)
186
+
187
+ def priority(
188
+ name: str,
189
+ stratum_size: int = stratum_size,
190
+ local_counts: dict[str, int] = local_counts,
191
+ ) -> tuple[float, float, int]:
192
+ local_deficit = stratum_size * ratios[name] - local_counts[name]
193
+ target = targets[name]
194
+ global_pressure = remaining[name] / target if target else 0.0
195
+ return local_deficit, global_pressure, -split_order[name]
196
+
197
+ chosen = max(candidates, key=priority)
198
+ record_index = stratum[offsets[stratum_index]]
199
+ assignment[record_index] = chosen
200
+ local_counts[chosen] += 1
201
+ remaining[chosen] -= 1
202
+ offsets[stratum_index] += 1
203
+ if offsets[stratum_index] < len(stratum):
204
+ active.append(stratum_index)
205
+
206
+ if any(remaining.values()):
207
+ raise AssertionError("Global split quotas were not filled by stratified assignment.")
208
+ return assignment
209
+
210
+
211
+ class _MissingMetadataGroup:
212
+ """Identity-only key that cannot be represented by user JSON metadata."""
213
+
214
+ __slots__ = ()
215
+
216
+
217
+ def _metadata_units(
218
+ records: tuple[DNARecord, ...],
219
+ key: str,
220
+ *,
221
+ missing_policy: str,
222
+ ) -> tuple[tuple[int, ...], ...]:
223
+ grouped: dict[Hashable, list[int]] = {}
224
+ for index, record in enumerate(records):
225
+ if key not in record.metadata:
226
+ if missing_policy == "error":
227
+ raise ConfigurationError(
228
+ "A record is missing the required split metadata.",
229
+ code="SPLIT_METADATA_MISSING",
230
+ context={"input_index": index, "record_id": record.id, "metadata_key": key},
231
+ )
232
+ group: Hashable = _MissingMetadataGroup()
233
+ else:
234
+ group = metadata_value_key(record.metadata[key])
235
+ grouped.setdefault(group, []).append(index)
236
+ return tuple(tuple(indices) for indices in grouped.values())
237
+
238
+
239
+ def _assign_units(
240
+ units: Sequence[tuple[int, ...]],
241
+ ratios: Mapping[str, float],
242
+ *,
243
+ total_size: int,
244
+ generator: random.Random,
245
+ shuffle: bool,
246
+ ) -> dict[int, str]:
247
+ ordered = list(units)
248
+ if shuffle:
249
+ generator.shuffle(ordered)
250
+ ordered.sort(key=len, reverse=True)
251
+ targets = {name: total_size * ratio for name, ratio in ratios.items()}
252
+ counts = {name: 0 for name in ratios}
253
+ assignment: dict[int, str] = {}
254
+ for unit in ordered:
255
+ unit_size = len(unit)
256
+
257
+ def objective(candidate: str, unit_size: int = unit_size) -> tuple[float, int]:
258
+ score = 0.0
259
+ for name in ratios:
260
+ projected = counts[name] + (unit_size if name == candidate else 0)
261
+ denominator = max(targets[name], 1.0)
262
+ score += ((projected - targets[name]) ** 2) / denominator
263
+ return score, tuple(ratios).index(candidate)
264
+
265
+ chosen = min(ratios, key=objective)
266
+ counts[chosen] += unit_size
267
+ assignment.update((index, chosen) for index in unit)
268
+ return assignment
269
+
270
+
271
+ class _UnionFind:
272
+ def __init__(self, size: int) -> None:
273
+ self.parent = list(range(size))
274
+
275
+ def find(self, value: int) -> int:
276
+ root = value
277
+ while self.parent[root] != root:
278
+ root = self.parent[root]
279
+ while self.parent[value] != value:
280
+ parent = self.parent[value]
281
+ self.parent[value] = root
282
+ value = parent
283
+ return root
284
+
285
+ def union(self, left: int, right: int) -> None:
286
+ left_root = self.find(left)
287
+ right_root = self.find(right)
288
+ if left_root != right_root:
289
+ self.parent[right_root] = left_root
290
+
291
+
292
+ def _jaccard(left: frozenset[str], right: frozenset[str]) -> float:
293
+ union = left | right
294
+ return len(left & right) / len(union) if union else 1.0
295
+
296
+
297
+ def _similarity_units(
298
+ records: tuple[DNARecord, ...], config: SplitConfig
299
+ ) -> tuple[tuple[tuple[int, ...], ...], int]:
300
+ if len(records) > config.max_pairwise_records:
301
+ raise ConfigurationError(
302
+ "Similarity split input exceeds max_pairwise_records.",
303
+ code="SIMILARITY_SPLIT_SIZE_LIMIT",
304
+ context={"record_count": len(records), "limit": config.max_pairwise_records},
305
+ hint="Pre-cluster records externally or explicitly raise the safety limit.",
306
+ )
307
+ fingerprints: list[frozenset[str]] = []
308
+ for index, record in enumerate(records):
309
+ if record.sequence.topology is Topology.CIRCULAR:
310
+ raise ConfigurationError(
311
+ "Basic similarity split does not model circular origin wrapping.",
312
+ code="CIRCULAR_SIMILARITY_SPLIT_UNSUPPORTED",
313
+ context={"input_index": index, "record_id": record.id},
314
+ )
315
+ if record.sequence.is_gapped and config.similarity_gap_policy == "error":
316
+ raise UnsupportedGapOperationError(
317
+ "Basic similarity split does not silently omit or cross sequence Gaps.",
318
+ code="SIMILARITY_SPLIT_GAP_NOT_ALLOWED",
319
+ context={"input_index": index, "record_id": record.id},
320
+ hint="Choose similarity_gap_policy='split' to treat fragments independently.",
321
+ )
322
+ statistics = kmer_statistics(
323
+ record.sequence,
324
+ config.similarity_k,
325
+ ambiguity_policy=config.similarity_ambiguity_policy,
326
+ cross_gaps=False,
327
+ )
328
+ fingerprints.append(frozenset(statistics.counts))
329
+ components = _UnionFind(len(records))
330
+ comparisons = 0
331
+ for left in range(len(records)):
332
+ for right in range(left + 1, len(records)):
333
+ comparisons += 1
334
+ if _jaccard(fingerprints[left], fingerprints[right]) >= config.similarity_threshold:
335
+ components.union(left, right)
336
+ grouped: dict[int, list[int]] = {}
337
+ for index in range(len(records)):
338
+ grouped.setdefault(components.find(index), []).append(index)
339
+ return tuple(tuple(indices) for indices in grouped.values()), comparisons
340
+
341
+
342
+ def split(records: Iterable[DNARecord], *, config: SplitConfig) -> SplitResult:
343
+ """Split records reproducibly while keeping declared atomic groups intact."""
344
+
345
+ if not isinstance(config, SplitConfig):
346
+ raise ConfigurationError(
347
+ "config must be a SplitConfig.",
348
+ code="INVALID_SPLIT_CONFIG",
349
+ )
350
+ materialized = (
351
+ _materialize_limited(records, limit=config.max_pairwise_records)
352
+ if config.method == "similarity"
353
+ else _materialize(records)
354
+ )
355
+ ratios = config.ratios
356
+ generator = random.Random(config.seed)
357
+ component_count: int | None = None
358
+ comparisons = 0
359
+ if config.method == "random":
360
+ assignment = _assign_individuals(
361
+ tuple(range(len(materialized))),
362
+ ratios,
363
+ generator=generator,
364
+ shuffle=config.shuffle,
365
+ )
366
+ elif config.method == "hash":
367
+ assignment = _assign_hashed_individuals(
368
+ materialized,
369
+ ratios,
370
+ seed=config.seed,
371
+ shuffle=config.shuffle,
372
+ )
373
+ elif config.method == "stratified":
374
+ assert config.metadata_key is not None
375
+ strata = _metadata_units(
376
+ materialized,
377
+ config.metadata_key,
378
+ missing_policy=config.missing_metadata_policy,
379
+ )
380
+ assignment = _assign_strata(
381
+ strata,
382
+ ratios,
383
+ total_size=len(materialized),
384
+ generator=generator,
385
+ shuffle=config.shuffle,
386
+ )
387
+ else:
388
+ if config.method == "group":
389
+ assert config.metadata_key is not None
390
+ units = _metadata_units(
391
+ materialized,
392
+ config.metadata_key,
393
+ missing_policy=config.missing_metadata_policy,
394
+ )
395
+ else:
396
+ units, comparisons = _similarity_units(materialized, config)
397
+ component_count = len(units)
398
+ assignment = _assign_units(
399
+ units,
400
+ ratios,
401
+ total_size=len(materialized),
402
+ generator=generator,
403
+ shuffle=config.shuffle,
404
+ )
405
+
406
+ indices_by_split: dict[str, list[int]] = {name: [] for name in ratios}
407
+ for index in range(len(materialized)):
408
+ indices_by_split[assignment[index]].append(index)
409
+ if not config.preserve_order:
410
+ rank = {index: rank for rank, index in enumerate(assignment)}
411
+ for indices in indices_by_split.values():
412
+ indices.sort(key=rank.__getitem__)
413
+ subsets = tuple(
414
+ SplitSubset(name, DNASet(materialized[index] for index in indices_by_split[name]))
415
+ for name in ratios
416
+ )
417
+ assignments = tuple(
418
+ SplitAssignment(index, record.id, assignment[index])
419
+ for index, record in enumerate(materialized)
420
+ )
421
+ counts = FrozenDict({name: len(indices_by_split[name]) for name in ratios})
422
+ return SplitResult(
423
+ subsets=subsets,
424
+ assignments=assignments,
425
+ method=config.method,
426
+ ratios=cast(FrozenDict, config.ratios),
427
+ counts=counts,
428
+ seed=config.seed,
429
+ preserve_order=config.preserve_order,
430
+ metadata_key=config.metadata_key,
431
+ component_count=component_count,
432
+ pairwise_comparison_count=comparisons,
433
+ similarity_method="kmer_jaccard" if config.method == "similarity" else None,
434
+ similarity_threshold=(
435
+ float(config.similarity_threshold) if config.method == "similarity" else None
436
+ ),
437
+ shuffle=config.shuffle,
438
+ similarity_k=config.similarity_k,
439
+ max_pairwise_records=config.max_pairwise_records,
440
+ assignment_strategy={
441
+ "random": "global_largest_remainder_individual",
442
+ "hash": "sha256_record_id_rank_v1_largest_remainder",
443
+ "stratified": "global_quota_round_robin_stratified",
444
+ "group": "atomic_unit_greedy_target_error",
445
+ "similarity": "connected_component_atomic_unit_greedy_target_error",
446
+ }[config.method],
447
+ )
448
+
449
+
450
+ __all__ = ["split"]
@@ -0,0 +1,102 @@
1
+ """Deterministic, dependency-free DNA sequence descriptors."""
2
+
3
+ from dnakit.descriptors._dinucleotide import (
4
+ DEFAULT_MAX_DINUCLEOTIDE_TABLE_BYTES,
5
+ DINUCLEOTIDE_PROPERTY_SPECS,
6
+ DINUCLEOTIDE_TABLE_SCHEMA_VERSION,
7
+ DINUCLEOTIDES,
8
+ DinucleotideProperty,
9
+ DinucleotidePropertySpec,
10
+ DinucleotidePropertyTable,
11
+ load_dinucleotide_property_table,
12
+ )
13
+ from dnakit.descriptors._shared import DescriptorAmbiguityPolicy, SequenceInput
14
+ from dnakit.descriptors.all import all_descriptors
15
+ from dnakit.descriptors.basic import (
16
+ base_composition,
17
+ base_skew,
18
+ cpg_features,
19
+ gc_at_content,
20
+ length_features,
21
+ )
22
+ from dnakit.descriptors.codon import codon_statistics
23
+ from dnakit.descriptors.complexity import exact_repeat_fraction, linguistic_complexity
24
+ from dnakit.descriptors.entropy import shannon_entropy
25
+ from dnakit.descriptors.homopolymer import homopolymer_runs
26
+ from dnakit.descriptors.kmer import canonical_kmer, kmer_statistics
27
+ from dnakit.descriptors.results import (
28
+ AllDescriptorsResult,
29
+ CodonResult,
30
+ ComplexityResult,
31
+ CompositionResult,
32
+ ContentResult,
33
+ CpGResult,
34
+ DescriptorResult,
35
+ EntropyResult,
36
+ ExactRepeatResult,
37
+ HomopolymerResult,
38
+ HomopolymerRun,
39
+ KmerResult,
40
+ LengthResult,
41
+ RepeatRun,
42
+ SkewResult,
43
+ WindowDescriptorResult,
44
+ WindowResult,
45
+ )
46
+ from dnakit.descriptors.schema import (
47
+ DESCRIPTOR_NAMES_V1,
48
+ DESCRIPTOR_SCHEMA_V1,
49
+ DESCRIPTOR_SCHEMA_VERSION,
50
+ DescriptorField,
51
+ descriptor_schema_v1,
52
+ )
53
+ from dnakit.descriptors.window import window_descriptors
54
+
55
+ __all__ = [
56
+ "DEFAULT_MAX_DINUCLEOTIDE_TABLE_BYTES",
57
+ "DESCRIPTOR_NAMES_V1",
58
+ "DESCRIPTOR_SCHEMA_V1",
59
+ "DESCRIPTOR_SCHEMA_VERSION",
60
+ "DINUCLEOTIDES",
61
+ "DINUCLEOTIDE_PROPERTY_SPECS",
62
+ "DINUCLEOTIDE_TABLE_SCHEMA_VERSION",
63
+ "AllDescriptorsResult",
64
+ "CodonResult",
65
+ "ComplexityResult",
66
+ "CompositionResult",
67
+ "ContentResult",
68
+ "CpGResult",
69
+ "DescriptorAmbiguityPolicy",
70
+ "DescriptorField",
71
+ "DescriptorResult",
72
+ "DinucleotideProperty",
73
+ "DinucleotidePropertySpec",
74
+ "DinucleotidePropertyTable",
75
+ "EntropyResult",
76
+ "ExactRepeatResult",
77
+ "HomopolymerResult",
78
+ "HomopolymerRun",
79
+ "KmerResult",
80
+ "LengthResult",
81
+ "RepeatRun",
82
+ "SequenceInput",
83
+ "SkewResult",
84
+ "WindowDescriptorResult",
85
+ "WindowResult",
86
+ "all_descriptors",
87
+ "base_composition",
88
+ "base_skew",
89
+ "canonical_kmer",
90
+ "codon_statistics",
91
+ "cpg_features",
92
+ "descriptor_schema_v1",
93
+ "exact_repeat_fraction",
94
+ "gc_at_content",
95
+ "homopolymer_runs",
96
+ "kmer_statistics",
97
+ "length_features",
98
+ "linguistic_complexity",
99
+ "load_dinucleotide_property_table",
100
+ "shannon_entropy",
101
+ "window_descriptors",
102
+ ]