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,829 @@
1
+ """Interpretable fixed-schema advanced DNA fingerprints and preprocessing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from itertools import islice
9
+ from typing import Literal, Protocol, cast
10
+
11
+ from dnakit.core import DNASequence, Provenance, Topology
12
+ from dnakit.core._json import FrozenDict, to_json_compatible
13
+ from dnakit.descriptors import codon_statistics, exact_repeat_fraction, gc_at_content
14
+ from dnakit.exceptions import (
15
+ BackendExecutionError,
16
+ ConfigurationError,
17
+ UnsupportedGapOperationError,
18
+ )
19
+ from dnakit.patterns import (
20
+ RestrictionEnzyme,
21
+ scan_codon_sites,
22
+ scan_motif,
23
+ scan_restriction_sites,
24
+ )
25
+ from dnakit.thermodynamics import (
26
+ Primer3ThermodynamicResult,
27
+ ThermodynamicConditions,
28
+ nearest_neighbor,
29
+ validate_primer3_result,
30
+ )
31
+
32
+ from ._shared import SequenceInput, sequence_and_id
33
+ from .kmer import kmer as kmer_fingerprint
34
+
35
+ AdvancedVector = tuple[float, ...]
36
+ PreprocessMode = Literal["none", "standard", "minmax", "l1", "l2"]
37
+ ThermodynamicMissingStrategy = Literal["zero", "sentinel", "error"]
38
+ DEFAULT_MAX_PREPROCESS_ROWS = 1_000_000
39
+ DEFAULT_MAX_MULTISCALE_LEVELS = 16
40
+
41
+
42
+ class ThermodynamicStructureAdapter(Protocol):
43
+ """Explicit structure adapter used by the thermodynamic fingerprint."""
44
+
45
+ def hairpin(
46
+ self,
47
+ sequence: DNASequence,
48
+ *,
49
+ conditions: ThermodynamicConditions | None = None,
50
+ max_loop: int = 30,
51
+ output_structure: bool = False,
52
+ ) -> Primer3ThermodynamicResult: ...
53
+
54
+ def self_dimer(
55
+ self,
56
+ sequence: DNASequence,
57
+ *,
58
+ conditions: ThermodynamicConditions | None = None,
59
+ max_loop: int = 30,
60
+ output_structure: bool = False,
61
+ ) -> Primer3ThermodynamicResult: ...
62
+
63
+ def heterodimer(
64
+ self,
65
+ sequence_a: DNASequence,
66
+ sequence_b: DNASequence,
67
+ *,
68
+ conditions: ThermodynamicConditions | None = None,
69
+ max_loop: int = 30,
70
+ output_structure: bool = False,
71
+ ) -> Primer3ThermodynamicResult: ...
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class AdvancedFingerprintResult:
76
+ """One fixed-order, JSON-compatible interpretable feature vector."""
77
+
78
+ name: str
79
+ method: str
80
+ schema_version: str
81
+ sequence_id: str | None
82
+ feature_names: tuple[str, ...]
83
+ values: AdvancedVector
84
+ parameters: FrozenDict
85
+ algorithm_version: str
86
+ provenance: Provenance
87
+
88
+ def __post_init__(self) -> None:
89
+ for name in ("name", "method", "schema_version", "algorithm_version"):
90
+ if not isinstance(getattr(self, name), str) or not getattr(self, name).strip():
91
+ raise ConfigurationError(f"Advanced fingerprint {name} must be non-empty.")
92
+ if len(self.feature_names) != len(self.values) or len(set(self.feature_names)) != len(
93
+ self.feature_names
94
+ ):
95
+ raise ConfigurationError("Advanced fingerprint schema and values do not align.")
96
+ if any(not isinstance(name, str) or not name for name in self.feature_names):
97
+ raise ConfigurationError("Advanced fingerprint names must be non-empty strings.")
98
+ if any(
99
+ isinstance(value, bool)
100
+ or not isinstance(value, (int, float))
101
+ or not math.isfinite(value)
102
+ for value in self.values
103
+ ):
104
+ raise ConfigurationError("Advanced fingerprint values must be finite numbers.")
105
+ if self.sequence_id is not None and (
106
+ not isinstance(self.sequence_id, str) or not self.sequence_id.strip()
107
+ ):
108
+ raise ConfigurationError("Advanced fingerprint sequence_id must be non-empty or None.")
109
+ if not isinstance(self.parameters, FrozenDict):
110
+ raise ConfigurationError("Advanced fingerprint parameters must be FrozenDict.")
111
+ if not isinstance(self.provenance, Provenance):
112
+ raise ConfigurationError("Advanced fingerprint provenance must be Provenance.")
113
+
114
+ def to_dict(self) -> dict[str, object]:
115
+ return cast(dict[str, object], to_json_compatible(self))
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class FeaturePreprocessor:
120
+ """Training-set-fitted deterministic column transform."""
121
+
122
+ feature_names: tuple[str, ...]
123
+ mode: PreprocessMode
124
+ centers: tuple[float, ...]
125
+ scales: tuple[float, ...]
126
+ imputation_values: tuple[float, ...]
127
+ kept_indices: tuple[int, ...]
128
+ variance_threshold: float
129
+ missing_strategy: Literal["error", "mean", "zero"]
130
+ max_rows: int = DEFAULT_MAX_PREPROCESS_ROWS
131
+ schema_version: str = "dnakit.feature-preprocessor.v2"
132
+
133
+ def __post_init__(self) -> None:
134
+ width = len(self.feature_names)
135
+ if not self.feature_names or any(
136
+ not isinstance(name, str) or not name.strip() for name in self.feature_names
137
+ ):
138
+ raise ConfigurationError("Preprocessor feature names must be non-empty strings.")
139
+ if len(set(self.feature_names)) != width:
140
+ raise ConfigurationError("Preprocessor feature names must be unique.")
141
+ if self.mode not in {"none", "standard", "minmax", "l1", "l2"}:
142
+ raise ConfigurationError("Unknown preprocessing mode.")
143
+ if self.missing_strategy not in {"error", "mean", "zero"}:
144
+ raise ConfigurationError("Unknown missing strategy.")
145
+ if not all(
146
+ isinstance(values, tuple) and len(values) == width
147
+ for values in (self.centers, self.scales, self.imputation_values)
148
+ ):
149
+ raise ConfigurationError("Preprocessor numeric vectors must match its feature schema.")
150
+ if any(
151
+ isinstance(value, bool)
152
+ or not isinstance(value, (int, float))
153
+ or not math.isfinite(value)
154
+ for values in (self.centers, self.scales, self.imputation_values)
155
+ for value in values
156
+ ):
157
+ raise ConfigurationError("Preprocessor numeric vectors must contain finite numbers.")
158
+ if any(value <= 0 for value in self.scales):
159
+ raise ConfigurationError("Preprocessor scales must be positive.")
160
+ if (
161
+ any(
162
+ isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < width
163
+ for index in self.kept_indices
164
+ )
165
+ or tuple(sorted(set(self.kept_indices))) != self.kept_indices
166
+ ):
167
+ raise ConfigurationError("Preprocessor kept_indices must be sorted unique indices.")
168
+ if (
169
+ isinstance(self.variance_threshold, bool)
170
+ or not isinstance(self.variance_threshold, (int, float))
171
+ or not math.isfinite(self.variance_threshold)
172
+ or self.variance_threshold < 0
173
+ ):
174
+ raise ConfigurationError("variance_threshold must be finite and non-negative.")
175
+ if (
176
+ isinstance(self.max_rows, bool)
177
+ or not isinstance(self.max_rows, int)
178
+ or not 0 < self.max_rows <= DEFAULT_MAX_PREPROCESS_ROWS
179
+ ):
180
+ raise ConfigurationError(f"max_rows must be in [1, {DEFAULT_MAX_PREPROCESS_ROWS}].")
181
+ if self.schema_version != "dnakit.feature-preprocessor.v2":
182
+ raise ConfigurationError("Unsupported feature-preprocessor schema version.")
183
+
184
+ def transform(self, rows: Iterable[Iterable[int | float | None]]) -> tuple[AdvancedVector, ...]:
185
+ return _transform_rows(rows, self)
186
+
187
+ def to_dict(self) -> dict[str, object]:
188
+ return cast(dict[str, object], to_json_compatible(self))
189
+
190
+
191
+ def _result(
192
+ value: SequenceInput,
193
+ *,
194
+ name: str,
195
+ method: str,
196
+ feature_names: Sequence[str],
197
+ values: Sequence[int | float],
198
+ parameters: Mapping[str, object],
199
+ schema_version: str | None = None,
200
+ provenance: Provenance | None = None,
201
+ ) -> AdvancedFingerprintResult:
202
+ _, sequence_id = sequence_and_id(value)
203
+ return AdvancedFingerprintResult(
204
+ name,
205
+ method,
206
+ schema_version or f"dnakit.{name}.v1",
207
+ sequence_id,
208
+ tuple(feature_names),
209
+ tuple(float(item) for item in values),
210
+ FrozenDict(parameters),
211
+ "1.0",
212
+ provenance or Provenance(),
213
+ )
214
+
215
+
216
+ def motif_fingerprint(
217
+ value: SequenceInput,
218
+ motifs: Mapping[str, str],
219
+ *,
220
+ binary: bool = False,
221
+ ) -> AdvancedFingerprintResult:
222
+ """Encode ordered exact/IUPAC motif counts or presence."""
223
+
224
+ if not isinstance(motifs, Mapping) or not motifs:
225
+ raise ConfigurationError("motifs must be a non-empty ordered mapping.")
226
+ if any(not isinstance(name, str) or not name.strip() for name in motifs):
227
+ raise ConfigurationError("motif names must be non-empty strings.")
228
+ if not isinstance(binary, bool):
229
+ raise ConfigurationError("binary must be a boolean.")
230
+ names = tuple(sorted(motifs))
231
+ results = [
232
+ scan_motif(value, motifs[name], mode="iupac", name=name, merge_strands=True)
233
+ for name in names
234
+ ]
235
+ counts = tuple(len(result.hits) for result in results)
236
+ return _result(
237
+ value,
238
+ name="motif_fingerprint",
239
+ method="iupac-motif-presence" if binary else "iupac-motif-count",
240
+ feature_names=tuple(f"motif:{name}" for name in names),
241
+ values=tuple(int(count > 0) for count in counts) if binary else counts,
242
+ parameters={"motifs": {name: motifs[name] for name in names}, "binary": binary},
243
+ )
244
+
245
+
246
+ def restriction_fingerprint(
247
+ value: SequenceInput,
248
+ enzymes: Iterable[str | RestrictionEnzyme],
249
+ *,
250
+ binary: bool = False,
251
+ max_enzymes: int = 10_000,
252
+ ) -> AdvancedFingerprintResult:
253
+ """Encode recognition-site counts for a versioned/user-defined panel."""
254
+
255
+ if isinstance(enzymes, (str, bytes)):
256
+ raise ConfigurationError("enzymes must be an iterable of enzyme definitions.")
257
+ if not isinstance(binary, bool):
258
+ raise ConfigurationError("binary must be a boolean.")
259
+ scan = scan_restriction_sites(value, enzymes, max_enzymes=max_enzymes)
260
+ requested = scan.parameters.get("enzymes")
261
+ if not isinstance(requested, tuple):
262
+ raise AssertionError("Restriction scan must audit its resolved enzyme panel.")
263
+ resolved: list[str] = []
264
+ for item in requested:
265
+ if not isinstance(item, Mapping):
266
+ raise AssertionError("Restriction scan enzyme audit entries must be mappings.")
267
+ name = item.get("name")
268
+ if not isinstance(name, str):
269
+ raise AssertionError("Restriction scan enzyme audit entries must contain names.")
270
+ resolved.append(name)
271
+ resolved_names = tuple(resolved)
272
+ counts = {name: 0 for name in resolved_names}
273
+ for hit in scan.hits:
274
+ counts[hit.enzyme] += 1
275
+ return _result(
276
+ value,
277
+ name="restriction_fingerprint",
278
+ method="restriction-site-presence" if binary else "restriction-site-count",
279
+ feature_names=tuple(f"restriction:{name}" for name in resolved_names),
280
+ values=tuple(int(counts[name] > 0) if binary else counts[name] for name in resolved_names),
281
+ parameters={"enzymes": resolved_names, "binary": binary, "max_enzymes": max_enzymes},
282
+ )
283
+
284
+
285
+ def gc_spatial_fingerprint(
286
+ value: SequenceInput,
287
+ *,
288
+ bins: int = 10,
289
+ ) -> AdvancedFingerprintResult:
290
+ """Pool canonical GC fraction over a fixed number of normalized-position bins."""
291
+
292
+ if isinstance(bins, bool) or not isinstance(bins, int) or bins <= 0 or bins > 10_000:
293
+ raise ConfigurationError("bins must be an integer in [1, 10000].")
294
+ sequence, _ = sequence_and_id(value)
295
+ if sequence.is_gapped:
296
+ raise UnsupportedGapOperationError(
297
+ "Spatial fingerprints cannot silently omit explicit Gap objects.",
298
+ code="SPATIAL_FINGERPRINT_GAP_NOT_ALLOWED",
299
+ )
300
+ text = sequence.symbols
301
+ fractions: list[float] = []
302
+ for index in range(bins):
303
+ start = len(text) * index // bins
304
+ end = len(text) * (index + 1) // bins
305
+ window = text[start:end]
306
+ canonical = tuple(symbol for symbol in window if symbol in "ACGT")
307
+ fractions.append(
308
+ sum(symbol in "GC" for symbol in canonical) / len(canonical) if canonical else 0.0
309
+ )
310
+ return _result(
311
+ value,
312
+ name="gc_spatial_fingerprint",
313
+ method="equal-symbol-bin-gc-fraction",
314
+ feature_names=tuple(f"gc_bin:{index}" for index in range(bins)),
315
+ values=fractions,
316
+ parameters={
317
+ "bins": bins,
318
+ "empty_bin_value": 0.0,
319
+ "ambiguity_policy": "ignore",
320
+ "topology": sequence.topology.value,
321
+ "circular_origin": "stored-origin" if sequence.topology is Topology.CIRCULAR else None,
322
+ },
323
+ )
324
+
325
+
326
+ def repeat_fingerprint(value: SequenceInput) -> AdvancedFingerprintResult:
327
+ """Encode homopolymer/tandem-repeat summary values."""
328
+
329
+ repeat = exact_repeat_fraction(value)
330
+ from dnakit.descriptors import homopolymer_runs
331
+
332
+ homopolymer = homopolymer_runs(value, ambiguity_policy="ignore")
333
+ return _result(
334
+ value,
335
+ name="repeat_fingerprint",
336
+ method="interpretable-exact-repeat-summary",
337
+ feature_names=(
338
+ "repeat_fraction",
339
+ "repeat_run_count",
340
+ "longest_homopolymer",
341
+ "homopolymer_run_count",
342
+ ),
343
+ values=(
344
+ repeat.repeat_fraction,
345
+ len(repeat.runs),
346
+ homopolymer.longest_length,
347
+ len(homopolymer.runs),
348
+ ),
349
+ parameters={"repeat_method": repeat.method, "homopolymer_method": homopolymer.method},
350
+ )
351
+
352
+
353
+ def coding_fingerprint(
354
+ value: SequenceInput,
355
+ *,
356
+ frame: int = 0,
357
+ genetic_code: int = 1,
358
+ ) -> AdvancedFingerprintResult:
359
+ """Encode codon/start/stop/ORF summaries without activity prediction."""
360
+
361
+ codons = codon_statistics(value, frame=frame, genetic_code=genetic_code)
362
+ sites = scan_codon_sites(value, genetic_code=genetic_code)
363
+ return _result(
364
+ value,
365
+ name="coding_fingerprint",
366
+ method="codon-and-site-summary",
367
+ feature_names=("codon_count", "start_count", "stop_count", "six_frame_site_count"),
368
+ values=(codons.codon_count, codons.start_count, codons.stop_count, len(sites.hits)),
369
+ parameters={"frame": frame, "genetic_code": genetic_code},
370
+ )
371
+
372
+
373
+ def thermodynamic_fingerprint(
374
+ value: SequenceInput,
375
+ *,
376
+ conditions: ThermodynamicConditions | None = None,
377
+ structure_adapter: ThermodynamicStructureAdapter | None = None,
378
+ paired_value: SequenceInput | None = None,
379
+ missing_strategy: ThermodynamicMissingStrategy = "zero",
380
+ missing_value: float = -999.0,
381
+ max_loop: int = 30,
382
+ ) -> AdvancedFingerprintResult:
383
+ """Encode a fixed v2 duplex/hairpin/dimer schema with explicit missing data."""
384
+
385
+ sequence, _ = sequence_and_id(value)
386
+ paired_sequence = None if paired_value is None else sequence_and_id(paired_value)[0]
387
+ if missing_strategy not in {"zero", "sentinel", "error"}:
388
+ raise ConfigurationError("Unknown thermodynamic fingerprint missing strategy.")
389
+ if (
390
+ isinstance(missing_value, bool)
391
+ or not isinstance(missing_value, (int, float))
392
+ or not math.isfinite(missing_value)
393
+ ):
394
+ raise ConfigurationError("missing_value must be finite numeric data.")
395
+ if isinstance(max_loop, bool) or not isinstance(max_loop, int) or not 1 <= max_loop <= 30:
396
+ raise ConfigurationError("max_loop must be an integer in [1, 30].")
397
+ if structure_adapter is not None and any(
398
+ not callable(getattr(structure_adapter, name, None))
399
+ for name in ("hairpin", "self_dimer", "heterodimer")
400
+ ):
401
+ raise ConfigurationError(
402
+ "structure_adapter must implement hairpin, self_dimer, and heterodimer.",
403
+ code="INVALID_THERMODYNAMIC_STRUCTURE_ADAPTER",
404
+ )
405
+ nearest = nearest_neighbor(sequence, conditions=conditions)
406
+ fill = 0.0 if missing_strategy == "zero" else float(missing_value)
407
+ if missing_strategy == "error" and structure_adapter is None:
408
+ raise ConfigurationError(
409
+ "The hairpin fingerprint feature is unavailable under missing_strategy=error.",
410
+ code="THERMODYNAMIC_FINGERPRINT_MISSING_FEATURE",
411
+ context={"capability": "hairpin"},
412
+ )
413
+ if missing_strategy == "error" and paired_sequence is None:
414
+ raise ConfigurationError(
415
+ "The heterodimer fingerprint feature is unavailable under missing_strategy=error.",
416
+ code="THERMODYNAMIC_FINGERPRINT_MISSING_FEATURE",
417
+ context={"capability": "heterodimer"},
418
+ )
419
+
420
+ def missing_features(capability: str) -> tuple[float, float, float, float]:
421
+ if missing_strategy == "error":
422
+ raise ConfigurationError(
423
+ f"The {capability} fingerprint feature is unavailable under "
424
+ "missing_strategy=error.",
425
+ code="THERMODYNAMIC_FINGERPRINT_MISSING_FEATURE",
426
+ context={"capability": capability},
427
+ )
428
+ return (0.0, fill, fill, fill)
429
+
430
+ validated_structure_results: list[Primer3ThermodynamicResult] = []
431
+
432
+ def structure_features(
433
+ result: object,
434
+ *,
435
+ capability: str,
436
+ expected_sequences: tuple[str, ...],
437
+ ) -> tuple[float, float, float, float]:
438
+ validated = validate_primer3_result(
439
+ result,
440
+ capability=capability,
441
+ sequences_5to3=expected_sequences,
442
+ conditions=nearest.conditions,
443
+ max_loop=max_loop,
444
+ output_structure=False,
445
+ error_code="MISMATCHED_THERMODYNAMIC_FINGERPRINT_RESULT",
446
+ )
447
+ if validated.structure_found is None or validated.delta_g_kcal_per_mol is None:
448
+ raise BackendExecutionError(
449
+ "Structure adapter omitted required fingerprint values.",
450
+ code="INVALID_THERMODYNAMIC_FINGERPRINT_RESULT",
451
+ context={"capability": capability},
452
+ )
453
+ validated_structure_results.append(validated)
454
+ return (
455
+ 1.0,
456
+ float(validated.structure_found),
457
+ validated.tm_celsius,
458
+ validated.delta_g_kcal_per_mol,
459
+ )
460
+
461
+ adapter_provenance: Provenance | None = None
462
+ backend_name: str | None = None
463
+ backend_version: str | None = None
464
+ if structure_adapter is None:
465
+ hairpin_values = missing_features("hairpin")
466
+ self_dimer_values = missing_features("self_dimer")
467
+ heterodimer_values = missing_features("heterodimer")
468
+ else:
469
+ hairpin_result = structure_adapter.hairpin(
470
+ sequence,
471
+ conditions=conditions,
472
+ max_loop=max_loop,
473
+ output_structure=False,
474
+ )
475
+ self_dimer_result = structure_adapter.self_dimer(
476
+ sequence,
477
+ conditions=conditions,
478
+ max_loop=max_loop,
479
+ output_structure=False,
480
+ )
481
+ hairpin_values = structure_features(
482
+ hairpin_result,
483
+ capability="hairpin",
484
+ expected_sequences=(sequence.symbols,),
485
+ )
486
+ self_dimer_values = structure_features(
487
+ self_dimer_result,
488
+ capability="self_dimer",
489
+ expected_sequences=(sequence.symbols,),
490
+ )
491
+ if paired_sequence is None:
492
+ heterodimer_values = missing_features("heterodimer")
493
+ else:
494
+ heterodimer_values = structure_features(
495
+ structure_adapter.heterodimer(
496
+ sequence,
497
+ paired_sequence,
498
+ conditions=conditions,
499
+ max_loop=max_loop,
500
+ output_structure=False,
501
+ ),
502
+ capability="heterodimer",
503
+ expected_sequences=(sequence.symbols, paired_sequence.symbols),
504
+ )
505
+ adapter_provenance = hairpin_result.provenance
506
+ backend_name = hairpin_result.backend.name
507
+ backend_version = hairpin_result.backend.version
508
+ if any(
509
+ result.backend != hairpin_result.backend
510
+ or result.provenance != hairpin_result.provenance
511
+ for result in validated_structure_results[1:]
512
+ ):
513
+ raise BackendExecutionError(
514
+ "Structure adapter returned inconsistent backend or provenance metadata.",
515
+ code="INCONSISTENT_THERMODYNAMIC_FINGERPRINT_RESULTS",
516
+ )
517
+ return _result(
518
+ value,
519
+ name="thermodynamic_fingerprint",
520
+ method="santalucia1998-duplex-with-explicit-primer3-structure-features",
521
+ feature_names=(
522
+ "tm_celsius",
523
+ "delta_h",
524
+ "delta_s",
525
+ "delta_g",
526
+ "hairpin_available",
527
+ "hairpin_found",
528
+ "hairpin_tm_celsius",
529
+ "hairpin_delta_g",
530
+ "self_dimer_available",
531
+ "self_dimer_found",
532
+ "self_dimer_tm_celsius",
533
+ "self_dimer_delta_g",
534
+ "heterodimer_available",
535
+ "heterodimer_found",
536
+ "heterodimer_tm_celsius",
537
+ "heterodimer_delta_g",
538
+ ),
539
+ values=(
540
+ nearest.tm_celsius,
541
+ nearest.delta_h_kcal_per_mol,
542
+ nearest.delta_s_cal_per_k_mol,
543
+ nearest.delta_g_kcal_per_mol,
544
+ *hairpin_values,
545
+ *self_dimer_values,
546
+ *heterodimer_values,
547
+ ),
548
+ parameters={
549
+ "conditions": to_json_compatible(nearest.conditions),
550
+ "parameter_set": nearest.parameter_set,
551
+ "hairpin_dimer_included": structure_adapter is not None,
552
+ "heterodimer_included": structure_adapter is not None and paired_sequence is not None,
553
+ "structure_adapter_supplied": structure_adapter is not None,
554
+ "automatic_backend_probe": False,
555
+ "automatic_backend_execution": False,
556
+ "missing_strategy": missing_strategy,
557
+ "missing_value": fill,
558
+ "paired_sequence_supplied": paired_sequence is not None,
559
+ "max_loop": max_loop,
560
+ "backend_name": backend_name,
561
+ "backend_version": backend_version,
562
+ },
563
+ schema_version="dnakit.thermodynamic_fingerprint.v2",
564
+ provenance=adapter_provenance,
565
+ )
566
+
567
+
568
+ def multiscale_fingerprint(
569
+ value: SequenceInput,
570
+ *,
571
+ k_values: Iterable[int] = (1, 2, 3),
572
+ ) -> AdvancedFingerprintResult:
573
+ """Concatenate frequency k-mer vectors at explicit scales plus global GC."""
574
+
575
+ if isinstance(k_values, (str, bytes)):
576
+ raise ConfigurationError("k_values must be an iterable of positive integers.")
577
+ scales_list: list[int] = []
578
+ try:
579
+ iterator = iter(k_values)
580
+ except TypeError as exc:
581
+ raise ConfigurationError("k_values must be an iterable of positive integers.") from exc
582
+ for index, scale in enumerate(iterator):
583
+ if index >= DEFAULT_MAX_MULTISCALE_LEVELS:
584
+ raise ConfigurationError(
585
+ "k_values exceeds the hard multiscale-level limit.",
586
+ code="MULTISCALE_LEVEL_LIMIT",
587
+ )
588
+ scales_list.append(scale)
589
+ scales = tuple(scales_list)
590
+ if not scales or any(isinstance(k, bool) or not isinstance(k, int) or k <= 0 for k in scales):
591
+ raise ConfigurationError("k_values must contain positive integers.")
592
+ if len(scales) != len(set(scales)):
593
+ raise ConfigurationError("k_values must be unique.")
594
+ names: list[str] = []
595
+ values: list[float] = []
596
+ for k in scales:
597
+ result = kmer_fingerprint(value, k=k, mode="frequency")
598
+ names.extend(f"k{k}:{name}" for name in result.feature_names)
599
+ values.extend(float(item) for item in result.dense_values())
600
+ gc = gc_at_content(value, ambiguity_policy="ignore")
601
+ names.append("global_gc")
602
+ values.append(0.0 if gc.gc_fraction is None else gc.gc_fraction)
603
+ return _result(
604
+ value,
605
+ name="multiscale_fingerprint",
606
+ method="concatenated-kmer-frequency-and-global-gc",
607
+ feature_names=names,
608
+ values=values,
609
+ parameters={"k_values": scales},
610
+ )
611
+
612
+
613
+ def hybrid_fingerprint(
614
+ components: Mapping[str, AdvancedFingerprintResult],
615
+ *,
616
+ weights: Mapping[str, int | float] | None = None,
617
+ ) -> AdvancedFingerprintResult:
618
+ """Concatenate named compatible component results with optional scalar weights."""
619
+
620
+ if not isinstance(components, Mapping) or not components:
621
+ raise ConfigurationError("components must be a non-empty mapping.")
622
+ if any(not isinstance(name, str) or not name.strip() for name in components):
623
+ raise ConfigurationError("Component names must be non-empty strings.")
624
+ if any(not isinstance(value, AdvancedFingerprintResult) for value in components.values()):
625
+ raise ConfigurationError("Component values must be AdvancedFingerprintResult objects.")
626
+ if weights is not None and not isinstance(weights, Mapping):
627
+ raise ConfigurationError("weights must be a mapping or None.")
628
+ ordered = tuple(sorted(components))
629
+ resolved_weights = {name: 1.0 for name in ordered}
630
+ for name, raw in (weights or {}).items():
631
+ if name not in resolved_weights:
632
+ raise ConfigurationError("Weight names must match component names.")
633
+ if (
634
+ isinstance(raw, bool)
635
+ or not isinstance(raw, (int, float))
636
+ or not math.isfinite(raw)
637
+ or raw < 0
638
+ ):
639
+ raise ConfigurationError("Component weights must be finite non-negative numbers.")
640
+ resolved_weights[name] = float(raw)
641
+ sequence_ids = {components[name].sequence_id for name in ordered}
642
+ if len(sequence_ids) > 1:
643
+ raise ConfigurationError("Hybrid components must refer to the same sequence ID.")
644
+ names = tuple(
645
+ f"{component}:{feature}"
646
+ for component in ordered
647
+ for feature in components[component].feature_names
648
+ )
649
+ values = tuple(
650
+ value * resolved_weights[component]
651
+ for component in ordered
652
+ for value in components[component].values
653
+ )
654
+ return AdvancedFingerprintResult(
655
+ "hybrid_fingerprint",
656
+ "weighted-component-concatenation",
657
+ "dnakit.hybrid_fingerprint.v1",
658
+ next(iter(sequence_ids)),
659
+ names,
660
+ values,
661
+ FrozenDict(
662
+ {
663
+ "components": {name: components[name].schema_version for name in ordered},
664
+ "weights": resolved_weights,
665
+ }
666
+ ),
667
+ "1.0",
668
+ Provenance(),
669
+ )
670
+
671
+
672
+ def _materialize_rows(
673
+ rows: Iterable[Iterable[int | float | None]],
674
+ *,
675
+ max_rows: int,
676
+ width: int,
677
+ ) -> tuple[tuple[float | None, ...], ...]:
678
+ output: list[tuple[float | None, ...]] = []
679
+ for index, row in enumerate(rows):
680
+ if index >= max_rows:
681
+ raise ConfigurationError("Feature rows exceed max_rows.")
682
+ try:
683
+ values = tuple(islice(iter(row), width + 1))
684
+ except TypeError as exc:
685
+ raise ConfigurationError("Each feature row must be an iterable.") from exc
686
+ if len(values) != width:
687
+ raise ConfigurationError("Feature row width does not match the fitted schema.")
688
+ for value in values:
689
+ if value is not None and (
690
+ isinstance(value, bool)
691
+ or not isinstance(value, (int, float))
692
+ or not math.isfinite(value)
693
+ ):
694
+ raise ConfigurationError("Feature values must be finite numbers or None.")
695
+ output.append(tuple(None if item is None else float(item) for item in values))
696
+ if not output:
697
+ raise ConfigurationError("Feature matrix must contain at least one row.")
698
+ return tuple(output)
699
+
700
+
701
+ def fit_preprocessor(
702
+ rows: Iterable[Iterable[int | float | None]],
703
+ *,
704
+ feature_names: Sequence[str],
705
+ mode: PreprocessMode = "standard",
706
+ missing_strategy: Literal["error", "mean", "zero"] = "error",
707
+ variance_threshold: float = 0.0,
708
+ max_rows: int = DEFAULT_MAX_PREPROCESS_ROWS,
709
+ ) -> FeaturePreprocessor:
710
+ """Fit imputation/scaling/low-variance parameters on training rows only."""
711
+
712
+ if isinstance(feature_names, (str, bytes)):
713
+ raise ConfigurationError("feature_names must be a sequence of names, not text.")
714
+ names = tuple(feature_names)
715
+ if (
716
+ not names
717
+ or any(not isinstance(name, str) or not name.strip() for name in names)
718
+ or len(set(names)) != len(names)
719
+ ):
720
+ raise ConfigurationError("feature_names must be unique non-empty strings.")
721
+ if mode not in {"none", "standard", "minmax", "l1", "l2"}:
722
+ raise ConfigurationError("Unknown preprocessing mode.")
723
+ if missing_strategy not in {"error", "mean", "zero"}:
724
+ raise ConfigurationError("Unknown missing strategy.")
725
+ if (
726
+ isinstance(variance_threshold, bool)
727
+ or not isinstance(variance_threshold, (int, float))
728
+ or not math.isfinite(variance_threshold)
729
+ or variance_threshold < 0
730
+ ):
731
+ raise ConfigurationError("variance_threshold must be finite and non-negative.")
732
+ if (
733
+ isinstance(max_rows, bool)
734
+ or not isinstance(max_rows, int)
735
+ or not 0 < max_rows <= DEFAULT_MAX_PREPROCESS_ROWS
736
+ ):
737
+ raise ConfigurationError(f"max_rows must be in [1, {DEFAULT_MAX_PREPROCESS_ROWS}].")
738
+ matrix = _materialize_rows(rows, max_rows=max_rows, width=len(names))
739
+ columns = tuple(tuple(row[index] for row in matrix) for index in range(len(names)))
740
+ centers: list[float] = []
741
+ scales: list[float] = []
742
+ imputations: list[float] = []
743
+ variances: list[float] = []
744
+ for column in columns:
745
+ observed = tuple(value for value in column if value is not None)
746
+ if missing_strategy == "error" and len(observed) != len(column):
747
+ raise ConfigurationError("Missing feature encountered under error policy.")
748
+ center = math.fsum(observed) / len(observed) if observed else 0.0
749
+ imputation = center if missing_strategy == "mean" else 0.0
750
+ imputed = tuple(imputation if value is None else value for value in column)
751
+ mean = math.fsum(imputed) / len(imputed)
752
+ variance = math.fsum((value - mean) ** 2 for value in imputed) / len(imputed)
753
+ if mode == "standard":
754
+ centers.append(mean)
755
+ scales.append(math.sqrt(variance) or 1.0)
756
+ elif mode == "minmax":
757
+ minimum, maximum = min(imputed), max(imputed)
758
+ centers.append(minimum)
759
+ scales.append(maximum - minimum or 1.0)
760
+ else:
761
+ centers.append(0.0)
762
+ scales.append(1.0)
763
+ imputations.append(imputation)
764
+ variances.append(variance)
765
+ kept = tuple(
766
+ index for index, variance in enumerate(variances) if variance >= variance_threshold
767
+ )
768
+ return FeaturePreprocessor(
769
+ names,
770
+ mode,
771
+ tuple(centers),
772
+ tuple(scales),
773
+ tuple(imputations),
774
+ kept,
775
+ float(variance_threshold),
776
+ missing_strategy,
777
+ max_rows,
778
+ )
779
+
780
+
781
+ def _transform_rows(
782
+ rows: Iterable[Iterable[int | float | None]], preprocessor: FeaturePreprocessor
783
+ ) -> tuple[AdvancedVector, ...]:
784
+ if not isinstance(preprocessor, FeaturePreprocessor):
785
+ raise ConfigurationError("preprocessor must be FeaturePreprocessor.")
786
+ matrix = _materialize_rows(
787
+ rows,
788
+ max_rows=preprocessor.max_rows,
789
+ width=len(preprocessor.feature_names),
790
+ )
791
+ transformed: list[AdvancedVector] = []
792
+ for row in matrix:
793
+ values: list[float] = []
794
+ for index, raw in enumerate(row):
795
+ if raw is None:
796
+ if preprocessor.missing_strategy == "error":
797
+ raise ConfigurationError("Missing feature encountered under error policy.")
798
+ raw = preprocessor.imputation_values[index]
799
+ values.append((raw - preprocessor.centers[index]) / preprocessor.scales[index])
800
+ selected = [values[index] for index in preprocessor.kept_indices]
801
+ if preprocessor.mode in {"l1", "l2"}:
802
+ norm = (
803
+ math.fsum(abs(value) for value in selected)
804
+ if preprocessor.mode == "l1"
805
+ else math.sqrt(math.fsum(value * value for value in selected))
806
+ )
807
+ if norm:
808
+ selected = [value / norm for value in selected]
809
+ transformed.append(tuple(selected))
810
+ return tuple(transformed)
811
+
812
+
813
+ __all__ = [
814
+ "DEFAULT_MAX_MULTISCALE_LEVELS",
815
+ "DEFAULT_MAX_PREPROCESS_ROWS",
816
+ "AdvancedFingerprintResult",
817
+ "FeaturePreprocessor",
818
+ "ThermodynamicMissingStrategy",
819
+ "ThermodynamicStructureAdapter",
820
+ "coding_fingerprint",
821
+ "fit_preprocessor",
822
+ "gc_spatial_fingerprint",
823
+ "hybrid_fingerprint",
824
+ "motif_fingerprint",
825
+ "multiscale_fingerprint",
826
+ "repeat_fingerprint",
827
+ "restriction_fingerprint",
828
+ "thermodynamic_fingerprint",
829
+ ]