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,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
+ ]