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,288 @@
1
+ """Shared bounded primitives for DNA evaluation implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from collections.abc import Iterable, Mapping
9
+ from dataclasses import dataclass
10
+ from typing import TypeAlias, cast
11
+
12
+ from dnakit.alignment import AlignmentConfig, align_pairwise
13
+ from dnakit.core import (
14
+ DNA,
15
+ DNARecord,
16
+ DNASequence,
17
+ DNASet,
18
+ ImplementationInfo,
19
+ ImplementationLabel,
20
+ Issue,
21
+ IssueSeverity,
22
+ Provenance,
23
+ ReferenceInfo,
24
+ )
25
+ from dnakit.core._json import FrozenDict, to_json_compatible
26
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
27
+ from dnakit.similarity import edit_distance, exact_similarity, kmer_similarity
28
+
29
+ from .config import EvaluationLimits, PairSimilarityMethod
30
+ from .results import EvaluationEntry, EvaluationReport, ReferenceLibrary
31
+
32
+ EvaluationInput: TypeAlias = DNA | DNASequence | DNARecord | DNASet | Iterable[DNARecord]
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class InputItem:
37
+ sequence: DNASequence
38
+ subject_id: str
39
+ record: DNARecord | None
40
+
41
+
42
+ def provenance(*, reference: ReferenceLibrary | None = None) -> Provenance:
43
+ reference_info = None
44
+ if reference is not None:
45
+ reference_info = ReferenceInfo(
46
+ reference.name,
47
+ version=reference.version,
48
+ date=reference.date,
49
+ checksum=reference.digest,
50
+ filters=reference.filters,
51
+ )
52
+ return Provenance(
53
+ implementation=ImplementationInfo(label=ImplementationLabel.NATIVE),
54
+ reference=reference_info,
55
+ )
56
+
57
+
58
+ def issue(code: str, severity: IssueSeverity, message: str, **details: object) -> Issue:
59
+ return Issue(code, severity, message, details=details)
60
+
61
+
62
+ def materialize_input(value: EvaluationInput, *, limits: EvaluationLimits) -> tuple[InputItem, ...]:
63
+ if isinstance(value, DNA) and value.is_single:
64
+ record = value.record
65
+ items: tuple[InputItem, ...] = (InputItem(record.sequence, record.id, record),)
66
+ elif isinstance(value, DNASequence):
67
+ items = (InputItem(value, "sequence", None),)
68
+ elif isinstance(value, DNARecord):
69
+ items = (InputItem(value.sequence, value.id, value),)
70
+ else:
71
+ source = value.records if isinstance(value, (DNA, DNASet)) else value
72
+ try:
73
+ iterator = iter(source)
74
+ except TypeError as exc:
75
+ raise ConfigurationError(
76
+ "Evaluation input must be a DNASequence, DNARecord, DNASet, or record iterable.",
77
+ code="INVALID_EVALUATION_INPUT",
78
+ ) from exc
79
+ collected: list[InputItem] = []
80
+ for index, record in enumerate(iterator):
81
+ if not isinstance(record, DNARecord):
82
+ raise ConfigurationError(
83
+ "Evaluation collections must contain DNARecord objects.",
84
+ code="INVALID_EVALUATION_RECORD",
85
+ context={"input_index": index, "type": type(record).__name__},
86
+ )
87
+ if index >= limits.max_records:
88
+ raise ConfigurationError(
89
+ "Evaluation input exceeds max_records.",
90
+ code="EVALUATION_RECORD_LIMIT",
91
+ context={
92
+ "record_count_lower_bound": index + 1,
93
+ "max_records": limits.max_records,
94
+ },
95
+ )
96
+ collected.append(InputItem(record.sequence, record.id, record))
97
+ items = tuple(collected)
98
+ if len(items) > limits.max_records:
99
+ raise ConfigurationError(
100
+ "Evaluation input exceeds max_records.",
101
+ code="EVALUATION_RECORD_LIMIT",
102
+ context={"record_count": len(items), "max_records": limits.max_records},
103
+ )
104
+ total_symbols = sum(item.sequence.symbol_length for item in items)
105
+ if total_symbols > limits.max_total_symbols:
106
+ raise ConfigurationError(
107
+ "Evaluation input exceeds max_total_symbols.",
108
+ code="EVALUATION_SYMBOL_LIMIT",
109
+ context={"total_symbols": total_symbols, "max_total_symbols": limits.max_total_symbols},
110
+ )
111
+ return items
112
+
113
+
114
+ def require_nonempty(items: tuple[InputItem, ...], operation: str) -> None:
115
+ if not items:
116
+ raise ConfigurationError(
117
+ f"{operation} requires at least one record.", code="EMPTY_EVALUATION_DATASET"
118
+ )
119
+
120
+
121
+ def pair_count(left_size: int, right_size: int | None = None) -> int:
122
+ return left_size * (left_size - 1) // 2 if right_size is None else left_size * right_size
123
+
124
+
125
+ def enforce_pair_limit(count: int, limits: EvaluationLimits) -> None:
126
+ if count > limits.max_pairwise_comparisons:
127
+ raise ConfigurationError(
128
+ "Evaluation workload exceeds max_pairwise_comparisons.",
129
+ code="EVALUATION_PAIRWISE_LIMIT",
130
+ context={"pairwise_comparisons": count, "limit": limits.max_pairwise_comparisons},
131
+ )
132
+
133
+
134
+ def require_pairwise_compatible(sequence: DNASequence, *, role: str) -> None:
135
+ if sequence.is_gapped:
136
+ raise UnsupportedGapOperationError(
137
+ "Pairwise evaluation does not silently omit or bridge Gap objects.",
138
+ code="EVALUATION_GAP_UNSUPPORTED",
139
+ context={"role": role},
140
+ )
141
+
142
+
143
+ def pair_similarity(
144
+ left: DNARecord,
145
+ right: DNARecord,
146
+ *,
147
+ method: PairSimilarityMethod,
148
+ k: int,
149
+ canonical: bool,
150
+ max_alignment_cells: int,
151
+ ) -> tuple[float, float | None]:
152
+ require_pairwise_compatible(left.sequence, role="left")
153
+ require_pairwise_compatible(right.sequence, role="right")
154
+ if method == "exact":
155
+ return exact_similarity(left, right).value, 1.0
156
+ if method == "identity":
157
+ result = align_pairwise(
158
+ left,
159
+ right,
160
+ config=AlignmentConfig(mode="global", max_cells=max_alignment_cells),
161
+ )
162
+ return (1.0 if result.identity is None else result.identity), min(
163
+ result.query_coverage, result.target_coverage
164
+ )
165
+ if method == "edit":
166
+ distance_result = edit_distance(left, right, max_cells=max_alignment_cells)
167
+ denominator = max(left.sequence.symbol_length, right.sequence.symbol_length)
168
+ return (1.0 if denominator == 0 else 1.0 - distance_result.distance / denominator), None
169
+ if left.sequence.symbol_length < k or right.sequence.symbol_length < k:
170
+ return (1.0 if left.sequence.symbols == right.sequence.symbols else 0.0), None
171
+ return kmer_similarity(left, right, k=k, canonical=canonical).value, None
172
+
173
+
174
+ def aggregate_numeric(entries: tuple[EvaluationEntry, ...], metric: str) -> FrozenDict:
175
+ values = tuple(
176
+ float(value)
177
+ for entry in entries
178
+ if (value := entry.metrics.get(metric)) is not None
179
+ and not isinstance(value, bool)
180
+ and isinstance(value, (int, float))
181
+ )
182
+ return FrozenDict(
183
+ {
184
+ "count": len(values),
185
+ "mean": math.fsum(values) / len(values) if values else None,
186
+ "minimum": min(values) if values else None,
187
+ "maximum": max(values) if values else None,
188
+ }
189
+ )
190
+
191
+
192
+ def report(
193
+ *,
194
+ name: str,
195
+ method: str,
196
+ version: str,
197
+ parameters: Mapping[str, object],
198
+ metrics: Mapping[str, object],
199
+ entries: Iterable[EvaluationEntry] = (),
200
+ issues: Iterable[Issue] = (),
201
+ reference: ReferenceLibrary | None = None,
202
+ ) -> EvaluationReport:
203
+ return EvaluationReport(
204
+ name,
205
+ method,
206
+ version,
207
+ FrozenDict(cast(Mapping[str, object], to_json_compatible(parameters))),
208
+ FrozenDict(cast(Mapping[str, object], to_json_compatible(metrics))),
209
+ tuple(entries),
210
+ provenance(reference=reference),
211
+ tuple(issues),
212
+ )
213
+
214
+
215
+ def sequence_digest_payload(records: DNASet) -> dict[str, object]:
216
+ return {
217
+ "schema": "dnakit.reference-library-sequences.v1",
218
+ "records": [
219
+ {
220
+ "id": record.id,
221
+ "parts": [
222
+ part
223
+ if isinstance(part, str)
224
+ else {
225
+ "gap": {
226
+ "length": part.length,
227
+ "kind": part.kind.value,
228
+ "crossable": part.crossable,
229
+ "evidence": part.evidence,
230
+ "metadata": part.metadata,
231
+ }
232
+ }
233
+ for part in record.sequence.parts
234
+ ],
235
+ "alphabet": record.sequence.alphabet.value,
236
+ "topology": record.sequence.topology.value,
237
+ "strandedness": record.sequence.strandedness.value,
238
+ }
239
+ for record in records
240
+ ],
241
+ }
242
+
243
+
244
+ def digest_json(value: object) -> str:
245
+ payload = json.dumps(
246
+ to_json_compatible(value),
247
+ ensure_ascii=False,
248
+ sort_keys=True,
249
+ separators=(",", ":"),
250
+ allow_nan=False,
251
+ ).encode("utf-8")
252
+ return hashlib.sha256(payload).hexdigest()
253
+
254
+
255
+ def record_for(item: InputItem) -> DNARecord:
256
+ return item.record or DNARecord(item.sequence, item.subject_id)
257
+
258
+
259
+ def mean(values: Iterable[float]) -> float | None:
260
+ materialized = tuple(values)
261
+ return math.fsum(materialized) / len(materialized) if materialized else None
262
+
263
+
264
+ def as_float(mapping: Mapping[str, object], key: str) -> float:
265
+ value = mapping[key]
266
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
267
+ raise ConfigurationError("Internal evaluation metric is not numeric.")
268
+ return float(value)
269
+
270
+
271
+ __all__ = [
272
+ "EvaluationInput",
273
+ "InputItem",
274
+ "aggregate_numeric",
275
+ "as_float",
276
+ "digest_json",
277
+ "enforce_pair_limit",
278
+ "issue",
279
+ "materialize_input",
280
+ "mean",
281
+ "pair_count",
282
+ "pair_similarity",
283
+ "provenance",
284
+ "record_for",
285
+ "report",
286
+ "require_nonempty",
287
+ "sequence_digest_payload",
288
+ ]
@@ -0,0 +1,270 @@
1
+ """Collection uniqueness, diversity, and redundancy evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dnakit.core import DNARecord
6
+ from dnakit.datasets import (
7
+ ClusterConfig,
8
+ IUPACDeduplicationConfig,
9
+ cluster_sequences,
10
+ deduplicate,
11
+ deduplicate_iupac,
12
+ )
13
+
14
+ from ._shared import (
15
+ EvaluationInput,
16
+ enforce_pair_limit,
17
+ materialize_input,
18
+ mean,
19
+ pair_count,
20
+ pair_similarity,
21
+ record_for,
22
+ report,
23
+ )
24
+ from .config import DiversityEvaluationConfig, UniquenessEvaluationConfig
25
+ from .results import EvaluationReport
26
+
27
+
28
+ def _records(value: EvaluationInput, config: UniquenessEvaluationConfig) -> tuple[DNARecord, ...]:
29
+ return tuple(record_for(item) for item in materialize_input(value, limits=config.limits))
30
+
31
+
32
+ def evaluate_uniqueness(
33
+ value: EvaluationInput,
34
+ *,
35
+ config: UniquenessEvaluationConfig | None = None,
36
+ ) -> EvaluationReport:
37
+ """Compute non-duplicate fraction under one explicitly configured equivalence."""
38
+
39
+ resolved = UniquenessEvaluationConfig() if config is None else config
40
+ if not isinstance(resolved, UniquenessEvaluationConfig):
41
+ raise TypeError("config must be UniquenessEvaluationConfig or None.")
42
+ records = _records(value, resolved)
43
+ if resolved.equivalence == "iupac":
44
+ iupac_result = deduplicate_iupac(
45
+ records,
46
+ config=IUPACDeduplicationConfig(
47
+ max_records=resolved.limits.max_records,
48
+ max_pairwise_comparisons=resolved.limits.max_pairwise_comparisons,
49
+ ),
50
+ )
51
+ groups = tuple(group.member_ids for group in iupac_result.groups)
52
+ unique_count = iupac_result.output_count
53
+ comparisons = iupac_result.pairwise_comparison_count
54
+ strategy = iupac_result.grouping_strategy
55
+ elif resolved.equivalence == "approximate":
56
+ cluster_method = (
57
+ "identity" if resolved.approximate_method == "exact" else resolved.approximate_method
58
+ )
59
+ cluster_result = cluster_sequences(
60
+ records,
61
+ config=ClusterConfig(
62
+ method=cluster_method,
63
+ threshold=1.0 if resolved.approximate_method == "exact" else resolved.threshold,
64
+ k=resolved.k,
65
+ canonical=resolved.canonical,
66
+ max_records=resolved.limits.max_records,
67
+ max_pairwise_comparisons=resolved.limits.max_pairwise_comparisons,
68
+ max_alignment_cells=resolved.limits.max_alignment_cells,
69
+ ),
70
+ )
71
+ groups = tuple(cluster.member_ids for cluster in cluster_result.clusters)
72
+ unique_count = len(cluster_result.clusters)
73
+ comparisons = cluster_result.pairwise_comparison_count
74
+ strategy = cluster_result.clustering_strategy
75
+ else:
76
+ dedup_result = deduplicate(records, equivalence=resolved.equivalence)
77
+ groups = tuple(group.member_ids for group in dedup_result.groups)
78
+ unique_count = len(dedup_result.groups)
79
+ comparisons = 0
80
+ strategy = "hash-equivalence-groups"
81
+ duplicate_groups = tuple(group for group in groups if len(group) > 1)
82
+ total = len(records)
83
+ score = unique_count / total if total else 1.0
84
+ return report(
85
+ name="uniqueness",
86
+ method=f"{resolved.equivalence}-equivalence",
87
+ version="eval-uniqueness-v1",
88
+ parameters={
89
+ "equivalence": resolved.equivalence,
90
+ "approximate_method": resolved.approximate_method,
91
+ "threshold": resolved.threshold,
92
+ "k": resolved.k,
93
+ "canonical": resolved.canonical,
94
+ "grouping_strategy": strategy,
95
+ "limits": resolved.limits,
96
+ },
97
+ metrics={
98
+ "score": score,
99
+ "uniqueness_score": score,
100
+ "record_count": total,
101
+ "unique_count": unique_count,
102
+ "duplicate_record_count": total - unique_count,
103
+ "duplicate_group_count": len(duplicate_groups),
104
+ "groups": groups,
105
+ "duplicate_groups": duplicate_groups,
106
+ "pairwise_comparison_count": comparisons,
107
+ },
108
+ )
109
+
110
+
111
+ def _similarity_state(
112
+ records: tuple[DNARecord, ...], config: DiversityEvaluationConfig
113
+ ) -> tuple[tuple[tuple[float, ...], ...], int]:
114
+ comparisons = pair_count(len(records))
115
+ enforce_pair_limit(comparisons, config.limits)
116
+ matrix = [
117
+ [1.0 if left == right else 0.0 for right in range(len(records))]
118
+ for left in range(len(records))
119
+ ]
120
+ for left in range(len(records)):
121
+ for right in range(left + 1, len(records)):
122
+ similarity, _ = pair_similarity(
123
+ records[left],
124
+ records[right],
125
+ method=config.method,
126
+ k=config.k,
127
+ canonical=config.canonical,
128
+ max_alignment_cells=config.limits.max_alignment_cells,
129
+ )
130
+ matrix[left][right] = matrix[right][left] = similarity
131
+ return tuple(tuple(row) for row in matrix), comparisons
132
+
133
+
134
+ def _component_count(matrix: tuple[tuple[float, ...], ...], threshold: float) -> int:
135
+ parents = list(range(len(matrix)))
136
+
137
+ def find(value: int) -> int:
138
+ while parents[value] != value:
139
+ parents[value] = parents[parents[value]]
140
+ value = parents[value]
141
+ return value
142
+
143
+ for left in range(len(matrix)):
144
+ for right in range(left + 1, len(matrix)):
145
+ if matrix[left][right] >= threshold:
146
+ left_root, right_root = find(left), find(right)
147
+ if left_root != right_root:
148
+ parents[right_root] = left_root
149
+ return len({find(index) for index in range(len(matrix))})
150
+
151
+
152
+ def evaluate_diversity(
153
+ value: EvaluationInput,
154
+ *,
155
+ config: DiversityEvaluationConfig | None = None,
156
+ ) -> EvaluationReport:
157
+ """Report exhaustive mean distance, nearest-neighbor distance, and cluster coverage."""
158
+
159
+ resolved = DiversityEvaluationConfig() if config is None else config
160
+ if not isinstance(resolved, DiversityEvaluationConfig):
161
+ raise TypeError("config must be DiversityEvaluationConfig or None.")
162
+ items = materialize_input(value, limits=resolved.limits)
163
+ records = tuple(record_for(item) for item in items)
164
+ matrix, comparisons = _similarity_state(records, resolved)
165
+ distances = tuple(
166
+ 1.0 - matrix[left][right]
167
+ for left in range(len(records))
168
+ for right in range(left + 1, len(records))
169
+ )
170
+ nearest_distances = (
171
+ tuple(
172
+ min(1.0 - matrix[index][other] for other in range(len(records)) if other != index)
173
+ for index in range(len(records))
174
+ )
175
+ if len(records) > 1
176
+ else ()
177
+ )
178
+ cluster_count = _component_count(matrix, resolved.cluster_threshold)
179
+ cluster_coverage = cluster_count / len(records) if records else 1.0
180
+ mean_distance = mean(distances)
181
+ mean_nearest = mean(nearest_distances)
182
+ score = mean_nearest if mean_nearest is not None else (1.0 if len(records) <= 1 else 0.0)
183
+ return report(
184
+ name="diversity",
185
+ method="exhaustive-pair-distance-and-threshold-components",
186
+ version="eval-diversity-v1",
187
+ parameters={
188
+ "similarity_method": resolved.method,
189
+ "distance_definition": "1 - pair similarity",
190
+ "k": resolved.k,
191
+ "canonical": resolved.canonical,
192
+ "cluster_threshold": resolved.cluster_threshold,
193
+ "cluster_strategy": "connected-components-of-threshold-graph",
194
+ "short_k_policy": "literal equality when either sequence is shorter than k",
195
+ "topology_policy": "linearized at stored origin; no circular rotation",
196
+ "singleton_policy": "score=1; pair metrics undefined",
197
+ "limits": resolved.limits,
198
+ },
199
+ metrics={
200
+ "score": score,
201
+ "record_count": len(records),
202
+ "mean_pair_distance": mean_distance,
203
+ "mean_nearest_neighbor_distance": mean_nearest,
204
+ "minimum_nearest_neighbor_distance": min(nearest_distances)
205
+ if nearest_distances
206
+ else None,
207
+ "cluster_count": cluster_count,
208
+ "cluster_coverage": cluster_coverage,
209
+ "pairwise_comparison_count": comparisons,
210
+ },
211
+ )
212
+
213
+
214
+ def evaluate_redundancy(
215
+ value: EvaluationInput,
216
+ *,
217
+ config: DiversityEvaluationConfig | None = None,
218
+ ) -> EvaluationReport:
219
+ """Report exact, similarity-threshold, and cluster-level redundancy."""
220
+
221
+ resolved = DiversityEvaluationConfig() if config is None else config
222
+ if not isinstance(resolved, DiversityEvaluationConfig):
223
+ raise TypeError("config must be DiversityEvaluationConfig or None.")
224
+ items = materialize_input(value, limits=resolved.limits)
225
+ records = tuple(record_for(item) for item in items)
226
+ matrix, comparisons = _similarity_state(records, resolved)
227
+ exact_unique_count = len(deduplicate(records, equivalence="exact").groups)
228
+ near_pair_count = sum(
229
+ matrix[left][right] >= resolved.cluster_threshold
230
+ for left in range(len(records))
231
+ for right in range(left + 1, len(records))
232
+ )
233
+ total_pairs = pair_count(len(records))
234
+ cluster_count = _component_count(matrix, resolved.cluster_threshold)
235
+ exact_fraction = (len(records) - exact_unique_count) / len(records) if records else 0.0
236
+ cluster_fraction = (len(records) - cluster_count) / len(records) if records else 0.0
237
+ near_pair_fraction = near_pair_count / total_pairs if total_pairs else 0.0
238
+ score = (exact_fraction + near_pair_fraction + cluster_fraction) / 3.0
239
+ return report(
240
+ name="redundancy",
241
+ method="exact-near-pair-threshold-component-mean",
242
+ version="eval-redundancy-v1",
243
+ parameters={
244
+ "similarity_method": resolved.method,
245
+ "threshold": resolved.cluster_threshold,
246
+ "k": resolved.k,
247
+ "canonical": resolved.canonical,
248
+ "score_formula": (
249
+ "mean(exact_duplicate_fraction,near_pair_fraction,cluster_redundancy_fraction)"
250
+ ),
251
+ "short_k_policy": "literal equality when either sequence is shorter than k",
252
+ "topology_policy": "linearized at stored origin; no circular rotation",
253
+ "limits": resolved.limits,
254
+ },
255
+ metrics={
256
+ "score": score,
257
+ "record_count": len(records),
258
+ "exact_unique_count": exact_unique_count,
259
+ "exact_duplicate_fraction": exact_fraction,
260
+ "near_pair_count": near_pair_count,
261
+ "pair_count": total_pairs,
262
+ "near_pair_fraction": near_pair_fraction,
263
+ "cluster_count": cluster_count,
264
+ "cluster_redundancy_fraction": cluster_fraction,
265
+ "pairwise_comparison_count": comparisons,
266
+ },
267
+ )
268
+
269
+
270
+ __all__ = ["evaluate_diversity", "evaluate_redundancy", "evaluate_uniqueness"]