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,279 @@
1
+ """Literal Hamming and weighted Levenshtein distances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dnakit.core._json import FrozenDict
6
+ from dnakit.exceptions import ConfigurationError
7
+ from dnakit.similarity._shared import (
8
+ SequenceInput,
9
+ sequence_and_id,
10
+ validate_bool,
11
+ validate_non_negative_number,
12
+ validate_positive_int,
13
+ )
14
+ from dnakit.similarity.results import DistanceResult, EditStep, Mismatch
15
+
16
+ DEFAULT_MAX_EDIT_CELLS = 5_000_000
17
+
18
+ _DIAGONAL = 0
19
+ _DELETE = 1
20
+ _INSERT = 2
21
+
22
+
23
+ def _validate_max_distance(value: int | float | None) -> float | None:
24
+ if value is None:
25
+ return None
26
+ return validate_non_negative_number(value, "max_distance")
27
+
28
+
29
+ def hamming_distance(
30
+ left: SequenceInput,
31
+ right: SequenceInput,
32
+ *,
33
+ max_distance: int | float | None = None,
34
+ ) -> DistanceResult:
35
+ """Count literal symbol differences between two equal-length sequences.
36
+
37
+ Every IUPAC symbol compares as itself: for example, ``N == N`` and
38
+ ``N != A``. Explicit gaps are rejected rather than ignored.
39
+ """
40
+
41
+ left_sequence, left_id = sequence_and_id(left, role="left")
42
+ right_sequence, right_id = sequence_and_id(right, role="right")
43
+ if left_sequence.symbol_length != right_sequence.symbol_length:
44
+ raise ConfigurationError(
45
+ "Hamming distance requires equal symbol lengths.",
46
+ code="HAMMING_LENGTH_MISMATCH",
47
+ context={
48
+ "left_length": left_sequence.symbol_length,
49
+ "right_length": right_sequence.symbol_length,
50
+ },
51
+ )
52
+ maximum = _validate_max_distance(max_distance)
53
+ mismatches = tuple(
54
+ Mismatch(position, left_symbol, right_symbol)
55
+ for position, (left_symbol, right_symbol) in enumerate(
56
+ zip(left_sequence.symbols, right_sequence.symbols, strict=True)
57
+ )
58
+ if left_symbol != right_symbol
59
+ )
60
+ distance = float(len(mismatches))
61
+ return DistanceResult(
62
+ name="hamming_distance",
63
+ method="hamming",
64
+ left_id=left_id,
65
+ right_id=right_id,
66
+ left_length=left_sequence.symbol_length,
67
+ right_length=right_sequence.symbol_length,
68
+ distance=distance,
69
+ mismatches=mismatches,
70
+ edit_path=None,
71
+ costs=FrozenDict({"substitution": 1.0}),
72
+ max_distance=maximum,
73
+ exceeded_max_distance=maximum is not None and distance > maximum,
74
+ )
75
+
76
+
77
+ def _levenshtein_rows(
78
+ left: str,
79
+ right: str,
80
+ *,
81
+ substitution_cost: float,
82
+ insertion_cost: float,
83
+ deletion_cost: float,
84
+ ) -> float:
85
+ previous = [column * insertion_cost for column in range(len(right) + 1)]
86
+ for row, left_symbol in enumerate(left, start=1):
87
+ current = [row * deletion_cost]
88
+ for column, right_symbol in enumerate(right, start=1):
89
+ diagonal_cost = 0.0 if left_symbol == right_symbol else substitution_cost
90
+ current.append(
91
+ min(
92
+ previous[column - 1] + diagonal_cost,
93
+ previous[column] + deletion_cost,
94
+ current[column - 1] + insertion_cost,
95
+ )
96
+ )
97
+ previous = current
98
+ return previous[-1]
99
+
100
+
101
+ def _levenshtein_with_path(
102
+ left: str,
103
+ right: str,
104
+ *,
105
+ substitution_cost: float,
106
+ insertion_cost: float,
107
+ deletion_cost: float,
108
+ ) -> tuple[float, tuple[EditStep, ...]]:
109
+ rows = len(left) + 1
110
+ columns = len(right) + 1
111
+ table = [[0.0] * columns for _ in range(rows)]
112
+ predecessors = [bytearray(columns) for _ in range(rows)]
113
+ for row in range(1, rows):
114
+ table[row][0] = row * deletion_cost
115
+ predecessors[row][0] = _DELETE
116
+ for column in range(1, columns):
117
+ table[0][column] = column * insertion_cost
118
+ predecessors[0][column] = _INSERT
119
+ for row in range(1, rows):
120
+ for column in range(1, columns):
121
+ diagonal_cost = 0.0 if left[row - 1] == right[column - 1] else substitution_cost
122
+ best_cost = table[row - 1][column - 1] + diagonal_cost
123
+ predecessor = _DIAGONAL
124
+ deletion_candidate = table[row - 1][column] + deletion_cost
125
+ if deletion_candidate < best_cost:
126
+ best_cost = deletion_candidate
127
+ predecessor = _DELETE
128
+ insertion_candidate = table[row][column - 1] + insertion_cost
129
+ if insertion_candidate < best_cost:
130
+ best_cost = insertion_candidate
131
+ predecessor = _INSERT
132
+ table[row][column] = best_cost
133
+ predecessors[row][column] = predecessor
134
+
135
+ steps: list[EditStep] = []
136
+ row, column = len(left), len(right)
137
+ while row or column:
138
+ predecessor = predecessors[row][column]
139
+ if predecessor == _DIAGONAL and row and column:
140
+ is_match = left[row - 1] == right[column - 1]
141
+ steps.append(
142
+ EditStep(
143
+ "match" if is_match else "substitute",
144
+ row - 1,
145
+ row,
146
+ column - 1,
147
+ column,
148
+ left[row - 1],
149
+ right[column - 1],
150
+ )
151
+ )
152
+ row -= 1
153
+ column -= 1
154
+ continue
155
+ if predecessor == _DELETE and row:
156
+ steps.append(
157
+ EditStep(
158
+ "delete",
159
+ row - 1,
160
+ row,
161
+ column,
162
+ column,
163
+ left[row - 1],
164
+ None,
165
+ )
166
+ )
167
+ row -= 1
168
+ continue
169
+ if predecessor == _INSERT and column:
170
+ steps.append(
171
+ EditStep(
172
+ "insert",
173
+ row,
174
+ row,
175
+ column - 1,
176
+ column,
177
+ None,
178
+ right[column - 1],
179
+ )
180
+ )
181
+ column -= 1
182
+ continue
183
+ raise AssertionError("Levenshtein traceback could not resolve a predecessor.")
184
+ steps.reverse()
185
+ return table[-1][-1], tuple(steps)
186
+
187
+
188
+ def edit_distance(
189
+ left: SequenceInput,
190
+ right: SequenceInput,
191
+ *,
192
+ substitution_cost: int | float = 1,
193
+ insertion_cost: int | float = 1,
194
+ deletion_cost: int | float = 1,
195
+ max_distance: int | float | None = None,
196
+ return_path: bool = False,
197
+ max_cells: int = DEFAULT_MAX_EDIT_CELLS,
198
+ ) -> DistanceResult:
199
+ """Compute weighted literal Levenshtein distance by dynamic programming.
200
+
201
+ ``max_distance`` is an auditable threshold, not an approximation: DNAKit
202
+ still returns the exact distance and records whether it exceeds the limit.
203
+ Without a requested path, memory is linear in the right sequence length.
204
+ Both modes refuse more than ``max_cells`` dynamic-programming cells before
205
+ allocating a table or starting the recurrence.
206
+ """
207
+
208
+ left_sequence, left_id = sequence_and_id(left, role="left")
209
+ right_sequence, right_id = sequence_and_id(right, role="right")
210
+ substitution = validate_non_negative_number(substitution_cost, "substitution_cost")
211
+ insertion = validate_non_negative_number(insertion_cost, "insertion_cost")
212
+ deletion = validate_non_negative_number(deletion_cost, "deletion_cost")
213
+ maximum = _validate_max_distance(max_distance)
214
+ validate_bool(return_path, "return_path")
215
+ validate_positive_int(max_cells, "max_cells")
216
+ dp_cells = (left_sequence.symbol_length + 1) * (right_sequence.symbol_length + 1)
217
+ if dp_cells > max_cells:
218
+ raise ConfigurationError(
219
+ "Edit-distance dynamic-programming input exceeds max_cells.",
220
+ code="EDIT_DISTANCE_CELL_LIMIT",
221
+ context={
222
+ "left_length": left_sequence.symbol_length,
223
+ "right_length": right_sequence.symbol_length,
224
+ "dp_cells": dp_cells,
225
+ "max_cells": max_cells,
226
+ "return_path": return_path,
227
+ },
228
+ hint="Raise max_cells deliberately or compare shorter sequences.",
229
+ )
230
+ if return_path:
231
+ distance, path = _levenshtein_with_path(
232
+ left_sequence.symbols,
233
+ right_sequence.symbols,
234
+ substitution_cost=substitution,
235
+ insertion_cost=insertion,
236
+ deletion_cost=deletion,
237
+ )
238
+ else:
239
+ distance = _levenshtein_rows(
240
+ left_sequence.symbols,
241
+ right_sequence.symbols,
242
+ substitution_cost=substitution,
243
+ insertion_cost=insertion,
244
+ deletion_cost=deletion,
245
+ )
246
+ path = None
247
+ return DistanceResult(
248
+ name="edit_distance",
249
+ method="levenshtein",
250
+ left_id=left_id,
251
+ right_id=right_id,
252
+ left_length=left_sequence.symbol_length,
253
+ right_length=right_sequence.symbol_length,
254
+ distance=distance,
255
+ mismatches=(),
256
+ edit_path=path,
257
+ costs=FrozenDict(
258
+ {
259
+ "substitution": substitution,
260
+ "insertion": insertion,
261
+ "deletion": deletion,
262
+ }
263
+ ),
264
+ max_distance=maximum,
265
+ exceeded_max_distance=maximum is not None and distance > maximum,
266
+ max_cells=max_cells,
267
+ dp_cells=dp_cells,
268
+ )
269
+
270
+
271
+ levenshtein_distance = edit_distance
272
+
273
+
274
+ __all__ = [
275
+ "DEFAULT_MAX_EDIT_CELLS",
276
+ "edit_distance",
277
+ "hamming_distance",
278
+ "levenshtein_distance",
279
+ ]
@@ -0,0 +1,365 @@
1
+ """Deterministic in-memory sketch index with integrity-checked JSON persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import tempfile
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, cast
13
+
14
+ from dnakit.core import DNA, DNARecord, DNASet
15
+ from dnakit.core._json import FrozenDict, to_json_compatible
16
+ from dnakit.exceptions import ConfigurationError, InputFormatError
17
+ from dnakit.fingerprints import SketchResult, minhash
18
+
19
+ from .results import NearestNeighborHit, NearestNeighborResult
20
+ from .sketch import sketch_similarity
21
+
22
+ _INDEX_SCHEMA = "dnakit.sketch-index.v1"
23
+ DEFAULT_MAX_INDEX_HASHES = 10_000_000
24
+ DEFAULT_MAX_INDEX_FILE_BYTES = 512 * 1024 * 1024
25
+
26
+
27
+ def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
28
+ result: dict[str, object] = {}
29
+ for key, value in pairs:
30
+ if key in result:
31
+ raise InputFormatError(
32
+ "Sketch index contains a duplicate JSON key.",
33
+ code="INVALID_SKETCH_INDEX",
34
+ context={"key": key},
35
+ )
36
+ result[key] = value
37
+ return result
38
+
39
+
40
+ def _canonical_json(value: object) -> bytes:
41
+ return json.dumps(
42
+ value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
43
+ ).encode("utf-8")
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SketchIndex:
48
+ """Versioned exact scan index over compatible bottom-k sketches."""
49
+
50
+ ids: tuple[str, ...]
51
+ sketches: tuple[SketchResult, ...]
52
+ k: int
53
+ num_hashes: int
54
+ canonical: bool
55
+ seed: int
56
+ schema_version: str = _INDEX_SCHEMA
57
+
58
+ def __post_init__(self) -> None:
59
+ if self.schema_version != _INDEX_SCHEMA:
60
+ raise ConfigurationError("Unsupported sketch index schema.")
61
+ if not isinstance(self.ids, tuple) or not isinstance(self.sketches, tuple):
62
+ raise ConfigurationError("Sketch index IDs and sketches must be immutable tuples.")
63
+ if len(self.ids) != len(self.sketches) or len(set(self.ids)) != len(self.ids):
64
+ raise ConfigurationError("Sketch index IDs must be unique and align with sketches.")
65
+ if any(not isinstance(item, str) or not item.strip() for item in self.ids):
66
+ raise ConfigurationError("Sketch index IDs must be non-empty strings.")
67
+ if any(not isinstance(item, SketchResult) for item in self.sketches):
68
+ raise ConfigurationError("Sketch index entries must be SketchResult objects.")
69
+ for name in ("k", "num_hashes"):
70
+ value = getattr(self, name)
71
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
72
+ raise ConfigurationError(f"Sketch index {name} must be a positive integer.")
73
+ if not isinstance(self.canonical, bool):
74
+ raise ConfigurationError("Sketch index canonical must be boolean.")
75
+ if (
76
+ isinstance(self.seed, bool)
77
+ or not isinstance(self.seed, int)
78
+ or not 0 <= self.seed < 2**64
79
+ ):
80
+ raise ConfigurationError("Sketch index seed must be an unsigned 64-bit integer.")
81
+ total_hashes = sum(len(item.hashes) for item in self.sketches)
82
+ if total_hashes > DEFAULT_MAX_INDEX_HASHES:
83
+ raise ConfigurationError(
84
+ "Sketch index exceeds the hard total-hash limit.",
85
+ code="INDEX_HASH_LIMIT_EXCEEDED",
86
+ context={
87
+ "total_hashes": total_hashes,
88
+ "hard_limit": DEFAULT_MAX_INDEX_HASHES,
89
+ },
90
+ )
91
+ for item in self.sketches:
92
+ if (
93
+ item.selection != "bottom_k"
94
+ or item.k != self.k
95
+ or item.num_hashes != self.num_hashes
96
+ or item.canonical != self.canonical
97
+ or item.seed != self.seed
98
+ ):
99
+ raise ConfigurationError("Sketch index contains an incompatible entry.")
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ return cast(dict[str, Any], to_json_compatible(self))
103
+
104
+
105
+ def build_sketch_index(
106
+ records: DNA | DNASet | Iterable[DNARecord],
107
+ *,
108
+ k: int = 15,
109
+ num_hashes: int = 1_000,
110
+ canonical: bool = True,
111
+ seed: int = 0,
112
+ max_records: int = 100_000,
113
+ ) -> SketchIndex:
114
+ """Build a bounded, deterministic bottom-k index in input order."""
115
+
116
+ if isinstance(max_records, bool) or not isinstance(max_records, int) or max_records <= 0:
117
+ raise ConfigurationError("max_records must be a positive integer.")
118
+ if isinstance(k, bool) or not isinstance(k, int) or k <= 0:
119
+ raise ConfigurationError("k must be a positive integer.")
120
+ if isinstance(num_hashes, bool) or not isinstance(num_hashes, int) or num_hashes <= 0:
121
+ raise ConfigurationError("num_hashes must be a positive integer.")
122
+ if not isinstance(canonical, bool):
123
+ raise ConfigurationError("canonical must be boolean.")
124
+ if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed < 2**64:
125
+ raise ConfigurationError("seed must be an unsigned 64-bit integer.")
126
+ try:
127
+ source = records.records if isinstance(records, (DNA, DNASet)) else iter(records)
128
+ except TypeError as exc:
129
+ raise ConfigurationError("records must be DNASet or an iterable of DNARecord.") from exc
130
+ materialized: list[DNARecord] = []
131
+ for index, record in enumerate(source):
132
+ if not isinstance(record, DNARecord):
133
+ raise ConfigurationError(
134
+ "Index inputs must be DNARecord objects.",
135
+ code="INVALID_INDEX_RECORD",
136
+ context={"record_index": index},
137
+ )
138
+ if index >= max_records:
139
+ raise ConfigurationError(
140
+ "Index input exceeds max_records.",
141
+ code="INDEX_RECORD_LIMIT_EXCEEDED",
142
+ context={"record_count_lower_bound": max_records + 1},
143
+ )
144
+ materialized.append(record)
145
+ ids = tuple(record.id for record in materialized)
146
+ if len(set(ids)) != len(ids):
147
+ raise ConfigurationError("Index record IDs must be unique.", code="DUPLICATE_INDEX_ID")
148
+ sketches_list: list[SketchResult] = []
149
+ total_hashes = 0
150
+ for record in materialized:
151
+ sketch = minhash(record, k=k, num_hashes=num_hashes, canonical=canonical, seed=seed)
152
+ total_hashes += len(sketch.hashes)
153
+ if total_hashes > DEFAULT_MAX_INDEX_HASHES:
154
+ raise ConfigurationError(
155
+ "Sketch index exceeds the hard total-hash limit.",
156
+ code="INDEX_HASH_LIMIT_EXCEEDED",
157
+ context={"total_hashes_lower_bound": total_hashes},
158
+ )
159
+ sketches_list.append(sketch)
160
+ sketches = tuple(sketches_list)
161
+ return SketchIndex(ids, sketches, k, num_hashes, canonical, seed)
162
+
163
+
164
+ def nearest_neighbors(
165
+ query: DNA | DNARecord | SketchResult,
166
+ index: SketchIndex,
167
+ *,
168
+ top_k: int = 10,
169
+ min_similarity: float = 0.0,
170
+ ) -> NearestNeighborResult:
171
+ """Return stable top-k Jaccard hits from an in-memory sketch index."""
172
+
173
+ if not isinstance(index, SketchIndex):
174
+ raise ConfigurationError("index must be SketchIndex.", code="INVALID_SKETCH_INDEX")
175
+ if isinstance(top_k, bool) or not isinstance(top_k, int) or top_k <= 0:
176
+ raise ConfigurationError("top_k must be a positive integer.")
177
+ if (
178
+ isinstance(min_similarity, bool)
179
+ or not isinstance(min_similarity, (int, float))
180
+ or not 0 <= min_similarity <= 1
181
+ ):
182
+ raise ConfigurationError("min_similarity must be in [0, 1].")
183
+ sketch = (
184
+ query
185
+ if isinstance(query, SketchResult)
186
+ else minhash(
187
+ query,
188
+ k=index.k,
189
+ num_hashes=index.num_hashes,
190
+ canonical=index.canonical,
191
+ seed=index.seed,
192
+ )
193
+ )
194
+ scored: list[NearestNeighborHit] = []
195
+ for position, (record_id, target) in enumerate(zip(index.ids, index.sketches, strict=True)):
196
+ comparison = sketch_similarity(sketch, target)
197
+ if comparison.value >= min_similarity:
198
+ scored.append(
199
+ NearestNeighborHit(
200
+ record_id=record_id,
201
+ index=position,
202
+ similarity=comparison.value,
203
+ shared_hash_count=comparison.shared_hash_count,
204
+ )
205
+ )
206
+ scored.sort(key=lambda item: (-item.similarity, -item.shared_hash_count, item.index))
207
+ return NearestNeighborResult(
208
+ name="nearest_neighbors",
209
+ method="exact-scan-minhash-jaccard",
210
+ algorithm_version="dnakit-sketch-nearest-neighbor-v1",
211
+ query_id=sketch.sequence_id,
212
+ index_size=len(index.ids),
213
+ hits=tuple(scored[:top_k]),
214
+ top_k=top_k,
215
+ min_similarity=float(min_similarity),
216
+ parameters=FrozenDict(
217
+ {
218
+ "index_schema": index.schema_version,
219
+ "k": index.k,
220
+ "num_hashes": index.num_hashes,
221
+ "canonical": index.canonical,
222
+ "seed": index.seed,
223
+ "tie_break": "similarity-desc,shared-desc,input-index-asc",
224
+ }
225
+ ),
226
+ )
227
+
228
+
229
+ def save_sketch_index(
230
+ index: SketchIndex,
231
+ path: str | os.PathLike[str],
232
+ *,
233
+ overwrite: bool = False,
234
+ ) -> str:
235
+ """Atomically persist a self-checksummed index and return its SHA-256."""
236
+
237
+ if not isinstance(index, SketchIndex):
238
+ raise ConfigurationError("index must be SketchIndex.", code="INVALID_SKETCH_INDEX")
239
+ if not isinstance(overwrite, bool):
240
+ raise ConfigurationError("overwrite must be boolean.")
241
+ target = Path(path).expanduser().absolute()
242
+ if target.exists() and not overwrite:
243
+ raise ConfigurationError("Index target exists.", code="INDEX_EXISTS")
244
+ target.parent.mkdir(parents=True, exist_ok=True)
245
+ payload = index.to_dict()
246
+ digest = hashlib.sha256(_canonical_json(payload)).hexdigest()
247
+ envelope = {"schema_version": _INDEX_SCHEMA, "sha256": digest, "payload": payload}
248
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
249
+ try:
250
+ with os.fdopen(descriptor, "wb") as handle:
251
+ handle.write(_canonical_json(envelope) + b"\n")
252
+ handle.flush()
253
+ os.fsync(handle.fileno())
254
+ if overwrite:
255
+ os.replace(temporary_name, target)
256
+ else:
257
+ try:
258
+ os.link(temporary_name, target)
259
+ except FileExistsError as exc:
260
+ raise ConfigurationError("Index target exists.", code="INDEX_EXISTS") from exc
261
+ Path(temporary_name).unlink()
262
+ except BaseException:
263
+ Path(temporary_name).unlink(missing_ok=True)
264
+ raise
265
+ return digest
266
+
267
+
268
+ def _sketch_from_payload(payload: object) -> SketchResult:
269
+ if not isinstance(payload, dict):
270
+ raise InputFormatError("Invalid sketch index entry.", code="INVALID_SKETCH_INDEX")
271
+ try:
272
+ return SketchResult(
273
+ name=payload["name"],
274
+ method=payload["method"],
275
+ schema_version=payload["schema_version"],
276
+ sequence_id=payload.get("sequence_id"),
277
+ symbol_length=payload["symbol_length"],
278
+ k=payload["k"],
279
+ canonical=payload["canonical"],
280
+ seed=payload["seed"],
281
+ selection=payload["selection"],
282
+ hashes=tuple(payload["hashes"]),
283
+ num_hashes=payload.get("num_hashes"),
284
+ scaled=payload.get("scaled"),
285
+ threshold=payload.get("threshold"),
286
+ observation_count=payload["observation_count"],
287
+ unique_hash_count=payload["unique_hash_count"],
288
+ max_hashes=payload["max_hashes"],
289
+ parameters=FrozenDict(payload["parameters"]),
290
+ )
291
+ except (KeyError, TypeError, ValueError, ConfigurationError) as exc:
292
+ raise InputFormatError("Invalid sketch index entry.", code="INVALID_SKETCH_INDEX") from exc
293
+
294
+
295
+ def load_sketch_index(path: str | os.PathLike[str]) -> SketchIndex:
296
+ """Load and verify a DNAKit sketch index."""
297
+
298
+ target = Path(path).expanduser().absolute()
299
+ if not target.is_file() or target.is_symlink():
300
+ raise InputFormatError("Index path must be a regular file.", code="INVALID_SKETCH_INDEX")
301
+ if target.stat().st_size > DEFAULT_MAX_INDEX_FILE_BYTES:
302
+ raise InputFormatError(
303
+ "Sketch index file exceeds the hard byte limit.",
304
+ code="SKETCH_INDEX_FILE_LIMIT",
305
+ context={
306
+ "byte_size": target.stat().st_size,
307
+ "hard_limit": DEFAULT_MAX_INDEX_FILE_BYTES,
308
+ },
309
+ )
310
+ try:
311
+ with target.open("rb") as handle:
312
+ raw = handle.read(DEFAULT_MAX_INDEX_FILE_BYTES + 1)
313
+ if len(raw) > DEFAULT_MAX_INDEX_FILE_BYTES:
314
+ raise InputFormatError(
315
+ "Sketch index grew beyond the hard byte limit while being read.",
316
+ code="SKETCH_INDEX_FILE_LIMIT",
317
+ context={"hard_limit": DEFAULT_MAX_INDEX_FILE_BYTES},
318
+ )
319
+ envelope = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
320
+ if not isinstance(envelope, dict) or envelope.get("schema_version") != _INDEX_SCHEMA:
321
+ raise ValueError("unsupported envelope schema")
322
+ payload = envelope["payload"]
323
+ expected = envelope["sha256"]
324
+ except InputFormatError:
325
+ raise
326
+ except (
327
+ OSError,
328
+ UnicodeError,
329
+ json.JSONDecodeError,
330
+ KeyError,
331
+ TypeError,
332
+ ValueError,
333
+ RecursionError,
334
+ ) as exc:
335
+ raise InputFormatError("Cannot decode sketch index.", code="INVALID_SKETCH_INDEX") from exc
336
+ actual = hashlib.sha256(_canonical_json(payload)).hexdigest()
337
+ if not isinstance(expected, str) or actual != expected:
338
+ raise InputFormatError("Sketch index checksum mismatch.", code="SKETCH_INDEX_CHECKSUM")
339
+ if not isinstance(payload, dict) or payload.get("schema_version") != _INDEX_SCHEMA:
340
+ raise InputFormatError("Unsupported sketch index schema.", code="INVALID_SKETCH_INDEX")
341
+ try:
342
+ return SketchIndex(
343
+ ids=tuple(payload["ids"]),
344
+ sketches=tuple(_sketch_from_payload(item) for item in payload["sketches"]),
345
+ k=payload["k"],
346
+ num_hashes=payload["num_hashes"],
347
+ canonical=payload["canonical"],
348
+ seed=payload["seed"],
349
+ schema_version=payload["schema_version"],
350
+ )
351
+ except (KeyError, TypeError, ValueError, ConfigurationError) as exc:
352
+ raise InputFormatError(
353
+ "Invalid sketch index payload.", code="INVALID_SKETCH_INDEX"
354
+ ) from exc
355
+
356
+
357
+ __all__ = [
358
+ "DEFAULT_MAX_INDEX_FILE_BYTES",
359
+ "DEFAULT_MAX_INDEX_HASHES",
360
+ "SketchIndex",
361
+ "build_sketch_index",
362
+ "load_sketch_index",
363
+ "nearest_neighbors",
364
+ "save_sketch_index",
365
+ ]