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,302 @@
1
+ """Temporal and explicitly heuristic multi-constraint splitting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+ from collections.abc import Iterable, Mapping
8
+ from datetime import datetime, timezone
9
+ from itertools import pairwise
10
+ from typing import cast
11
+
12
+ from dnakit.core import DNARecord, DNASet
13
+ from dnakit.core._json import FrozenDict
14
+ from dnakit.exceptions import ConfigurationError
15
+
16
+ from ._advanced_shared import (
17
+ UnionFind,
18
+ ensure_pair_limit,
19
+ materialize_limited,
20
+ pair_similarity,
21
+ validate_pairwise_sequences,
22
+ )
23
+ from ._metadata import metadata_value_key
24
+ from .config import JointSplitConfig, TemporalSplitConfig
25
+ from .results import (
26
+ JointSplitResult,
27
+ SplitAssignment,
28
+ SplitSubset,
29
+ TemporalSplitResult,
30
+ )
31
+
32
+
33
+ def _allocate_counts(size: int, ratios: Mapping[str, object]) -> dict[str, int]:
34
+ numeric = {name: float(cast(int | float, value)) for name, value in ratios.items()}
35
+ exact = {name: size * ratio for name, ratio in numeric.items()}
36
+ counts = {name: math.floor(value) for name, value in exact.items()}
37
+ remainder = size - sum(counts.values())
38
+ order = sorted(numeric, key=lambda name: (-(exact[name] - counts[name]), name))
39
+ for name in order[:remainder]:
40
+ counts[name] += 1
41
+ return counts
42
+
43
+
44
+ def _parse_iso(value: object, *, index: int, key: str) -> datetime:
45
+ if not isinstance(value, str) or not value.strip():
46
+ raise ConfigurationError(
47
+ "Temporal metadata must be a non-empty ISO-8601 string.",
48
+ code="INVALID_TEMPORAL_METADATA",
49
+ context={"input_index": index, "metadata_key": key},
50
+ )
51
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
52
+ try:
53
+ result = datetime.fromisoformat(normalized)
54
+ except ValueError as exc:
55
+ raise ConfigurationError(
56
+ "Temporal metadata is not valid ISO-8601.",
57
+ code="INVALID_TEMPORAL_METADATA",
58
+ context={"input_index": index, "value": value},
59
+ ) from exc
60
+ if result.tzinfo is not None:
61
+ result = result.astimezone(timezone.utc).replace(tzinfo=None)
62
+ return result
63
+
64
+
65
+ def temporal_split(
66
+ records: Iterable[DNARecord],
67
+ *,
68
+ config: TemporalSplitConfig | None = None,
69
+ ) -> TemporalSplitResult:
70
+ """Assign oldest records first using explicit cutoffs or target chronological ratios."""
71
+
72
+ resolved = TemporalSplitConfig() if config is None else config
73
+ if not isinstance(resolved, TemporalSplitConfig):
74
+ raise ConfigurationError("config must be TemporalSplitConfig.")
75
+ materialized = materialize_limited(records, max_records=resolved.max_records)
76
+ dated: list[tuple[datetime, int]] = []
77
+ for index, record in enumerate(materialized):
78
+ if resolved.metadata_key not in record.metadata:
79
+ raise ConfigurationError(
80
+ "A record is missing temporal metadata.",
81
+ code="TEMPORAL_METADATA_MISSING",
82
+ context={"input_index": index, "record_id": record.id},
83
+ )
84
+ dated.append(
85
+ (
86
+ _parse_iso(
87
+ record.metadata[resolved.metadata_key], index=index, key=resolved.metadata_key
88
+ ),
89
+ index,
90
+ )
91
+ )
92
+ dated.sort(key=lambda item: (item[0], item[1]))
93
+ split_names = tuple(resolved.ratios)
94
+ assignment: dict[int, str] = {}
95
+ cutoff_strings: tuple[str, ...]
96
+ if resolved.cutoffs is None:
97
+ counts = _allocate_counts(len(materialized), resolved.ratios)
98
+ cursor = 0
99
+ for name in split_names:
100
+ for _, index in dated[cursor : cursor + counts[name]]:
101
+ assignment[index] = name
102
+ cursor += counts[name]
103
+ cutoff_strings = tuple(
104
+ dated[sum(counts[name] for name in split_names[:position]) - 1][0].isoformat()
105
+ for position in range(1, len(split_names))
106
+ if sum(counts[name] for name in split_names[:position]) > 0
107
+ )
108
+ else:
109
+ parsed_cutoffs = tuple(
110
+ _parse_iso(value, index=index, key="cutoffs")
111
+ for index, value in enumerate(resolved.cutoffs)
112
+ )
113
+ if any(right <= left for left, right in pairwise(parsed_cutoffs)):
114
+ raise ConfigurationError(
115
+ "Temporal cutoffs must be strictly increasing.",
116
+ code="INVALID_TEMPORAL_CUTOFFS",
117
+ )
118
+ for date, index in dated:
119
+ split_position = sum(date > cutoff for cutoff in parsed_cutoffs)
120
+ assignment[index] = split_names[split_position]
121
+ cutoff_strings = resolved.cutoffs
122
+ counts = {name: sum(value == name for value in assignment.values()) for name in split_names}
123
+ indices = {
124
+ name: [index for index in range(len(materialized)) if assignment[index] == name]
125
+ for name in split_names
126
+ }
127
+ if not resolved.preserve_order:
128
+ chronological_rank = {index: rank for rank, (_, index) in enumerate(dated)}
129
+ for values in indices.values():
130
+ values.sort(key=chronological_rank.__getitem__)
131
+ return TemporalSplitResult(
132
+ tuple(
133
+ SplitSubset(name, DNASet(materialized[index] for index in indices[name]))
134
+ for name in split_names
135
+ ),
136
+ tuple(
137
+ SplitAssignment(index, record.id, assignment[index])
138
+ for index, record in enumerate(materialized)
139
+ ),
140
+ FrozenDict(counts),
141
+ cast(FrozenDict, resolved.ratios),
142
+ resolved.metadata_key,
143
+ cutoff_strings,
144
+ "chronological-explicit-cutoffs" if resolved.cutoffs else "chronological-largest-remainder",
145
+ resolved.preserve_order,
146
+ "naive timestamps are interpreted as UTC; aware timestamps are normalized to UTC",
147
+ resolved.max_records,
148
+ )
149
+
150
+
151
+ def _joint_units(
152
+ records: tuple[DNARecord, ...],
153
+ config: JointSplitConfig,
154
+ ) -> tuple[tuple[tuple[int, ...], ...], int]:
155
+ union = UnionFind(len(records))
156
+ for key in config.group_keys:
157
+ grouped: dict[object, int] = {}
158
+ for index, record in enumerate(records):
159
+ if key not in record.metadata:
160
+ raise ConfigurationError(
161
+ "Joint split group metadata is missing.",
162
+ code="JOINT_SPLIT_METADATA_MISSING",
163
+ context={"input_index": index, "metadata_key": key},
164
+ )
165
+ typed_key = metadata_value_key(record.metadata[key])
166
+ if typed_key in grouped:
167
+ union.union(grouped[typed_key], index)
168
+ else:
169
+ grouped[typed_key] = index
170
+ comparisons = 0
171
+ if config.similarity_threshold is not None:
172
+ validate_pairwise_sequences(records, operation="joint_split similarity constraint")
173
+ comparisons = ensure_pair_limit(len(records), config.max_pairwise_comparisons)
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="kmer",
180
+ k=config.similarity_k,
181
+ canonical=config.similarity_canonical,
182
+ max_alignment_cells=1,
183
+ )
184
+ if value >= config.similarity_threshold:
185
+ union.union(left, right)
186
+ return union.groups(), comparisons
187
+
188
+
189
+ def joint_split(
190
+ records: Iterable[DNARecord],
191
+ *,
192
+ config: JointSplitConfig,
193
+ ) -> JointSplitResult:
194
+ """Greedily assign atomic constraint components; never claim global optimality."""
195
+
196
+ if not isinstance(config, JointSplitConfig):
197
+ raise ConfigurationError("config must be JointSplitConfig.")
198
+ materialized = materialize_limited(records, max_records=config.max_records)
199
+ units, comparisons = _joint_units(materialized, config)
200
+ targets = _allocate_counts(len(materialized), config.ratios)
201
+ split_names = tuple(config.ratios)
202
+ counts = {name: 0 for name in split_names}
203
+ assignment: dict[int, str] = {}
204
+ generator = random.Random(config.seed)
205
+ ordered = list(units)
206
+ generator.shuffle(ordered)
207
+ ordered.sort(key=lambda unit: -len(unit))
208
+ label_totals: dict[object, int] = {}
209
+ if config.label_key is not None:
210
+ for index, record in enumerate(materialized):
211
+ if config.label_key not in record.metadata:
212
+ raise ConfigurationError(
213
+ "Joint split label metadata is missing.",
214
+ code="JOINT_SPLIT_METADATA_MISSING",
215
+ context={"input_index": index, "metadata_key": config.label_key},
216
+ )
217
+ label = metadata_value_key(record.metadata[config.label_key])
218
+ label_totals[label] = label_totals.get(label, 0) + 1
219
+ label_counts: dict[str, dict[object, int]] = {name: {} for name in split_names}
220
+ for unit in ordered:
221
+ unit_labels: dict[object, int] = {}
222
+ if config.label_key is not None:
223
+ for index in unit:
224
+ label = metadata_value_key(materialized[index].metadata[config.label_key])
225
+ unit_labels[label] = unit_labels.get(label, 0) + 1
226
+
227
+ def objective(
228
+ name: str,
229
+ unit: tuple[int, ...] = unit,
230
+ unit_labels: dict[object, int] = unit_labels,
231
+ ) -> tuple[float, int]:
232
+ projected = counts[name] + len(unit)
233
+ ratio_penalty = ((projected - targets[name]) / max(targets[name], 1)) ** 2
234
+ label_penalty = 0.0
235
+ for label, total in label_totals.items():
236
+ desired = total * float(cast(int | float, config.ratios[name]))
237
+ current = label_counts[name].get(label, 0) + unit_labels.get(label, 0)
238
+ label_penalty += ((current - desired) / max(desired, 1.0)) ** 2
239
+ return ratio_penalty + label_penalty, split_names.index(name)
240
+
241
+ chosen = min(split_names, key=objective)
242
+ counts[chosen] += len(unit)
243
+ for label, value in unit_labels.items():
244
+ label_counts[chosen][label] = label_counts[chosen].get(label, 0) + value
245
+ for index in unit:
246
+ assignment[index] = chosen
247
+ total = len(materialized)
248
+ achieved = {name: (counts[name] / total if total else 0.0) for name in split_names}
249
+ deviations = {
250
+ name: abs(achieved[name] - float(cast(int | float, config.ratios[name])))
251
+ for name in split_names
252
+ }
253
+ max_deviation = max(deviations.values(), default=0.0)
254
+ feasible = max_deviation <= config.ratio_tolerance
255
+ if not feasible and config.infeasible_policy == "error":
256
+ raise ConfigurationError(
257
+ "Atomic constraints make the requested split ratios infeasible within tolerance.",
258
+ code="JOINT_SPLIT_INFEASIBLE",
259
+ context={
260
+ "counts": counts,
261
+ "target_counts": targets,
262
+ "max_ratio_deviation": max_deviation,
263
+ "ratio_tolerance": config.ratio_tolerance,
264
+ },
265
+ hint="Choose infeasible_policy='relax' to return the audited greedy assignment.",
266
+ )
267
+ indices = {
268
+ name: [index for index in range(total) if assignment[index] == name] for name in split_names
269
+ }
270
+ return JointSplitResult(
271
+ tuple(
272
+ SplitSubset(name, DNASet(materialized[index] for index in indices[name]))
273
+ for name in split_names
274
+ ),
275
+ tuple(
276
+ SplitAssignment(index, record.id, assignment[index])
277
+ for index, record in enumerate(materialized)
278
+ ),
279
+ FrozenDict(counts),
280
+ FrozenDict(targets),
281
+ FrozenDict(achieved),
282
+ config.group_keys,
283
+ config.label_key,
284
+ config.similarity_threshold,
285
+ config.similarity_k,
286
+ config.similarity_canonical,
287
+ config.seed,
288
+ "seeded-greedy-atomic-components-ratio-and-label-penalty",
289
+ feasible,
290
+ not feasible,
291
+ ("ratio_tolerance",) if not feasible else (),
292
+ max_deviation,
293
+ config.ratio_tolerance,
294
+ len(units),
295
+ comparisons,
296
+ config.max_records,
297
+ config.max_pairwise_comparisons,
298
+ config.infeasible_policy,
299
+ )
300
+
301
+
302
+ __all__ = ["joint_split", "temporal_split"]
@@ -0,0 +1,286 @@
1
+ """Bounded threshold and agglomerative clustering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import math
7
+ from collections.abc import Iterable, Sequence
8
+
9
+ from dnakit.core import DNARecord, DNASet
10
+ from dnakit.exceptions import ConfigurationError
11
+
12
+ from ._advanced_shared import (
13
+ UnionFind,
14
+ ensure_pair_limit,
15
+ materialize_limited,
16
+ quality_score,
17
+ similarity_matrix,
18
+ validate_pairwise_sequences,
19
+ )
20
+ from .config import (
21
+ AdvancedRepresentativePolicy,
22
+ ClusterConfig,
23
+ ClusterMethod,
24
+ HierarchicalClusteringConfig,
25
+ )
26
+ from .results import (
27
+ Cluster,
28
+ ClusteringResult,
29
+ HierarchicalClusteringResult,
30
+ LinkageStep,
31
+ RepresentativeSelectionResult,
32
+ )
33
+
34
+
35
+ def _choose_representative(
36
+ members: tuple[int, ...],
37
+ records: tuple[DNARecord, ...],
38
+ *,
39
+ policy: AdvancedRepresentativePolicy,
40
+ similarities: tuple[tuple[float, ...], ...] | None,
41
+ ) -> int:
42
+ if policy == "first":
43
+ return members[0]
44
+ if policy == "shortest":
45
+ return min(members, key=lambda index: (records[index].sequence.symbol_length, index))
46
+ if policy == "longest":
47
+ return max(members, key=lambda index: (records[index].sequence.symbol_length, -index))
48
+ if policy == "best_quality":
49
+ return max(members, key=lambda index: (quality_score(records[index]), -index))
50
+ if similarities is None:
51
+ raise ConfigurationError("Medoid selection requires a similarity matrix.")
52
+ return min(
53
+ members,
54
+ key=lambda candidate: (
55
+ math.fsum(1.0 - similarities[candidate][member] for member in members),
56
+ candidate,
57
+ ),
58
+ )
59
+
60
+
61
+ def cluster_sequences(
62
+ records: Iterable[DNARecord],
63
+ *,
64
+ config: ClusterConfig | None = None,
65
+ ) -> ClusteringResult:
66
+ """Cluster by connected components of an exhaustive similarity-threshold graph."""
67
+
68
+ resolved = ClusterConfig() if config is None else config
69
+ if not isinstance(resolved, ClusterConfig):
70
+ raise ConfigurationError("config must be ClusterConfig.", code="INVALID_CLUSTER_CONFIG")
71
+ materialized = materialize_limited(records, max_records=resolved.max_records)
72
+ comparisons = ensure_pair_limit(len(materialized), resolved.max_pairwise_comparisons)
73
+ validate_pairwise_sequences(materialized, operation="cluster_sequences")
74
+ similarities = similarity_matrix(
75
+ materialized,
76
+ method=resolved.method,
77
+ k=resolved.k,
78
+ canonical=resolved.canonical,
79
+ max_alignment_cells=resolved.max_alignment_cells,
80
+ )
81
+ components = UnionFind(len(materialized))
82
+ for left in range(len(materialized)):
83
+ for right in range(left + 1, len(materialized)):
84
+ if similarities[left][right] >= resolved.threshold:
85
+ components.union(left, right)
86
+ groups = components.groups()
87
+ labels = [0] * len(materialized)
88
+ clusters: list[Cluster] = []
89
+ representatives: list[DNARecord] = []
90
+ for cluster_index, members in enumerate(groups):
91
+ representative = _choose_representative(
92
+ members,
93
+ materialized,
94
+ policy=resolved.representative_policy,
95
+ similarities=similarities,
96
+ )
97
+ for member in members:
98
+ labels[member] = cluster_index
99
+ representatives.append(materialized[representative])
100
+ clusters.append(
101
+ Cluster(
102
+ cluster_index,
103
+ members,
104
+ tuple(materialized[index].id for index in members),
105
+ representative,
106
+ materialized[representative].id,
107
+ )
108
+ )
109
+ return ClusteringResult(
110
+ tuple(clusters),
111
+ tuple(labels),
112
+ DNASet(representatives),
113
+ resolved.method,
114
+ resolved.threshold,
115
+ resolved.k,
116
+ resolved.canonical,
117
+ resolved.representative_policy,
118
+ "exhaustive-threshold-graph-connected-components",
119
+ resolved.seed,
120
+ False,
121
+ comparisons,
122
+ resolved.max_records,
123
+ resolved.max_pairwise_comparisons,
124
+ resolved.max_alignment_cells,
125
+ )
126
+
127
+
128
+ def hierarchical_cluster(
129
+ records: Iterable[DNARecord],
130
+ *,
131
+ config: HierarchicalClusteringConfig | None = None,
132
+ ) -> HierarchicalClusteringResult:
133
+ """Agglomerate the closest clusters with deterministic linkage tie-breaking."""
134
+
135
+ resolved = HierarchicalClusteringConfig() if config is None else config
136
+ if not isinstance(resolved, HierarchicalClusteringConfig):
137
+ raise ConfigurationError("config must be HierarchicalClusteringConfig.")
138
+ materialized = materialize_limited(records, max_records=resolved.max_records)
139
+ comparisons = ensure_pair_limit(len(materialized), resolved.max_pairwise_comparisons)
140
+ validate_pairwise_sequences(materialized, operation="hierarchical_cluster")
141
+ similarities = similarity_matrix(
142
+ materialized,
143
+ method=resolved.method,
144
+ k=resolved.k,
145
+ canonical=resolved.canonical,
146
+ max_alignment_cells=resolved.max_alignment_cells,
147
+ )
148
+ active = set(range(len(materialized)))
149
+ sizes = {index: 1 for index in active}
150
+ distances: dict[tuple[int, int], float] = {
151
+ (left, right): 1.0 - similarities[left][right]
152
+ for left in range(len(materialized))
153
+ for right in range(left + 1, len(materialized))
154
+ }
155
+ candidates = [(distance, left, right) for (left, right), distance in distances.items()]
156
+ heapq.heapify(candidates)
157
+ next_node = len(materialized)
158
+ linkage: list[LinkageStep] = []
159
+ distance_updates = 0
160
+
161
+ def key(left: int, right: int) -> tuple[int, int]:
162
+ return (left, right) if left < right else (right, left)
163
+
164
+ while len(active) > 1:
165
+ while candidates:
166
+ distance, left, right = heapq.heappop(candidates)
167
+ if left in active and right in active and distances.get((left, right)) == distance:
168
+ break
169
+ else:
170
+ raise AssertionError("Hierarchical clustering candidate heap was exhausted.")
171
+ left_size, right_size = sizes[left], sizes[right]
172
+ others = tuple(sorted(active - {left, right}))
173
+ active.remove(left)
174
+ active.remove(right)
175
+ for other in others:
176
+ left_distance = distances[key(left, other)]
177
+ right_distance = distances[key(right, other)]
178
+ if resolved.linkage == "single":
179
+ new_distance = min(left_distance, right_distance)
180
+ elif resolved.linkage == "complete":
181
+ new_distance = max(left_distance, right_distance)
182
+ else:
183
+ new_distance = (left_size * left_distance + right_size * right_distance) / (
184
+ left_size + right_size
185
+ )
186
+ new_key = key(next_node, other)
187
+ distances[new_key] = new_distance
188
+ heapq.heappush(candidates, (new_distance, *new_key))
189
+ distance_updates += 1
190
+ for other in others:
191
+ distances.pop(key(left, other))
192
+ distances.pop(key(right, other))
193
+ distances.pop(key(left, right))
194
+ sizes[next_node] = left_size + right_size
195
+ active.add(next_node)
196
+ linkage.append(
197
+ LinkageStep(
198
+ len(linkage),
199
+ left,
200
+ right,
201
+ next_node,
202
+ distance,
203
+ sizes[next_node],
204
+ )
205
+ )
206
+ next_node += 1
207
+ return HierarchicalClusteringResult(
208
+ tuple(record.id for record in materialized),
209
+ tuple(linkage),
210
+ resolved.method,
211
+ resolved.linkage,
212
+ "1 - configured pair similarity",
213
+ resolved.k,
214
+ resolved.canonical,
215
+ comparisons,
216
+ distance_updates,
217
+ resolved.max_records,
218
+ resolved.max_pairwise_comparisons,
219
+ resolved.max_alignment_cells,
220
+ )
221
+
222
+
223
+ def select_representatives(
224
+ records: Iterable[DNARecord],
225
+ labels: Sequence[int],
226
+ *,
227
+ policy: AdvancedRepresentativePolicy = "first",
228
+ medoid_method: ClusterMethod = "identity",
229
+ k: int = 3,
230
+ canonical: bool = False,
231
+ max_records: int = 1_000,
232
+ max_pairwise_comparisons: int = 500_000,
233
+ max_alignment_cells: int = 5_000_000,
234
+ ) -> RepresentativeSelectionResult:
235
+ """Select one stable representative per user-supplied cluster label."""
236
+
237
+ validation = ClusterConfig(
238
+ method=medoid_method,
239
+ k=k,
240
+ canonical=canonical,
241
+ representative_policy=policy,
242
+ max_records=max_records,
243
+ max_pairwise_comparisons=max_pairwise_comparisons,
244
+ max_alignment_cells=max_alignment_cells,
245
+ )
246
+ materialized = materialize_limited(records, max_records=validation.max_records)
247
+ label_tuple = tuple(labels)
248
+ if len(label_tuple) != len(materialized) or any(
249
+ isinstance(label, bool) or not isinstance(label, int) or label < 0 for label in label_tuple
250
+ ):
251
+ raise ConfigurationError("labels must be non-negative integers aligned with records.")
252
+ grouped: dict[int, list[int]] = {}
253
+ for index, label in enumerate(label_tuple):
254
+ grouped.setdefault(label, []).append(index)
255
+ comparisons = 0
256
+ similarities: tuple[tuple[float, ...], ...] | None = None
257
+ if policy == "medoid":
258
+ comparisons = ensure_pair_limit(len(materialized), validation.max_pairwise_comparisons)
259
+ validate_pairwise_sequences(materialized, operation="select_representatives")
260
+ similarities = similarity_matrix(
261
+ materialized,
262
+ method=validation.method,
263
+ k=validation.k,
264
+ canonical=validation.canonical,
265
+ max_alignment_cells=validation.max_alignment_cells,
266
+ )
267
+ chosen = tuple(
268
+ _choose_representative(
269
+ tuple(members),
270
+ materialized,
271
+ policy=policy,
272
+ similarities=similarities,
273
+ )
274
+ for _, members in sorted(grouped.items())
275
+ )
276
+ return RepresentativeSelectionResult(
277
+ DNASet(materialized[index] for index in chosen),
278
+ tuple(materialized[index].id for index in chosen),
279
+ policy,
280
+ len(grouped),
281
+ validation.method if policy == "medoid" else None,
282
+ comparisons,
283
+ )
284
+
285
+
286
+ __all__ = ["cluster_sequences", "hierarchical_cluster", "select_representatives"]