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