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,142 @@
1
+ """Immutable secondary-structure and ensemble result objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, cast
7
+
8
+ from dnakit.core import BackendInfo, Provenance
9
+ from dnakit.core._json import to_json_compatible
10
+
11
+
12
+ class _SerializableResult:
13
+ def to_dict(self) -> dict[str, Any]:
14
+ return cast(dict[str, Any], to_json_compatible(self))
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SecondaryBasePair(_SerializableResult):
19
+ first_global_index: int
20
+ second_global_index: int
21
+ first_strand_index: int
22
+ first_local_index: int
23
+ second_strand_index: int
24
+ second_local_index: int
25
+ bracket_type: str
26
+ inter_strand: bool
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SecondaryStem(_SerializableResult):
31
+ base_pairs: tuple[SecondaryBasePair, ...]
32
+ length: int
33
+ inter_strand: bool
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SecondaryStructureSummary(_SerializableResult):
38
+ strands_5to3: tuple[str, ...]
39
+ dot_bracket: str
40
+ base_pairs: tuple[SecondaryBasePair, ...]
41
+ stems: tuple[SecondaryStem, ...]
42
+ structure_type: str
43
+ base_pair_count: int
44
+ paired_base_fraction: float
45
+ stem_lengths: tuple[int, ...]
46
+ hairpin_count: int
47
+ hairpin_loop_lengths: tuple[int, ...]
48
+ max_contiguous_pair_count: int
49
+ three_prime_window: int
50
+ three_prime_dimer: bool
51
+ three_prime_dimer_max_contiguous_pairs: int
52
+ method: str
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class AccessibilityWindow(_SerializableResult):
57
+ start: int
58
+ end: int
59
+ mean_unpaired_probability: float
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class PairProbabilityResult(_SerializableResult):
64
+ strands_5to3: tuple[str, ...]
65
+ pair_probabilities: tuple[tuple[float, ...], ...]
66
+ pairing_probabilities_by_base: tuple[float, ...]
67
+ unpaired_probabilities_by_base: tuple[float, ...]
68
+ accessibility_window_size: int
69
+ accessibility_windows: tuple[AccessibilityWindow, ...]
70
+ most_accessible_window_start: int | None
71
+ method: str
72
+ applicability: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class PredictedSecondaryStructure(_SerializableResult):
77
+ summary: SecondaryStructureSummary
78
+ free_energy_kcal_per_mol: float
79
+ stack_free_energy_kcal_per_mol: float | None
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class NupackComplexResult(_SerializableResult):
84
+ strands_5to3: tuple[str, ...]
85
+ material: str
86
+ temperature_celsius: float
87
+ monovalent_molar: float
88
+ magnesium_molar: float
89
+ ensemble: str
90
+ partition_function_log: float
91
+ ensemble_free_energy_kcal_per_mol: float
92
+ mfe_structures: tuple[PredictedSecondaryStructure, ...]
93
+ pair_probabilities: PairProbabilityResult
94
+ suboptimal_structures: tuple[PredictedSecondaryStructure, ...]
95
+ boltzmann_samples: tuple[str, ...]
96
+ ensemble_size: int
97
+ target_structure: SecondaryStructureSummary | None
98
+ target_structure_probability: float | None
99
+ target_ensemble_defect: float | None
100
+ method: str
101
+ backend: BackendInfo
102
+ provenance: Provenance
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class ComplexConcentration(_SerializableResult):
107
+ name: str
108
+ strand_names: tuple[str, ...]
109
+ concentration_molar: float
110
+ is_target: bool
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class NupackTubeResult(_SerializableResult):
115
+ strand_names: tuple[str, ...]
116
+ sequences_5to3: tuple[str, ...]
117
+ input_concentrations_molar: tuple[float, ...]
118
+ complex_concentrations: tuple[ComplexConcentration, ...]
119
+ target_complex_name: str
120
+ target_strand_names: tuple[str, ...]
121
+ target_complex_concentration_molar: float
122
+ complex_fraction_denominator_molar: float
123
+ target_complex_fraction: float
124
+ non_target_complex_fraction: float
125
+ fraction_bases_unpaired: float
126
+ max_complex_size: int
127
+ method: str
128
+ backend: BackendInfo
129
+ provenance: Provenance
130
+
131
+
132
+ __all__ = [
133
+ "AccessibilityWindow",
134
+ "ComplexConcentration",
135
+ "NupackComplexResult",
136
+ "NupackTubeResult",
137
+ "PairProbabilityResult",
138
+ "PredictedSecondaryStructure",
139
+ "SecondaryBasePair",
140
+ "SecondaryStem",
141
+ "SecondaryStructureSummary",
142
+ ]
@@ -0,0 +1,125 @@
1
+ """Deterministic native sequence and fingerprint similarity tools."""
2
+
3
+ from dnakit.similarity.approximate import (
4
+ DEFAULT_MAX_APPROXIMATE_CELLS,
5
+ DEFAULT_MAX_APPROXIMATE_MATCHES,
6
+ DEFAULT_MAX_APPROXIMATE_TARGETS,
7
+ approximate_search,
8
+ )
9
+ from dnakit.similarity.compare import compare, exact_similarity
10
+ from dnakit.similarity.dashing import (
11
+ DEFAULT_MAX_DASHING_CAPTURE_BYTES,
12
+ DEFAULT_MAX_DASHING_INPUT_BYTES,
13
+ DEFAULT_MAX_DASHING_ITEMS,
14
+ DEFAULT_MAX_DASHING_OUTPUT_BYTES,
15
+ DEFAULT_MAX_DASHING_SKETCH_MEMORY_BYTES,
16
+ DashingAdapter,
17
+ DashingInput,
18
+ DashingMode,
19
+ )
20
+ from dnakit.similarity.distance import (
21
+ DEFAULT_MAX_EDIT_CELLS,
22
+ edit_distance,
23
+ hamming_distance,
24
+ levenshtein_distance,
25
+ )
26
+ from dnakit.similarity.index import (
27
+ SketchIndex,
28
+ build_sketch_index,
29
+ load_sketch_index,
30
+ nearest_neighbors,
31
+ save_sketch_index,
32
+ )
33
+ from dnakit.similarity.matrix import (
34
+ DEFAULT_MAX_MATRIX_ITEMS,
35
+ pairwise_matrix,
36
+ similarity_matrix,
37
+ )
38
+ from dnakit.similarity.results import (
39
+ ApproximateMatch,
40
+ ApproximateSearchResult,
41
+ DashingJaccardMatrixResult,
42
+ DashingNeighborHit,
43
+ DashingNeighborRow,
44
+ DashingTopKResult,
45
+ DistanceResult,
46
+ EditStep,
47
+ Mismatch,
48
+ NearestNeighborHit,
49
+ NearestNeighborResult,
50
+ SearchResult,
51
+ SequenceMatch,
52
+ SimilarityMatrixResult,
53
+ SimilarityResult,
54
+ SketchSimilarityResult,
55
+ )
56
+ from dnakit.similarity.search import (
57
+ DEFAULT_MAX_SEARCH_MATCHES,
58
+ DEFAULT_MAX_SEARCH_TARGETS,
59
+ exact_search,
60
+ find_subsequence,
61
+ reverse_complement_search,
62
+ subsequence_search,
63
+ )
64
+ from dnakit.similarity.sketch import SketchMetric, sketch_similarity
65
+ from dnakit.similarity.vector import (
66
+ fingerprint_similarity,
67
+ kmer_similarity,
68
+ kmer_vector_similarity,
69
+ )
70
+
71
+ __all__ = [
72
+ "DEFAULT_MAX_APPROXIMATE_CELLS",
73
+ "DEFAULT_MAX_APPROXIMATE_MATCHES",
74
+ "DEFAULT_MAX_APPROXIMATE_TARGETS",
75
+ "DEFAULT_MAX_DASHING_CAPTURE_BYTES",
76
+ "DEFAULT_MAX_DASHING_INPUT_BYTES",
77
+ "DEFAULT_MAX_DASHING_ITEMS",
78
+ "DEFAULT_MAX_DASHING_OUTPUT_BYTES",
79
+ "DEFAULT_MAX_DASHING_SKETCH_MEMORY_BYTES",
80
+ "DEFAULT_MAX_EDIT_CELLS",
81
+ "DEFAULT_MAX_MATRIX_ITEMS",
82
+ "DEFAULT_MAX_SEARCH_MATCHES",
83
+ "DEFAULT_MAX_SEARCH_TARGETS",
84
+ "ApproximateMatch",
85
+ "ApproximateSearchResult",
86
+ "DashingAdapter",
87
+ "DashingInput",
88
+ "DashingJaccardMatrixResult",
89
+ "DashingMode",
90
+ "DashingNeighborHit",
91
+ "DashingNeighborRow",
92
+ "DashingTopKResult",
93
+ "DistanceResult",
94
+ "EditStep",
95
+ "Mismatch",
96
+ "NearestNeighborHit",
97
+ "NearestNeighborResult",
98
+ "SearchResult",
99
+ "SequenceMatch",
100
+ "SimilarityMatrixResult",
101
+ "SimilarityResult",
102
+ "SketchIndex",
103
+ "SketchMetric",
104
+ "SketchSimilarityResult",
105
+ "approximate_search",
106
+ "build_sketch_index",
107
+ "compare",
108
+ "edit_distance",
109
+ "exact_search",
110
+ "exact_similarity",
111
+ "find_subsequence",
112
+ "fingerprint_similarity",
113
+ "hamming_distance",
114
+ "kmer_similarity",
115
+ "kmer_vector_similarity",
116
+ "levenshtein_distance",
117
+ "load_sketch_index",
118
+ "nearest_neighbors",
119
+ "pairwise_matrix",
120
+ "reverse_complement_search",
121
+ "save_sketch_index",
122
+ "similarity_matrix",
123
+ "sketch_similarity",
124
+ "subsequence_search",
125
+ ]
@@ -0,0 +1,152 @@
1
+ """Shared input and policy helpers for native similarity functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from typing import TypeAlias
8
+
9
+ from dnakit.core import DNA, DNARecord, DNASequence, DNASet, Gap
10
+ from dnakit.core.facade import resolve_single_dna
11
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
12
+
13
+ SequenceInput: TypeAlias = DNA | DNASequence | DNARecord
14
+ NumericVector: TypeAlias = Sequence[int | float]
15
+ NamedVector: TypeAlias = Mapping[str, int | float]
16
+
17
+
18
+ def sequence_and_id(value: SequenceInput, *, role: str) -> tuple[DNASequence, str | None]:
19
+ """Resolve a sequence input and reject untyped values consistently."""
20
+
21
+ if isinstance(value, (DNA, DNARecord, DNASequence)):
22
+ sequence, record = resolve_single_dna(value)
23
+ sequence_id = None if record is None else record.id
24
+ else:
25
+ raise ConfigurationError(
26
+ f"{role} must be DNASequence or DNARecord.",
27
+ code="INVALID_SIMILARITY_SEQUENCE",
28
+ context={"role": role, "input_type": type(value).__name__},
29
+ )
30
+ reject_gaps(sequence, role=role)
31
+ return sequence, sequence_id
32
+
33
+
34
+ def reject_gaps(sequence: DNASequence, *, role: str) -> None:
35
+ """Reject every explicit Gap instead of silently omitting it."""
36
+
37
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
38
+ if gaps:
39
+ raise UnsupportedGapOperationError(
40
+ "Native MVP similarity does not accept explicit Gap objects.",
41
+ code="SIMILARITY_GAP_NOT_ALLOWED",
42
+ context={
43
+ "role": role,
44
+ "gap_count": len(gaps),
45
+ "unknown_gap_count": sum(gap.length is None for gap in gaps),
46
+ },
47
+ hint="Compare gap-free fragments explicitly; gaps are never silently omitted.",
48
+ )
49
+
50
+
51
+ def materialize_targets(
52
+ targets: object,
53
+ *,
54
+ max_targets: int,
55
+ ) -> tuple[SequenceInput, ...]:
56
+ """Materialize at most ``max_targets`` targets without exhausting iterators."""
57
+
58
+ resolved: tuple[SequenceInput, ...]
59
+ if isinstance(targets, DNA):
60
+ resolved = targets.records[: max_targets + 1]
61
+ elif isinstance(targets, (DNASequence, DNARecord)):
62
+ resolved = (targets,)
63
+ elif isinstance(targets, DNASet):
64
+ resolved = targets.records[: max_targets + 1]
65
+ elif isinstance(targets, (str, bytes)):
66
+ raise ConfigurationError(
67
+ "Search targets must be DNASequence/DNARecord objects, not raw text.",
68
+ code="INVALID_SEARCH_TARGETS",
69
+ )
70
+ elif not isinstance(targets, Iterable):
71
+ raise ConfigurationError(
72
+ "Search targets must be one sequence, DNASet, or iterable of sequences.",
73
+ code="INVALID_SEARCH_TARGETS",
74
+ )
75
+ else:
76
+ collected: list[SequenceInput] = []
77
+ for index, item in enumerate(targets):
78
+ if not isinstance(item, (DNA, DNASequence, DNARecord)):
79
+ raise ConfigurationError(
80
+ "Every search target must be DNASequence or DNARecord.",
81
+ code="INVALID_SEARCH_TARGET",
82
+ context={"target_index": index, "type": type(item).__name__},
83
+ )
84
+ if index >= max_targets:
85
+ raise ConfigurationError(
86
+ "Search target input exceeds max_targets.",
87
+ code="SEARCH_TARGET_LIMIT_EXCEEDED",
88
+ context={
89
+ "target_count": max_targets + 1,
90
+ "target_count_is_lower_bound": True,
91
+ "max_targets": max_targets,
92
+ },
93
+ hint="Reduce the target input or increase max_targets explicitly.",
94
+ )
95
+ if isinstance(item, DNA):
96
+ if not item.is_single:
97
+ raise ConfigurationError(
98
+ "A nested DNA search target must contain exactly one record.",
99
+ code="INVALID_SEARCH_TARGET",
100
+ context={"target_index": index, "record_count": len(item)},
101
+ )
102
+ collected.append(item.record)
103
+ else:
104
+ collected.append(item)
105
+ resolved = tuple(collected)
106
+ if len(resolved) > max_targets:
107
+ raise ConfigurationError(
108
+ "Search target input exceeds max_targets.",
109
+ code="SEARCH_TARGET_LIMIT_EXCEEDED",
110
+ context={
111
+ "target_count": max_targets + 1,
112
+ "target_count_is_lower_bound": True,
113
+ "max_targets": max_targets,
114
+ },
115
+ hint="Reduce the target input or increase max_targets explicitly.",
116
+ )
117
+ if any(not isinstance(item, (DNASequence, DNARecord)) for item in resolved):
118
+ raise ConfigurationError(
119
+ "Every search target must be DNASequence or DNARecord.",
120
+ code="INVALID_SEARCH_TARGET",
121
+ )
122
+ return resolved
123
+
124
+
125
+ def validate_bool(value: bool, name: str) -> None:
126
+ if not isinstance(value, bool):
127
+ raise ConfigurationError(f"{name} must be boolean.", context={name: value})
128
+
129
+
130
+ def validate_positive_int(value: int, name: str) -> None:
131
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
132
+ raise ConfigurationError(
133
+ f"{name} must be a positive integer.",
134
+ context={name: value},
135
+ )
136
+
137
+
138
+ def validate_non_negative_number(value: int | float, name: str) -> float:
139
+ if (
140
+ isinstance(value, bool)
141
+ or not isinstance(value, (int, float))
142
+ or not math.isfinite(value)
143
+ or value < 0
144
+ ):
145
+ raise ConfigurationError(
146
+ f"{name} must be a finite non-negative number.",
147
+ context={name: value},
148
+ )
149
+ return float(value)
150
+
151
+
152
+ __all__ = ["NamedVector", "NumericVector", "SequenceInput"]
@@ -0,0 +1,258 @@
1
+ """Bounded approximate substring matching by dynamic programming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable
7
+
8
+ from dnakit.core import DNA, DNARecord, DNASequence, Strand, Topology
9
+ from dnakit.core._json import FrozenDict
10
+ from dnakit.core.facade import resolve_single_dna
11
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
12
+
13
+ from ._shared import SequenceInput, materialize_targets, validate_bool
14
+ from .results import ApproximateMatch, ApproximateSearchResult
15
+
16
+ DEFAULT_MAX_APPROXIMATE_CELLS = 5_000_000
17
+ DEFAULT_MAX_APPROXIMATE_MATCHES = 100_000
18
+ DEFAULT_MAX_APPROXIMATE_TARGETS = 100_000
19
+
20
+
21
+ def _finite_non_negative(value: object, name: str) -> float:
22
+ if (
23
+ isinstance(value, bool)
24
+ or not isinstance(value, (int, float))
25
+ or not math.isfinite(value)
26
+ or value < 0
27
+ ):
28
+ raise ConfigurationError(
29
+ f"{name} must be a finite non-negative number.",
30
+ code="INVALID_APPROXIMATE_MATCH_COST",
31
+ context={"field": name, "value": value},
32
+ )
33
+ return float(value)
34
+
35
+
36
+ def _positive_int(value: object, name: str) -> int:
37
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
38
+ raise ConfigurationError(
39
+ f"{name} must be a positive integer.",
40
+ code="INVALID_APPROXIMATE_MATCH_LIMIT",
41
+ )
42
+ return value
43
+
44
+
45
+ def _sequence_and_id_without_gap_policy(
46
+ value: SequenceInput, *, role: str
47
+ ) -> tuple[DNASequence, str | None]:
48
+ """Resolve approximate-search inputs before applying its domain-specific Gap error."""
49
+
50
+ if isinstance(value, (DNA, DNARecord, DNASequence)):
51
+ sequence, record = resolve_single_dna(value)
52
+ return sequence, None if record is None else record.id
53
+ raise ConfigurationError(
54
+ f"{role} must be DNASequence or DNARecord.",
55
+ code="INVALID_SIMILARITY_SEQUENCE",
56
+ context={"role": role, "input_type": type(value).__name__},
57
+ )
58
+
59
+
60
+ def _scan_one(
61
+ query: str,
62
+ target: str,
63
+ *,
64
+ max_distance: float,
65
+ substitution_cost: float,
66
+ insertion_cost: float,
67
+ deletion_cost: float,
68
+ target_index: int,
69
+ target_id: str | None,
70
+ strand: Strand,
71
+ remaining_matches: int,
72
+ ) -> tuple[ApproximateMatch, ...]:
73
+ columns = len(target) + 1
74
+ previous = [0.0] * columns
75
+ starts_previous = list(range(columns))
76
+ for query_index, query_symbol in enumerate(query, start=1):
77
+ current = [query_index * deletion_cost] + [0.0] * len(target)
78
+ starts_current = [0] * columns
79
+ for target_index_1, target_symbol in enumerate(target, start=1):
80
+ candidates = (
81
+ (
82
+ previous[target_index_1 - 1]
83
+ + (0.0 if query_symbol == target_symbol else substitution_cost),
84
+ starts_previous[target_index_1 - 1],
85
+ 0,
86
+ ),
87
+ (
88
+ previous[target_index_1] + deletion_cost,
89
+ starts_previous[target_index_1],
90
+ 1,
91
+ ),
92
+ (
93
+ current[target_index_1 - 1] + insertion_cost,
94
+ starts_current[target_index_1 - 1],
95
+ 2,
96
+ ),
97
+ )
98
+ score, start, _ = min(candidates, key=lambda item: (item[0], item[2], item[1]))
99
+ current[target_index_1] = score
100
+ starts_current[target_index_1] = start
101
+ previous, current = current, previous
102
+ starts_previous, starts_current = starts_current, starts_previous
103
+ matches: list[ApproximateMatch] = []
104
+ seen: set[tuple[int, int, float]] = set()
105
+ for end, distance in enumerate(previous[1:], start=1):
106
+ start = starts_previous[end]
107
+ key = (start, end, distance)
108
+ if distance <= max_distance and key not in seen:
109
+ if len(matches) >= remaining_matches:
110
+ raise ConfigurationError(
111
+ "Approximate search exceeds max_matches.",
112
+ code="APPROXIMATE_MATCH_LIMIT_EXCEEDED",
113
+ context={"match_count_lower_bound": remaining_matches + 1},
114
+ )
115
+ seen.add(key)
116
+ matches.append(
117
+ ApproximateMatch(
118
+ target_index=target_index,
119
+ target_id=target_id,
120
+ start=start,
121
+ end=end,
122
+ strand=strand,
123
+ distance=distance,
124
+ )
125
+ )
126
+ return tuple(matches)
127
+
128
+
129
+ def approximate_search(
130
+ query: SequenceInput,
131
+ targets: SequenceInput | Iterable[SequenceInput],
132
+ *,
133
+ max_distance: int | float,
134
+ substitution_cost: int | float = 1.0,
135
+ insertion_cost: int | float = 1.0,
136
+ deletion_cost: int | float = 1.0,
137
+ reverse_complement: bool = False,
138
+ max_targets: int = DEFAULT_MAX_APPROXIMATE_TARGETS,
139
+ max_matches: int = DEFAULT_MAX_APPROXIMATE_MATCHES,
140
+ max_cells: int = DEFAULT_MAX_APPROXIMATE_CELLS,
141
+ ) -> ApproximateSearchResult:
142
+ """Find bounded-cost query alignments ending at each target coordinate."""
143
+
144
+ maximum = _finite_non_negative(max_distance, "max_distance")
145
+ substitution = _finite_non_negative(substitution_cost, "substitution_cost")
146
+ insertion = _finite_non_negative(insertion_cost, "insertion_cost")
147
+ deletion = _finite_non_negative(deletion_cost, "deletion_cost")
148
+ if substitution == insertion == deletion == 0.0:
149
+ raise ConfigurationError(
150
+ "At least one edit cost must be positive.", code="DEGENERATE_APPROXIMATE_COSTS"
151
+ )
152
+ validate_bool(reverse_complement, "reverse_complement")
153
+ for name, value in (
154
+ ("max_targets", max_targets),
155
+ ("max_matches", max_matches),
156
+ ("max_cells", max_cells),
157
+ ):
158
+ _positive_int(value, name)
159
+ query_sequence, query_id = _sequence_and_id_without_gap_policy(query, role="query")
160
+ if query_sequence.topology is Topology.CIRCULAR:
161
+ raise ConfigurationError(
162
+ "Approximate query matching requires a linear query.",
163
+ code="APPROXIMATE_CIRCULAR_UNSUPPORTED",
164
+ )
165
+ if query_sequence.is_gapped:
166
+ raise UnsupportedGapOperationError(
167
+ "Approximate matching cannot silently omit explicit query Gap objects.",
168
+ code="APPROXIMATE_GAP_NOT_ALLOWED",
169
+ context={"role": "query"},
170
+ )
171
+ if query_sequence.symbol_length == 0:
172
+ raise ConfigurationError(
173
+ "Approximate matching does not accept an empty query.",
174
+ code="EMPTY_APPROXIMATE_QUERY",
175
+ )
176
+ materialized = materialize_targets(targets, max_targets=max_targets)
177
+ resolved_targets: list[tuple[DNASequence, str | None]] = []
178
+ total_cells = 0
179
+ for target in materialized:
180
+ target_sequence, target_id = _sequence_and_id_without_gap_policy(target, role="target")
181
+ if target_sequence.topology is Topology.CIRCULAR:
182
+ raise ConfigurationError(
183
+ "Approximate matching currently requires linear targets.",
184
+ code="APPROXIMATE_CIRCULAR_UNSUPPORTED",
185
+ )
186
+ if target_sequence.is_gapped:
187
+ raise UnsupportedGapOperationError(
188
+ "Approximate matching cannot silently omit explicit target Gap objects.",
189
+ code="APPROXIMATE_GAP_NOT_ALLOWED",
190
+ context={"role": "target"},
191
+ )
192
+ cells = (query_sequence.symbol_length + 1) * (target_sequence.symbol_length + 1)
193
+ total_cells += cells * (2 if reverse_complement else 1)
194
+ if total_cells > max_cells:
195
+ raise ConfigurationError(
196
+ "Approximate search exceeds max_cells.",
197
+ code="APPROXIMATE_MATCH_CELL_LIMIT",
198
+ context={"required_cells_lower_bound": total_cells, "max_cells": max_cells},
199
+ )
200
+ resolved_targets.append((target_sequence, target_id))
201
+ strands = [(query_sequence.symbols, Strand.FORWARD)]
202
+ if reverse_complement:
203
+ reverse = query_sequence.reverse_complement().symbols
204
+ if reverse != query_sequence.symbols:
205
+ strands.append((reverse, Strand.REVERSE))
206
+ matches: list[ApproximateMatch] = []
207
+ for target_index, (target_sequence, target_id) in enumerate(resolved_targets):
208
+ for query_symbols, strand in strands:
209
+ for match in _scan_one(
210
+ query_symbols,
211
+ target_sequence.symbols,
212
+ max_distance=maximum,
213
+ substitution_cost=substitution,
214
+ insertion_cost=insertion,
215
+ deletion_cost=deletion,
216
+ target_index=target_index,
217
+ target_id=target_id,
218
+ strand=strand,
219
+ remaining_matches=max_matches - len(matches),
220
+ ):
221
+ matches.append(match)
222
+ ordered = tuple(
223
+ sorted(
224
+ matches, key=lambda item: (item.target_index, item.start, item.end, item.strand.value)
225
+ )
226
+ )
227
+ return ApproximateSearchResult(
228
+ name="approximate_search",
229
+ method="semi-global-weighted-edit-distance",
230
+ algorithm_version="dnakit-approximate-search-v1",
231
+ query_id=query_id,
232
+ query_length=query_sequence.symbol_length,
233
+ target_count=len(resolved_targets),
234
+ matches=ordered,
235
+ max_distance=maximum,
236
+ reverse_complement=reverse_complement,
237
+ max_targets=max_targets,
238
+ max_matches=max_matches,
239
+ max_cells=max_cells,
240
+ dp_cells=total_cells,
241
+ parameters=FrozenDict(
242
+ {
243
+ "substitution_cost": substitution,
244
+ "insertion_cost": insertion,
245
+ "deletion_cost": deletion,
246
+ "iupac_matching": "literal",
247
+ "coordinate_system": "0-based-half-open",
248
+ }
249
+ ),
250
+ )
251
+
252
+
253
+ __all__ = [
254
+ "DEFAULT_MAX_APPROXIMATE_CELLS",
255
+ "DEFAULT_MAX_APPROXIMATE_MATCHES",
256
+ "DEFAULT_MAX_APPROXIMATE_TARGETS",
257
+ "approximate_search",
258
+ ]