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,676 @@
1
+ """Immutable structured results for native similarity operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Any, Literal, TypeAlias, cast
9
+
10
+ from dnakit.core import Provenance, Strand
11
+ from dnakit.core._json import FrozenDict, to_json_compatible
12
+ from dnakit.exceptions import ConfigurationError
13
+
14
+ DistanceKind: TypeAlias = Literal["hamming", "levenshtein"]
15
+ EditOperation: TypeAlias = Literal["match", "substitute", "insert", "delete"]
16
+ ValueKind: TypeAlias = Literal["similarity", "distance"]
17
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
18
+
19
+
20
+ class _SerializableResult:
21
+ def to_dict(self) -> dict[str, Any]:
22
+ """Return a plain JSON-compatible representation."""
23
+
24
+ return cast(dict[str, Any], to_json_compatible(self))
25
+
26
+
27
+ def _non_negative_int(value: object, name: str) -> None:
28
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
29
+ raise ConfigurationError(f"{name} must be a non-negative integer.")
30
+
31
+
32
+ def _optional_identifier(value: str | None, name: str) -> None:
33
+ if value is not None and (not isinstance(value, str) or not value.strip()):
34
+ raise ConfigurationError(f"{name} must be a non-empty string or None.")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class SequenceMatch(_SerializableResult):
39
+ """One exact hit in target 0-based, half-open coordinates."""
40
+
41
+ target_index: int
42
+ target_id: str | None
43
+ start: int
44
+ end: int
45
+ strand: Strand
46
+
47
+ def __post_init__(self) -> None:
48
+ _non_negative_int(self.target_index, "target_index")
49
+ _optional_identifier(self.target_id, "target_id")
50
+ _non_negative_int(self.start, "start")
51
+ _non_negative_int(self.end, "end")
52
+ if self.end < self.start:
53
+ raise ConfigurationError("SequenceMatch end cannot be smaller than start.")
54
+ if not isinstance(self.strand, Strand):
55
+ raise ConfigurationError("SequenceMatch strand must be Strand.")
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ApproximateMatch(SequenceMatch):
60
+ """One approximate substring hit with its weighted edit distance."""
61
+
62
+ distance: float
63
+
64
+ def __post_init__(self) -> None:
65
+ super().__post_init__()
66
+ if (
67
+ isinstance(self.distance, bool)
68
+ or not isinstance(self.distance, (int, float))
69
+ or not math.isfinite(self.distance)
70
+ or self.distance < 0
71
+ ):
72
+ raise ConfigurationError("Approximate match distance must be non-negative and finite.")
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class SearchResult(_SerializableResult):
77
+ """Exact full-sequence or exact-subsequence search result."""
78
+
79
+ name: str
80
+ method: str
81
+ query_id: str | None
82
+ query_length: int
83
+ target_count: int
84
+ matches: tuple[SequenceMatch, ...]
85
+ overlapping: bool
86
+ reverse_complement: bool
87
+ merge_strands: bool
88
+ full_length: bool
89
+ max_targets: int
90
+ max_matches: int
91
+ iupac_matching: Literal["literal"] = "literal"
92
+ coordinate_system: Literal["0-based-half-open"] = "0-based-half-open"
93
+ circular_wrap: bool = False
94
+
95
+ def __post_init__(self) -> None:
96
+ for name in ("name", "method"):
97
+ value = getattr(self, name)
98
+ if not isinstance(value, str) or not value.strip():
99
+ raise ConfigurationError(f"SearchResult {name} must be non-empty.")
100
+ _optional_identifier(self.query_id, "query_id")
101
+ _non_negative_int(self.query_length, "query_length")
102
+ _non_negative_int(self.target_count, "target_count")
103
+ if any(not isinstance(match, SequenceMatch) for match in self.matches):
104
+ raise ConfigurationError("SearchResult matches must all be SequenceMatch objects.")
105
+ if any(match.target_index >= self.target_count for match in self.matches):
106
+ raise ConfigurationError("A search match target_index is outside target_count.")
107
+ for name in ("overlapping", "reverse_complement", "merge_strands", "full_length"):
108
+ if not isinstance(getattr(self, name), bool):
109
+ raise ConfigurationError(f"SearchResult {name} must be boolean.")
110
+ for name in ("max_targets", "max_matches"):
111
+ value = getattr(self, name)
112
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
113
+ raise ConfigurationError(f"SearchResult {name} must be a positive integer.")
114
+ if self.target_count > self.max_targets:
115
+ raise ConfigurationError("SearchResult target_count cannot exceed max_targets.")
116
+ if len(self.matches) > self.max_matches:
117
+ raise ConfigurationError("SearchResult matches cannot exceed max_matches.")
118
+ if self.iupac_matching != "literal":
119
+ raise ConfigurationError("Only literal IUPAC matching is implemented in the MVP.")
120
+ if self.coordinate_system != "0-based-half-open":
121
+ raise ConfigurationError("Internal search coordinates must be 0-based half-open.")
122
+ if self.circular_wrap:
123
+ raise ConfigurationError("Circular origin-wrapping search is not implemented in MVP.")
124
+
125
+ @property
126
+ def found(self) -> bool:
127
+ return bool(self.matches)
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class ApproximateSearchResult(_SerializableResult):
132
+ """Audited collection of bounded approximate substring matches."""
133
+
134
+ name: str
135
+ method: str
136
+ algorithm_version: str
137
+ query_id: str | None
138
+ query_length: int
139
+ target_count: int
140
+ matches: tuple[ApproximateMatch, ...]
141
+ max_distance: float
142
+ reverse_complement: bool
143
+ max_targets: int
144
+ max_matches: int
145
+ max_cells: int
146
+ dp_cells: int
147
+ parameters: FrozenDict
148
+
149
+ def __post_init__(self) -> None:
150
+ for name in ("name", "method", "algorithm_version"):
151
+ if not isinstance(getattr(self, name), str) or not getattr(self, name).strip():
152
+ raise ConfigurationError(f"ApproximateSearchResult {name} must be non-empty.")
153
+ _optional_identifier(self.query_id, "query_id")
154
+ for name in ("query_length", "target_count", "dp_cells"):
155
+ _non_negative_int(getattr(self, name), name)
156
+ for name in ("max_targets", "max_matches", "max_cells"):
157
+ value = getattr(self, name)
158
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
159
+ raise ConfigurationError(f"{name} must be a positive integer.")
160
+ if any(not isinstance(item, ApproximateMatch) for item in self.matches):
161
+ raise ConfigurationError("matches must contain ApproximateMatch objects.")
162
+ if any(item.target_index >= self.target_count for item in self.matches):
163
+ raise ConfigurationError("Approximate match target index is outside target_count.")
164
+ if any(item.distance > self.max_distance for item in self.matches):
165
+ raise ConfigurationError("Approximate match exceeds max_distance.")
166
+ if self.dp_cells > self.max_cells or len(self.matches) > self.max_matches:
167
+ raise ConfigurationError("Approximate result exceeds a declared resource limit.")
168
+ if not isinstance(self.reverse_complement, bool):
169
+ raise ConfigurationError("reverse_complement must be boolean.")
170
+
171
+ @property
172
+ def found(self) -> bool:
173
+ return bool(self.matches)
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class Mismatch(_SerializableResult):
178
+ """One literal symbol mismatch at a shared sequence position."""
179
+
180
+ position: int
181
+ left_symbol: str
182
+ right_symbol: str
183
+
184
+ def __post_init__(self) -> None:
185
+ _non_negative_int(self.position, "position")
186
+ if len(self.left_symbol) != 1 or len(self.right_symbol) != 1:
187
+ raise ConfigurationError("Mismatch symbols must each contain one character.")
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class EditStep(_SerializableResult):
192
+ """One deterministic Levenshtein traceback step."""
193
+
194
+ operation: EditOperation
195
+ left_start: int
196
+ left_end: int
197
+ right_start: int
198
+ right_end: int
199
+ left_symbol: str | None
200
+ right_symbol: str | None
201
+
202
+ def __post_init__(self) -> None:
203
+ if self.operation not in ("match", "substitute", "insert", "delete"):
204
+ raise ConfigurationError("Unknown edit-path operation.")
205
+ for name in ("left_start", "left_end", "right_start", "right_end"):
206
+ _non_negative_int(getattr(self, name), name)
207
+ if self.left_end < self.left_start or self.right_end < self.right_start:
208
+ raise ConfigurationError("EditStep coordinates must be ordered.")
209
+ for name in ("left_symbol", "right_symbol"):
210
+ symbol = getattr(self, name)
211
+ if symbol is not None and (not isinstance(symbol, str) or len(symbol) != 1):
212
+ raise ConfigurationError(f"EditStep {name} must be one character or None.")
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class DistanceResult(_SerializableResult):
217
+ """Hamming or weighted Levenshtein distance with optional details."""
218
+
219
+ name: str
220
+ method: DistanceKind
221
+ left_id: str | None
222
+ right_id: str | None
223
+ left_length: int
224
+ right_length: int
225
+ distance: float
226
+ mismatches: tuple[Mismatch, ...]
227
+ edit_path: tuple[EditStep, ...] | None
228
+ costs: FrozenDict
229
+ max_distance: float | None
230
+ exceeded_max_distance: bool
231
+ iupac_matching: Literal["literal"] = "literal"
232
+ max_cells: int | None = None
233
+ dp_cells: int | None = None
234
+
235
+ def __post_init__(self) -> None:
236
+ if not isinstance(self.name, str) or not self.name.strip():
237
+ raise ConfigurationError("DistanceResult name must be non-empty.")
238
+ if self.method not in ("hamming", "levenshtein"):
239
+ raise ConfigurationError("Unknown distance method.")
240
+ _optional_identifier(self.left_id, "left_id")
241
+ _optional_identifier(self.right_id, "right_id")
242
+ _non_negative_int(self.left_length, "left_length")
243
+ _non_negative_int(self.right_length, "right_length")
244
+ if (
245
+ isinstance(self.distance, bool)
246
+ or not isinstance(self.distance, (int, float))
247
+ or not math.isfinite(self.distance)
248
+ or self.distance < 0
249
+ ):
250
+ raise ConfigurationError("distance must be finite and non-negative.")
251
+ if any(not isinstance(item, Mismatch) for item in self.mismatches):
252
+ raise ConfigurationError("mismatches must contain Mismatch objects.")
253
+ if self.edit_path is not None and any(
254
+ not isinstance(item, EditStep) for item in self.edit_path
255
+ ):
256
+ raise ConfigurationError("edit_path must contain EditStep objects or be None.")
257
+ if self.max_distance is not None and (
258
+ isinstance(self.max_distance, bool)
259
+ or not isinstance(self.max_distance, (int, float))
260
+ or not math.isfinite(self.max_distance)
261
+ or self.max_distance < 0
262
+ ):
263
+ raise ConfigurationError("max_distance must be finite and non-negative or None.")
264
+ if not isinstance(self.exceeded_max_distance, bool):
265
+ raise ConfigurationError("exceeded_max_distance must be boolean.")
266
+ if self.max_cells is not None and (
267
+ isinstance(self.max_cells, bool)
268
+ or not isinstance(self.max_cells, int)
269
+ or self.max_cells <= 0
270
+ ):
271
+ raise ConfigurationError("max_cells must be a positive integer or None.")
272
+ if self.dp_cells is not None and (
273
+ isinstance(self.dp_cells, bool)
274
+ or not isinstance(self.dp_cells, int)
275
+ or self.dp_cells <= 0
276
+ ):
277
+ raise ConfigurationError("dp_cells must be a positive integer or None.")
278
+ if (
279
+ self.max_cells is not None
280
+ and self.dp_cells is not None
281
+ and self.dp_cells > self.max_cells
282
+ ):
283
+ raise ConfigurationError("dp_cells cannot exceed max_cells in a completed result.")
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class SimilarityResult(_SerializableResult):
288
+ """Scalar sequence, k-mer, or fingerprint comparison."""
289
+
290
+ name: str
291
+ method: str
292
+ value: float
293
+ value_kind: ValueKind
294
+ left_id: str | None
295
+ right_id: str | None
296
+ left_dimension: int
297
+ right_dimension: int
298
+ parameters: FrozenDict
299
+ components: FrozenDict
300
+ zero_vector_policy: str | None
301
+ iupac_matching: Literal["literal"] | None
302
+
303
+ def __post_init__(self) -> None:
304
+ for name in ("name", "method"):
305
+ value = getattr(self, name)
306
+ if not isinstance(value, str) or not value.strip():
307
+ raise ConfigurationError(f"SimilarityResult {name} must be non-empty.")
308
+ if self.value_kind not in ("similarity", "distance"):
309
+ raise ConfigurationError("value_kind must be 'similarity' or 'distance'.")
310
+ if (
311
+ isinstance(self.value, bool)
312
+ or not isinstance(self.value, (int, float))
313
+ or not math.isfinite(self.value)
314
+ ):
315
+ raise ConfigurationError("SimilarityResult value must be finite.")
316
+ if self.value_kind == "distance" and self.value < 0:
317
+ raise ConfigurationError("A distance value cannot be negative.")
318
+ if self.value_kind == "similarity" and not -1 <= self.value <= 1:
319
+ raise ConfigurationError("A similarity value must be in [-1, 1].")
320
+ _optional_identifier(self.left_id, "left_id")
321
+ _optional_identifier(self.right_id, "right_id")
322
+ _non_negative_int(self.left_dimension, "left_dimension")
323
+ _non_negative_int(self.right_dimension, "right_dimension")
324
+ if self.zero_vector_policy is not None and (
325
+ not isinstance(self.zero_vector_policy, str) or not self.zero_vector_policy.strip()
326
+ ):
327
+ raise ConfigurationError("zero_vector_policy must be non-empty or None.")
328
+ if self.iupac_matching not in (None, "literal"):
329
+ raise ConfigurationError("Only literal IUPAC matching is implemented in the MVP.")
330
+
331
+
332
+ @dataclass(frozen=True)
333
+ class SimilarityMatrixResult(_SerializableResult):
334
+ """Bounded in-memory dense pairwise matrix."""
335
+
336
+ name: str
337
+ method: str
338
+ value_kind: ValueKind
339
+ labels: tuple[str, ...]
340
+ values: tuple[tuple[float, ...], ...]
341
+ symmetric: bool
342
+ max_items: int
343
+ parameters: FrozenDict
344
+
345
+ def __post_init__(self) -> None:
346
+ if not isinstance(self.name, str) or not self.name.strip():
347
+ raise ConfigurationError("SimilarityMatrixResult name must be non-empty.")
348
+ if not isinstance(self.method, str) or not self.method.strip():
349
+ raise ConfigurationError("SimilarityMatrixResult method must be non-empty.")
350
+ if self.value_kind not in ("similarity", "distance"):
351
+ raise ConfigurationError("Matrix value_kind is invalid.")
352
+ if any(not isinstance(label, str) or not label for label in self.labels):
353
+ raise ConfigurationError("Matrix labels must be non-empty strings.")
354
+ _non_negative_int(self.max_items, "max_items")
355
+ size = len(self.labels)
356
+ if len(self.values) != size or any(len(row) != size for row in self.values):
357
+ raise ConfigurationError("Matrix values must be square and match labels.")
358
+ if any(
359
+ isinstance(value, bool)
360
+ or not isinstance(value, (int, float))
361
+ or not math.isfinite(value)
362
+ or (self.value_kind == "distance" and value < 0)
363
+ or (self.value_kind == "similarity" and not -1 <= value <= 1)
364
+ for row in self.values
365
+ for value in row
366
+ ):
367
+ raise ConfigurationError("Matrix contains an invalid value.")
368
+ if self.symmetric and any(
369
+ not math.isclose(self.values[i][j], self.values[j][i])
370
+ for i in range(size)
371
+ for j in range(i)
372
+ ):
373
+ raise ConfigurationError("Matrix declared symmetric but values are asymmetric.")
374
+
375
+ @property
376
+ def item_count(self) -> int:
377
+ return len(self.labels)
378
+
379
+
380
+ def _validate_dashing_common(
381
+ algorithm_version: str,
382
+ input_sha256: tuple[str, ...],
383
+ provenance: Provenance,
384
+ raw_output_path: str | None,
385
+ raw_output_sha256: str,
386
+ ) -> None:
387
+ if not isinstance(algorithm_version, str) or not algorithm_version.strip():
388
+ raise ConfigurationError("Dashing algorithm_version must be non-empty.")
389
+ if not input_sha256 or any(not _SHA256.fullmatch(value) for value in input_sha256):
390
+ raise ConfigurationError("Dashing input_sha256 must contain lowercase SHA-256 digests.")
391
+ if not isinstance(provenance, Provenance):
392
+ raise ConfigurationError("Dashing provenance must be Provenance.")
393
+ _optional_identifier(raw_output_path, "raw_output_path")
394
+ if not _SHA256.fullmatch(raw_output_sha256):
395
+ raise ConfigurationError("Dashing raw_output_sha256 must be a lowercase SHA-256 digest.")
396
+
397
+
398
+ @dataclass(frozen=True)
399
+ class DashingJaccardMatrixResult(_SerializableResult):
400
+ """Validated symmetric Jaccard matrix emitted by a user-supplied Dashing CLI."""
401
+
402
+ name: str
403
+ method: Literal["exact-kmer-set", "hll-sketch"]
404
+ algorithm_version: str
405
+ labels: tuple[str, ...]
406
+ values: tuple[tuple[float, ...], ...]
407
+ input_sha256: tuple[str, ...]
408
+ parameters: FrozenDict
409
+ provenance: Provenance
410
+ raw_output_path: str | None
411
+ raw_output_sha256: str
412
+
413
+ def __post_init__(self) -> None:
414
+ if not isinstance(self.name, str) or not self.name.strip():
415
+ raise ConfigurationError("Dashing matrix name must be non-empty.")
416
+ if self.method not in {"exact-kmer-set", "hll-sketch"}:
417
+ raise ConfigurationError("Dashing matrix method is invalid.")
418
+ if len(self.labels) < 2 or any(
419
+ not isinstance(label, str) or not label for label in self.labels
420
+ ):
421
+ raise ConfigurationError("Dashing matrix requires at least two non-empty labels.")
422
+ size = len(self.labels)
423
+ if len(self.values) != size or any(len(row) != size for row in self.values):
424
+ raise ConfigurationError("Dashing matrix values must be square and match labels.")
425
+ if len(self.input_sha256) != size:
426
+ raise ConfigurationError("Dashing matrix checksums must match labels.")
427
+ if any(
428
+ isinstance(value, bool)
429
+ or not isinstance(value, (int, float))
430
+ or not math.isfinite(value)
431
+ or not 0 <= value <= 1
432
+ for row in self.values
433
+ for value in row
434
+ ):
435
+ raise ConfigurationError("Dashing matrix contains an invalid Jaccard value.")
436
+ if any(not math.isclose(self.values[index][index], 1.0) for index in range(size)):
437
+ raise ConfigurationError("Dashing Jaccard matrix diagonal must equal one.")
438
+ if any(
439
+ not math.isclose(self.values[left][right], self.values[right][left])
440
+ for left in range(size)
441
+ for right in range(left)
442
+ ):
443
+ raise ConfigurationError("Dashing Jaccard matrix must be symmetric.")
444
+ _validate_dashing_common(
445
+ self.algorithm_version,
446
+ self.input_sha256,
447
+ self.provenance,
448
+ self.raw_output_path,
449
+ self.raw_output_sha256,
450
+ )
451
+
452
+ @property
453
+ def item_count(self) -> int:
454
+ return len(self.labels)
455
+
456
+
457
+ @dataclass(frozen=True)
458
+ class DashingNeighborHit(_SerializableResult):
459
+ """One deterministic neighbor selected from a validated Dashing matrix."""
460
+
461
+ label: str
462
+ index: int
463
+ jaccard: float
464
+
465
+ def __post_init__(self) -> None:
466
+ if not isinstance(self.label, str) or not self.label:
467
+ raise ConfigurationError("Dashing neighbor label must be non-empty.")
468
+ _non_negative_int(self.index, "index")
469
+ if (
470
+ isinstance(self.jaccard, bool)
471
+ or not isinstance(self.jaccard, (int, float))
472
+ or not math.isfinite(self.jaccard)
473
+ or not 0 <= self.jaccard <= 1
474
+ ):
475
+ raise ConfigurationError("Dashing neighbor Jaccard must be finite in [0, 1].")
476
+
477
+
478
+ @dataclass(frozen=True)
479
+ class DashingNeighborRow(_SerializableResult):
480
+ """Top-k neighbors for one matrix row."""
481
+
482
+ query_label: str
483
+ query_index: int
484
+ hits: tuple[DashingNeighborHit, ...]
485
+
486
+ def __post_init__(self) -> None:
487
+ if not isinstance(self.query_label, str) or not self.query_label:
488
+ raise ConfigurationError("Dashing query label must be non-empty.")
489
+ _non_negative_int(self.query_index, "query_index")
490
+ if any(not isinstance(hit, DashingNeighborHit) for hit in self.hits):
491
+ raise ConfigurationError("Dashing hits must contain DashingNeighborHit objects.")
492
+ if any(hit.index == self.query_index for hit in self.hits):
493
+ raise ConfigurationError("Dashing Top-k rows cannot contain the query itself.")
494
+ expected = tuple(sorted(self.hits, key=lambda hit: (-hit.jaccard, hit.index)))
495
+ if self.hits != expected:
496
+ raise ConfigurationError("Dashing Top-k hits must use deterministic score/index order.")
497
+
498
+
499
+ @dataclass(frozen=True)
500
+ class DashingTopKResult(_SerializableResult):
501
+ """Per-item Top-k neighbors derived from a validated Dashing Jaccard matrix."""
502
+
503
+ name: str
504
+ method: Literal["exact-kmer-set", "hll-sketch"]
505
+ algorithm_version: str
506
+ labels: tuple[str, ...]
507
+ rows: tuple[DashingNeighborRow, ...]
508
+ top_k: int
509
+ input_sha256: tuple[str, ...]
510
+ parameters: FrozenDict
511
+ provenance: Provenance
512
+ raw_output_path: str | None
513
+ raw_output_sha256: str
514
+
515
+ def __post_init__(self) -> None:
516
+ if not isinstance(self.name, str) or not self.name.strip():
517
+ raise ConfigurationError("Dashing Top-k name must be non-empty.")
518
+ if self.method not in {"exact-kmer-set", "hll-sketch"}:
519
+ raise ConfigurationError("Dashing Top-k method is invalid.")
520
+ size = len(self.labels)
521
+ if size < 2 or any(not isinstance(label, str) or not label for label in self.labels):
522
+ raise ConfigurationError("Dashing Top-k requires at least two non-empty labels.")
523
+ if (
524
+ isinstance(self.top_k, bool)
525
+ or not isinstance(self.top_k, int)
526
+ or not 1 <= self.top_k < size
527
+ ):
528
+ raise ConfigurationError("Dashing top_k must be in [1, item_count - 1].")
529
+ if len(self.rows) != size or any(
530
+ not isinstance(row, DashingNeighborRow) for row in self.rows
531
+ ):
532
+ raise ConfigurationError("Dashing Top-k rows must match labels.")
533
+ if tuple(row.query_index for row in self.rows) != tuple(range(size)):
534
+ raise ConfigurationError("Dashing Top-k rows must cover query indexes in order.")
535
+ if any(row.query_label != self.labels[row.query_index] for row in self.rows):
536
+ raise ConfigurationError("Dashing Top-k query labels do not match labels.")
537
+ if any(len(row.hits) != self.top_k for row in self.rows):
538
+ raise ConfigurationError("Every Dashing Top-k row must contain top_k hits.")
539
+ if any(
540
+ hit.index >= size or hit.label != self.labels[hit.index]
541
+ for row in self.rows
542
+ for hit in row.hits
543
+ ):
544
+ raise ConfigurationError("A Dashing Top-k hit is outside the result labels.")
545
+ if len(self.input_sha256) != size:
546
+ raise ConfigurationError("Dashing Top-k checksums must match labels.")
547
+ _validate_dashing_common(
548
+ self.algorithm_version,
549
+ self.input_sha256,
550
+ self.provenance,
551
+ self.raw_output_path,
552
+ self.raw_output_sha256,
553
+ )
554
+
555
+
556
+ @dataclass(frozen=True)
557
+ class SketchSimilarityResult(_SerializableResult):
558
+ """Set-based comparison of two compatible sketches."""
559
+
560
+ name: str
561
+ method: str
562
+ value: float
563
+ metric: Literal["jaccard", "containment"]
564
+ left_id: str | None
565
+ right_id: str | None
566
+ left_hash_count: int
567
+ right_hash_count: int
568
+ shared_hash_count: int
569
+ union_hash_count: int
570
+ denominator: int
571
+ passed_min_shared_hashes: bool
572
+ parameters: FrozenDict
573
+
574
+ def __post_init__(self) -> None:
575
+ if not self.name or not self.method:
576
+ raise ConfigurationError("Sketch similarity name and method must be non-empty.")
577
+ if self.metric not in {"jaccard", "containment"}:
578
+ raise ConfigurationError("Sketch similarity metric is invalid.")
579
+ if (
580
+ isinstance(self.value, bool)
581
+ or not isinstance(self.value, (int, float))
582
+ or not math.isfinite(self.value)
583
+ or not 0 <= self.value <= 1
584
+ ):
585
+ raise ConfigurationError("Sketch similarity value must be finite in [0, 1].")
586
+ _optional_identifier(self.left_id, "left_id")
587
+ _optional_identifier(self.right_id, "right_id")
588
+ for name in (
589
+ "left_hash_count",
590
+ "right_hash_count",
591
+ "shared_hash_count",
592
+ "union_hash_count",
593
+ "denominator",
594
+ ):
595
+ _non_negative_int(getattr(self, name), name)
596
+ if self.shared_hash_count > min(self.left_hash_count, self.right_hash_count):
597
+ raise ConfigurationError("Shared sketch hashes exceed an input hash count.")
598
+ if self.union_hash_count < max(self.left_hash_count, self.right_hash_count):
599
+ raise ConfigurationError("Sketch union is smaller than an input hash count.")
600
+ if not isinstance(self.passed_min_shared_hashes, bool):
601
+ raise ConfigurationError("passed_min_shared_hashes must be boolean.")
602
+
603
+
604
+ @dataclass(frozen=True)
605
+ class NearestNeighborHit(_SerializableResult):
606
+ record_id: str
607
+ index: int
608
+ similarity: float
609
+ shared_hash_count: int
610
+
611
+ def __post_init__(self) -> None:
612
+ if not isinstance(self.record_id, str) or not self.record_id.strip():
613
+ raise ConfigurationError("Nearest-neighbor ID must be non-empty.")
614
+ _non_negative_int(self.index, "index")
615
+ _non_negative_int(self.shared_hash_count, "shared_hash_count")
616
+ if (
617
+ isinstance(self.similarity, bool)
618
+ or not isinstance(self.similarity, (int, float))
619
+ or not math.isfinite(self.similarity)
620
+ or not 0 <= self.similarity <= 1
621
+ ):
622
+ raise ConfigurationError("Nearest-neighbor similarity must be in [0, 1].")
623
+
624
+
625
+ @dataclass(frozen=True)
626
+ class NearestNeighborResult(_SerializableResult):
627
+ name: str
628
+ method: str
629
+ algorithm_version: str
630
+ query_id: str | None
631
+ index_size: int
632
+ hits: tuple[NearestNeighborHit, ...]
633
+ top_k: int
634
+ min_similarity: float
635
+ parameters: FrozenDict
636
+
637
+ def __post_init__(self) -> None:
638
+ for name in ("name", "method", "algorithm_version"):
639
+ if not isinstance(getattr(self, name), str) or not getattr(self, name).strip():
640
+ raise ConfigurationError(f"NearestNeighborResult {name} must be non-empty.")
641
+ _optional_identifier(self.query_id, "query_id")
642
+ _non_negative_int(self.index_size, "index_size")
643
+ if isinstance(self.top_k, bool) or not isinstance(self.top_k, int) or self.top_k <= 0:
644
+ raise ConfigurationError("top_k must be positive.")
645
+ if len(self.hits) > self.top_k or any(
646
+ not isinstance(item, NearestNeighborHit) for item in self.hits
647
+ ):
648
+ raise ConfigurationError("Nearest-neighbor hits violate the declared top_k.")
649
+ if any(item.index >= self.index_size for item in self.hits):
650
+ raise ConfigurationError("Nearest-neighbor hit index is outside the index.")
651
+ if (
652
+ isinstance(self.min_similarity, bool)
653
+ or not isinstance(self.min_similarity, (int, float))
654
+ or not 0 <= self.min_similarity <= 1
655
+ ):
656
+ raise ConfigurationError("min_similarity must be in [0, 1].")
657
+
658
+
659
+ __all__ = [
660
+ "ApproximateMatch",
661
+ "ApproximateSearchResult",
662
+ "DashingJaccardMatrixResult",
663
+ "DashingNeighborHit",
664
+ "DashingNeighborRow",
665
+ "DashingTopKResult",
666
+ "DistanceResult",
667
+ "EditStep",
668
+ "Mismatch",
669
+ "NearestNeighborHit",
670
+ "NearestNeighborResult",
671
+ "SearchResult",
672
+ "SequenceMatch",
673
+ "SimilarityMatrixResult",
674
+ "SimilarityResult",
675
+ "SketchSimilarityResult",
676
+ ]