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
dnakit/__init__.py ADDED
@@ -0,0 +1,108 @@
1
+ """Stable, lightweight DNAKit public package namespace."""
2
+
3
+ from dnakit import download, search
4
+ from dnakit._version import __version__
5
+ from dnakit.chunking import (
6
+ ChunkingConfig,
7
+ ChunkProgress,
8
+ ChunkProgressCallback,
9
+ ChunkStrategy,
10
+ LengthCurriculum,
11
+ LengthCurriculumStage,
12
+ SequenceChunk,
13
+ iter_fasta_chunks,
14
+ iter_sequence_chunks,
15
+ make_length_curriculum,
16
+ )
17
+ from dnakit.core import (
18
+ DNA,
19
+ DNAAlphabet,
20
+ DNAFeature,
21
+ DNARecord,
22
+ DNASequence,
23
+ DNASet,
24
+ Gap,
25
+ GapKind,
26
+ Interval,
27
+ MetricResult,
28
+ Topology,
29
+ )
30
+ from dnakit.io import (
31
+ ReadConfig,
32
+ ReadMode,
33
+ RecordSource,
34
+ WriteConfig,
35
+ WriteResult,
36
+ read,
37
+ read_one,
38
+ read_set,
39
+ write,
40
+ )
41
+ from dnakit.references import (
42
+ DownloadProgress,
43
+ GenomeAssembly,
44
+ GenomeDownloadResult,
45
+ download_genome,
46
+ resolve_genome_assembly,
47
+ )
48
+ from dnakit.standardize import (
49
+ DatasetValidationConfig,
50
+ DatasetValidationReport,
51
+ NormalizationConfig,
52
+ NormalizationResult,
53
+ ValidationConfig,
54
+ ValidationReport,
55
+ normalize,
56
+ validate,
57
+ validate_set,
58
+ )
59
+
60
+ __all__ = [
61
+ "DNA",
62
+ "ChunkProgress",
63
+ "ChunkProgressCallback",
64
+ "ChunkStrategy",
65
+ "ChunkingConfig",
66
+ "DNAAlphabet",
67
+ "DNAFeature",
68
+ "DNARecord",
69
+ "DNASequence",
70
+ "DNASet",
71
+ "DatasetValidationConfig",
72
+ "DatasetValidationReport",
73
+ "DownloadProgress",
74
+ "Gap",
75
+ "GapKind",
76
+ "GenomeAssembly",
77
+ "GenomeDownloadResult",
78
+ "Interval",
79
+ "LengthCurriculum",
80
+ "LengthCurriculumStage",
81
+ "MetricResult",
82
+ "NormalizationConfig",
83
+ "NormalizationResult",
84
+ "ReadConfig",
85
+ "ReadMode",
86
+ "RecordSource",
87
+ "SequenceChunk",
88
+ "Topology",
89
+ "ValidationConfig",
90
+ "ValidationReport",
91
+ "WriteConfig",
92
+ "WriteResult",
93
+ "__version__",
94
+ "download",
95
+ "download_genome",
96
+ "iter_fasta_chunks",
97
+ "iter_sequence_chunks",
98
+ "make_length_curriculum",
99
+ "normalize",
100
+ "read",
101
+ "read_one",
102
+ "read_set",
103
+ "resolve_genome_assembly",
104
+ "search",
105
+ "validate",
106
+ "validate_set",
107
+ "write",
108
+ ]
dnakit/_version.py ADDED
@@ -0,0 +1,5 @@
1
+ """Single source for the DNAKit package version."""
2
+
3
+ from typing import Final
4
+
5
+ __version__: Final = "0.1.0.dev0"
@@ -0,0 +1,6 @@
1
+ """Deterministic bounded native pairwise DNA alignment."""
2
+
3
+ from dnakit.alignment.pairwise import AlignmentConfig, align_pairwise
4
+ from dnakit.alignment.results import AlignmentColumn, AlignmentResult
5
+
6
+ __all__ = ["AlignmentColumn", "AlignmentConfig", "AlignmentResult", "align_pairwise"]
@@ -0,0 +1,512 @@
1
+ """Needleman-Wunsch and Smith-Waterman alignment with bounded memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from itertools import chain
8
+ from typing import Literal, TypeAlias, cast
9
+
10
+ from dnakit.alignment.results import AlignmentColumn, AlignmentResult
11
+ from dnakit.core import (
12
+ Citation,
13
+ DNARecord,
14
+ DNASequence,
15
+ ExecutionMode,
16
+ ImplementationInfo,
17
+ ImplementationLabel,
18
+ OriginClass,
19
+ Provenance,
20
+ Topology,
21
+ )
22
+ from dnakit.core._json import FrozenDict
23
+ from dnakit.core.facade import DNA, resolve_single_dna
24
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
25
+
26
+ SequenceInput: TypeAlias = DNA | DNASequence | DNARecord
27
+ AlignmentMode: TypeAlias = Literal["global", "local", "semi_global"]
28
+
29
+ _STOP = 0
30
+ _DIAGONAL = 1
31
+ _DELETE = 2
32
+ _INSERT = 3
33
+ _FROM_MATCH = 1
34
+ _FROM_DELETE = 2
35
+ _FROM_INSERT = 3
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class AlignmentConfig:
40
+ """Scoring and resource limits for native linear-gap alignment."""
41
+
42
+ mode: AlignmentMode = "global"
43
+ match_score: float = 1.0
44
+ mismatch_score: float = -1.0
45
+ gap_score: float = -1.0
46
+ gap_open_score: float | None = None
47
+ gap_extend_score: float | None = None
48
+ max_cells: int = 5_000_000
49
+
50
+ def __post_init__(self) -> None:
51
+ if self.mode not in ("global", "local", "semi_global"):
52
+ raise ConfigurationError(
53
+ "mode must be global, local, or semi_global.", code="INVALID_ALIGNMENT_MODE"
54
+ )
55
+ for name in ("match_score", "mismatch_score", "gap_score"):
56
+ value = getattr(self, name)
57
+ if (
58
+ isinstance(value, bool)
59
+ or not isinstance(value, (int, float))
60
+ or not math.isfinite(value)
61
+ ):
62
+ raise ConfigurationError(
63
+ f"{name} must be a finite number.", code="INVALID_ALIGNMENT_SCORE"
64
+ )
65
+ object.__setattr__(self, name, float(value))
66
+ for name in ("gap_open_score", "gap_extend_score"):
67
+ value = getattr(self, name)
68
+ if value is not None and (
69
+ isinstance(value, bool)
70
+ or not isinstance(value, (int, float))
71
+ or not math.isfinite(value)
72
+ ):
73
+ raise ConfigurationError(
74
+ f"{name} must be a finite number or None.", code="INVALID_ALIGNMENT_SCORE"
75
+ )
76
+ if value is not None:
77
+ object.__setattr__(self, name, float(value))
78
+ if (self.gap_open_score is None) != (self.gap_extend_score is None):
79
+ raise ConfigurationError(
80
+ "gap_open_score and gap_extend_score must be provided together.",
81
+ code="INVALID_ALIGNMENT_SCORE",
82
+ )
83
+ if (
84
+ isinstance(self.max_cells, bool)
85
+ or not isinstance(self.max_cells, int)
86
+ or self.max_cells <= 0
87
+ ):
88
+ raise ConfigurationError(
89
+ "max_cells must be a positive integer.", code="INVALID_ALIGNMENT_CELL_LIMIT"
90
+ )
91
+
92
+
93
+ def _sequence_and_id(value: SequenceInput, role: str) -> tuple[DNASequence, str | None]:
94
+ if isinstance(value, (DNA, DNARecord, DNASequence)):
95
+ sequence, record = resolve_single_dna(value)
96
+ sequence_id = None if record is None else record.id
97
+ else:
98
+ raise ConfigurationError(
99
+ f"{role} must be DNASequence or DNARecord.",
100
+ code="INVALID_ALIGNMENT_INPUT",
101
+ context={"role": role, "type": type(value).__name__},
102
+ )
103
+ if sequence.is_gapped:
104
+ raise UnsupportedGapOperationError(
105
+ "Pairwise alignment requires gap-free input; '-' is reserved for its output.",
106
+ code="ALIGNMENT_INPUT_GAP_NOT_SUPPORTED",
107
+ context={"role": role},
108
+ )
109
+ if sequence.topology is Topology.CIRCULAR:
110
+ raise ConfigurationError(
111
+ "Pairwise alignment requires an explicitly linearized sequence.",
112
+ code="ALIGNMENT_CIRCULAR_UNSUPPORTED",
113
+ context={"role": role},
114
+ hint="Rotate the sequence to the desired origin and convert it to linear topology.",
115
+ )
116
+ return sequence, sequence_id
117
+
118
+
119
+ def _choose(candidates: tuple[tuple[float, int], ...]) -> tuple[float, int]:
120
+ """Choose the largest score and retain deterministic candidate order on ties."""
121
+
122
+ best_score, best_pointer = candidates[0]
123
+ for score, pointer in candidates[1:]:
124
+ if score > best_score:
125
+ best_score, best_pointer = score, pointer
126
+ return best_score, best_pointer
127
+
128
+
129
+ def _alignment_provenance(config: AlignmentConfig) -> Provenance:
130
+ primary = (
131
+ Citation(
132
+ "smith-waterman-1981",
133
+ title="Identification of common molecular subsequences",
134
+ doi="10.1016/0022-2836(81)90087-5",
135
+ )
136
+ if config.mode == "local"
137
+ else Citation(
138
+ "needleman-wunsch-1970",
139
+ title="A general method applicable to the search for similarities",
140
+ doi="10.1016/0022-2836(70)90057-4",
141
+ )
142
+ )
143
+ citations: tuple[Citation, ...] = (primary,)
144
+ if config.gap_open_score is not None:
145
+ citations += (
146
+ Citation(
147
+ "gotoh-1982",
148
+ title="An improved algorithm for matching biological sequences",
149
+ doi="10.1016/0022-2836(82)90398-9",
150
+ ),
151
+ )
152
+ return Provenance(
153
+ implementation=ImplementationInfo(
154
+ label=ImplementationLabel.REIMPLEMENTATION,
155
+ execution_mode=ExecutionMode.INTERNAL,
156
+ origin_class=OriginClass.PUBLISHED_ALGORITHM,
157
+ citations=citations,
158
+ )
159
+ )
160
+
161
+
162
+ def _fill(
163
+ query: str,
164
+ target: str,
165
+ config: AlignmentConfig,
166
+ ) -> tuple[list[list[float]], list[bytearray], tuple[int, int]]:
167
+ rows = len(query) + 1
168
+ columns = len(target) + 1
169
+ cells = rows * columns
170
+ if cells > config.max_cells:
171
+ raise ConfigurationError(
172
+ "Alignment dynamic-programming matrix exceeds max_cells.",
173
+ code="ALIGNMENT_CELL_LIMIT",
174
+ context={"dp_cells": cells, "max_cells": config.max_cells},
175
+ )
176
+ scores = [[0.0] * columns for _ in range(rows)]
177
+ pointers = [bytearray(columns) for _ in range(rows)]
178
+ if config.mode == "global":
179
+ for row in range(1, rows):
180
+ scores[row][0] = row * config.gap_score
181
+ pointers[row][0] = _DELETE
182
+ for column in range(1, columns):
183
+ scores[0][column] = column * config.gap_score
184
+ pointers[0][column] = _INSERT
185
+ best = (0, 0)
186
+ best_score = 0.0
187
+ for row in range(1, rows):
188
+ for column in range(1, columns):
189
+ diagonal = scores[row - 1][column - 1] + (
190
+ config.match_score
191
+ if query[row - 1] == target[column - 1]
192
+ else config.mismatch_score
193
+ )
194
+ deleted = scores[row - 1][column] + config.gap_score
195
+ inserted = scores[row][column - 1] + config.gap_score
196
+ candidates = (
197
+ ((0.0, _STOP), (diagonal, _DIAGONAL), (deleted, _DELETE), (inserted, _INSERT))
198
+ if config.mode == "local"
199
+ else ((diagonal, _DIAGONAL), (deleted, _DELETE), (inserted, _INSERT))
200
+ )
201
+ score, pointer = _choose(candidates)
202
+ scores[row][column] = score
203
+ pointers[row][column] = pointer
204
+ if config.mode == "local" and score > best_score:
205
+ best_score = score
206
+ best = (row, column)
207
+ if config.mode == "semi_global":
208
+ _, end_row, end_column = max(
209
+ chain(
210
+ ((scores[len(query)][column], len(query), column) for column in range(columns)),
211
+ ((scores[row][len(target)], row, len(target)) for row in range(rows - 1)),
212
+ ),
213
+ key=lambda item: item[0],
214
+ )
215
+ return scores, pointers, (end_row, end_column)
216
+ return scores, pointers, (best if config.mode == "local" else (len(query), len(target)))
217
+
218
+
219
+ def _fill_affine(
220
+ query: str,
221
+ target: str,
222
+ config: AlignmentConfig,
223
+ ) -> tuple[
224
+ list[list[float]],
225
+ tuple[list[bytearray], list[bytearray], list[bytearray]],
226
+ tuple[int, int, int],
227
+ ]:
228
+ rows = len(query) + 1
229
+ columns = len(target) + 1
230
+ cells = rows * columns * 3
231
+ if cells > config.max_cells:
232
+ raise ConfigurationError(
233
+ "Alignment affine dynamic-programming matrices exceed max_cells.",
234
+ code="ALIGNMENT_CELL_LIMIT",
235
+ context={"dp_cells": cells, "max_cells": config.max_cells},
236
+ )
237
+ gap_open = cast(float, config.gap_open_score)
238
+ gap_extend = cast(float, config.gap_extend_score)
239
+ negative = float("-inf")
240
+ match = [[negative] * columns for _ in range(rows)]
241
+ deleted = [[negative] * columns for _ in range(rows)]
242
+ inserted = [[negative] * columns for _ in range(rows)]
243
+ scores = [[negative] * columns for _ in range(rows)]
244
+ match_pointers = [bytearray(columns) for _ in range(rows)]
245
+ delete_pointers = [bytearray(columns) for _ in range(rows)]
246
+ insert_pointers = [bytearray(columns) for _ in range(rows)]
247
+ match[0][0] = scores[0][0] = 0.0
248
+ if config.mode == "global":
249
+ for row in range(1, rows):
250
+ deleted[row][0] = scores[row][0] = gap_open + (row - 1) * gap_extend
251
+ delete_pointers[row][0] = _FROM_MATCH if row == 1 else _FROM_DELETE
252
+ for column in range(1, columns):
253
+ inserted[0][column] = scores[0][column] = gap_open + (column - 1) * gap_extend
254
+ insert_pointers[0][column] = _FROM_MATCH if column == 1 else _FROM_INSERT
255
+ elif config.mode in ("local", "semi_global"):
256
+ for row in range(rows):
257
+ match[row][0] = scores[row][0] = 0.0
258
+ for column in range(columns):
259
+ match[0][column] = scores[0][column] = 0.0
260
+ for row in range(1, rows):
261
+ for column in range(1, columns):
262
+ substitution = (
263
+ config.match_score
264
+ if query[row - 1] == target[column - 1]
265
+ else config.mismatch_score
266
+ )
267
+ match_candidates: tuple[tuple[float, int], ...] = (
268
+ (match[row - 1][column - 1] + substitution, _FROM_MATCH),
269
+ (deleted[row - 1][column - 1] + substitution, _FROM_DELETE),
270
+ (inserted[row - 1][column - 1] + substitution, _FROM_INSERT),
271
+ )
272
+ if config.mode == "local":
273
+ match_candidates = ((0.0, _STOP), *match_candidates)
274
+ match_previous = _choose(match_candidates)
275
+ match[row][column] = match_previous[0]
276
+ match_pointers[row][column] = match_previous[1]
277
+ deleted[row][column], delete_pointers[row][column] = _choose(
278
+ (
279
+ (match[row - 1][column] + gap_open, _FROM_MATCH),
280
+ (deleted[row - 1][column] + gap_extend, _FROM_DELETE),
281
+ (inserted[row - 1][column] + gap_open, _FROM_INSERT),
282
+ )
283
+ )
284
+ inserted[row][column], insert_pointers[row][column] = _choose(
285
+ (
286
+ (match[row][column - 1] + gap_open, _FROM_MATCH),
287
+ (deleted[row][column - 1] + gap_open, _FROM_DELETE),
288
+ (inserted[row][column - 1] + gap_extend, _FROM_INSERT),
289
+ )
290
+ )
291
+ scores[row][column] = max(
292
+ match[row][column], deleted[row][column], inserted[row][column]
293
+ )
294
+ if config.mode == "local":
295
+ scores[row][column] = max(0.0, scores[row][column])
296
+ pointers = (match_pointers, delete_pointers, insert_pointers)
297
+ states = (
298
+ (_FROM_MATCH, match),
299
+ (_FROM_DELETE, deleted),
300
+ (_FROM_INSERT, inserted),
301
+ )
302
+ if config.mode == "local":
303
+ _, row, column, state = max(
304
+ (
305
+ (matrix[row][column], row, column, state)
306
+ for row in range(rows)
307
+ for column in range(columns)
308
+ for state, matrix in states
309
+ ),
310
+ key=lambda item: item[0],
311
+ )
312
+ return scores, pointers, (row, column, state)
313
+ if config.mode == "semi_global":
314
+ _, row, column, state = max(
315
+ chain(
316
+ (
317
+ (matrix[-1][column], len(query), column, state)
318
+ for column in range(columns)
319
+ for state, matrix in states
320
+ ),
321
+ (
322
+ (matrix[row][-1], row, len(target), state)
323
+ for row in range(rows - 1)
324
+ for state, matrix in states
325
+ ),
326
+ ),
327
+ key=lambda item: item[0],
328
+ )
329
+ return scores, pointers, (row, column, state)
330
+ state = _choose(tuple((matrix[-1][-1], state) for state, matrix in states))[1]
331
+ return scores, pointers, (len(query), len(target), state)
332
+
333
+
334
+ def _trace_affine(
335
+ query: str,
336
+ target: str,
337
+ pointers: tuple[list[bytearray], list[bytearray], list[bytearray]],
338
+ endpoint: tuple[int, int, int],
339
+ mode: AlignmentMode,
340
+ ) -> tuple[list[AlignmentColumn], list[str], list[str], int, int]:
341
+ row, column, state = endpoint
342
+ match_pointers, delete_pointers, insert_pointers = pointers
343
+ reverse_columns: list[AlignmentColumn] = []
344
+ reverse_query: list[str] = []
345
+ reverse_target: list[str] = []
346
+ while row > 0 or column > 0:
347
+ if mode == "semi_global" and (row == 0 or column == 0):
348
+ break
349
+ if state == _FROM_MATCH:
350
+ previous = match_pointers[row][column]
351
+ if previous == _STOP:
352
+ break
353
+ query_symbol, target_symbol = query[row - 1], target[column - 1]
354
+ operation: Literal["match", "mismatch", "insertion", "deletion"] = (
355
+ "match" if query_symbol == target_symbol else "mismatch"
356
+ )
357
+ reverse_columns.append(
358
+ AlignmentColumn(query_symbol, target_symbol, row - 1, column - 1, operation)
359
+ )
360
+ reverse_query.append(query_symbol)
361
+ reverse_target.append(target_symbol)
362
+ row -= 1
363
+ column -= 1
364
+ elif state == _FROM_DELETE:
365
+ previous = delete_pointers[row][column]
366
+ query_symbol = query[row - 1]
367
+ reverse_columns.append(AlignmentColumn(query_symbol, "-", row - 1, None, "deletion"))
368
+ reverse_query.append(query_symbol)
369
+ reverse_target.append("-")
370
+ row -= 1
371
+ elif state == _FROM_INSERT:
372
+ previous = insert_pointers[row][column]
373
+ target_symbol = target[column - 1]
374
+ reverse_columns.append(
375
+ AlignmentColumn("-", target_symbol, None, column - 1, "insertion")
376
+ )
377
+ reverse_query.append("-")
378
+ reverse_target.append(target_symbol)
379
+ column -= 1
380
+ else:
381
+ raise RuntimeError("Invalid affine-alignment traceback state.")
382
+ state = previous
383
+ return reverse_columns, reverse_query, reverse_target, row, column
384
+
385
+
386
+ def align_pairwise(
387
+ query: SequenceInput,
388
+ target: SequenceInput,
389
+ *,
390
+ config: AlignmentConfig | None = None,
391
+ ) -> AlignmentResult:
392
+ """Align two sequences with literal-IUPAC equality and deterministic traceback."""
393
+
394
+ resolved = AlignmentConfig() if config is None else config
395
+ if not isinstance(resolved, AlignmentConfig):
396
+ raise ConfigurationError(
397
+ "config must be AlignmentConfig or None.", code="INVALID_ALIGNMENT_CONFIG"
398
+ )
399
+ query_sequence, query_id = _sequence_and_id(query, "query")
400
+ target_sequence, target_id = _sequence_and_id(target, "target")
401
+ query_text = query_sequence.symbols
402
+ target_text = target_sequence.symbols
403
+ if resolved.gap_open_score is None:
404
+ scores, linear_pointers, (row, column) = _fill(query_text, target_text, resolved)
405
+ query_end, target_end = row, column
406
+ reverse_columns: list[AlignmentColumn] = []
407
+ reverse_query: list[str] = []
408
+ reverse_target: list[str] = []
409
+ while row > 0 or column > 0:
410
+ if resolved.mode == "semi_global" and (row == 0 or column == 0):
411
+ break
412
+ pointer = linear_pointers[row][column]
413
+ if pointer == _STOP:
414
+ break
415
+ if pointer == _DIAGONAL:
416
+ query_symbol, target_symbol = query_text[row - 1], target_text[column - 1]
417
+ operation: Literal["match", "mismatch", "insertion", "deletion"] = (
418
+ "match" if query_symbol == target_symbol else "mismatch"
419
+ )
420
+ reverse_columns.append(
421
+ AlignmentColumn(query_symbol, target_symbol, row - 1, column - 1, operation)
422
+ )
423
+ reverse_query.append(query_symbol)
424
+ reverse_target.append(target_symbol)
425
+ row -= 1
426
+ column -= 1
427
+ elif pointer == _DELETE:
428
+ query_symbol = query_text[row - 1]
429
+ reverse_columns.append(
430
+ AlignmentColumn(query_symbol, "-", row - 1, None, "deletion")
431
+ )
432
+ reverse_query.append(query_symbol)
433
+ reverse_target.append("-")
434
+ row -= 1
435
+ elif pointer == _INSERT:
436
+ target_symbol = target_text[column - 1]
437
+ reverse_columns.append(
438
+ AlignmentColumn("-", target_symbol, None, column - 1, "insertion")
439
+ )
440
+ reverse_query.append("-")
441
+ reverse_target.append(target_symbol)
442
+ column -= 1
443
+ else:
444
+ raise RuntimeError("Invalid alignment traceback pointer.")
445
+ score = scores[query_end][target_end]
446
+ else:
447
+ scores, affine_pointers, endpoint = _fill_affine(query_text, target_text, resolved)
448
+ query_end, target_end, _ = endpoint
449
+ reverse_columns, reverse_query, reverse_target, row, column = _trace_affine(
450
+ query_text,
451
+ target_text,
452
+ affine_pointers,
453
+ endpoint,
454
+ resolved.mode,
455
+ )
456
+ score = scores[query_end][target_end]
457
+ columns = tuple(reversed(reverse_columns))
458
+ matches = sum(item.operation == "match" for item in columns)
459
+ mismatches = sum(item.operation == "mismatch" for item in columns)
460
+ insertions = sum(item.operation == "insertion" for item in columns)
461
+ deletions = sum(item.operation == "deletion" for item in columns)
462
+ comparable = matches + mismatches + insertions + deletions
463
+ query_consumed = matches + mismatches + deletions
464
+ target_consumed = matches + mismatches + insertions
465
+ return AlignmentResult(
466
+ name="pairwise_alignment",
467
+ method=resolved.mode,
468
+ algorithm_version=(
469
+ "affine-gap-dp-v1" if resolved.gap_open_score is not None else "linear-gap-dp-v1"
470
+ ),
471
+ score=score,
472
+ aligned_query="".join(reversed(reverse_query)),
473
+ aligned_target="".join(reversed(reverse_target)),
474
+ query_id=query_id,
475
+ target_id=target_id,
476
+ query_start=row,
477
+ query_end=query_end,
478
+ target_start=column,
479
+ target_end=target_end,
480
+ matches=matches,
481
+ mismatches=mismatches,
482
+ insertions=insertions,
483
+ deletions=deletions,
484
+ identity=None if comparable == 0 else matches / comparable,
485
+ query_coverage=1.0 if not query_text else query_consumed / len(query_text),
486
+ target_coverage=1.0 if not target_text else target_consumed / len(target_text),
487
+ columns=columns,
488
+ parameters=FrozenDict(
489
+ {
490
+ "match_score": resolved.match_score,
491
+ "mismatch_score": resolved.mismatch_score,
492
+ "gap_score": resolved.gap_score,
493
+ "gap_open_score": resolved.gap_open_score,
494
+ "gap_extend_score": resolved.gap_extend_score,
495
+ "max_cells": resolved.max_cells,
496
+ "dp_cells": (len(query_text) + 1)
497
+ * (len(target_text) + 1)
498
+ * (3 if resolved.gap_open_score is not None else 1),
499
+ "iupac_matching": "literal",
500
+ "tie_break": "diagonal-delete-insert",
501
+ "end_gap_policy": {
502
+ "global": "penalized",
503
+ "local": "excluded-local-flanks",
504
+ "semi_global": "free-query-and-target-ends",
505
+ }[resolved.mode],
506
+ }
507
+ ),
508
+ provenance=_alignment_provenance(resolved),
509
+ )
510
+
511
+
512
+ __all__ = ["AlignmentConfig", "align_pairwise"]