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,409 @@
1
+ """Local versioned reference libraries, nearest references, novelty, and memorization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ from collections.abc import Mapping
7
+
8
+ from dnakit.core import DNA, DNASet
9
+ from dnakit.core._json import FrozenDict
10
+ from dnakit.exceptions import ConfigurationError
11
+
12
+ from ._shared import (
13
+ EvaluationInput,
14
+ digest_json,
15
+ enforce_pair_limit,
16
+ materialize_input,
17
+ mean,
18
+ pair_count,
19
+ pair_similarity,
20
+ record_for,
21
+ report,
22
+ sequence_digest_payload,
23
+ )
24
+ from .config import ReferenceSearchConfig
25
+ from .results import EvaluationEntry, EvaluationReport, ReferenceLibrary
26
+
27
+ _ReferenceHit = tuple[float, float | None, int, str, bool]
28
+ _RankedReferenceHit = tuple[float, int, _ReferenceHit]
29
+
30
+
31
+ def create_reference_library(
32
+ records: DNA | DNASet,
33
+ *,
34
+ name: str,
35
+ version: str,
36
+ source: str,
37
+ date: str | None = None,
38
+ filters: Mapping[str, object] | None = None,
39
+ index_parameters: Mapping[str, object] | None = None,
40
+ ) -> ReferenceLibrary:
41
+ """Bind caller-supplied DNA to version and content provenance.
42
+
43
+ This function never performs a network request or downloads a database.
44
+ The digest covers ordered IDs, sequence parts, alphabet, topology, and
45
+ strandedness; metadata and annotations are deliberately out of scope.
46
+ """
47
+
48
+ if not isinstance(records, (DNA, DNASet)):
49
+ raise ConfigurationError("records must be DNA or DNASet.", code="INVALID_REFERENCE_RECORDS")
50
+ dataset = records.dataset if isinstance(records, DNA) else records
51
+ digest_scope = "ordered IDs + sequence parts + alphabet + topology + strandedness"
52
+ return ReferenceLibrary(
53
+ records=dataset,
54
+ name=name,
55
+ version=version,
56
+ source=source,
57
+ digest=digest_json(sequence_digest_payload(dataset)),
58
+ digest_scope=digest_scope,
59
+ date=date,
60
+ filters=FrozenDict(filters),
61
+ index_parameters=FrozenDict(index_parameters),
62
+ )
63
+
64
+
65
+ def _nearest_entries(
66
+ queries: EvaluationInput,
67
+ reference: ReferenceLibrary,
68
+ config: ReferenceSearchConfig,
69
+ ) -> tuple[EvaluationEntry, ...]:
70
+ if not isinstance(reference, ReferenceLibrary):
71
+ raise ConfigurationError(
72
+ "reference must be ReferenceLibrary.", code="UNVERSIONED_REFERENCE"
73
+ )
74
+ query_items = materialize_input(queries, limits=config.limits)
75
+ reference_items = materialize_input(reference.records, limits=config.limits)
76
+ comparisons = pair_count(len(query_items), len(reference_items))
77
+ enforce_pair_limit(comparisons, config.limits)
78
+ entries: list[EvaluationEntry] = []
79
+ for query_index, query_item in enumerate(query_items):
80
+ query_record = record_for(query_item)
81
+ ranked_hits: list[_RankedReferenceHit] = []
82
+ for reference_index, reference_item in enumerate(reference_items):
83
+ reference_record = record_for(reference_item)
84
+ similarity, _ = pair_similarity(
85
+ query_record,
86
+ reference_record,
87
+ method=config.method,
88
+ k=config.k,
89
+ canonical=config.canonical,
90
+ max_alignment_cells=config.limits.max_alignment_cells,
91
+ )
92
+ exact = query_record.sequence.symbols == reference_record.sequence.symbols
93
+ longest = max(
94
+ query_record.sequence.symbol_length,
95
+ reference_record.sequence.symbol_length,
96
+ )
97
+ coverage = (
98
+ 1.0
99
+ if longest == 0
100
+ else min(
101
+ query_record.sequence.symbol_length,
102
+ reference_record.sequence.symbol_length,
103
+ )
104
+ / longest
105
+ )
106
+ if similarity >= config.min_similarity and coverage >= config.min_coverage:
107
+ hit: _ReferenceHit = (
108
+ similarity,
109
+ coverage,
110
+ reference_index,
111
+ reference_record.id,
112
+ exact,
113
+ )
114
+ ranked_hit: _RankedReferenceHit = (similarity, -reference_index, hit)
115
+ if len(ranked_hits) < config.top_k:
116
+ heapq.heappush(ranked_hits, ranked_hit)
117
+ elif (ranked_hit[0], ranked_hit[1]) > (
118
+ ranked_hits[0][0],
119
+ ranked_hits[0][1],
120
+ ):
121
+ heapq.heapreplace(ranked_hits, ranked_hit)
122
+ selected = sorted(
123
+ (ranked_hit[2] for ranked_hit in ranked_hits),
124
+ key=lambda hit: (-hit[0], hit[2]),
125
+ )
126
+ nearest = selected[0] if selected else None
127
+ entries.append(
128
+ EvaluationEntry(
129
+ query_item.subject_id,
130
+ query_index,
131
+ FrozenDict(
132
+ {
133
+ "nearest_reference_id": nearest[3] if nearest else None,
134
+ "nearest_reference_index": nearest[2] if nearest else None,
135
+ "nearest_similarity": nearest[0] if nearest else None,
136
+ "nearest_coverage": nearest[1] if nearest else None,
137
+ "exact_match": nearest[4] if nearest else False,
138
+ "hits": tuple(
139
+ {
140
+ "reference_id": hit[3],
141
+ "reference_index": hit[2],
142
+ "similarity": hit[0],
143
+ "similarities": {
144
+ resolved_method: hit[0],
145
+ "exact": float(hit[4]),
146
+ },
147
+ "coverage": hit[1],
148
+ "exact": hit[4],
149
+ }
150
+ for hit in selected
151
+ for resolved_method in (config.method,)
152
+ ),
153
+ }
154
+ ),
155
+ )
156
+ )
157
+ return tuple(entries)
158
+
159
+
160
+ def nearest_reference(
161
+ queries: EvaluationInput,
162
+ reference: ReferenceLibrary,
163
+ *,
164
+ config: ReferenceSearchConfig | None = None,
165
+ ) -> EvaluationReport:
166
+ """Return deterministic top-k references from a bounded exhaustive local scan."""
167
+
168
+ resolved = ReferenceSearchConfig() if config is None else config
169
+ if not isinstance(resolved, ReferenceSearchConfig):
170
+ raise TypeError("config must be ReferenceSearchConfig or None.")
171
+ entries = _nearest_entries(queries, reference, resolved)
172
+ found = tuple(
173
+ float(value)
174
+ for entry in entries
175
+ if (value := entry.metrics["nearest_similarity"]) is not None
176
+ and isinstance(value, (int, float))
177
+ and not isinstance(value, bool)
178
+ )
179
+ return report(
180
+ name="nearest_reference",
181
+ method=f"bounded-exhaustive-{resolved.method}-scan",
182
+ version="eval-nearest-reference-v1",
183
+ parameters={
184
+ "similarity_method": resolved.method,
185
+ "k": resolved.k,
186
+ "canonical": resolved.canonical,
187
+ "top_k": resolved.top_k,
188
+ "min_similarity": resolved.min_similarity,
189
+ "min_coverage": resolved.min_coverage,
190
+ "coverage_definition": "min(query_length,reference_length)/max(lengths)",
191
+ "short_k_policy": "literal equality when either sequence is shorter than k",
192
+ "reported_similarities": "selected method plus exact-symbol equality",
193
+ "topology_policy": "linearized at stored origin; no circular rotation",
194
+ "tie_break": "similarity-desc,reference-input-index-asc",
195
+ "limits": resolved.limits,
196
+ "reference": reference.to_dict(),
197
+ },
198
+ metrics={
199
+ "query_count": len(entries),
200
+ "reference_count": len(reference.records),
201
+ "matched_query_count": len(found),
202
+ "mean_nearest_similarity": mean(found),
203
+ "pairwise_comparison_count": len(entries) * len(reference.records),
204
+ },
205
+ entries=entries,
206
+ reference=reference,
207
+ )
208
+
209
+
210
+ def evaluate_novelty(
211
+ queries: EvaluationInput,
212
+ reference: ReferenceLibrary,
213
+ *,
214
+ config: ReferenceSearchConfig | None = None,
215
+ ) -> EvaluationReport:
216
+ """Score novelty as ``1 - nearest similarity`` relative to one versioned library."""
217
+
218
+ resolved = ReferenceSearchConfig() if config is None else config
219
+ if not isinstance(resolved, ReferenceSearchConfig):
220
+ raise TypeError("config must be ReferenceSearchConfig or None.")
221
+ _validate_copy_search_thresholds(resolved)
222
+ nearest_entries = _nearest_entries(queries, reference, resolved)
223
+ entries: list[EvaluationEntry] = []
224
+ for entry in nearest_entries:
225
+ raw_similarity = entry.metrics["nearest_similarity"]
226
+ similarity = (
227
+ float(raw_similarity)
228
+ if isinstance(raw_similarity, (int, float)) and not isinstance(raw_similarity, bool)
229
+ else None
230
+ )
231
+ novelty = 1.0 - similarity if similarity is not None else 1.0
232
+ metrics = dict(entry.metrics)
233
+ metrics.update(
234
+ {
235
+ "novelty": novelty,
236
+ "is_novel": similarity is None or similarity < resolved.copy_threshold,
237
+ "threshold": resolved.copy_threshold,
238
+ }
239
+ )
240
+ entries.append(EvaluationEntry(entry.subject_id, entry.input_index, FrozenDict(metrics)))
241
+ materialized = tuple(entries)
242
+ novel_count = sum(entry.metrics["is_novel"] is True for entry in materialized)
243
+ novelty_values = tuple(
244
+ value for entry in materialized if isinstance((value := entry.metrics["novelty"]), float)
245
+ )
246
+ return report(
247
+ name="novelty",
248
+ method=f"one-minus-nearest-{resolved.method}-similarity",
249
+ version="eval-novelty-v1",
250
+ parameters={
251
+ "copy_threshold": resolved.copy_threshold,
252
+ "threshold_rule": "novel iff no hit or nearest_similarity < copy_threshold",
253
+ "k": resolved.k,
254
+ "canonical": resolved.canonical,
255
+ "min_similarity": resolved.min_similarity,
256
+ "min_coverage": resolved.min_coverage,
257
+ "topology_policy": "linearized at stored origin; no circular rotation",
258
+ "limits": resolved.limits,
259
+ "reference": reference.to_dict(),
260
+ },
261
+ metrics={
262
+ "score": mean(novelty_values),
263
+ "query_count": len(materialized),
264
+ "novel_count": novel_count,
265
+ "novel_fraction": novel_count / len(materialized) if materialized else None,
266
+ "mean_novelty": mean(novelty_values),
267
+ "pairwise_comparison_count": len(materialized) * len(reference.records),
268
+ },
269
+ entries=materialized,
270
+ reference=reference,
271
+ )
272
+
273
+
274
+ def evaluate_memorization(
275
+ generated: EvaluationInput,
276
+ training_reference: ReferenceLibrary,
277
+ *,
278
+ config: ReferenceSearchConfig | None = None,
279
+ ) -> EvaluationReport:
280
+ """Flag exact or threshold-near copies of a versioned training reference."""
281
+
282
+ resolved = ReferenceSearchConfig() if config is None else config
283
+ if not isinstance(resolved, ReferenceSearchConfig):
284
+ raise TypeError("config must be ReferenceSearchConfig or None.")
285
+ _validate_copy_search_thresholds(resolved)
286
+ nearest_entries = _nearest_entries(generated, training_reference, resolved)
287
+ entries: list[EvaluationEntry] = []
288
+ exact_count = 0
289
+ near_count = 0
290
+ for entry in nearest_entries:
291
+ raw_similarity = entry.metrics["nearest_similarity"]
292
+ similarity = (
293
+ float(raw_similarity)
294
+ if isinstance(raw_similarity, (int, float)) and not isinstance(raw_similarity, bool)
295
+ else None
296
+ )
297
+ exact = entry.metrics["exact_match"] is True
298
+ copied = exact or (similarity is not None and similarity >= resolved.copy_threshold)
299
+ exact_count += exact
300
+ near_count += copied and not exact
301
+ metrics = dict(entry.metrics)
302
+ metrics.update(
303
+ {
304
+ "memorized": copied,
305
+ "memorization_class": "exact" if exact else ("near" if copied else "none"),
306
+ "threshold": resolved.copy_threshold,
307
+ }
308
+ )
309
+ entries.append(EvaluationEntry(entry.subject_id, entry.input_index, FrozenDict(metrics)))
310
+ materialized = tuple(entries)
311
+ copied_count = exact_count + near_count
312
+ fraction = copied_count / len(materialized) if materialized else None
313
+ return report(
314
+ name="memorization",
315
+ method=f"exact-or-nearest-{resolved.method}-threshold",
316
+ version="eval-memorization-v1",
317
+ parameters={
318
+ "copy_threshold": resolved.copy_threshold,
319
+ "definition": "exact symbol equality OR nearest similarity >= copy_threshold",
320
+ "length_stratification": False,
321
+ "task_model_used": False,
322
+ "min_similarity": resolved.min_similarity,
323
+ "min_coverage": resolved.min_coverage,
324
+ "topology_policy": "linearized at stored origin; no circular rotation",
325
+ "limits": resolved.limits,
326
+ "reference": training_reference.to_dict(),
327
+ },
328
+ metrics={
329
+ "score": fraction,
330
+ "generated_count": len(materialized),
331
+ "memorized_count": copied_count,
332
+ "memorization_fraction": fraction,
333
+ "exact_copy_count": exact_count,
334
+ "near_copy_count": near_count,
335
+ "pairwise_comparison_count": len(materialized) * len(training_reference.records),
336
+ },
337
+ entries=materialized,
338
+ reference=training_reference,
339
+ )
340
+
341
+
342
+ def evaluate_reference_similarity(
343
+ queries: EvaluationInput,
344
+ reference: ReferenceLibrary,
345
+ *,
346
+ config: ReferenceSearchConfig | None = None,
347
+ ) -> EvaluationReport:
348
+ """Summarize nearest-reference similarity for a query collection."""
349
+
350
+ resolved = ReferenceSearchConfig() if config is None else config
351
+ if not isinstance(resolved, ReferenceSearchConfig):
352
+ raise TypeError("config must be ReferenceSearchConfig or None.")
353
+ entries = _nearest_entries(queries, reference, resolved)
354
+ values = tuple(
355
+ float(value)
356
+ for entry in entries
357
+ if (value := entry.metrics["nearest_similarity"]) is not None
358
+ and isinstance(value, (int, float))
359
+ and not isinstance(value, bool)
360
+ )
361
+ return report(
362
+ name="reference_similarity",
363
+ method=f"nearest-{resolved.method}-similarity",
364
+ version="eval-reference-similarity-v1",
365
+ parameters={
366
+ "aggregation": "arithmetic mean of per-query nearest similarities",
367
+ "missing_hit_policy": "omitted from mean and counted separately",
368
+ "k": resolved.k,
369
+ "canonical": resolved.canonical,
370
+ "min_similarity": resolved.min_similarity,
371
+ "min_coverage": resolved.min_coverage,
372
+ "coverage_definition": "min(query_length,reference_length)/max(lengths)",
373
+ "topology_policy": "linearized at stored origin; no circular rotation",
374
+ "limits": resolved.limits,
375
+ "reference": reference.to_dict(),
376
+ },
377
+ metrics={
378
+ "score": mean(values),
379
+ "query_count": len(entries),
380
+ "matched_query_count": len(values),
381
+ "mean_nearest_similarity": mean(values),
382
+ "minimum_nearest_similarity": min(values) if values else None,
383
+ "maximum_nearest_similarity": max(values) if values else None,
384
+ "pairwise_comparison_count": len(entries) * len(reference.records),
385
+ },
386
+ entries=entries,
387
+ reference=reference,
388
+ )
389
+
390
+
391
+ def _validate_copy_search_thresholds(config: ReferenceSearchConfig) -> None:
392
+ if config.min_similarity > config.copy_threshold:
393
+ raise ConfigurationError(
394
+ "min_similarity cannot exceed copy_threshold for novelty or memorization.",
395
+ code="REFERENCE_COPY_THRESHOLD_CONFLICT",
396
+ context={
397
+ "min_similarity": config.min_similarity,
398
+ "copy_threshold": config.copy_threshold,
399
+ },
400
+ )
401
+
402
+
403
+ __all__ = [
404
+ "create_reference_library",
405
+ "evaluate_memorization",
406
+ "evaluate_novelty",
407
+ "evaluate_reference_similarity",
408
+ "nearest_reference",
409
+ ]
@@ -0,0 +1,137 @@
1
+ """Immutable, JSON-compatible results for DNA evaluation workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any, cast
8
+
9
+ from dnakit.core import DNASet, Issue, Provenance
10
+ from dnakit.core._json import FrozenDict, to_json_compatible
11
+ from dnakit.exceptions import ConfigurationError
12
+
13
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
14
+
15
+
16
+ def _text(value: object, name: str) -> None:
17
+ if not isinstance(value, str) or not value.strip():
18
+ raise ConfigurationError(f"{name} must be a non-empty string.")
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class EvaluationEntry:
23
+ """One subject-level evaluation row in stable input order."""
24
+
25
+ subject_id: str
26
+ input_index: int
27
+ metrics: FrozenDict
28
+ issues: tuple[Issue, ...] = ()
29
+
30
+ def __post_init__(self) -> None:
31
+ _text(self.subject_id, "subject_id")
32
+ if (
33
+ isinstance(self.input_index, bool)
34
+ or not isinstance(self.input_index, int)
35
+ or self.input_index < 0
36
+ ):
37
+ raise ConfigurationError("input_index must be a non-negative integer.")
38
+ if not isinstance(self.metrics, FrozenDict):
39
+ raise ConfigurationError("EvaluationEntry metrics must be FrozenDict.")
40
+ if any(not isinstance(issue, Issue) for issue in self.issues):
41
+ raise ConfigurationError("EvaluationEntry issues must contain Issue objects.")
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ return cast(dict[str, Any], to_json_compatible(self))
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class EvaluationReport:
49
+ """A deterministic evaluation report with full method and provenance context."""
50
+
51
+ name: str
52
+ method: str
53
+ algorithm_version: str
54
+ parameters: FrozenDict
55
+ metrics: FrozenDict
56
+ entries: tuple[EvaluationEntry, ...]
57
+ provenance: Provenance
58
+ issues: tuple[Issue, ...] = ()
59
+
60
+ def __post_init__(self) -> None:
61
+ for name in ("name", "method", "algorithm_version"):
62
+ _text(getattr(self, name), name)
63
+ if not isinstance(self.parameters, FrozenDict) or not isinstance(self.metrics, FrozenDict):
64
+ raise ConfigurationError("Evaluation report mappings must be FrozenDict objects.")
65
+ if any(not isinstance(entry, EvaluationEntry) for entry in self.entries):
66
+ raise ConfigurationError("Evaluation report entries must be EvaluationEntry objects.")
67
+ if any(entry.input_index != index for index, entry in enumerate(self.entries)):
68
+ raise ConfigurationError("Evaluation entry indices must be contiguous input order.")
69
+ if not isinstance(self.provenance, Provenance):
70
+ raise ConfigurationError("Evaluation report provenance must be Provenance.")
71
+ if any(not isinstance(issue, Issue) for issue in self.issues):
72
+ raise ConfigurationError("Evaluation report issues must contain Issue objects.")
73
+
74
+ @property
75
+ def score(self) -> float | None:
76
+ value = self.metrics.get("score")
77
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
78
+ return None
79
+ return float(value)
80
+
81
+ def to_dict(self) -> dict[str, Any]:
82
+ return cast(dict[str, Any], to_json_compatible(self))
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class ReferenceLibrary:
87
+ """A local, versioned reference collection with a deterministic content digest."""
88
+
89
+ records: DNASet
90
+ name: str
91
+ version: str
92
+ source: str
93
+ digest: str
94
+ digest_scope: str
95
+ date: str | None
96
+ filters: FrozenDict
97
+ index_parameters: FrozenDict
98
+
99
+ def __post_init__(self) -> None:
100
+ if not isinstance(self.records, DNASet):
101
+ raise ConfigurationError("ReferenceLibrary records must be DNASet.")
102
+ for name in ("name", "version", "source", "digest_scope"):
103
+ _text(getattr(self, name), name)
104
+ if self.date is not None:
105
+ _text(self.date, "date")
106
+ if not isinstance(self.digest, str) or not _SHA256.fullmatch(self.digest):
107
+ raise ConfigurationError("ReferenceLibrary digest must be a lowercase SHA-256.")
108
+ if len(set(self.records.ids)) != len(self.records):
109
+ raise ConfigurationError("ReferenceLibrary record IDs must be unique.")
110
+ if not isinstance(self.filters, FrozenDict) or not isinstance(
111
+ self.index_parameters, FrozenDict
112
+ ):
113
+ raise ConfigurationError("ReferenceLibrary metadata mappings must be FrozenDict.")
114
+
115
+ def to_dict(self) -> dict[str, Any]:
116
+ """Serialize provenance without duplicating reference sequence content."""
117
+
118
+ return cast(
119
+ dict[str, Any],
120
+ to_json_compatible(
121
+ {
122
+ "name": self.name,
123
+ "version": self.version,
124
+ "source": self.source,
125
+ "digest": self.digest,
126
+ "digest_scope": self.digest_scope,
127
+ "date": self.date,
128
+ "filters": self.filters,
129
+ "index_parameters": self.index_parameters,
130
+ "record_count": len(self.records),
131
+ "record_ids": self.records.ids,
132
+ }
133
+ ),
134
+ )
135
+
136
+
137
+ __all__ = ["EvaluationEntry", "EvaluationReport", "ReferenceLibrary"]