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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- 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"]
|