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,100 @@
1
+ """Dataset deduplication, clustering, splitting, leakage, and quality evaluation."""
2
+
3
+ from dnakit.datasets.advanced_deduplicate import deduplicate_approximate, deduplicate_iupac
4
+ from dnakit.datasets.advanced_split import joint_split, temporal_split
5
+ from dnakit.datasets.clustering import (
6
+ cluster_sequences,
7
+ hierarchical_cluster,
8
+ select_representatives,
9
+ )
10
+ from dnakit.datasets.config import (
11
+ ClusterConfig,
12
+ DeduplicationConfig,
13
+ HierarchicalClusteringConfig,
14
+ IUPACDeduplicationConfig,
15
+ JointSplitConfig,
16
+ LeakageConfig,
17
+ NeuralClusteringConfig,
18
+ SplitConfig,
19
+ TemporalSplitConfig,
20
+ )
21
+ from dnakit.datasets.deduplicate import deduplicate
22
+ from dnakit.datasets.evaluation import detect_leakage, evaluate_split_quality
23
+ from dnakit.datasets.filter import (
24
+ MetadataExclusionField,
25
+ MetadataValues,
26
+ exclude_by_metadata,
27
+ exclude_chromosomes,
28
+ exclude_species,
29
+ )
30
+ from dnakit.datasets.neural_clustering import neural_cluster_sequences
31
+ from dnakit.datasets.results import (
32
+ Cluster,
33
+ ClusteringResult,
34
+ DedupGroup,
35
+ DeduplicationResult,
36
+ HierarchicalClusteringResult,
37
+ IUPACDeduplicationResult,
38
+ IUPACDuplicateGroup,
39
+ IUPACPairRelation,
40
+ JointSplitResult,
41
+ LeakageEvent,
42
+ LeakageReport,
43
+ LinkageStep,
44
+ NeuralClusteringResult,
45
+ RepresentativeSelectionResult,
46
+ SplitAssignment,
47
+ SplitQualityResult,
48
+ SplitResult,
49
+ SplitSubset,
50
+ TemporalSplitResult,
51
+ )
52
+ from dnakit.datasets.split import split
53
+
54
+ __all__ = [
55
+ "Cluster",
56
+ "ClusterConfig",
57
+ "ClusteringResult",
58
+ "DedupGroup",
59
+ "DeduplicationConfig",
60
+ "DeduplicationResult",
61
+ "HierarchicalClusteringConfig",
62
+ "HierarchicalClusteringResult",
63
+ "IUPACDeduplicationConfig",
64
+ "IUPACDeduplicationResult",
65
+ "IUPACDuplicateGroup",
66
+ "IUPACPairRelation",
67
+ "JointSplitConfig",
68
+ "JointSplitResult",
69
+ "LeakageConfig",
70
+ "LeakageEvent",
71
+ "LeakageReport",
72
+ "LinkageStep",
73
+ "MetadataExclusionField",
74
+ "MetadataValues",
75
+ "NeuralClusteringConfig",
76
+ "NeuralClusteringResult",
77
+ "RepresentativeSelectionResult",
78
+ "SplitAssignment",
79
+ "SplitConfig",
80
+ "SplitQualityResult",
81
+ "SplitResult",
82
+ "SplitSubset",
83
+ "TemporalSplitConfig",
84
+ "TemporalSplitResult",
85
+ "cluster_sequences",
86
+ "deduplicate",
87
+ "deduplicate_approximate",
88
+ "deduplicate_iupac",
89
+ "detect_leakage",
90
+ "evaluate_split_quality",
91
+ "exclude_by_metadata",
92
+ "exclude_chromosomes",
93
+ "exclude_species",
94
+ "hierarchical_cluster",
95
+ "joint_split",
96
+ "neural_cluster_sequences",
97
+ "select_representatives",
98
+ "split",
99
+ "temporal_split",
100
+ ]
@@ -0,0 +1,198 @@
1
+ """Shared bounded pairwise primitives for advanced dataset algorithms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable
7
+
8
+ from dnakit.alignment import AlignmentConfig, align_pairwise
9
+ from dnakit.core import DNARecord, Gap, Topology
10
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
11
+ from dnakit.fingerprints import kmer_fingerprint
12
+ from dnakit.similarity import edit_distance, fingerprint_similarity, kmer_similarity
13
+
14
+ from .config import ClusterMethod
15
+
16
+
17
+ class UnionFind:
18
+ def __init__(self, size: int) -> None:
19
+ self.parent = list(range(size))
20
+
21
+ def find(self, value: int) -> int:
22
+ root = value
23
+ while self.parent[root] != root:
24
+ root = self.parent[root]
25
+ while self.parent[value] != value:
26
+ parent = self.parent[value]
27
+ self.parent[value] = root
28
+ value = parent
29
+ return root
30
+
31
+ def union(self, left: int, right: int) -> None:
32
+ left_root, right_root = self.find(left), self.find(right)
33
+ if left_root != right_root:
34
+ self.parent[right_root] = left_root
35
+
36
+ def groups(self) -> tuple[tuple[int, ...], ...]:
37
+ grouped: dict[int, list[int]] = {}
38
+ for index in range(len(self.parent)):
39
+ grouped.setdefault(self.find(index), []).append(index)
40
+ return tuple(tuple(values) for values in grouped.values())
41
+
42
+
43
+ def validate_max_records(value: int, maximum: int) -> None:
44
+ if value > maximum:
45
+ raise ConfigurationError(
46
+ "Advanced dataset input exceeds max_records.",
47
+ code="ADVANCED_DATASET_SIZE_LIMIT",
48
+ context={"record_count": value, "max_records": maximum},
49
+ )
50
+
51
+
52
+ def materialize_limited(
53
+ records: Iterable[DNARecord],
54
+ *,
55
+ max_records: int,
56
+ ) -> tuple[DNARecord, ...]:
57
+ try:
58
+ iterator = iter(records)
59
+ except TypeError as exc:
60
+ raise ConfigurationError(
61
+ "records must be iterable.", code="INVALID_DATASET_RECORDS"
62
+ ) from exc
63
+ values: list[DNARecord] = []
64
+ for index, record in enumerate(iterator):
65
+ if not isinstance(record, DNARecord):
66
+ raise ConfigurationError(
67
+ "Every dataset item must be a DNARecord.",
68
+ code="INVALID_DATASET_RECORD",
69
+ context={"input_index": index},
70
+ )
71
+ values.append(record)
72
+ if len(values) > max_records:
73
+ validate_max_records(len(values), max_records)
74
+ return tuple(values)
75
+
76
+
77
+ def validate_pairwise_sequences(records: tuple[DNARecord, ...], *, operation: str) -> None:
78
+ for index, record in enumerate(records):
79
+ if record.sequence.topology is Topology.CIRCULAR:
80
+ raise ConfigurationError(
81
+ f"{operation} requires linear sequences.",
82
+ code="CIRCULAR_ADVANCED_DATASET_UNSUPPORTED",
83
+ context={"input_index": index, "record_id": record.id},
84
+ )
85
+ if any(isinstance(part, Gap) for part in record.sequence.parts):
86
+ raise UnsupportedGapOperationError(
87
+ f"{operation} does not silently omit or bridge Gap objects.",
88
+ code="ADVANCED_DATASET_GAP_UNSUPPORTED",
89
+ context={"input_index": index, "record_id": record.id},
90
+ )
91
+
92
+
93
+ def pair_count(size: int) -> int:
94
+ return size * (size - 1) // 2
95
+
96
+
97
+ def ensure_pair_limit(size: int, limit: int) -> int:
98
+ count = pair_count(size)
99
+ if count > limit:
100
+ raise ConfigurationError(
101
+ "Pairwise workload exceeds max_pairwise_comparisons.",
102
+ code="ADVANCED_PAIRWISE_LIMIT",
103
+ context={"record_count": size, "pairwise_comparisons": count, "limit": limit},
104
+ )
105
+ return count
106
+
107
+
108
+ def quality_score(record: DNARecord) -> float:
109
+ values = record.letter_annotations.get("phred_quality")
110
+ return float("-inf") if not values else math.fsum(values) / len(values)
111
+
112
+
113
+ def pair_similarity(
114
+ left: DNARecord,
115
+ right: DNARecord,
116
+ *,
117
+ method: ClusterMethod,
118
+ k: int,
119
+ canonical: bool,
120
+ max_alignment_cells: int,
121
+ ) -> float:
122
+ if method in {"kmer", "fingerprint"} and (
123
+ left.sequence.symbol_length < k or right.sequence.symbol_length < k
124
+ ):
125
+ raise ConfigurationError(
126
+ "k exceeds at least one sequence length in a pairwise dataset comparison.",
127
+ code="DATASET_K_EXCEEDS_SEQUENCE_LENGTH",
128
+ context={
129
+ "k": k,
130
+ "left_length": left.sequence.symbol_length,
131
+ "right_length": right.sequence.symbol_length,
132
+ },
133
+ )
134
+ if method == "identity":
135
+ alignment_result = align_pairwise(
136
+ left,
137
+ right,
138
+ config=AlignmentConfig(mode="global", max_cells=max_alignment_cells),
139
+ )
140
+ return 1.0 if alignment_result.identity is None else alignment_result.identity
141
+ if method == "edit":
142
+ distance_result = edit_distance(
143
+ left,
144
+ right,
145
+ max_cells=max_alignment_cells,
146
+ )
147
+ denominator = max(left.sequence.symbol_length, right.sequence.symbol_length)
148
+ return 1.0 if denominator == 0 else 1.0 - distance_result.distance / denominator
149
+ if method == "kmer":
150
+ return kmer_similarity(
151
+ left,
152
+ right,
153
+ k=k,
154
+ metric="jaccard",
155
+ canonical=canonical,
156
+ ).value
157
+ left_fingerprint = kmer_fingerprint(left, k=k, canonical=canonical, mode="binary")
158
+ right_fingerprint = kmer_fingerprint(right, k=k, canonical=canonical, mode="binary")
159
+ return fingerprint_similarity(left_fingerprint, right_fingerprint, metric="tanimoto").value
160
+
161
+
162
+ def similarity_matrix(
163
+ records: tuple[DNARecord, ...],
164
+ *,
165
+ method: ClusterMethod,
166
+ k: int,
167
+ canonical: bool,
168
+ max_alignment_cells: int,
169
+ ) -> tuple[tuple[float, ...], ...]:
170
+ rows = [
171
+ [1.0 if left == right else 0.0 for right in range(len(records))]
172
+ for left in range(len(records))
173
+ ]
174
+ for left in range(len(records)):
175
+ for right in range(left + 1, len(records)):
176
+ value = pair_similarity(
177
+ records[left],
178
+ records[right],
179
+ method=method,
180
+ k=k,
181
+ canonical=canonical,
182
+ max_alignment_cells=max_alignment_cells,
183
+ )
184
+ rows[left][right] = rows[right][left] = value
185
+ return tuple(tuple(row) for row in rows)
186
+
187
+
188
+ __all__ = [
189
+ "UnionFind",
190
+ "ensure_pair_limit",
191
+ "materialize_limited",
192
+ "pair_count",
193
+ "pair_similarity",
194
+ "quality_score",
195
+ "similarity_matrix",
196
+ "validate_max_records",
197
+ "validate_pairwise_sequences",
198
+ ]
@@ -0,0 +1,33 @@
1
+ """Type-preserving keys for immutable JSON metadata values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable
6
+
7
+ from dnakit.core._json import FrozenDict, JSONValue
8
+
9
+
10
+ def metadata_value_key(value: JSONValue) -> Hashable:
11
+ """Return a recursive key that does not apply Python numeric coercion rules."""
12
+
13
+ if value is None:
14
+ return ("null",)
15
+ if isinstance(value, bool):
16
+ return ("bool", value)
17
+ if isinstance(value, int):
18
+ return ("int", value)
19
+ if isinstance(value, float):
20
+ return ("float", value)
21
+ if isinstance(value, str):
22
+ return ("string", value)
23
+ if isinstance(value, tuple):
24
+ return ("array", tuple(metadata_value_key(item) for item in value))
25
+ if isinstance(value, FrozenDict):
26
+ return (
27
+ "object",
28
+ tuple((key, metadata_value_key(item)) for key, item in sorted(value.items())),
29
+ )
30
+ raise TypeError(f"Unsupported frozen JSON metadata type: {type(value).__name__}")
31
+
32
+
33
+ __all__ = ["metadata_value_key"]
@@ -0,0 +1,183 @@
1
+ """IUPAC-compatible and approximate deduplication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Literal
7
+
8
+ from dnakit.core import DNARecord, DNASet, Gap, Topology
9
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
10
+
11
+ from ._advanced_shared import ensure_pair_limit, materialize_limited, quality_score
12
+ from .clustering import cluster_sequences
13
+ from .config import ClusterConfig, IUPACDeduplicationConfig
14
+ from .results import (
15
+ ClusteringResult,
16
+ IUPACDeduplicationResult,
17
+ IUPACDuplicateGroup,
18
+ IUPACPairRelation,
19
+ )
20
+
21
+ _IUPAC = {
22
+ "A": frozenset("A"),
23
+ "C": frozenset("C"),
24
+ "G": frozenset("G"),
25
+ "T": frozenset("T"),
26
+ "R": frozenset("AG"),
27
+ "Y": frozenset("CT"),
28
+ "S": frozenset("GC"),
29
+ "W": frozenset("AT"),
30
+ "K": frozenset("GT"),
31
+ "M": frozenset("AC"),
32
+ "B": frozenset("CGT"),
33
+ "D": frozenset("AGT"),
34
+ "H": frozenset("ACT"),
35
+ "V": frozenset("ACG"),
36
+ "N": frozenset("ACGT"),
37
+ }
38
+
39
+
40
+ def _compatible(left: str, right: str) -> bool:
41
+ return len(left) == len(right) and all(
42
+ bool(_IUPAC[left_symbol] & _IUPAC[right_symbol])
43
+ for left_symbol, right_symbol in zip(left, right, strict=True)
44
+ )
45
+
46
+
47
+ def _representative(
48
+ members: tuple[int, ...],
49
+ records: tuple[DNARecord, ...],
50
+ policy: str,
51
+ ) -> int:
52
+ if policy == "first":
53
+ return members[0]
54
+ if policy == "shortest":
55
+ return min(members, key=lambda index: (records[index].sequence.symbol_length, index))
56
+ if policy == "longest":
57
+ return max(members, key=lambda index: (records[index].sequence.symbol_length, -index))
58
+ return max(members, key=lambda index: (quality_score(records[index]), -index))
59
+
60
+
61
+ def deduplicate_iupac(
62
+ records: Iterable[DNARecord],
63
+ *,
64
+ config: IUPACDeduplicationConfig | None = None,
65
+ ) -> IUPACDeduplicationResult:
66
+ """Greedily build stable complete-link groups of pairwise-compatible IUPAC strings."""
67
+
68
+ resolved = IUPACDeduplicationConfig() if config is None else config
69
+ if not isinstance(resolved, IUPACDeduplicationConfig):
70
+ raise ConfigurationError(
71
+ "config must be IUPACDeduplicationConfig or None.",
72
+ code="INVALID_IUPAC_DEDUPLICATION_CONFIG",
73
+ )
74
+ materialized = materialize_limited(records, max_records=resolved.max_records)
75
+ comparisons = ensure_pair_limit(len(materialized), resolved.max_pairwise_comparisons)
76
+ for index, record in enumerate(materialized):
77
+ if record.sequence.topology is not Topology.LINEAR:
78
+ raise ConfigurationError(
79
+ "IUPAC-aware deduplication currently requires linear sequences.",
80
+ code="IUPAC_DEDUPLICATION_LINEAR_REQUIRED",
81
+ context={"input_index": index},
82
+ )
83
+ if any(isinstance(part, Gap) for part in record.sequence.parts):
84
+ raise UnsupportedGapOperationError(
85
+ "IUPAC-aware deduplication does not bridge Gap objects.",
86
+ code="IUPAC_DEDUPLICATION_GAP_UNSUPPORTED",
87
+ )
88
+
89
+ compatibility = [bytearray(len(materialized) - left - 1) for left in range(len(materialized))]
90
+ identical_pairs = 0
91
+ compatible_pairs = 0
92
+ conflict_pairs = 0
93
+ pair_relations: list[IUPACPairRelation] = []
94
+ for left in range(len(materialized)):
95
+ left_symbols = materialized[left].sequence.symbols
96
+ for right in range(left + 1, len(materialized)):
97
+ right_symbols = materialized[right].sequence.symbols
98
+ is_compatible = _compatible(left_symbols, right_symbols)
99
+ compatibility[left][right - left - 1] = is_compatible
100
+ relation: Literal["identical", "compatible", "conflict"]
101
+ if left_symbols == right_symbols:
102
+ identical_pairs += 1
103
+ relation = "identical"
104
+ elif is_compatible:
105
+ compatible_pairs += 1
106
+ relation = "compatible"
107
+ else:
108
+ conflict_pairs += 1
109
+ relation = "conflict"
110
+ pair_relations.append(
111
+ IUPACPairRelation(
112
+ left,
113
+ right,
114
+ materialized[left].id,
115
+ materialized[right].id,
116
+ relation,
117
+ )
118
+ )
119
+
120
+ def pair_is_compatible(left: int, right: int) -> bool:
121
+ if left > right:
122
+ left, right = right, left
123
+ return bool(compatibility[left][right - left - 1])
124
+
125
+ groups: list[list[int]] = []
126
+ for index in range(len(materialized)):
127
+ for group in groups:
128
+ if all(pair_is_compatible(index, member) for member in group):
129
+ group.append(index)
130
+ break
131
+ else:
132
+ groups.append([index])
133
+
134
+ audit: list[IUPACDuplicateGroup] = []
135
+ selected: list[DNARecord] = []
136
+ for group_index, raw_members in enumerate(groups):
137
+ members = tuple(raw_members)
138
+ representative = _representative(members, materialized, resolved.representative_policy)
139
+ selected.append(materialized[representative])
140
+ symbols = {materialized[index].sequence.symbols for index in members}
141
+ audit.append(
142
+ IUPACDuplicateGroup(
143
+ group_index,
144
+ materialized[representative].id,
145
+ tuple(materialized[index].id for index in members),
146
+ (
147
+ "singleton"
148
+ if len(members) == 1
149
+ else "identical"
150
+ if len(symbols) == 1
151
+ else "compatible"
152
+ ),
153
+ )
154
+ )
155
+ return IUPACDeduplicationResult(
156
+ DNASet(selected),
157
+ tuple(audit),
158
+ tuple(pair_relations),
159
+ resolved.representative_policy,
160
+ "position-compatible iff IUPAC base-set intersection is non-empty",
161
+ "stable-greedy-complete-link",
162
+ len(materialized),
163
+ len(selected),
164
+ comparisons,
165
+ identical_pairs,
166
+ compatible_pairs,
167
+ conflict_pairs,
168
+ resolved.max_records,
169
+ resolved.max_pairwise_comparisons,
170
+ )
171
+
172
+
173
+ def deduplicate_approximate(
174
+ records: Iterable[DNARecord],
175
+ *,
176
+ config: ClusterConfig,
177
+ ) -> ClusteringResult:
178
+ """Return threshold-graph components and representatives as near-duplicate groups."""
179
+
180
+ return cluster_sequences(records, config=config)
181
+
182
+
183
+ __all__ = ["deduplicate_approximate", "deduplicate_iupac"]