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,287 @@
1
+ """Transparent descriptive distribution comparison between two DNA collections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import Counter
7
+
8
+ from dnakit.core import DNARecord
9
+ from dnakit.descriptors import exact_repeat_fraction, gc_at_content
10
+
11
+ from ._shared import EvaluationInput, materialize_input, mean, record_for, report, require_nonempty
12
+ from .config import DistributionEvaluationConfig
13
+ from .results import EvaluationReport
14
+
15
+
16
+ def _ecdf_distance(left: tuple[float, ...], right: tuple[float, ...]) -> float:
17
+ """Return the exact two-sample Kolmogorov-Smirnov D statistic."""
18
+
19
+ if not left or not right:
20
+ return 0.0
21
+ support = sorted(set(left) | set(right))
22
+ left_sorted = sorted(left)
23
+ right_sorted = sorted(right)
24
+ left_index = 0
25
+ right_index = 0
26
+ distance = 0.0
27
+ for value in support:
28
+ while left_index < len(left_sorted) and left_sorted[left_index] <= value:
29
+ left_index += 1
30
+ while right_index < len(right_sorted) and right_sorted[right_index] <= value:
31
+ right_index += 1
32
+ distance = max(
33
+ distance,
34
+ abs(left_index / len(left_sorted) - right_index / len(right_sorted)),
35
+ )
36
+ return distance
37
+
38
+
39
+ def _total_variation(left: Counter[str], right: Counter[str]) -> float:
40
+ left_total = sum(left.values())
41
+ right_total = sum(right.values())
42
+ if left_total == 0 and right_total == 0:
43
+ return 0.0
44
+ support = set(left) | set(right)
45
+ return 0.5 * math.fsum(
46
+ abs(
47
+ (left[key] / left_total if left_total else 0.0)
48
+ - (right[key] / right_total if right_total else 0.0)
49
+ )
50
+ for key in support
51
+ )
52
+
53
+
54
+ def _kmer_counts(records: tuple[DNARecord, ...], k: int, canonical: bool) -> Counter[str]:
55
+ complement = str.maketrans("ACGT", "TGCA")
56
+ counts: Counter[str] = Counter()
57
+ for record in records:
58
+ for part in record.sequence.parts:
59
+ if not isinstance(part, str):
60
+ continue
61
+ for start in range(max(0, len(part) - k + 1)):
62
+ word = part[start : start + k]
63
+ if set(word) - set("ACGT"):
64
+ continue
65
+ if canonical:
66
+ word = min(word, word.translate(complement)[::-1])
67
+ counts[word] += 1
68
+ return counts
69
+
70
+
71
+ def _kmer_observation_count(
72
+ records: tuple[DNARecord, ...],
73
+ k: int,
74
+ *,
75
+ initial_count: int,
76
+ maximum: int,
77
+ ) -> int:
78
+ """Count eligible k-mers without allocating a frequency table.
79
+
80
+ The scan stops at the first observation beyond ``maximum`` so an
81
+ over-budget input cannot build either collection's ``Counter`` first.
82
+ """
83
+
84
+ observation_count = initial_count
85
+ canonical_symbols = frozenset("ACGT")
86
+ for record in records:
87
+ for part in record.sequence.parts:
88
+ if not isinstance(part, str):
89
+ continue
90
+ for start in range(max(0, len(part) - k + 1)):
91
+ if set(part[start : start + k]) - canonical_symbols:
92
+ continue
93
+ observation_count += 1
94
+ if observation_count > maximum:
95
+ from dnakit.exceptions import ConfigurationError
96
+
97
+ raise ConfigurationError(
98
+ "Distribution comparison exceeds max_kmer_observations.",
99
+ code="DISTRIBUTION_KMER_LIMIT",
100
+ context={
101
+ "observation_count": observation_count,
102
+ "max_kmer_observations": maximum,
103
+ },
104
+ )
105
+ return observation_count
106
+
107
+
108
+ def _motif_rates(records: tuple[DNARecord, ...], motifs: tuple[str, ...]) -> dict[str, float]:
109
+ denominator = sum(record.sequence.symbol_length for record in records)
110
+ return {
111
+ motif: (
112
+ sum(
113
+ part.count(motif)
114
+ for record in records
115
+ for part in record.sequence.parts
116
+ if isinstance(part, str)
117
+ )
118
+ / denominator
119
+ if denominator
120
+ else 0.0
121
+ )
122
+ for motif in motifs
123
+ }
124
+
125
+
126
+ def _feature_distances(
127
+ left: tuple[DNARecord, ...],
128
+ right: tuple[DNARecord, ...],
129
+ config: DistributionEvaluationConfig,
130
+ ) -> tuple[dict[str, float], dict[str, object]]:
131
+ distances: dict[str, float] = {}
132
+ details: dict[str, object] = {}
133
+ if "length" in config.features:
134
+ left_values = tuple(float(record.sequence.symbol_length) for record in left)
135
+ right_values = tuple(float(record.sequence.symbol_length) for record in right)
136
+ distances["length"] = _ecdf_distance(left_values, right_values)
137
+ details["length"] = {
138
+ "distance": distances["length"],
139
+ "method": "two-sample empirical-CDF supremum distance (KS D; no p-value)",
140
+ "left_mean": mean(left_values),
141
+ "right_mean": mean(right_values),
142
+ }
143
+ if "gc" in config.features:
144
+ left_values = tuple(
145
+ result.gc_fraction
146
+ for record in left
147
+ if (result := gc_at_content(record, ambiguity_policy="ignore")).gc_fraction is not None
148
+ )
149
+ right_values = tuple(
150
+ result.gc_fraction
151
+ for record in right
152
+ if (result := gc_at_content(record, ambiguity_policy="ignore")).gc_fraction is not None
153
+ )
154
+ distances["gc"] = _ecdf_distance(left_values, right_values)
155
+ details["gc"] = {
156
+ "distance": distances["gc"],
157
+ "method": "two-sample empirical-CDF supremum distance (KS D; no p-value)",
158
+ "left_mean": mean(left_values),
159
+ "right_mean": mean(right_values),
160
+ "undefined_gc_omitted": (len(left) - len(left_values))
161
+ + (len(right) - len(right_values)),
162
+ }
163
+ if "kmer" in config.features:
164
+ left_observations = _kmer_observation_count(
165
+ left,
166
+ config.k,
167
+ initial_count=0,
168
+ maximum=config.max_kmer_observations,
169
+ )
170
+ observation_count = _kmer_observation_count(
171
+ right,
172
+ config.k,
173
+ initial_count=left_observations,
174
+ maximum=config.max_kmer_observations,
175
+ )
176
+ left_counts = _kmer_counts(left, config.k, config.canonical)
177
+ right_counts = _kmer_counts(right, config.k, config.canonical)
178
+ distances["kmer"] = _total_variation(left_counts, right_counts)
179
+ details["kmer"] = {
180
+ "distance": distances["kmer"],
181
+ "method": "total variation over pooled exact k-mer frequency vectors",
182
+ "left_observations": left_observations,
183
+ "right_observations": observation_count - left_observations,
184
+ }
185
+ if "motif" in config.features:
186
+ left_rates = _motif_rates(left, config.motifs)
187
+ right_rates = _motif_rates(right, config.motifs)
188
+ per_motif = {motif: abs(left_rates[motif] - right_rates[motif]) for motif in config.motifs}
189
+ distances["motif"] = math.fsum(per_motif.values()) / len(per_motif)
190
+ details["motif"] = {
191
+ "distance": distances["motif"],
192
+ "method": "mean absolute difference of non-overlapping motif counts per symbol",
193
+ "left_rates": left_rates,
194
+ "right_rates": right_rates,
195
+ "absolute_differences": per_motif,
196
+ }
197
+ if "repeat" in config.features:
198
+ left_values = tuple(
199
+ exact_repeat_fraction(
200
+ record,
201
+ ambiguity_policy="ignore",
202
+ max_comparisons=config.max_repeat_comparisons_per_sequence,
203
+ ).repeat_fraction
204
+ for record in left
205
+ )
206
+ right_values = tuple(
207
+ exact_repeat_fraction(
208
+ record,
209
+ ambiguity_policy="ignore",
210
+ max_comparisons=config.max_repeat_comparisons_per_sequence,
211
+ ).repeat_fraction
212
+ for record in right
213
+ )
214
+ distances["repeat"] = _ecdf_distance(left_values, right_values)
215
+ details["repeat"] = {
216
+ "distance": distances["repeat"],
217
+ "method": "two-sample empirical-CDF supremum distance of exact-repeat union fraction",
218
+ "left_mean": mean(left_values),
219
+ "right_mean": mean(right_values),
220
+ }
221
+ return distances, details
222
+
223
+
224
+ def evaluate_distribution_similarity(
225
+ left: EvaluationInput,
226
+ right: EvaluationInput,
227
+ *,
228
+ config: DistributionEvaluationConfig | None = None,
229
+ ) -> EvaluationReport:
230
+ """Compare length, GC, k-mer, motif, and repeat distributions without p-values."""
231
+
232
+ resolved = DistributionEvaluationConfig() if config is None else config
233
+ if not isinstance(resolved, DistributionEvaluationConfig):
234
+ raise TypeError("config must be DistributionEvaluationConfig or None.")
235
+ left_items = materialize_input(left, limits=resolved.limits)
236
+ right_items = materialize_input(right, limits=resolved.limits)
237
+ require_nonempty(left_items, "distribution similarity")
238
+ require_nonempty(right_items, "distribution similarity")
239
+ if (
240
+ sum(item.sequence.symbol_length for item in left_items + right_items)
241
+ > resolved.limits.max_total_symbols
242
+ ):
243
+ from dnakit.exceptions import ConfigurationError
244
+
245
+ raise ConfigurationError(
246
+ "Combined distribution inputs exceed max_total_symbols.",
247
+ code="EVALUATION_SYMBOL_LIMIT",
248
+ )
249
+ left_records = tuple(record_for(item) for item in left_items)
250
+ right_records = tuple(record_for(item) for item in right_items)
251
+ distances, details = _feature_distances(left_records, right_records, resolved)
252
+ aggregate_distance = math.fsum(distances.values()) / len(distances)
253
+ return report(
254
+ name="distribution_similarity",
255
+ method="mean-of-transparent-feature-distances",
256
+ version="eval-distribution-v1",
257
+ parameters={
258
+ "features": resolved.features,
259
+ "k": resolved.k,
260
+ "canonical": resolved.canonical,
261
+ "motifs": resolved.motifs,
262
+ "aggregation": "1 - arithmetic mean of feature distances",
263
+ "inference": "descriptive distances only; no hypothesis-test p-values",
264
+ "motif_counting": "str.count non-overlapping",
265
+ "gap_policy": "analyze text parts separately; no k-mer or motif crosses a Gap",
266
+ "topology_policy": "linearized at stored origin; no circular-origin wrap",
267
+ "ambiguity_policy": (
268
+ "length retains IUPAC symbols; GC, k-mer, motif, and repeat calculations "
269
+ "exclude affected noncanonical observations"
270
+ ),
271
+ "max_kmer_observations": resolved.max_kmer_observations,
272
+ "max_repeat_comparisons_per_sequence": resolved.max_repeat_comparisons_per_sequence,
273
+ "limits": resolved.limits,
274
+ },
275
+ metrics={
276
+ "score": 1.0 - aggregate_distance,
277
+ "distribution_similarity": 1.0 - aggregate_distance,
278
+ "aggregate_distance": aggregate_distance,
279
+ "feature_distances": distances,
280
+ "feature_details": details,
281
+ "left_count": len(left_records),
282
+ "right_count": len(right_records),
283
+ },
284
+ )
285
+
286
+
287
+ __all__ = ["evaluate_distribution_similarity"]
@@ -0,0 +1,223 @@
1
+ """Fréchet distance between two DNA representation distributions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ from dnakit.exceptions import (
9
+ BackendExecutionError,
10
+ BackendUnavailableError,
11
+ ConfigurationError,
12
+ )
13
+ from dnakit.representations import RepresentationBackend, extract_representations
14
+
15
+ from ._shared import EvaluationInput, materialize_input, record_for, report, require_nonempty
16
+ from .config import FrechetDistanceConfig
17
+ from .results import EvaluationReport
18
+
19
+
20
+ def _require_numpy() -> Any:
21
+ try:
22
+ import numpy as np
23
+ except ImportError as exc:
24
+ raise BackendUnavailableError(
25
+ "Fréchet DNA distance requires NumPy and a representation backend.",
26
+ code="MISSING_NEURAL_DEPENDENCY",
27
+ hint='Install the neural extra with: python -m pip install "dnakit[neural]"',
28
+ ) from exc
29
+ return np
30
+
31
+
32
+ def _l2_normalize(matrix: Any, np: Any) -> Any:
33
+ norms = np.linalg.norm(matrix, axis=1, keepdims=True)
34
+ zero_rows = np.flatnonzero(norms[:, 0] == 0)
35
+ if len(zero_rows):
36
+ raise ConfigurationError(
37
+ "Cannot L2-normalize zero-valued model representations.",
38
+ code="ZERO_REPRESENTATION_VECTOR",
39
+ context={"row_indices": tuple(int(value) for value in zero_rows[:20])},
40
+ )
41
+ return matrix / norms
42
+
43
+
44
+ def _frechet_components(left: Any, right: Any, np: Any) -> dict[str, float]:
45
+ """Compute Gaussian Fréchet terms through an exact sample-space identity.
46
+
47
+ If ``A`` and ``B`` are centered sample matrices scaled by ``sqrt(n - 1)``,
48
+ the covariance cross term equals the nuclear norm of ``A @ B.T``. This
49
+ avoids materializing dense feature-by-feature covariance matrices.
50
+ """
51
+
52
+ left_mean = np.mean(left, axis=0, dtype=np.float64)
53
+ right_mean = np.mean(right, axis=0, dtype=np.float64)
54
+ mean_delta = left_mean - right_mean
55
+ mean_component = float(np.dot(mean_delta, mean_delta))
56
+
57
+ left_scaled = (left - left_mean) / math.sqrt(int(left.shape[0]) - 1)
58
+ right_scaled = (right - right_mean) / math.sqrt(int(right.shape[0]) - 1)
59
+ left_covariance_trace = float(np.sum(left_scaled * left_scaled, dtype=np.float64))
60
+ right_covariance_trace = float(np.sum(right_scaled * right_scaled, dtype=np.float64))
61
+ try:
62
+ cross_gram = left_scaled @ right_scaled.T
63
+ singular_values = np.linalg.svd(cross_gram, compute_uv=False)
64
+ except (ValueError, np.linalg.LinAlgError) as exc:
65
+ raise BackendExecutionError(
66
+ "Could not compute the Fréchet covariance cross term.",
67
+ code="FRECHET_DECOMPOSITION_FAILED",
68
+ context={"error_type": type(exc).__name__},
69
+ ) from exc
70
+ cross_nuclear_norm = float(np.sum(singular_values, dtype=np.float64))
71
+ raw_covariance_component = (
72
+ left_covariance_trace + right_covariance_trace - 2.0 * cross_nuclear_norm
73
+ )
74
+ tolerance = 1e-10 * max(
75
+ 1.0,
76
+ left_covariance_trace + right_covariance_trace,
77
+ cross_nuclear_norm,
78
+ )
79
+ if raw_covariance_component < -tolerance:
80
+ raise BackendExecutionError(
81
+ "The Fréchet covariance term is numerically inconsistent.",
82
+ code="INVALID_FRECHET_COVARIANCE_TERM",
83
+ context={
84
+ "covariance_component": raw_covariance_component,
85
+ "tolerance": tolerance,
86
+ },
87
+ )
88
+ covariance_component = max(0.0, raw_covariance_component)
89
+ distance = mean_component + covariance_component
90
+ if not math.isfinite(distance):
91
+ raise BackendExecutionError(
92
+ "The Fréchet distance is not finite.",
93
+ code="INVALID_FRECHET_DISTANCE",
94
+ )
95
+ return {
96
+ "frechet_distance": distance,
97
+ "mean_component": mean_component,
98
+ "covariance_component": covariance_component,
99
+ "left_covariance_trace": left_covariance_trace,
100
+ "right_covariance_trace": right_covariance_trace,
101
+ "cross_covariance_nuclear_norm": cross_nuclear_norm,
102
+ }
103
+
104
+
105
+ def evaluate_frechet_distance(
106
+ left: EvaluationInput,
107
+ right: EvaluationInput,
108
+ *,
109
+ config: FrechetDistanceConfig | None = None,
110
+ backend: RepresentationBackend | None = None,
111
+ ) -> EvaluationReport:
112
+ """Compare two DNA collections with a Gaussian Fréchet representation distance.
113
+
114
+ The default representation model is LucaOne, matching ``DATA-027``. A
115
+ standard LucaOne backend still requires explicit ``allow_remote_code=True``
116
+ after the caller reviews its checkpoint code. Passing ``backend`` enables
117
+ controlled custom encoders and tests without a checkpoint download.
118
+ """
119
+
120
+ resolved = FrechetDistanceConfig() if config is None else config
121
+ if not isinstance(resolved, FrechetDistanceConfig):
122
+ raise ConfigurationError(
123
+ "config must be FrechetDistanceConfig or None.",
124
+ code="INVALID_FRECHET_DISTANCE_CONFIG",
125
+ )
126
+ left_items = materialize_input(left, limits=resolved.limits)
127
+ right_items = materialize_input(right, limits=resolved.limits)
128
+ require_nonempty(left_items, "Fréchet DNA distance")
129
+ require_nonempty(right_items, "Fréchet DNA distance")
130
+ if len(left_items) < 2 or len(right_items) < 2:
131
+ raise ConfigurationError(
132
+ "Fréchet DNA distance requires at least two records in each collection.",
133
+ code="FRECHET_MINIMUM_SAMPLE_SIZE",
134
+ context={"left_count": len(left_items), "right_count": len(right_items)},
135
+ )
136
+ total_symbols = sum(item.sequence.symbol_length for item in left_items + right_items)
137
+ if total_symbols > resolved.limits.max_total_symbols:
138
+ raise ConfigurationError(
139
+ "Combined Fréchet inputs exceed max_total_symbols.",
140
+ code="EVALUATION_SYMBOL_LIMIT",
141
+ context={
142
+ "total_symbols": total_symbols,
143
+ "max_total_symbols": resolved.limits.max_total_symbols,
144
+ },
145
+ )
146
+ cross_gram_elements = len(left_items) * len(right_items)
147
+ if cross_gram_elements > resolved.max_cross_gram_elements:
148
+ raise ConfigurationError(
149
+ "Fréchet DNA distance exceeds max_cross_gram_elements.",
150
+ code="FRECHET_CROSS_GRAM_LIMIT",
151
+ context={
152
+ "cross_gram_elements": cross_gram_elements,
153
+ "max_cross_gram_elements": resolved.max_cross_gram_elements,
154
+ },
155
+ )
156
+
157
+ records = tuple(record_for(item) for item in left_items + right_items)
158
+ representation_result = extract_representations(
159
+ records,
160
+ config=resolved.representation,
161
+ backend=backend,
162
+ )
163
+ np = _require_numpy()
164
+ matrix = np.asarray(representation_result.representations, dtype=np.float64)
165
+ if resolved.normalize:
166
+ matrix = _l2_normalize(matrix, np)
167
+ left_matrix = matrix[: len(left_items)]
168
+ right_matrix = matrix[len(left_items) :]
169
+ components = _frechet_components(left_matrix, right_matrix, np)
170
+
171
+ return report(
172
+ name="frechet_dna_distance",
173
+ method="gaussian-frechet-distance-over-dna-model-representations",
174
+ version="eval-frechet-dna-v1",
175
+ parameters={
176
+ "formula": (
177
+ "||mu_left-mu_right||_2^2 + trace(Sigma_left + Sigma_right "
178
+ "- 2*(Sigma_left^(1/2)*Sigma_right*Sigma_left^(1/2))^(1/2))"
179
+ ),
180
+ "encoder": representation_result.model_name,
181
+ "checkpoint_path": representation_result.checkpoint_path,
182
+ "pooling": representation_result.pooling,
183
+ "representation_config": {
184
+ "model": representation_result.model_name,
185
+ "checkpoint_path": representation_result.checkpoint_path,
186
+ "model_source_path": resolved.representation.model_source_path,
187
+ "pooling": representation_result.pooling,
188
+ "ambiguity_policy": representation_result.ambiguity_policy,
189
+ "device": resolved.representation.device,
190
+ "dtype": resolved.representation.dtype,
191
+ "batch_size": resolved.representation.batch_size,
192
+ "max_length": resolved.representation.max_length,
193
+ "allow_remote_code": resolved.representation.allow_remote_code,
194
+ },
195
+ "normalize": resolved.normalize,
196
+ "sample_covariance_ddof": 1,
197
+ "numerical_method": "exact sample-space cross-Gram nuclear norm",
198
+ "lower_is_better": True,
199
+ "max_cross_gram_elements": resolved.max_cross_gram_elements,
200
+ "limits": resolved.limits,
201
+ "inference": (
202
+ "FCD-analogous descriptive distance; not ChemNet FCD and not an "
203
+ "experimentally validated DNA quality score"
204
+ ),
205
+ },
206
+ metrics={
207
+ **components,
208
+ "left_count": len(left_items),
209
+ "right_count": len(right_items),
210
+ "embedding_dimension": representation_result.embedding_dimension,
211
+ "left_covariance_rank_upper_bound": min(
212
+ len(left_items) - 1,
213
+ representation_result.embedding_dimension,
214
+ ),
215
+ "right_covariance_rank_upper_bound": min(
216
+ len(right_items) - 1,
217
+ representation_result.embedding_dimension,
218
+ ),
219
+ },
220
+ )
221
+
222
+
223
+ __all__ = ["evaluate_frechet_distance"]