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,662 @@
1
+ """Exact k-mer and numeric fingerprint comparisons."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import Counter
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Literal, TypeAlias, TypeGuard
9
+
10
+ from dnakit.core._json import FrozenDict
11
+ from dnakit.exceptions import ConfigurationError
12
+ from dnakit.fingerprints import BitFingerprintResult, FingerprintResult
13
+ from dnakit.similarity._shared import (
14
+ NamedVector,
15
+ NumericVector,
16
+ SequenceInput,
17
+ sequence_and_id,
18
+ validate_bool,
19
+ validate_positive_int,
20
+ )
21
+ from dnakit.similarity.results import SimilarityResult
22
+
23
+ VectorMetric: TypeAlias = Literal["tanimoto", "jaccard", "cosine", "euclidean", "manhattan"]
24
+ KmerMetric: TypeAlias = Literal["jaccard", "containment", "cosine"]
25
+ KmerMode: TypeAlias = Literal["set", "count"]
26
+ ZeroVectorPolicy: TypeAlias = Literal["identity", "error"]
27
+ FingerprintVectorResult: TypeAlias = FingerprintResult | BitFingerprintResult
28
+ VectorInput: TypeAlias = FingerprintVectorResult | NumericVector | NamedVector
29
+ WeightInput: TypeAlias = NumericVector | NamedVector
30
+ KmerVectorInput: TypeAlias = FingerprintResult | NamedVector
31
+
32
+ _IUPAC_COMPLEMENT = str.maketrans(
33
+ {
34
+ "A": "T",
35
+ "C": "G",
36
+ "G": "C",
37
+ "T": "A",
38
+ "R": "Y",
39
+ "Y": "R",
40
+ "S": "S",
41
+ "W": "W",
42
+ "K": "M",
43
+ "M": "K",
44
+ "B": "V",
45
+ "D": "H",
46
+ "H": "D",
47
+ "V": "B",
48
+ "N": "N",
49
+ }
50
+ )
51
+
52
+
53
+ def _validate_zero_policy(value: ZeroVectorPolicy) -> ZeroVectorPolicy:
54
+ if value not in ("identity", "error"):
55
+ raise ConfigurationError(
56
+ "zero_vector_policy must be 'identity' or 'error'.",
57
+ code="INVALID_ZERO_VECTOR_POLICY",
58
+ context={"zero_vector_policy": value},
59
+ )
60
+ return value
61
+
62
+
63
+ def _finite_number(value: object, *, role: str, index: int | str) -> float:
64
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
65
+ raise ConfigurationError(
66
+ "Vector values must be finite numbers.",
67
+ code="INVALID_VECTOR_VALUE",
68
+ context={"role": role, "index": index, "value": value},
69
+ )
70
+ return float(value)
71
+
72
+
73
+ def _is_fingerprint_result(value: object) -> TypeGuard[FingerprintVectorResult]:
74
+ return isinstance(value, (FingerprintResult, BitFingerprintResult))
75
+
76
+
77
+ def _fingerprint_values(value: FingerprintVectorResult, *, role: str) -> tuple[float, ...]:
78
+ return tuple(
79
+ _finite_number(item, role=role, index=index)
80
+ for index, item in enumerate(value.dense_values())
81
+ )
82
+
83
+
84
+ def _mapping_values(
85
+ left: Mapping[str, int | float],
86
+ right: Mapping[str, int | float],
87
+ ) -> tuple[tuple[float, ...], tuple[float, ...], tuple[str, ...]]:
88
+ if any(not isinstance(key, str) or not key for key in (*left.keys(), *right.keys())):
89
+ raise ConfigurationError(
90
+ "Named-vector feature names must be non-empty strings.",
91
+ code="INVALID_VECTOR_FEATURE",
92
+ )
93
+ features = tuple(sorted(set(left) | set(right)))
94
+ left_values = tuple(
95
+ _finite_number(left.get(feature, 0), role="left", index=feature) for feature in features
96
+ )
97
+ right_values = tuple(
98
+ _finite_number(right.get(feature, 0), role="right", index=feature) for feature in features
99
+ )
100
+ return left_values, right_values, features
101
+
102
+
103
+ def _sequence_values(value: object, *, role: str) -> tuple[float, ...]:
104
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
105
+ raise ConfigurationError(
106
+ "Numeric vectors must be non-text sequences of finite numbers.",
107
+ code="INVALID_VECTOR_INPUT",
108
+ context={"role": role, "input_type": type(value).__name__},
109
+ )
110
+ return tuple(_finite_number(item, role=role, index=index) for index, item in enumerate(value))
111
+
112
+
113
+ def _align_vectors(
114
+ left: VectorInput,
115
+ right: VectorInput,
116
+ ) -> tuple[tuple[float, ...], tuple[float, ...], tuple[str, ...] | None, str | None, str | None]:
117
+ if _is_fingerprint_result(left) and _is_fingerprint_result(right):
118
+ left_parameters = left.parameters if isinstance(left, BitFingerprintResult) else None
119
+ right_parameters = right.parameters if isinstance(right, BitFingerprintResult) else None
120
+ if (
121
+ left.schema_version != right.schema_version
122
+ or left.feature_names != right.feature_names
123
+ or left_parameters != right_parameters
124
+ ):
125
+ raise ConfigurationError(
126
+ "Fingerprint schemas must match exactly before comparison.",
127
+ code="FINGERPRINT_SCHEMA_MISMATCH",
128
+ context={
129
+ "left_schema": left.schema_version,
130
+ "right_schema": right.schema_version,
131
+ },
132
+ )
133
+ return (
134
+ _fingerprint_values(left, role="left"),
135
+ _fingerprint_values(right, role="right"),
136
+ left.feature_names,
137
+ left.sequence_id,
138
+ right.sequence_id,
139
+ )
140
+ if _is_fingerprint_result(left) or _is_fingerprint_result(right):
141
+ raise ConfigurationError(
142
+ "A versioned fingerprint cannot be mixed with an unversioned vector.",
143
+ code="MIXED_VECTOR_TYPES",
144
+ )
145
+ if isinstance(left, Mapping) and isinstance(right, Mapping):
146
+ left_values, right_values, features = _mapping_values(left, right)
147
+ return left_values, right_values, features, None, None
148
+ if isinstance(left, Mapping) or isinstance(right, Mapping):
149
+ raise ConfigurationError(
150
+ "Named and positional vectors cannot be mixed.",
151
+ code="MIXED_VECTOR_TYPES",
152
+ )
153
+ left_values = _sequence_values(left, role="left")
154
+ right_values = _sequence_values(right, role="right")
155
+ if len(left_values) != len(right_values):
156
+ raise ConfigurationError(
157
+ "Positional vectors must have equal dimensions.",
158
+ code="VECTOR_DIMENSION_MISMATCH",
159
+ context={"left_dimension": len(left_values), "right_dimension": len(right_values)},
160
+ )
161
+ return left_values, right_values, None, None, None
162
+
163
+
164
+ def _resolve_weights(
165
+ weights: WeightInput | None,
166
+ *,
167
+ dimension: int,
168
+ features: tuple[str, ...] | None,
169
+ ) -> tuple[float, ...]:
170
+ if weights is None:
171
+ return (1.0,) * dimension
172
+ if isinstance(weights, Mapping):
173
+ if features is None:
174
+ raise ConfigurationError(
175
+ "Mapping weights require named vector features.",
176
+ code="INVALID_VECTOR_WEIGHTS",
177
+ )
178
+ if any(not isinstance(feature, str) or not feature for feature in weights):
179
+ raise ConfigurationError(
180
+ "Weight feature names must be non-empty strings.",
181
+ code="INVALID_VECTOR_WEIGHTS",
182
+ )
183
+ unknown = set(weights) - set(features)
184
+ if unknown:
185
+ raise ConfigurationError(
186
+ "Weights contain unknown feature names.",
187
+ code="UNKNOWN_WEIGHT_FEATURE",
188
+ context={"features": sorted(unknown)},
189
+ )
190
+ resolved = tuple(
191
+ _finite_number(weights.get(feature, 1), role="weight", index=feature)
192
+ for feature in features
193
+ )
194
+ else:
195
+ resolved = _sequence_values(weights, role="weight")
196
+ if len(resolved) != dimension:
197
+ raise ConfigurationError(
198
+ "Weight dimension must match vector dimension.",
199
+ code="WEIGHT_DIMENSION_MISMATCH",
200
+ context={"weight_dimension": len(resolved), "dimension": dimension},
201
+ )
202
+ if any(weight < 0 for weight in resolved):
203
+ raise ConfigurationError(
204
+ "Feature weights must be non-negative.",
205
+ code="NEGATIVE_VECTOR_WEIGHT",
206
+ )
207
+ return resolved
208
+
209
+
210
+ def _resolved_weight_parameter(
211
+ weights: WeightInput | None,
212
+ *,
213
+ resolved: tuple[float, ...],
214
+ features: tuple[str, ...] | None,
215
+ ) -> tuple[float, ...] | FrozenDict | None:
216
+ """Return the exact applied weights in a deterministic audit form."""
217
+
218
+ if weights is None:
219
+ return None
220
+ if isinstance(weights, Mapping):
221
+ if features is None: # Guarded by ``_resolve_weights``.
222
+ raise ConfigurationError(
223
+ "Mapping weights require named vector features.",
224
+ code="INVALID_VECTOR_WEIGHTS",
225
+ )
226
+ return FrozenDict(
227
+ {feature: weight for feature, weight in zip(features, resolved, strict=True)}
228
+ )
229
+ return resolved
230
+
231
+
232
+ def _undefined_zero_similarity(
233
+ *,
234
+ left_zero: bool,
235
+ right_zero: bool,
236
+ policy: ZeroVectorPolicy,
237
+ metric: str,
238
+ ) -> float:
239
+ if policy == "error":
240
+ raise ConfigurationError(
241
+ f"{metric} is undefined for an effective zero vector.",
242
+ code="ZERO_VECTOR_UNDEFINED",
243
+ context={"left_zero": left_zero, "right_zero": right_zero, "metric": metric},
244
+ )
245
+ return 1.0 if left_zero and right_zero else 0.0
246
+
247
+
248
+ def _compute_vector_metric(
249
+ left: tuple[float, ...],
250
+ right: tuple[float, ...],
251
+ weights: tuple[float, ...],
252
+ *,
253
+ metric: VectorMetric,
254
+ zero_policy: ZeroVectorPolicy,
255
+ ) -> tuple[float, Literal["similarity", "distance"], FrozenDict]:
256
+ if metric in ("tanimoto", "jaccard") and any(value < 0 for value in (*left, *right)):
257
+ raise ConfigurationError(
258
+ f"{metric} requires non-negative vector values.",
259
+ code="NEGATIVE_VECTOR_NOT_ALLOWED",
260
+ context={"metric": metric},
261
+ )
262
+ if metric == "jaccard":
263
+ intersection = math.fsum(
264
+ weight
265
+ for left_value, right_value, weight in zip(left, right, weights, strict=True)
266
+ if left_value > 0 and right_value > 0
267
+ )
268
+ union = math.fsum(
269
+ weight
270
+ for left_value, right_value, weight in zip(left, right, weights, strict=True)
271
+ if left_value > 0 or right_value > 0
272
+ )
273
+ if union == 0:
274
+ value = _undefined_zero_similarity(
275
+ left_zero=True,
276
+ right_zero=True,
277
+ policy=zero_policy,
278
+ metric=metric,
279
+ )
280
+ else:
281
+ value = intersection / union
282
+ return (
283
+ value,
284
+ "similarity",
285
+ FrozenDict({"intersection_weight": intersection, "union_weight": union}),
286
+ )
287
+
288
+ dot = math.fsum(
289
+ weight * left_value * right_value
290
+ for left_value, right_value, weight in zip(left, right, weights, strict=True)
291
+ )
292
+ left_squared = math.fsum(
293
+ weight * value * value for value, weight in zip(left, weights, strict=True)
294
+ )
295
+ right_squared = math.fsum(
296
+ weight * value * value for value, weight in zip(right, weights, strict=True)
297
+ )
298
+ if metric == "tanimoto":
299
+ denominator = math.fsum((left_squared, right_squared, -dot))
300
+ if math.isclose(denominator, 0.0):
301
+ value = _undefined_zero_similarity(
302
+ left_zero=math.isclose(left_squared, 0.0),
303
+ right_zero=math.isclose(right_squared, 0.0),
304
+ policy=zero_policy,
305
+ metric=metric,
306
+ )
307
+ else:
308
+ value = dot / denominator
309
+ return (
310
+ value,
311
+ "similarity",
312
+ FrozenDict(
313
+ {
314
+ "dot_product": dot,
315
+ "left_squared_norm": left_squared,
316
+ "right_squared_norm": right_squared,
317
+ "denominator": denominator,
318
+ }
319
+ ),
320
+ )
321
+ if metric == "cosine":
322
+ denominator = math.sqrt(left_squared * right_squared)
323
+ if math.isclose(denominator, 0.0):
324
+ value = _undefined_zero_similarity(
325
+ left_zero=math.isclose(left_squared, 0.0),
326
+ right_zero=math.isclose(right_squared, 0.0),
327
+ policy=zero_policy,
328
+ metric=metric,
329
+ )
330
+ else:
331
+ value = dot / denominator
332
+ value = min(1.0, max(-1.0, value))
333
+ return (
334
+ value,
335
+ "similarity",
336
+ FrozenDict(
337
+ {
338
+ "dot_product": dot,
339
+ "left_squared_norm": left_squared,
340
+ "right_squared_norm": right_squared,
341
+ "denominator": denominator,
342
+ }
343
+ ),
344
+ )
345
+ if metric == "euclidean":
346
+ squared_distance = math.fsum(
347
+ weight * (left_value - right_value) ** 2
348
+ for left_value, right_value, weight in zip(left, right, weights, strict=True)
349
+ )
350
+ return (
351
+ math.sqrt(squared_distance),
352
+ "distance",
353
+ FrozenDict({"squared_distance": squared_distance}),
354
+ )
355
+ if metric == "manhattan":
356
+ distance = math.fsum(
357
+ weight * abs(left_value - right_value)
358
+ for left_value, right_value, weight in zip(left, right, weights, strict=True)
359
+ )
360
+ return distance, "distance", FrozenDict({"absolute_distance": distance})
361
+ raise ConfigurationError(
362
+ "Unknown fingerprint similarity metric.",
363
+ code="INVALID_VECTOR_METRIC",
364
+ context={"metric": metric},
365
+ )
366
+
367
+
368
+ def fingerprint_similarity(
369
+ left: VectorInput,
370
+ right: VectorInput,
371
+ *,
372
+ metric: VectorMetric = "tanimoto",
373
+ weights: WeightInput | None = None,
374
+ zero_vector_policy: ZeroVectorPolicy = "identity",
375
+ ) -> SimilarityResult:
376
+ """Compare versioned fingerprints or homogeneous numeric vectors.
377
+
378
+ ``jaccard`` compares non-zero feature presence. ``tanimoto`` uses the
379
+ generalized non-negative dot-product formula. ``cosine`` allows signed
380
+ vectors. Euclidean and Manhattan methods return distances. Under the
381
+ default zero policy, two effective zero vectors have similarity 1 while
382
+ exactly one zero vector has similarity 0. A ``FingerprintResult`` is
383
+ compared as its already-materialized vector; its recorded upstream Gap
384
+ traversal policy is not reinterpreted here.
385
+ """
386
+
387
+ if metric not in ("tanimoto", "jaccard", "cosine", "euclidean", "manhattan"):
388
+ raise ConfigurationError(
389
+ "Unknown fingerprint similarity metric.",
390
+ code="INVALID_VECTOR_METRIC",
391
+ context={"metric": metric},
392
+ )
393
+ zero_policy = _validate_zero_policy(zero_vector_policy)
394
+ left_values, right_values, features, left_id, right_id = _align_vectors(left, right)
395
+ resolved_weights = _resolve_weights(
396
+ weights,
397
+ dimension=len(left_values),
398
+ features=features,
399
+ )
400
+ audited_weights = _resolved_weight_parameter(
401
+ weights,
402
+ resolved=resolved_weights,
403
+ features=features,
404
+ )
405
+ value, value_kind, components = _compute_vector_metric(
406
+ left_values,
407
+ right_values,
408
+ resolved_weights,
409
+ metric=metric,
410
+ zero_policy=zero_policy,
411
+ )
412
+ return SimilarityResult(
413
+ name="fingerprint_similarity",
414
+ method=metric,
415
+ value=value,
416
+ value_kind=value_kind,
417
+ left_id=left_id,
418
+ right_id=right_id,
419
+ left_dimension=len(left_values),
420
+ right_dimension=len(right_values),
421
+ parameters=FrozenDict(
422
+ {
423
+ "weighted": weights is not None,
424
+ "weights": audited_weights,
425
+ "feature_schema": features,
426
+ }
427
+ ),
428
+ components=components,
429
+ zero_vector_policy=zero_policy if value_kind == "similarity" else None,
430
+ iupac_matching=None,
431
+ )
432
+
433
+
434
+ def _canonical_word(word: str) -> str:
435
+ reverse_complement = word.translate(_IUPAC_COMPLEMENT)[::-1]
436
+ return min(word, reverse_complement)
437
+
438
+
439
+ def _kmer_counts(
440
+ text: str,
441
+ *,
442
+ k: int,
443
+ overlapping: bool,
444
+ canonical: bool,
445
+ ) -> Counter[str]:
446
+ step = 1 if overlapping else k
447
+ words = (text[start : start + k] for start in range(0, len(text) - k + 1, step))
448
+ return Counter(_canonical_word(word) if canonical else word for word in words)
449
+
450
+
451
+ def _kmer_components(
452
+ left: Mapping[str, int | float],
453
+ right: Mapping[str, int | float],
454
+ *,
455
+ mode: KmerMode,
456
+ metric: KmerMetric,
457
+ zero_policy: ZeroVectorPolicy,
458
+ ) -> tuple[float, FrozenDict]:
459
+ features = tuple(sorted(set(left) | set(right)))
460
+ if mode == "set":
461
+ left_values = {feature: float(left[feature] > 0) for feature in features}
462
+ right_values = {feature: float(right[feature] > 0) for feature in features}
463
+ else:
464
+ left_values = {feature: float(left[feature]) for feature in features}
465
+ right_values = {feature: float(right[feature]) for feature in features}
466
+ shared = math.fsum(min(left_values[feature], right_values[feature]) for feature in features)
467
+ left_total = math.fsum(left_values[feature] for feature in features)
468
+ right_total = math.fsum(right_values[feature] for feature in features)
469
+ union = math.fsum(max(left_values[feature], right_values[feature]) for feature in features)
470
+ if metric == "jaccard":
471
+ if union == 0:
472
+ value = _undefined_zero_similarity(
473
+ left_zero=True,
474
+ right_zero=True,
475
+ policy=zero_policy,
476
+ metric="kmer_jaccard",
477
+ )
478
+ else:
479
+ value = shared / union
480
+ denominator = union
481
+ elif metric == "containment":
482
+ if left_total == 0:
483
+ value = _undefined_zero_similarity(
484
+ left_zero=True,
485
+ right_zero=right_total == 0,
486
+ policy=zero_policy,
487
+ metric="kmer_containment",
488
+ )
489
+ else:
490
+ value = shared / left_total
491
+ denominator = left_total
492
+ elif metric == "cosine":
493
+ dot = math.fsum(left_values[feature] * right_values[feature] for feature in features)
494
+ left_squared = math.fsum(left_values[feature] ** 2 for feature in features)
495
+ right_squared = math.fsum(right_values[feature] ** 2 for feature in features)
496
+ denominator = math.sqrt(left_squared * right_squared)
497
+ if denominator == 0:
498
+ value = _undefined_zero_similarity(
499
+ left_zero=left_squared == 0,
500
+ right_zero=right_squared == 0,
501
+ policy=zero_policy,
502
+ metric="kmer_cosine",
503
+ )
504
+ else:
505
+ value = dot / denominator
506
+ else:
507
+ raise ConfigurationError(
508
+ "Unknown k-mer similarity metric.",
509
+ code="INVALID_KMER_METRIC",
510
+ context={"metric": metric},
511
+ )
512
+ return value, FrozenDict(
513
+ {
514
+ "shared_weight": shared,
515
+ "left_weight": left_total,
516
+ "right_weight": right_total,
517
+ "denominator": denominator,
518
+ }
519
+ )
520
+
521
+
522
+ def kmer_vector_similarity(
523
+ left: KmerVectorInput,
524
+ right: KmerVectorInput,
525
+ *,
526
+ metric: KmerMetric = "jaccard",
527
+ mode: KmerMode = "set",
528
+ zero_vector_policy: ZeroVectorPolicy = "identity",
529
+ ) -> SimilarityResult:
530
+ """Compare named exact k-mer vectors, including versioned fingerprints."""
531
+
532
+ if metric not in ("jaccard", "containment", "cosine"):
533
+ raise ConfigurationError(
534
+ "Unknown k-mer similarity metric.",
535
+ code="INVALID_KMER_METRIC",
536
+ context={"metric": metric},
537
+ )
538
+ if mode not in ("set", "count"):
539
+ raise ConfigurationError(
540
+ "k-mer mode must be 'set' or 'count'.",
541
+ code="INVALID_KMER_MODE",
542
+ context={"mode": mode},
543
+ )
544
+ zero_policy = _validate_zero_policy(zero_vector_policy)
545
+ left_values, right_values, features, left_id, right_id = _align_vectors(left, right)
546
+ if features is None:
547
+ raise ConfigurationError(
548
+ "k-mer vector comparison requires named features.",
549
+ code="UNNAMED_KMER_VECTOR",
550
+ )
551
+ if any(value < 0 for value in (*left_values, *right_values)):
552
+ raise ConfigurationError(
553
+ "k-mer vector values must be non-negative.",
554
+ code="NEGATIVE_VECTOR_NOT_ALLOWED",
555
+ )
556
+ left_mapping = dict(zip(features, left_values, strict=True))
557
+ right_mapping = dict(zip(features, right_values, strict=True))
558
+ value, components = _kmer_components(
559
+ left_mapping,
560
+ right_mapping,
561
+ mode=mode,
562
+ metric=metric,
563
+ zero_policy=zero_policy,
564
+ )
565
+ return SimilarityResult(
566
+ name="kmer_vector_similarity",
567
+ method=f"{mode}_{metric}",
568
+ value=value,
569
+ value_kind="similarity",
570
+ left_id=left_id,
571
+ right_id=right_id,
572
+ left_dimension=len(features),
573
+ right_dimension=len(features),
574
+ parameters=FrozenDict(
575
+ {
576
+ "metric": metric,
577
+ "mode": mode,
578
+ "feature_schema": features,
579
+ "source": "named_kmer_vector",
580
+ }
581
+ ),
582
+ components=components,
583
+ zero_vector_policy=zero_policy,
584
+ iupac_matching=None,
585
+ )
586
+
587
+
588
+ def kmer_similarity(
589
+ left: SequenceInput,
590
+ right: SequenceInput,
591
+ *,
592
+ k: int,
593
+ metric: KmerMetric = "jaccard",
594
+ mode: KmerMode = "set",
595
+ canonical: bool = False,
596
+ overlapping: bool = True,
597
+ zero_vector_policy: ZeroVectorPolicy = "identity",
598
+ ) -> SimilarityResult:
599
+ """Compare exact literal-IUPAC k-mer sets or count multisets."""
600
+
601
+ validate_positive_int(k, "k")
602
+ validate_bool(canonical, "canonical")
603
+ validate_bool(overlapping, "overlapping")
604
+ if metric not in ("jaccard", "containment", "cosine"):
605
+ raise ConfigurationError(
606
+ "Unknown k-mer similarity metric.",
607
+ code="INVALID_KMER_METRIC",
608
+ context={"metric": metric},
609
+ )
610
+ if mode not in ("set", "count"):
611
+ raise ConfigurationError(
612
+ "k-mer mode must be 'set' or 'count'.",
613
+ code="INVALID_KMER_MODE",
614
+ context={"mode": mode},
615
+ )
616
+ zero_policy = _validate_zero_policy(zero_vector_policy)
617
+ left_sequence, left_id = sequence_and_id(left, role="left")
618
+ right_sequence, right_id = sequence_and_id(right, role="right")
619
+ left_counts = _kmer_counts(
620
+ left_sequence.symbols,
621
+ k=k,
622
+ overlapping=overlapping,
623
+ canonical=canonical,
624
+ )
625
+ right_counts = _kmer_counts(
626
+ right_sequence.symbols,
627
+ k=k,
628
+ overlapping=overlapping,
629
+ canonical=canonical,
630
+ )
631
+ value, components = _kmer_components(
632
+ left_counts,
633
+ right_counts,
634
+ mode=mode,
635
+ metric=metric,
636
+ zero_policy=zero_policy,
637
+ )
638
+ return SimilarityResult(
639
+ name="kmer_similarity",
640
+ method=f"{mode}_{metric}",
641
+ value=value,
642
+ value_kind="similarity",
643
+ left_id=left_id,
644
+ right_id=right_id,
645
+ left_dimension=len(left_counts),
646
+ right_dimension=len(right_counts),
647
+ parameters=FrozenDict(
648
+ {
649
+ "k": k,
650
+ "metric": metric,
651
+ "mode": mode,
652
+ "canonical": canonical,
653
+ "overlapping": overlapping,
654
+ }
655
+ ),
656
+ components=components,
657
+ zero_vector_policy=zero_policy,
658
+ iupac_matching="literal",
659
+ )
660
+
661
+
662
+ __all__ = ["fingerprint_similarity", "kmer_similarity", "kmer_vector_similarity"]