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,356 @@
1
+ """On-demand checkpoint downloads into a user-visible ``ckpt`` directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import importlib
7
+ import json
8
+ import os
9
+ import shutil
10
+ import tarfile
11
+ import tempfile
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from dnakit.download import DownloadConfig, DownloadProgress, RemoteFile, download_file
17
+ from dnakit.exceptions import BackendUnavailableError, DownloadError
18
+
19
+ from .models import DNAEmbeddingModel, get_embedding_model
20
+
21
+ _MANIFEST_NAME = ".dnakit-checkpoint.json"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class CheckpointInfo:
26
+ """Resolved checkpoint path and whether this call downloaded it."""
27
+
28
+ model_name: str
29
+ path: str
30
+ source: str
31
+ source_repository: str
32
+ downloaded: bool
33
+ gated: bool
34
+
35
+ def to_dict(self) -> dict[str, object]:
36
+ """Return JSON-compatible checkpoint provenance."""
37
+
38
+ return {
39
+ "model_name": self.model_name,
40
+ "path": self.path,
41
+ "source": self.source,
42
+ "source_repository": self.source_repository,
43
+ "downloaded": self.downloaded,
44
+ "gated": self.gated,
45
+ }
46
+
47
+
48
+ def default_checkpoint_root() -> Path:
49
+ """Return ``./ckpt`` using the working directory at call time."""
50
+
51
+ return (Path.cwd() / "ckpt").resolve()
52
+
53
+
54
+ def _checkpoint_root(value: str | os.PathLike[str] | None) -> Path:
55
+ return default_checkpoint_root() if value is None else Path(value).expanduser().resolve()
56
+
57
+
58
+ def _read_json(path: Path) -> dict[str, Any] | None:
59
+ try:
60
+ payload = json.loads(path.read_text(encoding="utf-8"))
61
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
62
+ return None
63
+ return payload if isinstance(payload, dict) else None
64
+
65
+
66
+ def _indexed_weights_are_complete(target: Path) -> bool:
67
+ for index_path in (
68
+ target / "model.safetensors.index.json",
69
+ target / "pytorch_model.bin.index.json",
70
+ ):
71
+ if not index_path.is_file():
72
+ continue
73
+ payload = _read_json(index_path)
74
+ weight_map = None if payload is None else payload.get("weight_map")
75
+ if not isinstance(weight_map, dict) or not weight_map:
76
+ return False
77
+ filenames = {value for value in weight_map.values() if isinstance(value, str)}
78
+ return bool(filenames) and all((target / filename).is_file() for filename in filenames)
79
+ return False
80
+
81
+
82
+ def _checkpoint_ready(spec: DNAEmbeddingModel, target: Path) -> bool:
83
+ if not target.is_dir():
84
+ return False
85
+ if spec.loader == "janusdna":
86
+ return (target / "model_config.json").is_file() and (
87
+ target / "checkpoints" / "last.ckpt"
88
+ ).is_file()
89
+ if spec.loader == "alphagenome":
90
+ return (target / "_CHECKPOINT_METADATA").is_file() and (target / "manifest.ocdbt").is_file()
91
+ if spec.loader == "evo2":
92
+ return (target / "evo2_7b.pt").is_file()
93
+ if not (target / "config.json").is_file():
94
+ return False
95
+ if _indexed_weights_are_complete(target):
96
+ return True
97
+ weight_patterns = ("*.safetensors", "*.bin", "*.pt", "*.ckpt")
98
+ return any(any(target.glob(pattern)) for pattern in weight_patterns)
99
+
100
+
101
+ def _write_manifest(target: Path, spec: DNAEmbeddingModel) -> None:
102
+ payload = {
103
+ "schema_version": 1,
104
+ "model_name": spec.name,
105
+ "checkpoint_kind": spec.checkpoint_kind,
106
+ "checkpoint_id": spec.checkpoint_id,
107
+ "checkpoint_url": spec.checkpoint_url,
108
+ "source_repository": spec.source_repository,
109
+ "archive_md5": spec.archive_md5,
110
+ "gated": spec.gated,
111
+ }
112
+ target.mkdir(parents=True, exist_ok=True)
113
+ with tempfile.NamedTemporaryFile(
114
+ mode="w",
115
+ encoding="utf-8",
116
+ prefix=f".{_MANIFEST_NAME}.",
117
+ suffix=".part",
118
+ dir=target,
119
+ delete=False,
120
+ ) as output:
121
+ json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
122
+ output.write("\n")
123
+ temp_path = Path(output.name)
124
+ os.replace(temp_path, target / _MANIFEST_NAME)
125
+
126
+
127
+ def _missing_huggingface_dependency() -> BackendUnavailableError:
128
+ return BackendUnavailableError(
129
+ "Checkpoint download requires huggingface-hub.",
130
+ code="MISSING_NEURAL_DEPENDENCY",
131
+ hint='Install the neural extra with: python -m pip install "dnakit[neural]"',
132
+ )
133
+
134
+
135
+ def _download_huggingface(
136
+ spec: DNAEmbeddingModel,
137
+ target: Path,
138
+ *,
139
+ token: str | None,
140
+ show_progress: bool,
141
+ ) -> None:
142
+ try:
143
+ huggingface_hub = importlib.import_module("huggingface_hub")
144
+ except ImportError as exc:
145
+ raise _missing_huggingface_dependency() from exc
146
+
147
+ target.mkdir(parents=True, exist_ok=True)
148
+ progress_was_disabled = False
149
+ progress_utils: Any | None = None
150
+ if not show_progress:
151
+ try:
152
+ progress_utils = importlib.import_module("huggingface_hub.utils")
153
+ progress_was_disabled = bool(progress_utils.are_progress_bars_disabled())
154
+ progress_utils.disable_progress_bars()
155
+ except (ImportError, AttributeError):
156
+ progress_utils = None
157
+ try:
158
+ huggingface_hub.snapshot_download(
159
+ repo_id=spec.checkpoint_id,
160
+ repo_type="model",
161
+ local_dir=str(target),
162
+ allow_patterns=(None if spec.allow_patterns is None else list(spec.allow_patterns)),
163
+ token=token,
164
+ max_workers=4,
165
+ )
166
+ except Exception as exc:
167
+ hint = (
168
+ "Accept the model terms on Hugging Face and provide hf_token."
169
+ if spec.gated
170
+ else "Check network access and the official checkpoint page."
171
+ )
172
+ raise DownloadError(
173
+ "Could not download the model checkpoint from Hugging Face.",
174
+ code="MODEL_CHECKPOINT_DOWNLOAD_FAILED",
175
+ context={"model": spec.name, "checkpoint": spec.checkpoint_id},
176
+ hint=hint,
177
+ ) from exc
178
+ finally:
179
+ if not show_progress and not progress_was_disabled and progress_utils is not None:
180
+ progress_utils.enable_progress_bars()
181
+
182
+
183
+ def _md5(path: Path) -> str:
184
+ digest = hashlib.md5(usedforsecurity=False)
185
+ with path.open("rb") as source:
186
+ while chunk := source.read(1_048_576):
187
+ digest.update(chunk)
188
+ return digest.hexdigest()
189
+
190
+
191
+ def _janusdna_members(spec: DNAEmbeddingModel) -> dict[str, str]:
192
+ if spec.archive_name is None:
193
+ raise AssertionError("JanusDNA registry entry is missing archive_name.")
194
+ root_name = spec.archive_name.removesuffix(".tar.bz2")
195
+ return {
196
+ f"{root_name}/model_config.json": "model_config.json",
197
+ f"{root_name}/config.json": "config.json",
198
+ f"{root_name}/checkpoints/last.ckpt": "checkpoints/last.ckpt",
199
+ }
200
+
201
+
202
+ def _extract_janusdna_archive(archive: Path, target: Path, spec: DNAEmbeddingModel) -> None:
203
+ selected = _janusdna_members(spec)
204
+ extracted: set[str] = set()
205
+ with tarfile.open(archive, mode="r:bz2") as bundle:
206
+ members = {member.name: member for member in bundle.getmembers()}
207
+ for archive_name, relative_name in selected.items():
208
+ member = members.get(archive_name)
209
+ if member is None or not member.isfile():
210
+ raise DownloadError(
211
+ "JanusDNA archive is missing a required checkpoint member.",
212
+ code="INVALID_MODEL_CHECKPOINT_ARCHIVE",
213
+ context={"member": archive_name},
214
+ )
215
+ source = bundle.extractfile(member)
216
+ if source is None:
217
+ raise DownloadError(
218
+ "Could not read a required JanusDNA archive member.",
219
+ code="INVALID_MODEL_CHECKPOINT_ARCHIVE",
220
+ context={"member": archive_name},
221
+ )
222
+ destination = target / relative_name
223
+ destination.parent.mkdir(parents=True, exist_ok=True)
224
+ with tempfile.NamedTemporaryFile(
225
+ prefix=f".{destination.name}.",
226
+ suffix=".part",
227
+ dir=destination.parent,
228
+ delete=False,
229
+ ) as output:
230
+ shutil.copyfileobj(source, output)
231
+ temp_path = Path(output.name)
232
+ os.replace(temp_path, destination)
233
+ extracted.add(relative_name)
234
+ if extracted != set(selected.values()):
235
+ raise DownloadError(
236
+ "JanusDNA checkpoint extraction did not complete.",
237
+ code="INVALID_MODEL_CHECKPOINT_ARCHIVE",
238
+ )
239
+
240
+
241
+ def _download_janusdna(
242
+ spec: DNAEmbeddingModel,
243
+ target: Path,
244
+ *,
245
+ show_progress: bool,
246
+ ) -> None:
247
+ if spec.archive_name is None or spec.archive_md5 is None:
248
+ raise AssertionError("JanusDNA registry entry is incomplete.")
249
+ target.mkdir(parents=True, exist_ok=True)
250
+ archive = target / spec.archive_name
251
+ if archive.exists():
252
+ if not archive.is_file() or _md5(archive) != spec.archive_md5:
253
+ raise DownloadError(
254
+ "Existing JanusDNA archive failed its official MD5 checksum.",
255
+ code="CHECKSUM_MISMATCH",
256
+ context={"path": str(archive)},
257
+ hint="Move the invalid archive aside, then retry the download.",
258
+ )
259
+ else:
260
+ progress_ui: Any | None = None
261
+ callback = None
262
+ if show_progress:
263
+ from rich.progress import Progress
264
+
265
+ progress_ui = Progress()
266
+ progress_ui.start()
267
+ task_id = progress_ui.add_task(f"Downloading {spec.display_name}", total=None)
268
+
269
+ def update(event: DownloadProgress) -> None:
270
+ progress_ui.update(
271
+ task_id,
272
+ completed=event.bytes_completed,
273
+ total=event.total_bytes,
274
+ )
275
+
276
+ callback = update
277
+ try:
278
+ download_file(
279
+ RemoteFile(
280
+ spec.checkpoint_url,
281
+ filename=spec.archive_name,
282
+ expected_md5=spec.archive_md5,
283
+ ),
284
+ archive,
285
+ config=DownloadConfig(
286
+ timeout=300.0,
287
+ max_file_bytes=2_000_000_000,
288
+ max_total_bytes=2_000_000_000,
289
+ ),
290
+ progress=callback,
291
+ )
292
+ finally:
293
+ if progress_ui is not None:
294
+ progress_ui.stop()
295
+ _extract_janusdna_archive(archive, target, spec)
296
+
297
+
298
+ def ensure_model_checkpoint(
299
+ model: str,
300
+ *,
301
+ checkpoint_dir: str | os.PathLike[str] | None = None,
302
+ hf_token: str | None = None,
303
+ show_progress: bool = True,
304
+ ) -> CheckpointInfo:
305
+ """Return a local checkpoint, downloading it to ``./ckpt`` only when absent.
306
+
307
+ The model is stored under ``<checkpoint_dir>/<model-cache-name>``. A complete
308
+ existing checkpoint is never downloaded again. Incomplete directories are
309
+ resumed by the provider downloader instead of being reported as complete.
310
+ """
311
+
312
+ spec = get_embedding_model(model)
313
+ root = _checkpoint_root(checkpoint_dir)
314
+ target = root / spec.cache_name
315
+ if _checkpoint_ready(spec, target):
316
+ if not (target / _MANIFEST_NAME).is_file():
317
+ _write_manifest(target, spec)
318
+ return CheckpointInfo(
319
+ spec.name,
320
+ str(target),
321
+ spec.checkpoint_url,
322
+ spec.source_repository,
323
+ False,
324
+ spec.gated,
325
+ )
326
+
327
+ if spec.checkpoint_kind == "huggingface":
328
+ _download_huggingface(
329
+ spec,
330
+ target,
331
+ token=hf_token,
332
+ show_progress=show_progress,
333
+ )
334
+ elif spec.checkpoint_kind == "dataverse":
335
+ _download_janusdna(spec, target, show_progress=show_progress)
336
+ else: # pragma: no cover - registry type and tests guard this branch.
337
+ raise AssertionError(f"Unsupported checkpoint kind: {spec.checkpoint_kind}")
338
+
339
+ if not _checkpoint_ready(spec, target):
340
+ raise DownloadError(
341
+ "Downloaded checkpoint did not contain the required model files.",
342
+ code="INCOMPLETE_MODEL_CHECKPOINT",
343
+ context={"model": spec.name, "path": str(target)},
344
+ )
345
+ _write_manifest(target, spec)
346
+ return CheckpointInfo(
347
+ spec.name,
348
+ str(target),
349
+ spec.checkpoint_url,
350
+ spec.source_repository,
351
+ True,
352
+ spec.gated,
353
+ )
354
+
355
+
356
+ __all__ = ["CheckpointInfo", "default_checkpoint_root", "ensure_model_checkpoint"]
@@ -0,0 +1,220 @@
1
+ """Public sequence-level representation extraction API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+
8
+ from dnakit.core import DNARecord
9
+ from dnakit.exceptions import (
10
+ BackendExecutionError,
11
+ BackendUnavailableError,
12
+ ConfigurationError,
13
+ SequenceError,
14
+ UnsupportedGapOperationError,
15
+ )
16
+
17
+ from .backends import RepresentationBackend, create_representation_backend
18
+ from .checkpoints import ensure_model_checkpoint
19
+ from .models import RepresentationConfig
20
+ from .results import RepresentationResult
21
+
22
+ _CANONICAL_BASES = frozenset("ACGT")
23
+
24
+
25
+ def _materialize_records(
26
+ records: Iterable[DNARecord],
27
+ *,
28
+ max_records: int,
29
+ ) -> tuple[DNARecord, ...]:
30
+ iterator = iter(records)
31
+ materialized: list[DNARecord] = []
32
+ for index, record in enumerate(iterator):
33
+ if index >= max_records:
34
+ raise ConfigurationError(
35
+ "Representation extraction exceeded max_records.",
36
+ code="REPRESENTATION_RECORD_LIMIT",
37
+ context={"max_records": max_records},
38
+ )
39
+ if not isinstance(record, DNARecord):
40
+ raise SequenceError(
41
+ "Representation extraction accepts only DNARecord objects.",
42
+ code="INVALID_RECORD_SEQUENCE",
43
+ context={"index": index, "type": type(record).__name__},
44
+ )
45
+ materialized.append(record)
46
+ if not materialized:
47
+ raise ConfigurationError(
48
+ "At least one DNARecord is required for representation extraction.",
49
+ code="EMPTY_REPRESENTATION_INPUT",
50
+ )
51
+ return tuple(materialized)
52
+
53
+
54
+ def _model_sequence(record: DNARecord, *, ambiguity_policy: str) -> str:
55
+ sequence = record.sequence
56
+ if sequence.is_gapped:
57
+ raise UnsupportedGapOperationError(
58
+ "Neural representation extraction does not accept explicit Gap objects.",
59
+ code="MODEL_GAPPED_INPUT",
60
+ context={"record_id": record.id},
61
+ hint="Resolve/remove gaps before extracting model representations.",
62
+ )
63
+ symbols = sequence.symbols
64
+ if not symbols:
65
+ raise SequenceError(
66
+ "Neural representation extraction does not accept empty sequences.",
67
+ code="MODEL_EMPTY_INPUT",
68
+ context={"record_id": record.id},
69
+ )
70
+ ambiguous = sorted(set(symbols) - _CANONICAL_BASES)
71
+ if ambiguous and ambiguity_policy == "error":
72
+ raise SequenceError(
73
+ "The selected ambiguity policy rejects non-ACGT symbols.",
74
+ code="MODEL_AMBIGUOUS_INPUT",
75
+ context={"record_id": record.id, "symbols": tuple(ambiguous)},
76
+ hint='Use ambiguity_policy="replace_with_n" to map IUPAC symbols to N.',
77
+ )
78
+ if not ambiguous:
79
+ return symbols
80
+ return "".join(base if base in _CANONICAL_BASES else "N" for base in symbols)
81
+
82
+
83
+ def _require_numpy() -> object:
84
+ try:
85
+ import numpy as np
86
+ except ImportError as exc:
87
+ raise BackendUnavailableError(
88
+ "Representation extraction requires NumPy.",
89
+ code="MISSING_NEURAL_DEPENDENCY",
90
+ hint='Install the neural extra with: python -m pip install "dnakit[neural]"',
91
+ ) from exc
92
+ return np
93
+
94
+
95
+ def extract_representations(
96
+ records: Iterable[DNARecord],
97
+ *,
98
+ config: RepresentationConfig | None = None,
99
+ backend: RepresentationBackend | None = None,
100
+ ) -> RepresentationResult:
101
+ """Extract one fixed-size representation per DNA record.
102
+
103
+ With the standard backend, a missing checkpoint is downloaded once to
104
+ ``./ckpt/<model>``. Passing ``backend`` supports controlled custom models and
105
+ unit tests without downloading or importing a foundation-model runtime.
106
+ """
107
+
108
+ resolved = RepresentationConfig() if config is None else config
109
+ if not isinstance(resolved, RepresentationConfig):
110
+ raise ConfigurationError(
111
+ "config must be RepresentationConfig or None.",
112
+ code="INVALID_REPRESENTATION_CONFIG",
113
+ )
114
+ materialized = _materialize_records(records, max_records=resolved.max_records)
115
+ sequences = tuple(
116
+ _model_sequence(record, ambiguity_policy=resolved.ambiguity_policy)
117
+ for record in materialized
118
+ )
119
+
120
+ checkpoint_path: str | None = None
121
+ selected_backend = backend
122
+ if selected_backend is None:
123
+ if resolved.model_spec.trust_remote_code and not resolved.allow_remote_code:
124
+ raise ConfigurationError(
125
+ "The selected model requires loading checkpoint-provided Python code.",
126
+ code="MODEL_REMOTE_CODE_NOT_ALLOWED",
127
+ context={"model": resolved.model},
128
+ hint=(
129
+ "Review the official repository and checkpoint, then set "
130
+ "allow_remote_code=True to opt in."
131
+ ),
132
+ )
133
+ if resolved.checkpoint_path is None:
134
+ checkpoint = ensure_model_checkpoint(
135
+ resolved.model,
136
+ checkpoint_dir=resolved.checkpoint_dir,
137
+ hf_token=resolved.hf_token,
138
+ show_progress=resolved.show_progress,
139
+ )
140
+ checkpoint_path = checkpoint.path
141
+ else:
142
+ path = Path(resolved.checkpoint_path).expanduser().resolve()
143
+ if not path.is_dir():
144
+ raise ConfigurationError(
145
+ "checkpoint_path must be an existing checkpoint directory.",
146
+ code="MODEL_CHECKPOINT_NOT_FOUND",
147
+ context={"path": str(path)},
148
+ )
149
+ checkpoint_path = str(path)
150
+ try:
151
+ selected_backend = create_representation_backend(
152
+ resolved.model_spec,
153
+ checkpoint_path,
154
+ resolved,
155
+ )
156
+ except (
157
+ BackendExecutionError,
158
+ BackendUnavailableError,
159
+ ConfigurationError,
160
+ SequenceError,
161
+ ):
162
+ raise
163
+ except Exception as exc:
164
+ raise BackendExecutionError(
165
+ "Could not initialize the representation backend.",
166
+ code="MODEL_LOAD_FAILED",
167
+ context={"model": resolved.model, "error_type": type(exc).__name__},
168
+ ) from exc
169
+ elif not hasattr(selected_backend, "extract"):
170
+ raise ConfigurationError(
171
+ "backend must provide extract(sequences, show_progress=...).",
172
+ code="INVALID_REPRESENTATION_BACKEND",
173
+ )
174
+
175
+ try:
176
+ raw_matrix = selected_backend.extract(sequences, show_progress=resolved.show_progress)
177
+ except (BackendExecutionError, BackendUnavailableError, ConfigurationError, SequenceError):
178
+ raise
179
+ except Exception as exc:
180
+ raise BackendExecutionError(
181
+ "The representation backend failed while encoding DNA sequences.",
182
+ code="REPRESENTATION_EXTRACTION_FAILED",
183
+ context={"model": resolved.model, "error_type": type(exc).__name__},
184
+ ) from exc
185
+
186
+ np = _require_numpy()
187
+ try:
188
+ matrix = np.asarray(raw_matrix, dtype=np.float32) # type: ignore[attr-defined]
189
+ except (TypeError, ValueError) as exc:
190
+ raise BackendExecutionError(
191
+ "The representation backend returned a non-numeric matrix.",
192
+ code="INVALID_REPRESENTATION_MATRIX",
193
+ ) from exc
194
+ if matrix.ndim != 2 or matrix.shape[0] != len(materialized) or matrix.shape[1] < 1:
195
+ raise BackendExecutionError(
196
+ "The representation backend returned an invalid matrix shape.",
197
+ code="INVALID_REPRESENTATION_MATRIX",
198
+ context={
199
+ "shape": tuple(int(value) for value in matrix.shape),
200
+ "expected_rows": len(materialized),
201
+ },
202
+ )
203
+ if not bool(np.isfinite(matrix).all()): # type: ignore[attr-defined]
204
+ raise BackendExecutionError(
205
+ "The representation backend returned non-finite values.",
206
+ code="INVALID_REPRESENTATION_MATRIX",
207
+ )
208
+ return RepresentationResult(
209
+ tuple(record.id for record in materialized),
210
+ matrix,
211
+ resolved.model,
212
+ checkpoint_path,
213
+ resolved.pooling,
214
+ int(matrix.shape[1]),
215
+ len(materialized),
216
+ resolved.ambiguity_policy,
217
+ )
218
+
219
+
220
+ __all__ = ["extract_representations"]