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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
"""Explicit, bounded adapter for Dashing file-level Jaccard computation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from itertools import islice
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal, TypeAlias
|
|
16
|
+
|
|
17
|
+
from dnakit.backends import execute_bounded_command
|
|
18
|
+
from dnakit.core import (
|
|
19
|
+
DNA,
|
|
20
|
+
BackendInfo,
|
|
21
|
+
Citation,
|
|
22
|
+
DNARecord,
|
|
23
|
+
DNASequence,
|
|
24
|
+
DNASet,
|
|
25
|
+
ExecutionMode,
|
|
26
|
+
ImplementationInfo,
|
|
27
|
+
ImplementationLabel,
|
|
28
|
+
OriginClass,
|
|
29
|
+
Provenance,
|
|
30
|
+
Topology,
|
|
31
|
+
)
|
|
32
|
+
from dnakit.core._json import freeze_mapping
|
|
33
|
+
from dnakit.exceptions import (
|
|
34
|
+
BackendExecutionError,
|
|
35
|
+
BackendUnavailableError,
|
|
36
|
+
ConfigurationError,
|
|
37
|
+
)
|
|
38
|
+
from dnakit.similarity.results import (
|
|
39
|
+
DashingJaccardMatrixResult,
|
|
40
|
+
DashingNeighborHit,
|
|
41
|
+
DashingNeighborRow,
|
|
42
|
+
DashingTopKResult,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
DashingMode: TypeAlias = Literal["sketch", "exact"]
|
|
46
|
+
DashingInput: TypeAlias = DNA | DNASequence | DNARecord | str | Path
|
|
47
|
+
|
|
48
|
+
DEFAULT_MAX_DASHING_ITEMS = 1_000
|
|
49
|
+
DEFAULT_MAX_DASHING_INPUT_BYTES = 1_000_000_000
|
|
50
|
+
DEFAULT_MAX_DASHING_OUTPUT_BYTES = 100_000_000
|
|
51
|
+
DEFAULT_MAX_DASHING_CAPTURE_BYTES = 1_000_000
|
|
52
|
+
DEFAULT_MAX_DASHING_SKETCH_MEMORY_BYTES = 512_000_000
|
|
53
|
+
|
|
54
|
+
_DASHING_VERSION = re.compile(r"Dashing version:\s*(?P<version>[-+._A-Za-z0-9]{1,128})")
|
|
55
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
56
|
+
_COMMAND_PROFILE = "dashing-dist-jaccard-v1"
|
|
57
|
+
_FIXED_SUBCOMMANDS = frozenset({"dist"})
|
|
58
|
+
_FIXED_FLAGS = frozenset(
|
|
59
|
+
{
|
|
60
|
+
"--avoid-sorting",
|
|
61
|
+
"--use-full-khash-sets",
|
|
62
|
+
"-C",
|
|
63
|
+
"-F",
|
|
64
|
+
"-O",
|
|
65
|
+
"-S",
|
|
66
|
+
"-k",
|
|
67
|
+
"-o",
|
|
68
|
+
"-p",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
_JACCARD_TOLERANCE = 1e-6
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class _FileSnapshot:
|
|
76
|
+
device: int
|
|
77
|
+
inode: int
|
|
78
|
+
size: int
|
|
79
|
+
modified_ns: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class _PreparedInput:
|
|
84
|
+
label: str
|
|
85
|
+
path: Path
|
|
86
|
+
sha256: str
|
|
87
|
+
snapshot: _FileSnapshot
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _snapshot(path: Path) -> _FileSnapshot:
|
|
91
|
+
try:
|
|
92
|
+
details = path.stat()
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise ConfigurationError(
|
|
95
|
+
"Dashing input file could not be inspected.",
|
|
96
|
+
code="DASHING_INPUT_UNAVAILABLE",
|
|
97
|
+
context={"path": str(path)},
|
|
98
|
+
) from exc
|
|
99
|
+
if not stat.S_ISREG(details.st_mode):
|
|
100
|
+
raise ConfigurationError(
|
|
101
|
+
"Dashing path inputs must be regular files.",
|
|
102
|
+
code="INVALID_DASHING_INPUT_PATH",
|
|
103
|
+
context={"path": str(path)},
|
|
104
|
+
)
|
|
105
|
+
return _FileSnapshot(
|
|
106
|
+
device=details.st_dev,
|
|
107
|
+
inode=details.st_ino,
|
|
108
|
+
size=details.st_size,
|
|
109
|
+
modified_ns=details.st_mtime_ns,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _hash_file(path: Path, *, max_bytes: int) -> str:
|
|
114
|
+
digest = hashlib.sha256()
|
|
115
|
+
observed = 0
|
|
116
|
+
try:
|
|
117
|
+
with path.open("rb") as handle:
|
|
118
|
+
while True:
|
|
119
|
+
chunk = handle.read(min(1_048_576, max_bytes + 1 - observed))
|
|
120
|
+
if not chunk:
|
|
121
|
+
break
|
|
122
|
+
observed += len(chunk)
|
|
123
|
+
if observed > max_bytes:
|
|
124
|
+
raise ConfigurationError(
|
|
125
|
+
"Dashing input exceeds max_input_bytes while being hashed.",
|
|
126
|
+
code="DASHING_INPUT_LIMIT_EXCEEDED",
|
|
127
|
+
context={"path": str(path), "max_input_bytes": max_bytes},
|
|
128
|
+
)
|
|
129
|
+
digest.update(chunk)
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
raise ConfigurationError(
|
|
132
|
+
"Dashing input file could not be read.",
|
|
133
|
+
code="DASHING_INPUT_UNAVAILABLE",
|
|
134
|
+
context={"path": str(path)},
|
|
135
|
+
) from exc
|
|
136
|
+
return digest.hexdigest()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _stable_snapshot_and_hash(path: Path, *, max_bytes: int) -> tuple[_FileSnapshot, str]:
|
|
140
|
+
before = _snapshot(path)
|
|
141
|
+
if before.size > max_bytes:
|
|
142
|
+
raise ConfigurationError(
|
|
143
|
+
"Dashing input exceeds max_input_bytes.",
|
|
144
|
+
code="DASHING_INPUT_LIMIT_EXCEEDED",
|
|
145
|
+
context={"path": str(path), "max_input_bytes": max_bytes, "size": before.size},
|
|
146
|
+
)
|
|
147
|
+
digest = _hash_file(path, max_bytes=max_bytes)
|
|
148
|
+
after = _snapshot(path)
|
|
149
|
+
if before != after:
|
|
150
|
+
raise ConfigurationError(
|
|
151
|
+
"Dashing input changed while it was being prepared.",
|
|
152
|
+
code="DASHING_INPUT_CHANGED",
|
|
153
|
+
context={"path": str(path)},
|
|
154
|
+
)
|
|
155
|
+
return after, digest
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_positive_int(value: object, name: str, maximum: int) -> int:
|
|
159
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
|
|
160
|
+
raise ConfigurationError(f"{name} must be an integer in [1, {maximum}].")
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _validate_bool(value: object, name: str) -> bool:
|
|
165
|
+
if not isinstance(value, bool):
|
|
166
|
+
raise ConfigurationError(f"{name} must be boolean.")
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _materialize_inputs(
|
|
171
|
+
inputs: DashingInput | DNASet | Iterable[DashingInput], *, max_items: int
|
|
172
|
+
) -> tuple[DashingInput, ...]:
|
|
173
|
+
if isinstance(inputs, DNASet):
|
|
174
|
+
resolved: tuple[DashingInput, ...] = inputs.records[: max_items + 1]
|
|
175
|
+
elif isinstance(inputs, DNA):
|
|
176
|
+
resolved = inputs.records[: max_items + 1]
|
|
177
|
+
elif isinstance(inputs, (DNASequence, DNARecord, str, Path)):
|
|
178
|
+
resolved = (inputs,)
|
|
179
|
+
else:
|
|
180
|
+
try:
|
|
181
|
+
resolved = tuple(islice(iter(inputs), max_items + 1))
|
|
182
|
+
except TypeError as exc:
|
|
183
|
+
raise ConfigurationError(
|
|
184
|
+
"Dashing inputs must be sequence objects, a DNASet, or an iterable of paths."
|
|
185
|
+
) from exc
|
|
186
|
+
if len(resolved) > max_items:
|
|
187
|
+
raise ConfigurationError(
|
|
188
|
+
"Dashing input count exceeds max_items.",
|
|
189
|
+
code="DASHING_ITEM_LIMIT_EXCEEDED",
|
|
190
|
+
context={"max_items": max_items, "item_count_is_lower_bound": True},
|
|
191
|
+
)
|
|
192
|
+
if len(resolved) < 2:
|
|
193
|
+
raise ConfigurationError("Dashing Jaccard requires at least two inputs.")
|
|
194
|
+
if any(not isinstance(item, (DNA, DNASequence, DNARecord, str, Path)) for item in resolved):
|
|
195
|
+
raise ConfigurationError(
|
|
196
|
+
"Every Dashing input must be DNASequence, DNARecord, or a FASTA/FASTQ path."
|
|
197
|
+
)
|
|
198
|
+
return resolved
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _sequence_and_label(item: DNA | DNASequence | DNARecord, index: int) -> tuple[DNASequence, str]:
|
|
202
|
+
if isinstance(item, DNA):
|
|
203
|
+
return item.sequence, item.id
|
|
204
|
+
if isinstance(item, DNARecord):
|
|
205
|
+
return item.sequence, item.id
|
|
206
|
+
return item, f"sequence_{index + 1}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _write_sequence_fasta(path: Path, sequence: DNASequence, index: int) -> None:
|
|
210
|
+
symbols = sequence.to_string()
|
|
211
|
+
try:
|
|
212
|
+
with path.open("x", encoding="ascii", newline="\n") as handle:
|
|
213
|
+
handle.write(f">dnakit_input_{index + 1}\n")
|
|
214
|
+
for offset in range(0, len(symbols), 80):
|
|
215
|
+
handle.write(symbols[offset : offset + 80])
|
|
216
|
+
handle.write("\n")
|
|
217
|
+
handle.flush()
|
|
218
|
+
os.fsync(handle.fileno())
|
|
219
|
+
except (OSError, UnicodeEncodeError) as exc:
|
|
220
|
+
raise ConfigurationError(
|
|
221
|
+
"DNAKit could not stage a Dashing FASTA input.",
|
|
222
|
+
code="DASHING_INPUT_STAGING_FAILED",
|
|
223
|
+
context={"input_index": index},
|
|
224
|
+
) from exc
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _prepare_inputs(
|
|
228
|
+
values: tuple[DashingInput, ...],
|
|
229
|
+
workspace: Path,
|
|
230
|
+
*,
|
|
231
|
+
k: int,
|
|
232
|
+
max_input_bytes: int,
|
|
233
|
+
) -> tuple[_PreparedInput, ...]:
|
|
234
|
+
prepared: list[_PreparedInput] = []
|
|
235
|
+
total_bytes = 0
|
|
236
|
+
for index, item in enumerate(values):
|
|
237
|
+
if isinstance(item, (DNA, DNASequence, DNARecord)):
|
|
238
|
+
sequence, label = _sequence_and_label(item, index)
|
|
239
|
+
if sequence.topology is Topology.CIRCULAR:
|
|
240
|
+
raise ConfigurationError(
|
|
241
|
+
"Dashing adapter requires linear sequence objects.",
|
|
242
|
+
code="DASHING_CIRCULAR_INPUT_UNSUPPORTED",
|
|
243
|
+
context={"input_index": index},
|
|
244
|
+
hint=(
|
|
245
|
+
"Linearize the sequence explicitly so omitted origin-spanning "
|
|
246
|
+
"k-mers are auditable."
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
if sequence.is_gapped:
|
|
250
|
+
raise ConfigurationError(
|
|
251
|
+
"Dashing adapter does not silently discard sequence gaps.",
|
|
252
|
+
code="DASHING_GAPPED_INPUT_UNSUPPORTED",
|
|
253
|
+
context={"input_index": index},
|
|
254
|
+
)
|
|
255
|
+
if sequence.symbol_length < k:
|
|
256
|
+
raise ConfigurationError(
|
|
257
|
+
"Dashing k cannot exceed an in-memory sequence length.",
|
|
258
|
+
code="DASHING_K_EXCEEDS_SEQUENCE",
|
|
259
|
+
context={
|
|
260
|
+
"input_index": index,
|
|
261
|
+
"k": k,
|
|
262
|
+
"sequence_length": sequence.symbol_length,
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
predicted_bytes = sequence.symbol_length + (sequence.symbol_length + 79) // 80 + 32
|
|
266
|
+
if total_bytes + predicted_bytes > max_input_bytes:
|
|
267
|
+
raise ConfigurationError(
|
|
268
|
+
"Combined Dashing input size exceeds max_input_bytes.",
|
|
269
|
+
code="DASHING_INPUT_LIMIT_EXCEEDED",
|
|
270
|
+
context={
|
|
271
|
+
"max_input_bytes": max_input_bytes,
|
|
272
|
+
"observed_bytes_is_lower_bound": total_bytes + predicted_bytes,
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
path = workspace / f"input_{index + 1:06d}.fasta"
|
|
276
|
+
_write_sequence_fasta(path, sequence, index)
|
|
277
|
+
else:
|
|
278
|
+
raw_path = Path(item).expanduser()
|
|
279
|
+
if any(character in str(raw_path) for character in ("\x00", "\r", "\n", "\t")):
|
|
280
|
+
raise ConfigurationError(
|
|
281
|
+
"Dashing input paths cannot contain NUL, tabs, or line breaks.",
|
|
282
|
+
code="UNSAFE_DASHING_INPUT_PATH",
|
|
283
|
+
context={"input_index": index},
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
path = raw_path.resolve(strict=True)
|
|
287
|
+
except (OSError, RuntimeError) as exc:
|
|
288
|
+
raise ConfigurationError(
|
|
289
|
+
"Dashing path input is unavailable.",
|
|
290
|
+
code="DASHING_INPUT_UNAVAILABLE",
|
|
291
|
+
context={"input_index": index, "path": str(raw_path)},
|
|
292
|
+
) from exc
|
|
293
|
+
try:
|
|
294
|
+
str(path).encode("utf-8", errors="strict")
|
|
295
|
+
except UnicodeEncodeError as exc:
|
|
296
|
+
raise ConfigurationError(
|
|
297
|
+
"Dashing input paths must be valid UTF-8.",
|
|
298
|
+
code="UNSAFE_DASHING_INPUT_PATH",
|
|
299
|
+
context={"input_index": index},
|
|
300
|
+
) from exc
|
|
301
|
+
label = path.name
|
|
302
|
+
snapshot, digest = _stable_snapshot_and_hash(path, max_bytes=max_input_bytes)
|
|
303
|
+
total_bytes += snapshot.size
|
|
304
|
+
if total_bytes > max_input_bytes:
|
|
305
|
+
raise ConfigurationError(
|
|
306
|
+
"Combined Dashing input size exceeds max_input_bytes.",
|
|
307
|
+
code="DASHING_INPUT_LIMIT_EXCEEDED",
|
|
308
|
+
context={
|
|
309
|
+
"max_input_bytes": max_input_bytes,
|
|
310
|
+
"observed_bytes": total_bytes,
|
|
311
|
+
"input_count_is_lower_bound": True,
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
prepared.append(_PreparedInput(label, path, digest, snapshot))
|
|
315
|
+
return tuple(prepared)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _verify_inputs_unchanged(prepared: tuple[_PreparedInput, ...], *, max_bytes: int) -> None:
|
|
319
|
+
for item in prepared:
|
|
320
|
+
current, digest = _stable_snapshot_and_hash(item.path, max_bytes=max_bytes)
|
|
321
|
+
if current != item.snapshot or digest != item.sha256:
|
|
322
|
+
raise BackendExecutionError(
|
|
323
|
+
"A Dashing input changed during external execution.",
|
|
324
|
+
code="DASHING_INPUT_CHANGED",
|
|
325
|
+
context={"path": str(item.path)},
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _write_path_list(path: Path, prepared: tuple[_PreparedInput, ...]) -> None:
|
|
330
|
+
payload = "".join(f"{item.path}\n" for item in prepared)
|
|
331
|
+
try:
|
|
332
|
+
encoded_size = len(payload.encode("utf-8", errors="strict"))
|
|
333
|
+
except UnicodeEncodeError as exc: # pragma: no cover - rejected while preparing paths.
|
|
334
|
+
raise ConfigurationError("Dashing path list must be valid UTF-8.") from exc
|
|
335
|
+
if encoded_size > 1_000_000:
|
|
336
|
+
raise ConfigurationError(
|
|
337
|
+
"Encoded Dashing path list exceeds its 1 MB safety limit.",
|
|
338
|
+
code="DASHING_PATH_LIST_LIMIT_EXCEEDED",
|
|
339
|
+
)
|
|
340
|
+
try:
|
|
341
|
+
path.write_text(payload, encoding="utf-8", newline="\n")
|
|
342
|
+
except (OSError, UnicodeError) as exc:
|
|
343
|
+
raise ConfigurationError(
|
|
344
|
+
"DNAKit could not stage the Dashing path list.",
|
|
345
|
+
code="DASHING_INPUT_STAGING_FAILED",
|
|
346
|
+
) from exc
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _command_arguments(
|
|
350
|
+
*,
|
|
351
|
+
k: int,
|
|
352
|
+
threads: int,
|
|
353
|
+
canonical: bool,
|
|
354
|
+
mode: DashingMode,
|
|
355
|
+
sketch_size_log2: int | None,
|
|
356
|
+
matrix_path: Path,
|
|
357
|
+
sizes_path: Path,
|
|
358
|
+
paths_path: Path,
|
|
359
|
+
) -> tuple[str, ...]:
|
|
360
|
+
arguments = [
|
|
361
|
+
"dist",
|
|
362
|
+
"--avoid-sorting",
|
|
363
|
+
"-k",
|
|
364
|
+
str(k),
|
|
365
|
+
"-p",
|
|
366
|
+
str(threads),
|
|
367
|
+
"-O",
|
|
368
|
+
str(matrix_path),
|
|
369
|
+
"-o",
|
|
370
|
+
str(sizes_path),
|
|
371
|
+
"-F",
|
|
372
|
+
str(paths_path),
|
|
373
|
+
]
|
|
374
|
+
if sketch_size_log2 is not None:
|
|
375
|
+
arguments.extend(("-S", str(sketch_size_log2)))
|
|
376
|
+
if not canonical:
|
|
377
|
+
arguments.append("-C")
|
|
378
|
+
if mode == "exact":
|
|
379
|
+
arguments.append("--use-full-khash-sets")
|
|
380
|
+
_assert_command_whitelist(tuple(arguments))
|
|
381
|
+
return tuple(arguments)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _assert_command_whitelist(arguments: tuple[str, ...]) -> None:
|
|
385
|
+
if not arguments or arguments[0] not in _FIXED_SUBCOMMANDS:
|
|
386
|
+
raise AssertionError("Dashing command escaped the fixed subcommand whitelist.")
|
|
387
|
+
if any(
|
|
388
|
+
token.startswith("-") and token not in _FIXED_FLAGS
|
|
389
|
+
for token in arguments[1:]
|
|
390
|
+
if not token.lstrip("-").isdigit()
|
|
391
|
+
):
|
|
392
|
+
raise AssertionError("Dashing command escaped the fixed flag whitelist.")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _parse_matrix(
|
|
396
|
+
path: Path,
|
|
397
|
+
prepared: tuple[_PreparedInput, ...],
|
|
398
|
+
*,
|
|
399
|
+
max_output_bytes: int,
|
|
400
|
+
) -> tuple[tuple[float, ...], ...]:
|
|
401
|
+
if path.is_symlink():
|
|
402
|
+
raise BackendExecutionError(
|
|
403
|
+
"Dashing matrix output cannot be a symbolic link.",
|
|
404
|
+
code="UNSAFE_DASHING_OUTPUT_PATH",
|
|
405
|
+
)
|
|
406
|
+
try:
|
|
407
|
+
details = path.stat()
|
|
408
|
+
except OSError as exc:
|
|
409
|
+
raise BackendExecutionError(
|
|
410
|
+
"Dashing did not create its Jaccard matrix output.",
|
|
411
|
+
code="DASHING_OUTPUT_MISSING",
|
|
412
|
+
) from exc
|
|
413
|
+
if not stat.S_ISREG(details.st_mode):
|
|
414
|
+
raise BackendExecutionError(
|
|
415
|
+
"Dashing matrix output must be a regular file.",
|
|
416
|
+
code="UNSAFE_DASHING_OUTPUT_PATH",
|
|
417
|
+
)
|
|
418
|
+
size = details.st_size
|
|
419
|
+
if not 1 <= size <= max_output_bytes:
|
|
420
|
+
raise BackendExecutionError(
|
|
421
|
+
"Dashing matrix output violates max_output_bytes.",
|
|
422
|
+
code="DASHING_OUTPUT_LIMIT_EXCEEDED",
|
|
423
|
+
context={"observed_bytes": size, "max_output_bytes": max_output_bytes},
|
|
424
|
+
)
|
|
425
|
+
expected_paths = tuple(str(item.path) for item in prepared)
|
|
426
|
+
line_limit = max(1_000_000, max(len(value.encode("utf-8")) for value in expected_paths) + 256)
|
|
427
|
+
try:
|
|
428
|
+
with path.open("r", encoding="utf-8", errors="strict", newline=None) as handle:
|
|
429
|
+
lines = tuple(islice(handle, len(prepared) + 2))
|
|
430
|
+
except (OSError, UnicodeError) as exc:
|
|
431
|
+
raise BackendExecutionError(
|
|
432
|
+
"Dashing matrix output is not readable UTF-8 TSV.",
|
|
433
|
+
code="DASHING_OUTPUT_PROTOCOL_ERROR",
|
|
434
|
+
) from exc
|
|
435
|
+
if len(lines) != len(prepared) + 1 or any(
|
|
436
|
+
len(line.encode("utf-8")) > line_limit for line in lines
|
|
437
|
+
):
|
|
438
|
+
raise BackendExecutionError(
|
|
439
|
+
"Dashing matrix output has an invalid row count or line length.",
|
|
440
|
+
code="DASHING_OUTPUT_PROTOCOL_ERROR",
|
|
441
|
+
)
|
|
442
|
+
header = lines[0].rstrip("\r\n").split("\t")
|
|
443
|
+
if tuple(header) != ("##Names", *expected_paths):
|
|
444
|
+
raise BackendExecutionError(
|
|
445
|
+
"Dashing matrix header does not match the staged input order.",
|
|
446
|
+
code="DASHING_OUTPUT_PROTOCOL_ERROR",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
item_count = len(prepared)
|
|
450
|
+
matrix = [
|
|
451
|
+
[1.0 if row == column else 0.0 for column in range(item_count)] for row in range(item_count)
|
|
452
|
+
]
|
|
453
|
+
for row_index, line in enumerate(lines[1:]):
|
|
454
|
+
fields = line.rstrip("\r\n").split("\t")
|
|
455
|
+
if len(fields) != item_count + 1 or fields[0] != expected_paths[row_index]:
|
|
456
|
+
raise BackendExecutionError(
|
|
457
|
+
"Dashing matrix row does not match the staged input order.",
|
|
458
|
+
code="DASHING_OUTPUT_PROTOCOL_ERROR",
|
|
459
|
+
context={"row_index": row_index},
|
|
460
|
+
)
|
|
461
|
+
for column_index, token in enumerate(fields[1:]):
|
|
462
|
+
if column_index <= row_index:
|
|
463
|
+
if token != "-":
|
|
464
|
+
raise BackendExecutionError(
|
|
465
|
+
"Dashing upper-triangle placeholder is invalid.",
|
|
466
|
+
code="DASHING_OUTPUT_PROTOCOL_ERROR",
|
|
467
|
+
context={"row_index": row_index, "column_index": column_index},
|
|
468
|
+
)
|
|
469
|
+
continue
|
|
470
|
+
try:
|
|
471
|
+
value = float(token)
|
|
472
|
+
except ValueError as exc:
|
|
473
|
+
raise BackendExecutionError(
|
|
474
|
+
"Dashing matrix contains a non-numeric Jaccard value.",
|
|
475
|
+
code="DASHING_OUTPUT_PROTOCOL_ERROR",
|
|
476
|
+
context={"row_index": row_index, "column_index": column_index},
|
|
477
|
+
) from exc
|
|
478
|
+
if not -_JACCARD_TOLERANCE <= value <= 1 + _JACCARD_TOLERANCE:
|
|
479
|
+
raise BackendExecutionError(
|
|
480
|
+
"Dashing matrix contains a Jaccard value outside [0, 1].",
|
|
481
|
+
code="DASHING_OUTPUT_PROTOCOL_ERROR",
|
|
482
|
+
context={
|
|
483
|
+
"row_index": row_index,
|
|
484
|
+
"column_index": column_index,
|
|
485
|
+
"value": token,
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
bounded = min(1.0, max(0.0, value))
|
|
489
|
+
matrix[row_index][column_index] = bounded
|
|
490
|
+
matrix[column_index][row_index] = bounded
|
|
491
|
+
return tuple(tuple(row) for row in matrix)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _resolve_output_path(output_path: str | Path | None, *, overwrite: bool) -> Path | None:
|
|
495
|
+
if output_path is None:
|
|
496
|
+
return None
|
|
497
|
+
raw = Path(output_path).expanduser()
|
|
498
|
+
if not raw.name or any(character in raw.name for character in ("\x00", "\r", "\n")):
|
|
499
|
+
raise ConfigurationError("Dashing output_path has an unsafe filename.")
|
|
500
|
+
try:
|
|
501
|
+
parent = raw.parent.resolve(strict=True)
|
|
502
|
+
except (OSError, RuntimeError) as exc:
|
|
503
|
+
raise ConfigurationError("Dashing output_path parent must already exist.") from exc
|
|
504
|
+
if not parent.is_dir():
|
|
505
|
+
raise ConfigurationError("Dashing output_path parent must be a directory.")
|
|
506
|
+
target = parent / raw.name
|
|
507
|
+
if target.is_symlink():
|
|
508
|
+
raise ConfigurationError(
|
|
509
|
+
"Dashing output_path cannot be a symbolic link.", code="UNSAFE_DASHING_OUTPUT_PATH"
|
|
510
|
+
)
|
|
511
|
+
if target.exists() and not overwrite:
|
|
512
|
+
raise FileExistsError(f"Refusing to overwrite existing Dashing output: {target}")
|
|
513
|
+
if target.exists() and not target.is_file():
|
|
514
|
+
raise ConfigurationError("Dashing output_path must be a regular file path.")
|
|
515
|
+
return target
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _persist_output(source: Path, target: Path, *, overwrite: bool, max_bytes: int) -> None:
|
|
519
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
520
|
+
prefix=f".{target.name}.", suffix=".tmp", dir=target.parent
|
|
521
|
+
)
|
|
522
|
+
temporary = Path(temporary_name)
|
|
523
|
+
written = 0
|
|
524
|
+
try:
|
|
525
|
+
with source.open("rb") as input_handle, os.fdopen(descriptor, "wb") as output_handle:
|
|
526
|
+
while True:
|
|
527
|
+
chunk = input_handle.read(1_048_576)
|
|
528
|
+
if not chunk:
|
|
529
|
+
break
|
|
530
|
+
written += len(chunk)
|
|
531
|
+
if written > max_bytes:
|
|
532
|
+
raise BackendExecutionError(
|
|
533
|
+
"Dashing output exceeded max_output_bytes during persistence.",
|
|
534
|
+
code="DASHING_OUTPUT_LIMIT_EXCEEDED",
|
|
535
|
+
)
|
|
536
|
+
output_handle.write(chunk)
|
|
537
|
+
output_handle.flush()
|
|
538
|
+
os.fsync(output_handle.fileno())
|
|
539
|
+
if overwrite:
|
|
540
|
+
os.replace(temporary, target)
|
|
541
|
+
else:
|
|
542
|
+
os.link(temporary, target)
|
|
543
|
+
temporary.unlink()
|
|
544
|
+
except BaseException:
|
|
545
|
+
with suppress(OSError):
|
|
546
|
+
os.close(descriptor)
|
|
547
|
+
temporary.unlink(missing_ok=True)
|
|
548
|
+
raise
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _backend_version(output: str) -> str:
|
|
552
|
+
match = _DASHING_VERSION.search(output)
|
|
553
|
+
if match is None:
|
|
554
|
+
raise BackendExecutionError(
|
|
555
|
+
"Dashing execution did not report a parseable backend version.",
|
|
556
|
+
code="DASHING_VERSION_UNPARSEABLE",
|
|
557
|
+
)
|
|
558
|
+
return match.group("version")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _provenance(
|
|
562
|
+
executable: Path,
|
|
563
|
+
version: str,
|
|
564
|
+
*,
|
|
565
|
+
elapsed_seconds: float,
|
|
566
|
+
execution_output_sha256: str,
|
|
567
|
+
hybrid: bool,
|
|
568
|
+
) -> Provenance:
|
|
569
|
+
backend = BackendInfo(
|
|
570
|
+
"dashing",
|
|
571
|
+
version=version,
|
|
572
|
+
executable_path=str(executable),
|
|
573
|
+
license_expression="GPL-3.0-only",
|
|
574
|
+
capabilities={"sketch", "similarity"},
|
|
575
|
+
available=True,
|
|
576
|
+
metadata={
|
|
577
|
+
"probe_mode": "explicit-scientific-execution",
|
|
578
|
+
"command_profile": _COMMAND_PROFILE,
|
|
579
|
+
"elapsed_seconds": elapsed_seconds,
|
|
580
|
+
"execution_output_sha256": execution_output_sha256,
|
|
581
|
+
"redistributed": False,
|
|
582
|
+
"shell": False,
|
|
583
|
+
},
|
|
584
|
+
)
|
|
585
|
+
return Provenance(
|
|
586
|
+
dependency_versions={"dashing": version},
|
|
587
|
+
implementation=ImplementationInfo(
|
|
588
|
+
label=ImplementationLabel.ADAPTER,
|
|
589
|
+
execution_mode=ExecutionMode.HYBRID if hybrid else ExecutionMode.EXTERNAL,
|
|
590
|
+
origin_class=OriginClass.INTEGRATION,
|
|
591
|
+
license_expression="GPL-3.0-only",
|
|
592
|
+
citations=(
|
|
593
|
+
Citation(
|
|
594
|
+
"baker-langmead-2019-dashing",
|
|
595
|
+
title="Dashing: fast and accurate genomic distances with HyperLogLog",
|
|
596
|
+
doi="10.1186/s13059-019-1875-0",
|
|
597
|
+
),
|
|
598
|
+
),
|
|
599
|
+
),
|
|
600
|
+
backend=backend,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
605
|
+
class DashingAdapter:
|
|
606
|
+
"""Opt-in Dashing adapter that never discovers, installs, or bundles Dashing."""
|
|
607
|
+
|
|
608
|
+
executable_path: Path
|
|
609
|
+
|
|
610
|
+
def __init__(self, executable_path: str | Path) -> None:
|
|
611
|
+
try:
|
|
612
|
+
resolved = Path(executable_path).expanduser().resolve(strict=True)
|
|
613
|
+
details = resolved.stat()
|
|
614
|
+
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
|
615
|
+
raise BackendUnavailableError(
|
|
616
|
+
"A Dashing executable path must be supplied explicitly.",
|
|
617
|
+
context={"executable": str(executable_path)},
|
|
618
|
+
) from exc
|
|
619
|
+
if not stat.S_ISREG(details.st_mode) or not os.access(resolved, os.X_OK):
|
|
620
|
+
raise BackendUnavailableError(
|
|
621
|
+
"The supplied Dashing path must be an executable regular file.",
|
|
622
|
+
context={"executable": str(resolved)},
|
|
623
|
+
)
|
|
624
|
+
object.__setattr__(self, "executable_path", resolved)
|
|
625
|
+
|
|
626
|
+
def matrix(
|
|
627
|
+
self,
|
|
628
|
+
inputs: DashingInput | DNASet | Iterable[DashingInput],
|
|
629
|
+
*,
|
|
630
|
+
k: int = 31,
|
|
631
|
+
mode: DashingMode = "sketch",
|
|
632
|
+
sketch_size_log2: int | None = None,
|
|
633
|
+
canonical: bool = True,
|
|
634
|
+
threads: int = 1,
|
|
635
|
+
temp_dir: str | Path | None = None,
|
|
636
|
+
output_path: str | Path | None = None,
|
|
637
|
+
overwrite: bool = False,
|
|
638
|
+
timeout_seconds: float = 300.0,
|
|
639
|
+
max_items: int = DEFAULT_MAX_DASHING_ITEMS,
|
|
640
|
+
max_input_bytes: int = DEFAULT_MAX_DASHING_INPUT_BYTES,
|
|
641
|
+
max_output_bytes: int = DEFAULT_MAX_DASHING_OUTPUT_BYTES,
|
|
642
|
+
max_capture_bytes: int = DEFAULT_MAX_DASHING_CAPTURE_BYTES,
|
|
643
|
+
max_sketch_memory_bytes: int = DEFAULT_MAX_DASHING_SKETCH_MEMORY_BYTES,
|
|
644
|
+
) -> DashingJaccardMatrixResult:
|
|
645
|
+
"""Compute a validated file-level exact or HLL-sketch Jaccard matrix."""
|
|
646
|
+
|
|
647
|
+
resolved_k = _validate_positive_int(k, "k", 32)
|
|
648
|
+
resolved_threads = _validate_positive_int(threads, "threads", 256)
|
|
649
|
+
resolved_max_items = _validate_positive_int(max_items, "max_items", 1_000)
|
|
650
|
+
resolved_max_input = _validate_positive_int(
|
|
651
|
+
max_input_bytes, "max_input_bytes", 10_000_000_000
|
|
652
|
+
)
|
|
653
|
+
resolved_max_output = _validate_positive_int(
|
|
654
|
+
max_output_bytes, "max_output_bytes", 1_000_000_000
|
|
655
|
+
)
|
|
656
|
+
resolved_max_capture = _validate_positive_int(
|
|
657
|
+
max_capture_bytes, "max_capture_bytes", 100_000_000
|
|
658
|
+
)
|
|
659
|
+
resolved_max_sketch_memory = _validate_positive_int(
|
|
660
|
+
max_sketch_memory_bytes, "max_sketch_memory_bytes", 10_000_000_000
|
|
661
|
+
)
|
|
662
|
+
resolved_canonical = _validate_bool(canonical, "canonical")
|
|
663
|
+
resolved_overwrite = _validate_bool(overwrite, "overwrite")
|
|
664
|
+
if mode not in {"sketch", "exact"}:
|
|
665
|
+
raise ConfigurationError("mode must be 'sketch' or 'exact'.")
|
|
666
|
+
if (
|
|
667
|
+
isinstance(timeout_seconds, bool)
|
|
668
|
+
or not isinstance(timeout_seconds, (int, float))
|
|
669
|
+
or not 0 < timeout_seconds <= 86_400
|
|
670
|
+
):
|
|
671
|
+
raise ConfigurationError("timeout_seconds must be in (0, 86400].")
|
|
672
|
+
if mode == "sketch":
|
|
673
|
+
resolved_sketch_size = (
|
|
674
|
+
10
|
|
675
|
+
if sketch_size_log2 is None
|
|
676
|
+
else _validate_positive_int(sketch_size_log2, "sketch_size_log2", 30)
|
|
677
|
+
)
|
|
678
|
+
else:
|
|
679
|
+
if sketch_size_log2 is not None:
|
|
680
|
+
raise ConfigurationError("sketch_size_log2 must be None in exact mode.")
|
|
681
|
+
resolved_sketch_size = None
|
|
682
|
+
|
|
683
|
+
values = _materialize_inputs(inputs, max_items=resolved_max_items)
|
|
684
|
+
if (
|
|
685
|
+
resolved_sketch_size is not None
|
|
686
|
+
and len(values) * (1 << resolved_sketch_size) > resolved_max_sketch_memory
|
|
687
|
+
):
|
|
688
|
+
raise ConfigurationError(
|
|
689
|
+
"Requested Dashing sketches exceed max_sketch_memory_bytes.",
|
|
690
|
+
code="DASHING_SKETCH_MEMORY_LIMIT_EXCEEDED",
|
|
691
|
+
context={
|
|
692
|
+
"item_count": len(values),
|
|
693
|
+
"sketch_size_log2": resolved_sketch_size,
|
|
694
|
+
"max_sketch_memory_bytes": resolved_max_sketch_memory,
|
|
695
|
+
},
|
|
696
|
+
)
|
|
697
|
+
output_target = _resolve_output_path(output_path, overwrite=resolved_overwrite)
|
|
698
|
+
if temp_dir is None:
|
|
699
|
+
temp_base: str | None = None
|
|
700
|
+
else:
|
|
701
|
+
try:
|
|
702
|
+
resolved_temp_base = Path(temp_dir).expanduser().resolve(strict=True)
|
|
703
|
+
except (OSError, RuntimeError) as exc:
|
|
704
|
+
raise ConfigurationError("temp_dir must be an existing directory.") from exc
|
|
705
|
+
if not resolved_temp_base.is_dir():
|
|
706
|
+
raise ConfigurationError("temp_dir must be an existing directory.")
|
|
707
|
+
temp_base = str(resolved_temp_base)
|
|
708
|
+
|
|
709
|
+
with tempfile.TemporaryDirectory(prefix="dnakit-dashing-", dir=temp_base) as name:
|
|
710
|
+
workspace = Path(name).resolve()
|
|
711
|
+
workspace.chmod(0o700)
|
|
712
|
+
prepared = _prepare_inputs(
|
|
713
|
+
values,
|
|
714
|
+
workspace,
|
|
715
|
+
k=resolved_k,
|
|
716
|
+
max_input_bytes=resolved_max_input,
|
|
717
|
+
)
|
|
718
|
+
if output_target is not None and any(
|
|
719
|
+
output_target.resolve(strict=False) == item.path for item in prepared
|
|
720
|
+
):
|
|
721
|
+
raise ConfigurationError(
|
|
722
|
+
"Dashing output_path cannot replace an input file.",
|
|
723
|
+
code="DASHING_OUTPUT_INPUT_CONFLICT",
|
|
724
|
+
)
|
|
725
|
+
paths_path = workspace / "inputs.paths"
|
|
726
|
+
matrix_path = workspace / "jaccard.tsv"
|
|
727
|
+
sizes_path = workspace / "sizes.tsv"
|
|
728
|
+
_write_path_list(paths_path, prepared)
|
|
729
|
+
arguments = _command_arguments(
|
|
730
|
+
k=resolved_k,
|
|
731
|
+
threads=resolved_threads,
|
|
732
|
+
canonical=resolved_canonical,
|
|
733
|
+
mode=mode,
|
|
734
|
+
sketch_size_log2=resolved_sketch_size,
|
|
735
|
+
matrix_path=matrix_path,
|
|
736
|
+
sizes_path=sizes_path,
|
|
737
|
+
paths_path=paths_path,
|
|
738
|
+
)
|
|
739
|
+
try:
|
|
740
|
+
execution = execute_bounded_command(
|
|
741
|
+
self.executable_path,
|
|
742
|
+
arguments,
|
|
743
|
+
backend_id="dashing",
|
|
744
|
+
cwd=workspace,
|
|
745
|
+
timeout_seconds=float(timeout_seconds),
|
|
746
|
+
max_output_bytes=resolved_max_capture,
|
|
747
|
+
monitored_output_paths=(matrix_path, sizes_path),
|
|
748
|
+
max_monitored_output_bytes=resolved_max_output,
|
|
749
|
+
)
|
|
750
|
+
finally:
|
|
751
|
+
_verify_inputs_unchanged(prepared, max_bytes=resolved_max_input)
|
|
752
|
+
if execution.return_code != 0:
|
|
753
|
+
raise BackendExecutionError(
|
|
754
|
+
"Dashing Jaccard execution returned a non-zero exit status.",
|
|
755
|
+
code="DASHING_EXECUTION_FAILED",
|
|
756
|
+
context={
|
|
757
|
+
"return_code": execution.return_code,
|
|
758
|
+
"output_excerpt": execution.output[:1_000],
|
|
759
|
+
},
|
|
760
|
+
)
|
|
761
|
+
version = _backend_version(execution.output)
|
|
762
|
+
matrix = _parse_matrix(
|
|
763
|
+
matrix_path,
|
|
764
|
+
prepared,
|
|
765
|
+
max_output_bytes=resolved_max_output,
|
|
766
|
+
)
|
|
767
|
+
raw_digest = _hash_file(matrix_path, max_bytes=resolved_max_output)
|
|
768
|
+
if not _SHA256.fullmatch(raw_digest): # pragma: no cover - hashlib guarantee.
|
|
769
|
+
raise AssertionError("hashlib returned an invalid SHA-256 digest")
|
|
770
|
+
if output_target is not None:
|
|
771
|
+
_persist_output(
|
|
772
|
+
matrix_path,
|
|
773
|
+
output_target,
|
|
774
|
+
overwrite=resolved_overwrite,
|
|
775
|
+
max_bytes=resolved_max_output,
|
|
776
|
+
)
|
|
777
|
+
_, persisted_digest = _stable_snapshot_and_hash(
|
|
778
|
+
output_target, max_bytes=resolved_max_output
|
|
779
|
+
)
|
|
780
|
+
if persisted_digest != raw_digest:
|
|
781
|
+
raise BackendExecutionError(
|
|
782
|
+
"Persisted Dashing output failed its SHA-256 integrity check.",
|
|
783
|
+
code="DASHING_OUTPUT_INTEGRITY_ERROR",
|
|
784
|
+
)
|
|
785
|
+
execution_digest = hashlib.sha256(execution.output.encode("utf-8")).hexdigest()
|
|
786
|
+
provenance = _provenance(
|
|
787
|
+
self.executable_path,
|
|
788
|
+
version,
|
|
789
|
+
elapsed_seconds=execution.elapsed_seconds,
|
|
790
|
+
execution_output_sha256=execution_digest,
|
|
791
|
+
hybrid=False,
|
|
792
|
+
)
|
|
793
|
+
parameters = freeze_mapping(
|
|
794
|
+
{
|
|
795
|
+
"backend": "dashing",
|
|
796
|
+
"canonical": resolved_canonical,
|
|
797
|
+
"command_profile": _COMMAND_PROFILE,
|
|
798
|
+
"deterministic": True,
|
|
799
|
+
"jaccard_tolerance": _JACCARD_TOLERANCE,
|
|
800
|
+
"k": resolved_k,
|
|
801
|
+
"max_capture_bytes": resolved_max_capture,
|
|
802
|
+
"max_input_bytes": resolved_max_input,
|
|
803
|
+
"max_items": resolved_max_items,
|
|
804
|
+
"max_output_bytes": resolved_max_output,
|
|
805
|
+
"max_sketch_memory_bytes": resolved_max_sketch_memory,
|
|
806
|
+
"mode": mode,
|
|
807
|
+
"seed": None,
|
|
808
|
+
"sketch_size_log2": resolved_sketch_size,
|
|
809
|
+
"threads": resolved_threads,
|
|
810
|
+
"timeout_seconds": float(timeout_seconds),
|
|
811
|
+
}
|
|
812
|
+
)
|
|
813
|
+
return DashingJaccardMatrixResult(
|
|
814
|
+
name="dashing_jaccard_matrix",
|
|
815
|
+
method="hll-sketch" if mode == "sketch" else "exact-kmer-set",
|
|
816
|
+
algorithm_version=f"dashing-{version}",
|
|
817
|
+
labels=tuple(item.label for item in prepared),
|
|
818
|
+
values=matrix,
|
|
819
|
+
input_sha256=tuple(item.sha256 for item in prepared),
|
|
820
|
+
parameters=parameters,
|
|
821
|
+
provenance=provenance,
|
|
822
|
+
raw_output_path=None if output_target is None else str(output_target),
|
|
823
|
+
raw_output_sha256=raw_digest,
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
def top_k(
|
|
827
|
+
self,
|
|
828
|
+
inputs: DashingInput | DNASet | Iterable[DashingInput],
|
|
829
|
+
*,
|
|
830
|
+
top_k: int,
|
|
831
|
+
k: int = 31,
|
|
832
|
+
mode: DashingMode = "sketch",
|
|
833
|
+
sketch_size_log2: int | None = None,
|
|
834
|
+
canonical: bool = True,
|
|
835
|
+
threads: int = 1,
|
|
836
|
+
temp_dir: str | Path | None = None,
|
|
837
|
+
output_path: str | Path | None = None,
|
|
838
|
+
overwrite: bool = False,
|
|
839
|
+
timeout_seconds: float = 300.0,
|
|
840
|
+
max_items: int = DEFAULT_MAX_DASHING_ITEMS,
|
|
841
|
+
max_input_bytes: int = DEFAULT_MAX_DASHING_INPUT_BYTES,
|
|
842
|
+
max_output_bytes: int = DEFAULT_MAX_DASHING_OUTPUT_BYTES,
|
|
843
|
+
max_capture_bytes: int = DEFAULT_MAX_DASHING_CAPTURE_BYTES,
|
|
844
|
+
max_sketch_memory_bytes: int = DEFAULT_MAX_DASHING_SKETCH_MEMORY_BYTES,
|
|
845
|
+
) -> DashingTopKResult:
|
|
846
|
+
"""Return deterministic per-item Top-k neighbors from one Dashing matrix run."""
|
|
847
|
+
|
|
848
|
+
resolved_top_k = _validate_positive_int(top_k, "top_k", 999)
|
|
849
|
+
resolved_max_items = _validate_positive_int(max_items, "max_items", 1_000)
|
|
850
|
+
input_values = _materialize_inputs(inputs, max_items=resolved_max_items)
|
|
851
|
+
if resolved_top_k >= len(input_values):
|
|
852
|
+
raise ConfigurationError("top_k must be smaller than the Dashing input count.")
|
|
853
|
+
matrix = self.matrix(
|
|
854
|
+
input_values,
|
|
855
|
+
k=k,
|
|
856
|
+
mode=mode,
|
|
857
|
+
sketch_size_log2=sketch_size_log2,
|
|
858
|
+
canonical=canonical,
|
|
859
|
+
threads=threads,
|
|
860
|
+
temp_dir=temp_dir,
|
|
861
|
+
output_path=output_path,
|
|
862
|
+
overwrite=overwrite,
|
|
863
|
+
timeout_seconds=timeout_seconds,
|
|
864
|
+
max_items=resolved_max_items,
|
|
865
|
+
max_input_bytes=max_input_bytes,
|
|
866
|
+
max_output_bytes=max_output_bytes,
|
|
867
|
+
max_capture_bytes=max_capture_bytes,
|
|
868
|
+
max_sketch_memory_bytes=max_sketch_memory_bytes,
|
|
869
|
+
)
|
|
870
|
+
rows: list[DashingNeighborRow] = []
|
|
871
|
+
for query_index, row_values in enumerate(matrix.values):
|
|
872
|
+
ranked = sorted(
|
|
873
|
+
(
|
|
874
|
+
DashingNeighborHit(matrix.labels[index], index, value)
|
|
875
|
+
for index, value in enumerate(row_values)
|
|
876
|
+
if index != query_index
|
|
877
|
+
),
|
|
878
|
+
key=lambda hit: (-hit.jaccard, hit.index),
|
|
879
|
+
)[:resolved_top_k]
|
|
880
|
+
rows.append(
|
|
881
|
+
DashingNeighborRow(
|
|
882
|
+
query_label=matrix.labels[query_index],
|
|
883
|
+
query_index=query_index,
|
|
884
|
+
hits=tuple(ranked),
|
|
885
|
+
)
|
|
886
|
+
)
|
|
887
|
+
backend = matrix.provenance.backend
|
|
888
|
+
if backend is None or backend.version is None: # pragma: no cover - matrix invariant.
|
|
889
|
+
raise AssertionError("Dashing matrix provenance is missing its backend version")
|
|
890
|
+
elapsed_value = backend.metadata["elapsed_seconds"]
|
|
891
|
+
output_digest_value = backend.metadata["execution_output_sha256"]
|
|
892
|
+
if (
|
|
893
|
+
isinstance(elapsed_value, bool)
|
|
894
|
+
or not isinstance(elapsed_value, (int, float))
|
|
895
|
+
or not isinstance(output_digest_value, str)
|
|
896
|
+
): # pragma: no cover - created by _provenance.
|
|
897
|
+
raise AssertionError("Dashing matrix provenance metadata is invalid")
|
|
898
|
+
parameters = freeze_mapping(
|
|
899
|
+
{
|
|
900
|
+
**dict(matrix.parameters),
|
|
901
|
+
"top_k": resolved_top_k,
|
|
902
|
+
"top_k_selection": "dnakit-deterministic-score-index-order",
|
|
903
|
+
}
|
|
904
|
+
)
|
|
905
|
+
provenance = _provenance(
|
|
906
|
+
self.executable_path,
|
|
907
|
+
backend.version,
|
|
908
|
+
elapsed_seconds=float(elapsed_value),
|
|
909
|
+
execution_output_sha256=output_digest_value,
|
|
910
|
+
hybrid=True,
|
|
911
|
+
)
|
|
912
|
+
return DashingTopKResult(
|
|
913
|
+
name="dashing_jaccard_top_k",
|
|
914
|
+
method=matrix.method,
|
|
915
|
+
algorithm_version=matrix.algorithm_version,
|
|
916
|
+
labels=matrix.labels,
|
|
917
|
+
rows=tuple(rows),
|
|
918
|
+
top_k=resolved_top_k,
|
|
919
|
+
input_sha256=matrix.input_sha256,
|
|
920
|
+
parameters=parameters,
|
|
921
|
+
provenance=provenance,
|
|
922
|
+
raw_output_path=matrix.raw_output_path,
|
|
923
|
+
raw_output_sha256=matrix.raw_output_sha256,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
__all__ = [
|
|
928
|
+
"DEFAULT_MAX_DASHING_CAPTURE_BYTES",
|
|
929
|
+
"DEFAULT_MAX_DASHING_INPUT_BYTES",
|
|
930
|
+
"DEFAULT_MAX_DASHING_ITEMS",
|
|
931
|
+
"DEFAULT_MAX_DASHING_OUTPUT_BYTES",
|
|
932
|
+
"DEFAULT_MAX_DASHING_SKETCH_MEMORY_BYTES",
|
|
933
|
+
"DashingAdapter",
|
|
934
|
+
"DashingInput",
|
|
935
|
+
"DashingMode",
|
|
936
|
+
]
|