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,286 @@
1
+ """Literal exact sequence and subsequence search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Iterator
6
+
7
+ from dnakit.core import DNASequence, DNASet, Strand
8
+ from dnakit.exceptions import ConfigurationError
9
+ from dnakit.similarity._shared import (
10
+ SequenceInput,
11
+ materialize_targets,
12
+ sequence_and_id,
13
+ validate_bool,
14
+ validate_positive_int,
15
+ )
16
+ from dnakit.similarity.results import SearchResult, SequenceMatch
17
+
18
+ DEFAULT_MAX_SEARCH_TARGETS = 100_000
19
+ DEFAULT_MAX_SEARCH_MATCHES = 1_000_000
20
+
21
+
22
+ def _coerce_strand(value: Strand | str) -> Strand:
23
+ try:
24
+ resolved = value if isinstance(value, Strand) else Strand(value)
25
+ except (TypeError, ValueError) as exc:
26
+ raise ConfigurationError(
27
+ "Unknown search strand.",
28
+ code="INVALID_SEARCH_STRAND",
29
+ context={"strand": value},
30
+ hint="Choose 'forward', 'reverse', or 'both'.",
31
+ ) from exc
32
+ if resolved is Strand.UNKNOWN:
33
+ raise ConfigurationError(
34
+ "Search strand cannot be 'unknown'.",
35
+ code="INVALID_SEARCH_STRAND",
36
+ )
37
+ return resolved
38
+
39
+
40
+ def _literal_occurrences(query: str, target: str, *, overlapping: bool) -> Iterator[int]:
41
+ if query == "":
42
+ yield from range(len(target) + 1)
43
+ return
44
+ cursor = 0
45
+ step = 1 if overlapping else len(query)
46
+ while cursor <= len(target) - len(query):
47
+ position = target.find(query, cursor)
48
+ if position < 0:
49
+ break
50
+ yield position
51
+ cursor = position + step
52
+
53
+
54
+ def _orientations(query: DNASequence, strand: Strand) -> tuple[tuple[Strand, str], ...]:
55
+ selected: list[tuple[Strand, str]] = []
56
+ if strand in (Strand.FORWARD, Strand.BOTH):
57
+ selected.append((Strand.FORWARD, query.symbols))
58
+ if strand in (Strand.REVERSE, Strand.BOTH):
59
+ selected.append((Strand.REVERSE, query.reverse_complement().symbols))
60
+ return tuple(selected)
61
+
62
+
63
+ def _ordered_matches(matches: list[SequenceMatch]) -> tuple[SequenceMatch, ...]:
64
+ strand_order = {Strand.FORWARD: 0, Strand.REVERSE: 1, Strand.BOTH: 2}
65
+ return tuple(
66
+ sorted(
67
+ matches,
68
+ key=lambda match: (
69
+ match.target_index,
70
+ match.start,
71
+ match.end,
72
+ strand_order[match.strand],
73
+ ),
74
+ )
75
+ )
76
+
77
+
78
+ class _BoundedMatchCollector:
79
+ """Collect returned matches while enforcing a strict non-truncating limit."""
80
+
81
+ def __init__(self, *, max_matches: int, merge_strands: bool) -> None:
82
+ self._max_matches = max_matches
83
+ self._merge_strands = merge_strands
84
+ self._matches: list[SequenceMatch] = []
85
+ self._groups: dict[tuple[int, str | None, int, int], set[Strand]] = {}
86
+
87
+ def add(self, match: SequenceMatch) -> None:
88
+ if not self._merge_strands:
89
+ if len(self._matches) >= self._max_matches:
90
+ self._raise_limit()
91
+ self._matches.append(match)
92
+ return
93
+ key = (match.target_index, match.target_id, match.start, match.end)
94
+ strands = self._groups.get(key)
95
+ if strands is None:
96
+ if len(self._groups) >= self._max_matches:
97
+ self._raise_limit()
98
+ self._groups[key] = {match.strand}
99
+ else:
100
+ strands.add(match.strand)
101
+
102
+ def materialize(self) -> tuple[SequenceMatch, ...]:
103
+ if not self._merge_strands:
104
+ return _ordered_matches(self._matches)
105
+ matches = [
106
+ SequenceMatch(
107
+ target_index,
108
+ target_id,
109
+ start,
110
+ end,
111
+ Strand.BOTH if len(strands) > 1 else next(iter(strands)),
112
+ )
113
+ for (target_index, target_id, start, end), strands in self._groups.items()
114
+ ]
115
+ return _ordered_matches(matches)
116
+
117
+ def _raise_limit(self) -> None:
118
+ raise ConfigurationError(
119
+ "Search result exceeds max_matches.",
120
+ code="SEARCH_MATCH_LIMIT_EXCEEDED",
121
+ context={
122
+ "match_count": self._max_matches + 1,
123
+ "match_count_is_lower_bound": True,
124
+ "max_matches": self._max_matches,
125
+ },
126
+ hint="Narrow the search or increase max_matches explicitly.",
127
+ )
128
+
129
+
130
+ def exact_search(
131
+ query: SequenceInput,
132
+ targets: SequenceInput | DNASet | Iterable[SequenceInput],
133
+ *,
134
+ reverse_complement: bool = False,
135
+ merge_strands: bool = True,
136
+ max_targets: int = DEFAULT_MAX_SEARCH_TARGETS,
137
+ max_matches: int = DEFAULT_MAX_SEARCH_MATCHES,
138
+ ) -> SearchResult:
139
+ """Find targets whose complete symbol strings equal the query.
140
+
141
+ Record metadata, topology, and strandedness do not change literal symbol
142
+ equality. With ``reverse_complement=True``, a reverse-only match is marked
143
+ ``Strand.REVERSE``; a palindromic hit is merged to ``Strand.BOTH`` by
144
+ default. Empty query matches only an empty target.
145
+ """
146
+
147
+ validate_bool(reverse_complement, "reverse_complement")
148
+ validate_bool(merge_strands, "merge_strands")
149
+ validate_positive_int(max_targets, "max_targets")
150
+ validate_positive_int(max_matches, "max_matches")
151
+ query_sequence, query_id = sequence_and_id(query, role="query")
152
+ target_items = materialize_targets(targets, max_targets=max_targets)
153
+ strand = Strand.BOTH if reverse_complement else Strand.FORWARD
154
+ matches = _BoundedMatchCollector(
155
+ max_matches=max_matches,
156
+ merge_strands=merge_strands,
157
+ )
158
+ for target_index, target_input in enumerate(target_items):
159
+ target, target_id = sequence_and_id(target_input, role=f"target[{target_index}]")
160
+ for orientation, oriented_query in _orientations(query_sequence, strand):
161
+ if oriented_query == target.symbols:
162
+ matches.add(
163
+ SequenceMatch(
164
+ target_index,
165
+ target_id,
166
+ 0,
167
+ target.symbol_length,
168
+ orientation,
169
+ )
170
+ )
171
+ materialized = matches.materialize()
172
+ return SearchResult(
173
+ name="exact_search",
174
+ method="literal_full_sequence_equality",
175
+ query_id=query_id,
176
+ query_length=query_sequence.symbol_length,
177
+ target_count=len(target_items),
178
+ matches=materialized,
179
+ overlapping=False,
180
+ reverse_complement=reverse_complement,
181
+ merge_strands=merge_strands,
182
+ full_length=True,
183
+ max_targets=max_targets,
184
+ max_matches=max_matches,
185
+ )
186
+
187
+
188
+ def subsequence_search(
189
+ query: SequenceInput,
190
+ target: SequenceInput | DNASet | Iterable[SequenceInput],
191
+ *,
192
+ strand: Strand | str = Strand.FORWARD,
193
+ overlapping: bool = True,
194
+ merge_strands: bool = True,
195
+ max_targets: int = DEFAULT_MAX_SEARCH_TARGETS,
196
+ max_matches: int = DEFAULT_MAX_SEARCH_MATCHES,
197
+ ) -> SearchResult:
198
+ """Find literal query occurrences across one or more targets.
199
+
200
+ Coordinates are internal zero-based half-open intervals and never wrap a
201
+ circular origin. IUPAC symbols are ordinary exact symbols: ``N`` matches
202
+ ``N``, not every canonical nucleotide. An empty query matches every target
203
+ boundary ``0..len(target)``.
204
+ """
205
+
206
+ resolved_strand = _coerce_strand(strand)
207
+ validate_bool(overlapping, "overlapping")
208
+ validate_bool(merge_strands, "merge_strands")
209
+ validate_positive_int(max_targets, "max_targets")
210
+ validate_positive_int(max_matches, "max_matches")
211
+ query_sequence, query_id = sequence_and_id(query, role="query")
212
+ target_items = materialize_targets(target, max_targets=max_targets)
213
+ matches = _BoundedMatchCollector(
214
+ max_matches=max_matches,
215
+ merge_strands=merge_strands,
216
+ )
217
+ for target_index, target_input in enumerate(target_items):
218
+ target_sequence, target_id = sequence_and_id(
219
+ target_input,
220
+ role=f"target[{target_index}]",
221
+ )
222
+ for orientation, oriented_query in _orientations(query_sequence, resolved_strand):
223
+ for start in _literal_occurrences(
224
+ oriented_query,
225
+ target_sequence.symbols,
226
+ overlapping=overlapping,
227
+ ):
228
+ matches.add(
229
+ SequenceMatch(
230
+ target_index,
231
+ target_id,
232
+ start,
233
+ start + len(oriented_query),
234
+ orientation,
235
+ )
236
+ )
237
+ materialized = matches.materialize()
238
+ return SearchResult(
239
+ name="subsequence_search",
240
+ method="literal_exact_subsequence",
241
+ query_id=query_id,
242
+ query_length=query_sequence.symbol_length,
243
+ target_count=len(target_items),
244
+ matches=materialized,
245
+ overlapping=overlapping,
246
+ reverse_complement=resolved_strand in (Strand.REVERSE, Strand.BOTH),
247
+ merge_strands=merge_strands,
248
+ full_length=False,
249
+ max_targets=max_targets,
250
+ max_matches=max_matches,
251
+ )
252
+
253
+
254
+ def reverse_complement_search(
255
+ query: SequenceInput,
256
+ target: SequenceInput | DNASet | Iterable[SequenceInput],
257
+ *,
258
+ overlapping: bool = True,
259
+ merge_strands: bool = True,
260
+ max_targets: int = DEFAULT_MAX_SEARCH_TARGETS,
261
+ max_matches: int = DEFAULT_MAX_SEARCH_MATCHES,
262
+ ) -> SearchResult:
263
+ """Search both the supplied query and its reverse complement."""
264
+
265
+ return subsequence_search(
266
+ query,
267
+ target,
268
+ strand=Strand.BOTH,
269
+ overlapping=overlapping,
270
+ merge_strands=merge_strands,
271
+ max_targets=max_targets,
272
+ max_matches=max_matches,
273
+ )
274
+
275
+
276
+ find_subsequence = subsequence_search
277
+
278
+
279
+ __all__ = [
280
+ "DEFAULT_MAX_SEARCH_MATCHES",
281
+ "DEFAULT_MAX_SEARCH_TARGETS",
282
+ "exact_search",
283
+ "find_subsequence",
284
+ "reverse_complement_search",
285
+ "subsequence_search",
286
+ ]
@@ -0,0 +1,96 @@
1
+ """Similarity metrics for compatible DNAKit sketches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from dnakit.core._json import FrozenDict
8
+ from dnakit.exceptions import ConfigurationError
9
+ from dnakit.fingerprints import SketchResult
10
+
11
+ from .results import SketchSimilarityResult
12
+
13
+ SketchMetric = Literal["jaccard", "containment"]
14
+
15
+
16
+ def _validate_compatible(left: SketchResult, right: SketchResult) -> None:
17
+ if not isinstance(left, SketchResult) or not isinstance(right, SketchResult):
18
+ raise ConfigurationError(
19
+ "Sketch comparison requires two SketchResult objects.",
20
+ code="INVALID_SKETCH_INPUT",
21
+ )
22
+ fields = ("schema_version", "k", "canonical", "seed", "selection")
23
+ mismatches = tuple(name for name in fields if getattr(left, name) != getattr(right, name))
24
+ if left.selection == "bottom_k" and left.num_hashes != right.num_hashes:
25
+ mismatches += ("num_hashes",)
26
+ if left.selection == "scaled" and left.scaled != right.scaled:
27
+ mismatches += ("scaled",)
28
+ if mismatches:
29
+ raise ConfigurationError(
30
+ "Sketch schemas are incompatible.",
31
+ code="SKETCH_SCHEMA_MISMATCH",
32
+ context={"fields": mismatches},
33
+ )
34
+
35
+
36
+ def sketch_similarity(
37
+ left: SketchResult,
38
+ right: SketchResult,
39
+ *,
40
+ metric: SketchMetric = "jaccard",
41
+ min_shared_hashes: int = 0,
42
+ ) -> SketchSimilarityResult:
43
+ """Compare compatible sketches as sets, with explicit empty-set semantics."""
44
+
45
+ _validate_compatible(left, right)
46
+ if metric not in {"jaccard", "containment"}:
47
+ raise ConfigurationError("Unknown sketch metric.", code="UNKNOWN_SKETCH_METRIC")
48
+ if (
49
+ isinstance(min_shared_hashes, bool)
50
+ or not isinstance(min_shared_hashes, int)
51
+ or min_shared_hashes < 0
52
+ ):
53
+ raise ConfigurationError(
54
+ "min_shared_hashes must be a non-negative integer.",
55
+ code="INVALID_MIN_SHARED_HASHES",
56
+ )
57
+ left_set = set(left.hashes)
58
+ right_set = set(right.hashes)
59
+ shared = len(left_set & right_set)
60
+ union = len(left_set | right_set)
61
+ if metric == "jaccard":
62
+ value = shared / union if union else 1.0
63
+ denominator = union
64
+ else:
65
+ denominator = len(left_set)
66
+ value = shared / denominator if denominator else (1.0 if not right_set else 0.0)
67
+ passed = shared >= min_shared_hashes
68
+ return SketchSimilarityResult(
69
+ name="sketch_similarity",
70
+ method=f"set-{metric}",
71
+ value=value,
72
+ metric=metric,
73
+ left_id=left.sequence_id,
74
+ right_id=right.sequence_id,
75
+ left_hash_count=len(left_set),
76
+ right_hash_count=len(right_set),
77
+ shared_hash_count=shared,
78
+ union_hash_count=union,
79
+ denominator=denominator,
80
+ passed_min_shared_hashes=passed,
81
+ parameters=FrozenDict(
82
+ {
83
+ "schema_version": left.schema_version,
84
+ "k": left.k,
85
+ "canonical": left.canonical,
86
+ "seed": left.seed,
87
+ "selection": left.selection,
88
+ "num_hashes": left.num_hashes,
89
+ "scaled": left.scaled,
90
+ "min_shared_hashes": min_shared_hashes,
91
+ }
92
+ ),
93
+ )
94
+
95
+
96
+ __all__ = ["SketchMetric", "sketch_similarity"]