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
dnakit/chunking.py ADDED
@@ -0,0 +1,920 @@
1
+ """Coordinate-aware DNA sequence chunking strategies.
2
+
3
+ The module keeps sequence extraction lazy and records enough provenance to
4
+ trace every chunk back to its source record and interval. A BED file is
5
+ optional: without one, every input FASTA record is treated as a ``train``
6
+ region by default. BED intervals use the standard zero-based, half-open
7
+ coordinate convention already used by DNAKit.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import random
13
+ from collections.abc import Callable, Iterator, Sequence
14
+ from dataclasses import dataclass
15
+ from itertools import pairwise
16
+ from typing import Any, Literal, TypeAlias
17
+
18
+ from dnakit.core.coordinates import Interval
19
+ from dnakit.core.facade import DNA, resolve_single_dna
20
+ from dnakit.core.record import DNARecord
21
+ from dnakit.core.sequence import DNASequence
22
+ from dnakit.exceptions import ConfigurationError, CoordinateError, InputFormatError
23
+ from dnakit.io.annotations import AnnotationDocument, AnnotationSource, read_bed
24
+ from dnakit.io.api import ReadableSource, read
25
+ from dnakit.io.config import ReadConfig
26
+ from dnakit.ops.edit import subsequence
27
+
28
+ ChunkStrategy = Literal["fixed", "sliding", "random", "multiscale", "curriculum"]
29
+ ChunkProgressStatus = Literal["started", "yielded", "completed"]
30
+ ChunkProgressCallback: TypeAlias = Callable[["ChunkProgress"], None]
31
+ ChunkBedSource: TypeAlias = AnnotationDocument | AnnotationSource
32
+
33
+
34
+ def _positive_int(value: object, field_name: str) -> None:
35
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
36
+ raise ConfigurationError(
37
+ f"{field_name} must be a positive integer.",
38
+ code="INVALID_CHUNKING_CONFIG",
39
+ context={"field": field_name, "value": value},
40
+ )
41
+
42
+
43
+ def _non_negative_int(value: object, field_name: str) -> None:
44
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
45
+ raise ConfigurationError(
46
+ f"{field_name} must be a non-negative integer.",
47
+ code="INVALID_CHUNKING_CONFIG",
48
+ context={"field": field_name, "value": value},
49
+ )
50
+
51
+
52
+ def _coerce_int_tuple(value: Sequence[int], field_name: str) -> tuple[int, ...]:
53
+ if isinstance(value, (str, bytes)):
54
+ raise ConfigurationError(
55
+ f"{field_name} must be a sequence of integers.",
56
+ code="INVALID_CHUNKING_CONFIG",
57
+ context={"field": field_name},
58
+ )
59
+ try:
60
+ resolved = tuple(value)
61
+ except TypeError as exc:
62
+ raise ConfigurationError(
63
+ f"{field_name} must be a sequence of integers.",
64
+ code="INVALID_CHUNKING_CONFIG",
65
+ context={"field": field_name},
66
+ ) from exc
67
+ for index, item in enumerate(resolved):
68
+ _positive_int(item, f"{field_name}[{index}]")
69
+ return resolved
70
+
71
+
72
+ def _validate_text(value: object, field_name: str) -> None:
73
+ if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
74
+ raise ConfigurationError(
75
+ f"{field_name} must be non-empty text without whitespace.",
76
+ code="INVALID_CHUNKING_CONFIG",
77
+ context={"field": field_name, "value": value},
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class ChunkingConfig:
83
+ """Validate one sequence chunking strategy.
84
+
85
+ ``fixed`` creates non-overlapping windows. ``sliding`` uses ``step`` and
86
+ defaults to a one-base step. ``random`` requires a length range and a
87
+ sample count. ``multiscale`` creates windows for every value in
88
+ ``lengths``. ``curriculum`` has the same window behavior as multiscale,
89
+ while its optional ``stage_steps`` records how long a training stage is
90
+ intended to run.
91
+ """
92
+
93
+ strategy: ChunkStrategy = "fixed"
94
+ length: int = 1024
95
+ step: int | None = None
96
+ min_length: int | None = None
97
+ max_length: int | None = None
98
+ num_samples: int | None = None
99
+ lengths: tuple[int, ...] = ()
100
+ steps: tuple[int, ...] | None = None
101
+ stage_steps: tuple[int, ...] | None = None
102
+ include_partial: bool = False
103
+ seed: int | None = None
104
+ split: str = "train"
105
+ allow_gaps: bool = False
106
+
107
+ def __post_init__(self) -> None:
108
+ if not isinstance(self.strategy, str) or self.strategy not in {
109
+ "fixed",
110
+ "sliding",
111
+ "random",
112
+ "multiscale",
113
+ "curriculum",
114
+ }:
115
+ raise ConfigurationError(
116
+ "Unknown sequence chunking strategy.",
117
+ code="INVALID_CHUNKING_STRATEGY",
118
+ context={"strategy": self.strategy},
119
+ )
120
+ _positive_int(self.length, "length")
121
+ if self.step is not None:
122
+ _positive_int(self.step, "step")
123
+ for field_name, value in (
124
+ ("min_length", self.min_length),
125
+ ("max_length", self.max_length),
126
+ ("num_samples", self.num_samples),
127
+ ):
128
+ if value is not None:
129
+ _positive_int(value, field_name)
130
+ if self.seed is not None and (
131
+ isinstance(self.seed, bool) or not isinstance(self.seed, int)
132
+ ):
133
+ raise ConfigurationError(
134
+ "seed must be an integer or None.",
135
+ code="INVALID_CHUNKING_CONFIG",
136
+ context={"field": "seed", "value": self.seed},
137
+ )
138
+ if not isinstance(self.include_partial, bool):
139
+ raise ConfigurationError(
140
+ "include_partial must be boolean.",
141
+ code="INVALID_CHUNKING_CONFIG",
142
+ context={"field": "include_partial", "value": self.include_partial},
143
+ )
144
+ if not isinstance(self.allow_gaps, bool):
145
+ raise ConfigurationError(
146
+ "allow_gaps must be boolean.",
147
+ code="INVALID_CHUNKING_CONFIG",
148
+ context={"field": "allow_gaps", "value": self.allow_gaps},
149
+ )
150
+ _validate_text(self.split, "split")
151
+
152
+ resolved_lengths = _coerce_int_tuple(self.lengths, "lengths")
153
+ object.__setattr__(self, "lengths", resolved_lengths)
154
+ resolved_steps = None if self.steps is None else _coerce_int_tuple(self.steps, "steps")
155
+ object.__setattr__(self, "steps", resolved_steps)
156
+ resolved_stage_steps = (
157
+ None if self.stage_steps is None else _coerce_int_tuple(self.stage_steps, "stage_steps")
158
+ )
159
+ object.__setattr__(self, "stage_steps", resolved_stage_steps)
160
+
161
+ if self.strategy == "fixed":
162
+ if self.step is not None:
163
+ raise ConfigurationError(
164
+ "fixed strategy is non-overlapping; omit step.",
165
+ code="INVALID_CHUNKING_CONFIG",
166
+ )
167
+ if resolved_lengths or resolved_steps or resolved_stage_steps:
168
+ raise ConfigurationError(
169
+ "lengths, steps, and stage_steps are only for multiscale or curriculum.",
170
+ code="INVALID_CHUNKING_CONFIG",
171
+ )
172
+ elif self.strategy == "sliding":
173
+ resolved_step = 1 if self.step is None else self.step
174
+ if resolved_step > self.length:
175
+ raise ConfigurationError(
176
+ "sliding step cannot exceed window length; that would create gaps.",
177
+ code="INVALID_CHUNKING_CONFIG",
178
+ context={"length": self.length, "step": resolved_step},
179
+ )
180
+ object.__setattr__(self, "step", resolved_step)
181
+ if resolved_lengths or resolved_steps or resolved_stage_steps:
182
+ raise ConfigurationError(
183
+ "lengths, steps, and stage_steps are only for multiscale or curriculum.",
184
+ code="INVALID_CHUNKING_CONFIG",
185
+ )
186
+ elif self.strategy == "random":
187
+ if self.min_length is None or self.max_length is None or self.num_samples is None:
188
+ raise ConfigurationError(
189
+ "random strategy requires min_length, max_length, and num_samples.",
190
+ code="INVALID_CHUNKING_CONFIG",
191
+ )
192
+ if self.min_length > self.max_length:
193
+ raise ConfigurationError(
194
+ "min_length cannot exceed max_length.",
195
+ code="INVALID_CHUNKING_CONFIG",
196
+ context={
197
+ "min_length": self.min_length,
198
+ "max_length": self.max_length,
199
+ },
200
+ )
201
+ if resolved_lengths or resolved_steps or resolved_stage_steps:
202
+ raise ConfigurationError(
203
+ "lengths, steps, and stage_steps are only for multiscale or curriculum.",
204
+ code="INVALID_CHUNKING_CONFIG",
205
+ )
206
+ else:
207
+ if not resolved_lengths:
208
+ raise ConfigurationError(
209
+ f"{self.strategy} strategy requires at least one length.",
210
+ code="INVALID_CHUNKING_CONFIG",
211
+ )
212
+ if any(left >= right for left, right in pairwise(resolved_lengths)):
213
+ raise ConfigurationError(
214
+ "multiscale and curriculum lengths must be strictly increasing.",
215
+ code="INVALID_CHUNKING_CONFIG",
216
+ )
217
+ if resolved_steps is not None and len(resolved_steps) != len(resolved_lengths):
218
+ raise ConfigurationError(
219
+ "steps must have the same number of values as lengths.",
220
+ code="INVALID_CHUNKING_CONFIG",
221
+ )
222
+ if resolved_steps is not None and any(
223
+ window_step > window_length
224
+ for window_step, window_length in zip(resolved_steps, resolved_lengths, strict=True)
225
+ ):
226
+ raise ConfigurationError(
227
+ "multiscale or curriculum steps cannot exceed their window lengths.",
228
+ code="INVALID_CHUNKING_CONFIG",
229
+ )
230
+ if self.strategy == "multiscale" and resolved_stage_steps is not None:
231
+ raise ConfigurationError(
232
+ "stage_steps are only valid for curriculum.",
233
+ code="INVALID_CHUNKING_CONFIG",
234
+ )
235
+ if (
236
+ self.strategy == "curriculum"
237
+ and resolved_stage_steps is not None
238
+ and len(resolved_stage_steps) != len(resolved_lengths)
239
+ ):
240
+ raise ConfigurationError(
241
+ "stage_steps must have the same number of values as lengths.",
242
+ code="INVALID_CHUNKING_CONFIG",
243
+ )
244
+
245
+ def to_dict(self) -> dict[str, object]:
246
+ """Return a JSON-friendly configuration summary."""
247
+
248
+ return {
249
+ "strategy": self.strategy,
250
+ "length": self.length,
251
+ "step": self.step,
252
+ "min_length": self.min_length,
253
+ "max_length": self.max_length,
254
+ "num_samples": self.num_samples,
255
+ "lengths": list(self.lengths),
256
+ "steps": None if self.steps is None else list(self.steps),
257
+ "stage_steps": None if self.stage_steps is None else list(self.stage_steps),
258
+ "include_partial": self.include_partial,
259
+ "seed": self.seed,
260
+ "split": self.split,
261
+ "allow_gaps": self.allow_gaps,
262
+ }
263
+
264
+
265
+ @dataclass(frozen=True, slots=True)
266
+ class LengthCurriculumStage:
267
+ """One stage in a short-to-long training schedule."""
268
+
269
+ index: int
270
+ length: int
271
+ training_steps: int | None = None
272
+
273
+ def __post_init__(self) -> None:
274
+ _non_negative_int(self.index, "index")
275
+ _positive_int(self.length, "length")
276
+ if self.training_steps is not None:
277
+ _positive_int(self.training_steps, "training_steps")
278
+
279
+
280
+ @dataclass(frozen=True, slots=True)
281
+ class LengthCurriculum:
282
+ """A validated short-to-long sequence length schedule.
283
+
284
+ The schedule itself does not start training. Use :meth:`to_config` to
285
+ obtain a ``curriculum`` chunking configuration, then pass that config to
286
+ :func:`iter_fasta_chunks` or :func:`iter_sequence_chunks`.
287
+ """
288
+
289
+ lengths: tuple[int, ...]
290
+ stage_steps: tuple[int, ...] | None = None
291
+
292
+ def __post_init__(self) -> None:
293
+ resolved_lengths = _coerce_int_tuple(self.lengths, "lengths")
294
+ if not resolved_lengths:
295
+ raise ConfigurationError(
296
+ "A length curriculum requires at least one length.",
297
+ code="INVALID_LENGTH_CURRICULUM",
298
+ )
299
+ if any(left >= right for left, right in pairwise(resolved_lengths)):
300
+ raise ConfigurationError(
301
+ "Curriculum lengths must be strictly increasing.",
302
+ code="INVALID_LENGTH_CURRICULUM",
303
+ )
304
+ object.__setattr__(self, "lengths", resolved_lengths)
305
+ resolved_stage_steps = (
306
+ None if self.stage_steps is None else _coerce_int_tuple(self.stage_steps, "stage_steps")
307
+ )
308
+ if resolved_stage_steps is not None and len(resolved_stage_steps) != len(resolved_lengths):
309
+ raise ConfigurationError(
310
+ "stage_steps must have the same number of values as lengths.",
311
+ code="INVALID_LENGTH_CURRICULUM",
312
+ )
313
+ object.__setattr__(self, "stage_steps", resolved_stage_steps)
314
+
315
+ @property
316
+ def stages(self) -> tuple[LengthCurriculumStage, ...]:
317
+ """Return stages in the order in which they should be trained."""
318
+
319
+ return tuple(
320
+ LengthCurriculumStage(
321
+ index=index,
322
+ length=length,
323
+ training_steps=None if self.stage_steps is None else self.stage_steps[index],
324
+ )
325
+ for index, length in enumerate(self.lengths)
326
+ )
327
+
328
+ def to_config(
329
+ self,
330
+ *,
331
+ window_step: int | None = None,
332
+ include_partial: bool = False,
333
+ seed: int | None = None,
334
+ split: str = "train",
335
+ allow_gaps: bool = False,
336
+ ) -> ChunkingConfig:
337
+ """Convert the schedule into a chunking configuration."""
338
+
339
+ resolved_steps = None if window_step is None else tuple(window_step for _ in self.lengths)
340
+ return ChunkingConfig(
341
+ strategy="curriculum",
342
+ lengths=self.lengths,
343
+ steps=resolved_steps,
344
+ stage_steps=self.stage_steps,
345
+ include_partial=include_partial,
346
+ seed=seed,
347
+ split=split,
348
+ allow_gaps=allow_gaps,
349
+ )
350
+
351
+ def to_dict(self) -> dict[str, object]:
352
+ """Return a JSON-friendly curriculum summary."""
353
+
354
+ return {
355
+ "lengths": list(self.lengths),
356
+ "stage_steps": None if self.stage_steps is None else list(self.stage_steps),
357
+ "stages": [
358
+ {
359
+ "index": stage.index,
360
+ "length": stage.length,
361
+ "training_steps": stage.training_steps,
362
+ }
363
+ for stage in self.stages
364
+ ],
365
+ }
366
+
367
+
368
+ def make_length_curriculum(
369
+ lengths: Sequence[int], *, stage_steps: Sequence[int] | None = None
370
+ ) -> LengthCurriculum:
371
+ """Create a validated short-to-long curriculum."""
372
+
373
+ return LengthCurriculum(tuple(lengths), None if stage_steps is None else tuple(stage_steps))
374
+
375
+
376
+ @dataclass(frozen=True, slots=True)
377
+ class ChunkProgress:
378
+ """Advisory progress event emitted while chunks are generated."""
379
+
380
+ source_id: str
381
+ split: str
382
+ region_index: int
383
+ processed: int
384
+ total: int
385
+ status: ChunkProgressStatus
386
+
387
+
388
+ @dataclass(frozen=True, slots=True)
389
+ class SequenceChunk:
390
+ """One extracted sequence and its source-coordinate provenance."""
391
+
392
+ sequence: DNASequence
393
+ id: str
394
+ source_id: str
395
+ split: str
396
+ source_start: int
397
+ source_end: int
398
+ requested_length: int
399
+ window_index: int
400
+ strategy: ChunkStrategy
401
+ region_index: int = 0
402
+ level_index: int | None = None
403
+
404
+ def __post_init__(self) -> None:
405
+ if not isinstance(self.sequence, DNASequence):
406
+ raise ConfigurationError(
407
+ "SequenceChunk.sequence must be a DNASequence.",
408
+ code="INVALID_SEQUENCE_CHUNK",
409
+ )
410
+ for field_name, value in (
411
+ ("id", self.id),
412
+ ("source_id", self.source_id),
413
+ ("split", self.split),
414
+ ):
415
+ if not isinstance(value, str) or not value.strip():
416
+ raise ConfigurationError(
417
+ f"SequenceChunk {field_name} must be non-empty text.",
418
+ code="INVALID_SEQUENCE_CHUNK",
419
+ )
420
+ for integer_field_name, integer_value in (
421
+ ("source_start", self.source_start),
422
+ ("source_end", self.source_end),
423
+ ("requested_length", self.requested_length),
424
+ ):
425
+ if integer_field_name == "requested_length":
426
+ _positive_int(integer_value, integer_field_name)
427
+ else:
428
+ _non_negative_int(integer_value, integer_field_name)
429
+ if self.source_end <= self.source_start:
430
+ raise ConfigurationError(
431
+ "SequenceChunk source_end must be greater than source_start.",
432
+ code="INVALID_SEQUENCE_CHUNK",
433
+ )
434
+ if self.source_end - self.source_start > self.requested_length:
435
+ raise ConfigurationError(
436
+ "Extracted sequence cannot exceed requested_length.",
437
+ code="INVALID_SEQUENCE_CHUNK",
438
+ )
439
+ sequence_span = self.sequence.coordinate_span
440
+ if sequence_span is not None and sequence_span != self.source_end - self.source_start:
441
+ raise ConfigurationError(
442
+ "SequenceChunk coordinates do not match the extracted sequence span.",
443
+ code="INVALID_SEQUENCE_CHUNK",
444
+ context={
445
+ "sequence_span": sequence_span,
446
+ "source_start": self.source_start,
447
+ "source_end": self.source_end,
448
+ },
449
+ )
450
+ _non_negative_int(self.window_index, "window_index")
451
+ _non_negative_int(self.region_index, "region_index")
452
+ if self.level_index is not None:
453
+ _non_negative_int(self.level_index, "level_index")
454
+ if not isinstance(self.strategy, str) or self.strategy not in {
455
+ "fixed",
456
+ "sliding",
457
+ "random",
458
+ "multiscale",
459
+ "curriculum",
460
+ }:
461
+ raise ConfigurationError(
462
+ "SequenceChunk strategy is not supported.",
463
+ code="INVALID_SEQUENCE_CHUNK",
464
+ context={"strategy": self.strategy},
465
+ )
466
+
467
+ @property
468
+ def length(self) -> int:
469
+ """Actual sequence length in coordinates."""
470
+
471
+ return self.source_end - self.source_start
472
+
473
+ def to_record(self) -> DNARecord:
474
+ """Convert the chunk to a DNARecord while retaining provenance."""
475
+
476
+ return DNARecord(
477
+ self.sequence,
478
+ self.id,
479
+ description=(
480
+ f"{self.source_id}:{self.source_start}-{self.source_end}"
481
+ f" split={self.split} strategy={self.strategy}"
482
+ ),
483
+ metadata={
484
+ "chunking": {
485
+ "strategy": self.strategy,
486
+ "split": self.split,
487
+ "source_id": self.source_id,
488
+ "source_start": self.source_start,
489
+ "source_end": self.source_end,
490
+ "requested_length": self.requested_length,
491
+ "window_index": self.window_index,
492
+ "region_index": self.region_index,
493
+ "level_index": self.level_index,
494
+ }
495
+ },
496
+ )
497
+
498
+ def to_dict(self) -> dict[str, Any]:
499
+ """Return chunk metadata and symbols in a serialization-friendly form."""
500
+
501
+ return {
502
+ "id": self.id,
503
+ "source_id": self.source_id,
504
+ "split": self.split,
505
+ "source_start": self.source_start,
506
+ "source_end": self.source_end,
507
+ "length": self.length,
508
+ "requested_length": self.requested_length,
509
+ "window_index": self.window_index,
510
+ "region_index": self.region_index,
511
+ "level_index": self.level_index,
512
+ "strategy": self.strategy,
513
+ "sequence": self.sequence.symbols,
514
+ }
515
+
516
+
517
+ @dataclass(frozen=True, slots=True)
518
+ class _PlannedWindow:
519
+ start: int
520
+ end: int
521
+ requested_length: int
522
+ level_index: int | None = None
523
+
524
+
525
+ def _regular_windows(
526
+ start: int,
527
+ end: int,
528
+ *,
529
+ length: int,
530
+ step: int,
531
+ include_partial: bool,
532
+ level_index: int | None = None,
533
+ ) -> Iterator[_PlannedWindow]:
534
+ if end <= start:
535
+ return
536
+ if include_partial:
537
+ for window_start in range(start, end, step):
538
+ window_end = min(window_start + length, end)
539
+ yield _PlannedWindow(window_start, window_end, length, level_index)
540
+ return
541
+ last_start = end - length
542
+ if last_start < start:
543
+ return
544
+ for window_start in range(start, last_start + 1, step):
545
+ yield _PlannedWindow(window_start, window_start + length, length, level_index)
546
+
547
+
548
+ def _random_windows(
549
+ start: int,
550
+ end: int,
551
+ *,
552
+ config: ChunkingConfig,
553
+ rng: random.Random,
554
+ ) -> Iterator[_PlannedWindow]:
555
+ assert config.min_length is not None
556
+ assert config.max_length is not None
557
+ assert config.num_samples is not None
558
+ available = end - start
559
+ if available < config.min_length:
560
+ raise ConfigurationError(
561
+ "The requested random minimum length exceeds the input region.",
562
+ code="RANDOM_CHUNK_TOO_LONG",
563
+ context={"region_length": available, "min_length": config.min_length},
564
+ )
565
+ upper_length = min(config.max_length, available)
566
+ for _ in range(config.num_samples):
567
+ window_length = rng.randint(config.min_length, upper_length)
568
+ window_start = rng.randint(start, end - window_length)
569
+ yield _PlannedWindow(window_start, window_start + window_length, window_length)
570
+
571
+
572
+ def _planned_windows(
573
+ start: int,
574
+ end: int,
575
+ *,
576
+ config: ChunkingConfig,
577
+ rng: random.Random,
578
+ ) -> Iterator[_PlannedWindow]:
579
+ if config.strategy == "fixed":
580
+ yield from _regular_windows(
581
+ start,
582
+ end,
583
+ length=config.length,
584
+ step=config.length,
585
+ include_partial=config.include_partial,
586
+ )
587
+ elif config.strategy == "sliding":
588
+ assert config.step is not None
589
+ yield from _regular_windows(
590
+ start,
591
+ end,
592
+ length=config.length,
593
+ step=config.step,
594
+ include_partial=config.include_partial,
595
+ )
596
+ elif config.strategy == "random":
597
+ yield from _random_windows(start, end, config=config, rng=rng)
598
+ else:
599
+ for level_index, window_length in enumerate(config.lengths):
600
+ window_step = window_length if config.steps is None else config.steps[level_index]
601
+ yield from _regular_windows(
602
+ start,
603
+ end,
604
+ length=window_length,
605
+ step=window_step,
606
+ include_partial=config.include_partial,
607
+ level_index=level_index,
608
+ )
609
+
610
+
611
+ def _count_regular_windows(
612
+ region_length: int, *, length: int, step: int, include_partial: bool
613
+ ) -> int:
614
+ if region_length <= 0:
615
+ return 0
616
+ if include_partial:
617
+ return (region_length + step - 1) // step
618
+ if region_length < length:
619
+ return 0
620
+ return (region_length - length) // step + 1
621
+
622
+
623
+ def _planned_window_count(start: int, end: int, *, config: ChunkingConfig) -> int:
624
+ region_length = end - start
625
+ if config.strategy == "fixed":
626
+ return _count_regular_windows(
627
+ region_length,
628
+ length=config.length,
629
+ step=config.length,
630
+ include_partial=config.include_partial,
631
+ )
632
+ if config.strategy == "sliding":
633
+ assert config.step is not None
634
+ return _count_regular_windows(
635
+ region_length,
636
+ length=config.length,
637
+ step=config.step,
638
+ include_partial=config.include_partial,
639
+ )
640
+ if config.strategy == "random":
641
+ assert config.min_length is not None
642
+ assert config.num_samples is not None
643
+ if region_length < config.min_length:
644
+ raise ConfigurationError(
645
+ "The requested random minimum length exceeds the input region.",
646
+ code="RANDOM_CHUNK_TOO_LONG",
647
+ context={"region_length": region_length, "min_length": config.min_length},
648
+ )
649
+ return config.num_samples
650
+ total = 0
651
+ for level_index, window_length in enumerate(config.lengths):
652
+ window_step = window_length if config.steps is None else config.steps[level_index]
653
+ total += _count_regular_windows(
654
+ region_length,
655
+ length=window_length,
656
+ step=window_step,
657
+ include_partial=config.include_partial,
658
+ )
659
+ return total
660
+
661
+
662
+ def _emit_progress(callback: ChunkProgressCallback | None, event: ChunkProgress) -> None:
663
+ if callback is None:
664
+ return
665
+ try:
666
+ callback(event)
667
+ except Exception:
668
+ # Progress is advisory and must not change chunk generation semantics.
669
+ return
670
+
671
+
672
+ def _validate_region(sequence: DNASequence, start: int, end: int | None) -> int:
673
+ span = len(sequence)
674
+ if isinstance(start, bool) or not isinstance(start, int):
675
+ raise CoordinateError(
676
+ "Chunk start must be an integer.",
677
+ code="INVALID_CHUNK_COORDINATE",
678
+ context={"start": start},
679
+ )
680
+ resolved_end = span if end is None else end
681
+ if isinstance(resolved_end, bool) or not isinstance(resolved_end, int):
682
+ raise CoordinateError(
683
+ "Chunk end must be an integer or None.",
684
+ code="INVALID_CHUNK_COORDINATE",
685
+ context={"end": resolved_end},
686
+ )
687
+ if start < 0 or resolved_end < start or resolved_end > span:
688
+ raise CoordinateError(
689
+ "Chunk coordinates must be a 0-based half-open interval inside the sequence.",
690
+ code="INVALID_CHUNK_COORDINATE",
691
+ context={"start": start, "end": resolved_end, "sequence_length": span},
692
+ )
693
+ return resolved_end
694
+
695
+
696
+ def _record_identity(
697
+ value: DNA | DNASequence | DNARecord, *, source_id: str | None
698
+ ) -> tuple[DNASequence, str]:
699
+ if isinstance(value, (DNA, DNARecord, DNASequence)):
700
+ sequence, record = resolve_single_dna(value)
701
+ default_id = "sequence_1" if record is None else record.id
702
+ return sequence, default_id if source_id is None else source_id
703
+ raise TypeError("value must be DNA, DNASequence, or DNARecord.")
704
+
705
+
706
+ def _chunk_id(
707
+ source_id: str,
708
+ split: str,
709
+ region_index: int,
710
+ window_index: int,
711
+ level_index: int | None,
712
+ ) -> str:
713
+ level = "" if level_index is None else f"|level_{level_index}"
714
+ return f"{source_id}|{split}|region_{region_index}{level}|chunk_{window_index}"
715
+
716
+
717
+ def _iter_region_chunks(
718
+ sequence: DNASequence,
719
+ *,
720
+ source_id: str,
721
+ split: str,
722
+ start: int,
723
+ end: int,
724
+ region_index: int,
725
+ config: ChunkingConfig,
726
+ rng: random.Random,
727
+ progress: ChunkProgressCallback | None,
728
+ ) -> Iterator[SequenceChunk]:
729
+ total = _planned_window_count(start, end, config=config)
730
+ _emit_progress(
731
+ progress,
732
+ ChunkProgress(source_id, split, region_index, 0, total, "started"),
733
+ )
734
+ processed = 0
735
+ for plan in _planned_windows(start, end, config=config, rng=rng):
736
+ selected = subsequence(sequence, plan.start, plan.end, allow_gaps=config.allow_gaps)
737
+ chunk = SequenceChunk(
738
+ sequence=selected,
739
+ id=_chunk_id(source_id, split, region_index, processed, plan.level_index),
740
+ source_id=source_id,
741
+ split=split,
742
+ source_start=plan.start,
743
+ source_end=plan.end,
744
+ requested_length=plan.requested_length,
745
+ window_index=processed,
746
+ strategy=config.strategy,
747
+ region_index=region_index,
748
+ level_index=plan.level_index,
749
+ )
750
+ processed += 1
751
+ _emit_progress(
752
+ progress,
753
+ ChunkProgress(source_id, split, region_index, processed, total, "yielded"),
754
+ )
755
+ yield chunk
756
+ _emit_progress(
757
+ progress,
758
+ ChunkProgress(source_id, split, region_index, processed, total, "completed"),
759
+ )
760
+
761
+
762
+ def iter_sequence_chunks(
763
+ value: DNA | DNASequence | DNARecord,
764
+ *,
765
+ config: ChunkingConfig | None = None,
766
+ start: int = 0,
767
+ end: int | None = None,
768
+ source_id: str | None = None,
769
+ split: str | None = None,
770
+ region_index: int = 0,
771
+ progress: ChunkProgressCallback | None = None,
772
+ ) -> Iterator[SequenceChunk]:
773
+ """Lazily split one sequence or record according to ``config``.
774
+
775
+ The default is 1024-base, non-overlapping windows assigned to ``train``.
776
+ Coordinates are zero-based and half-open. Partial windows are discarded
777
+ unless ``ChunkingConfig.include_partial`` is enabled.
778
+ """
779
+
780
+ resolved_config = ChunkingConfig() if config is None else config
781
+ if not isinstance(resolved_config, ChunkingConfig):
782
+ raise TypeError("config must be ChunkingConfig or None.")
783
+ _non_negative_int(region_index, "region_index")
784
+ sequence, resolved_source_id = _record_identity(value, source_id=source_id)
785
+ resolved_split = resolved_config.split if split is None else split
786
+ _validate_text(resolved_source_id, "source_id")
787
+ _validate_text(resolved_split, "split")
788
+ resolved_end = _validate_region(sequence, start, end)
789
+ yield from _iter_region_chunks(
790
+ sequence,
791
+ source_id=resolved_source_id,
792
+ split=resolved_split,
793
+ start=start,
794
+ end=resolved_end,
795
+ region_index=region_index,
796
+ config=resolved_config,
797
+ rng=random.Random(resolved_config.seed),
798
+ progress=progress,
799
+ )
800
+
801
+
802
+ def _bed_regions(
803
+ document: AnnotationDocument, *, default_split: str
804
+ ) -> dict[str, tuple[tuple[int, int, str], ...]]:
805
+ if document.format != "bed":
806
+ raise InputFormatError(
807
+ "Sequence chunking accepts a BED annotation document.",
808
+ code="CHUNKING_BED_REQUIRED",
809
+ context={"format": document.format},
810
+ )
811
+ grouped: dict[str, list[tuple[int, int, str]]] = {}
812
+ for entry in document.entries:
813
+ location = entry.feature.location
814
+ if not isinstance(location, Interval):
815
+ raise InputFormatError(
816
+ "BED chunking requires simple intervals.",
817
+ code="INVALID_BED_INTERVAL",
818
+ context={"sequence_id": entry.sequence_id},
819
+ )
820
+ split = entry.feature.id or entry.feature.label or default_split
821
+ _validate_text(split, "BED split label")
822
+ grouped.setdefault(entry.sequence_id, []).append((location.start, location.end, split))
823
+ return {sequence_id: tuple(regions) for sequence_id, regions in grouped.items()}
824
+
825
+
826
+ def _resolve_bed(bed: ChunkBedSource) -> AnnotationDocument:
827
+ if isinstance(bed, AnnotationDocument):
828
+ return bed
829
+ return read_bed(bed)
830
+
831
+
832
+ def iter_fasta_chunks(
833
+ source: ReadableSource,
834
+ *,
835
+ config: ChunkingConfig | None = None,
836
+ bed: ChunkBedSource | None = None,
837
+ read_config: ReadConfig | None = None,
838
+ progress: ChunkProgressCallback | None = None,
839
+ ) -> Iterator[SequenceChunk]:
840
+ """Lazily split FASTA records, optionally restricted and labelled by BED.
841
+
842
+ Without ``bed``, every FASTA record is processed from coordinate 0 to its
843
+ end and uses ``config.split`` (``train`` by default). With BED, each BED
844
+ row selects one interval; its fourth column becomes the output split label
845
+ (for example ``train``, ``valid``, or ``test``). FASTA IDs and BED column
846
+ 1 values must match exactly.
847
+
848
+ This function uses DNAKit's record reader, so one FASTA record is held in
849
+ memory at a time. For chromosome-scale records, pass a ``ReadConfig`` with
850
+ sufficiently high input and sequence limits and plan memory accordingly.
851
+ """
852
+
853
+ resolved_config = ChunkingConfig() if config is None else config
854
+ if not isinstance(resolved_config, ChunkingConfig):
855
+ raise TypeError("config must be ChunkingConfig or None.")
856
+ if read_config is not None and not isinstance(read_config, ReadConfig):
857
+ raise TypeError("read_config must be ReadConfig or None.")
858
+ grouped_bed = (
859
+ None
860
+ if bed is None
861
+ else _bed_regions(_resolve_bed(bed), default_split=resolved_config.split)
862
+ )
863
+ seen_ids: set[str] = set()
864
+ rng = random.Random(resolved_config.seed)
865
+ with read(source, format="fasta", config=read_config) as records:
866
+ for record in records:
867
+ regions = None if grouped_bed is None else grouped_bed.get(record.id, ())
868
+ if grouped_bed is not None and regions:
869
+ seen_ids.add(record.id)
870
+ if grouped_bed is None:
871
+ resolved_end = _validate_region(record.sequence, 0, None)
872
+ yield from _iter_region_chunks(
873
+ record.sequence,
874
+ source_id=record.id,
875
+ split=resolved_config.split,
876
+ start=0,
877
+ end=resolved_end,
878
+ region_index=0,
879
+ config=resolved_config,
880
+ rng=rng,
881
+ progress=progress,
882
+ )
883
+ continue
884
+ assert regions is not None
885
+ for region_index, (start, end, split) in enumerate(regions):
886
+ resolved_end = _validate_region(record.sequence, start, end)
887
+ yield from _iter_region_chunks(
888
+ record.sequence,
889
+ source_id=record.id,
890
+ split=split,
891
+ start=start,
892
+ end=resolved_end,
893
+ region_index=region_index,
894
+ config=resolved_config,
895
+ rng=rng,
896
+ progress=progress,
897
+ )
898
+ if grouped_bed is not None:
899
+ missing = tuple(sequence_id for sequence_id in grouped_bed if sequence_id not in seen_ids)
900
+ if missing:
901
+ raise InputFormatError(
902
+ "BED contains sequence IDs that were not found in FASTA.",
903
+ code="BED_SEQUENCE_NOT_FOUND",
904
+ context={"missing_sequence_ids": missing},
905
+ )
906
+
907
+
908
+ __all__ = [
909
+ "ChunkBedSource",
910
+ "ChunkProgress",
911
+ "ChunkProgressCallback",
912
+ "ChunkStrategy",
913
+ "ChunkingConfig",
914
+ "LengthCurriculum",
915
+ "LengthCurriculumStage",
916
+ "SequenceChunk",
917
+ "iter_fasta_chunks",
918
+ "iter_sequence_chunks",
919
+ "make_length_curriculum",
920
+ ]