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,1060 @@
|
|
|
1
|
+
"""Auditable conversion of ambiguous runs and strict AGP object assembly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import itertools
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Iterable, Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Literal, TypeAlias, cast
|
|
12
|
+
|
|
13
|
+
from dnakit.core._json import FrozenDict, freeze_mapping, to_json_compatible
|
|
14
|
+
from dnakit.core.coordinates import Interval
|
|
15
|
+
from dnakit.core.enums import (
|
|
16
|
+
DNAAlphabet,
|
|
17
|
+
ExecutionMode,
|
|
18
|
+
GapKind,
|
|
19
|
+
ImplementationLabel,
|
|
20
|
+
IssueSeverity,
|
|
21
|
+
OriginClass,
|
|
22
|
+
Strandedness,
|
|
23
|
+
Topology,
|
|
24
|
+
)
|
|
25
|
+
from dnakit.core.facade import DNA
|
|
26
|
+
from dnakit.core.gap import Gap
|
|
27
|
+
from dnakit.core.issues import Issue
|
|
28
|
+
from dnakit.core.provenance import ImplementationInfo, Provenance
|
|
29
|
+
from dnakit.core.record import DNARecord
|
|
30
|
+
from dnakit.core.sequence import STRICT_SYMBOLS, DNASequence
|
|
31
|
+
from dnakit.exceptions import ConfigurationError, SequenceError, UnsupportedGapOperationError
|
|
32
|
+
from dnakit.io.annotations import AGPComponent, AGPDocument, AGPEntry, AGPGap
|
|
33
|
+
|
|
34
|
+
GapNormalizable: TypeAlias = DNA | DNASequence | DNARecord
|
|
35
|
+
AGPComponentValue: TypeAlias = DNA | DNASequence | DNARecord
|
|
36
|
+
CircularBoundaryPolicy = Literal["separate", "error"]
|
|
37
|
+
|
|
38
|
+
_N_RUN = re.compile(r"N+")
|
|
39
|
+
_GAP_AUDIT_KEY = "dnakit_gap_normalization"
|
|
40
|
+
_AGP_AUDIT_KEY = "dnakit_agp_assembly"
|
|
41
|
+
_AGP_KIND_BY_TYPE = {
|
|
42
|
+
"scaffold": GapKind.SCAFFOLD,
|
|
43
|
+
"contig": GapKind.CONTIG,
|
|
44
|
+
"centromere": GapKind.CENTROMERE,
|
|
45
|
+
"short_arm": GapKind.SHORT_ARM,
|
|
46
|
+
"heterochromatin": GapKind.HETEROCHROMATIN,
|
|
47
|
+
"telomere": GapKind.TELOMERE,
|
|
48
|
+
"repeat": GapKind.REPEAT,
|
|
49
|
+
"contamination": GapKind.CONTAMINATION,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _native_provenance() -> Provenance:
|
|
54
|
+
return Provenance(
|
|
55
|
+
implementation=ImplementationInfo(
|
|
56
|
+
label=ImplementationLabel.NATIVE,
|
|
57
|
+
execution_mode=ExecutionMode.INTERNAL,
|
|
58
|
+
origin_class=OriginClass.DNAKIT,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _positive_integer(value: object, name: str, code: str) -> int:
|
|
64
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
65
|
+
raise ConfigurationError(
|
|
66
|
+
f"{name} must be a positive integer.",
|
|
67
|
+
code=code,
|
|
68
|
+
context={"field": name, "value": value},
|
|
69
|
+
)
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _non_empty_optional_text(value: object, name: str, code: str) -> str | None:
|
|
74
|
+
if value is None:
|
|
75
|
+
return None
|
|
76
|
+
if not isinstance(value, str) or not value.strip():
|
|
77
|
+
raise ConfigurationError(
|
|
78
|
+
f"{name} must be a non-empty string or None.",
|
|
79
|
+
code=code,
|
|
80
|
+
context={"field": name},
|
|
81
|
+
)
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _coerce_evidence(value: object, code: str) -> tuple[str, ...]:
|
|
86
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
|
87
|
+
raise ConfigurationError(
|
|
88
|
+
"evidence must be an iterable of non-empty strings.",
|
|
89
|
+
code=code,
|
|
90
|
+
)
|
|
91
|
+
evidence = tuple(value)
|
|
92
|
+
if any(not isinstance(item, str) or not item.strip() for item in evidence):
|
|
93
|
+
raise ConfigurationError(
|
|
94
|
+
"evidence must contain only non-empty strings.",
|
|
95
|
+
code=code,
|
|
96
|
+
)
|
|
97
|
+
if len(set(evidence)) != len(evidence):
|
|
98
|
+
raise ConfigurationError("evidence must not contain duplicates.", code=code)
|
|
99
|
+
return cast(tuple[str, ...], evidence)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class GapNormalizationConfig:
|
|
104
|
+
"""Strict policy and resource limits for :func:`normalize_gaps`."""
|
|
105
|
+
|
|
106
|
+
min_run_length: int = 10
|
|
107
|
+
kind: GapKind | str = GapKind.UNKNOWN
|
|
108
|
+
crossable: bool | None = None
|
|
109
|
+
evidence: tuple[str, ...] = ()
|
|
110
|
+
metadata: Mapping[str, object] = field(default_factory=dict)
|
|
111
|
+
circular_boundary_policy: CircularBoundaryPolicy = "separate"
|
|
112
|
+
max_input_symbols: int = 10_000_000
|
|
113
|
+
max_n_run_length: int = 1_000_000
|
|
114
|
+
max_converted_runs: int = 100_000
|
|
115
|
+
max_output_parts: int = 200_001
|
|
116
|
+
|
|
117
|
+
def __post_init__(self) -> None:
|
|
118
|
+
for name in (
|
|
119
|
+
"min_run_length",
|
|
120
|
+
"max_input_symbols",
|
|
121
|
+
"max_n_run_length",
|
|
122
|
+
"max_converted_runs",
|
|
123
|
+
"max_output_parts",
|
|
124
|
+
):
|
|
125
|
+
_positive_integer(getattr(self, name), name, "INVALID_GAP_NORMALIZATION_LIMIT")
|
|
126
|
+
if self.min_run_length > self.max_n_run_length:
|
|
127
|
+
raise ConfigurationError(
|
|
128
|
+
"min_run_length must not exceed max_n_run_length.",
|
|
129
|
+
code="INVALID_GAP_NORMALIZATION_LIMIT",
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
kind = self.kind if isinstance(self.kind, GapKind) else GapKind(self.kind)
|
|
133
|
+
except (TypeError, ValueError) as exc:
|
|
134
|
+
raise ConfigurationError(
|
|
135
|
+
"kind is not a supported GapKind.",
|
|
136
|
+
code="INVALID_GAP_NORMALIZATION_KIND",
|
|
137
|
+
context={"kind": self.kind},
|
|
138
|
+
) from exc
|
|
139
|
+
if self.crossable is not None and not isinstance(self.crossable, bool):
|
|
140
|
+
raise ConfigurationError(
|
|
141
|
+
"crossable must be True, False, or None.",
|
|
142
|
+
code="INVALID_GAP_NORMALIZATION_POLICY",
|
|
143
|
+
)
|
|
144
|
+
evidence = _coerce_evidence(self.evidence, "INVALID_GAP_NORMALIZATION_EVIDENCE")
|
|
145
|
+
try:
|
|
146
|
+
metadata = freeze_mapping(self.metadata)
|
|
147
|
+
except (AttributeError, ConfigurationError, TypeError, ValueError) as exc:
|
|
148
|
+
raise ConfigurationError(
|
|
149
|
+
"metadata must be a JSON-compatible mapping.",
|
|
150
|
+
code="INVALID_GAP_NORMALIZATION_METADATA",
|
|
151
|
+
) from exc
|
|
152
|
+
if _GAP_AUDIT_KEY in metadata:
|
|
153
|
+
raise ConfigurationError(
|
|
154
|
+
f"metadata key {_GAP_AUDIT_KEY!r} is reserved for source audit data.",
|
|
155
|
+
code="RESERVED_GAP_NORMALIZATION_METADATA",
|
|
156
|
+
)
|
|
157
|
+
if self.circular_boundary_policy not in {"separate", "error"}:
|
|
158
|
+
raise ConfigurationError(
|
|
159
|
+
"circular_boundary_policy must be 'separate' or 'error'.",
|
|
160
|
+
code="INVALID_GAP_NORMALIZATION_POLICY",
|
|
161
|
+
)
|
|
162
|
+
object.__setattr__(self, "kind", kind)
|
|
163
|
+
object.__setattr__(self, "evidence", evidence)
|
|
164
|
+
object.__setattr__(self, "metadata", metadata)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class GapNormalizationChange:
|
|
169
|
+
"""One N-run replacement with original zero-based source coordinates."""
|
|
170
|
+
|
|
171
|
+
source_part_index: int
|
|
172
|
+
part_interval: Interval
|
|
173
|
+
symbol_interval: Interval
|
|
174
|
+
coordinate_interval: Interval | None
|
|
175
|
+
original_symbol: str
|
|
176
|
+
original_length: int
|
|
177
|
+
original_sha256: str
|
|
178
|
+
replacement: Gap
|
|
179
|
+
|
|
180
|
+
def to_dict(self) -> dict[str, Any]:
|
|
181
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class GapNormalizationResult:
|
|
186
|
+
"""Immutable output and audit trail for an N-run normalization operation."""
|
|
187
|
+
|
|
188
|
+
sequence: DNASequence
|
|
189
|
+
record: DNARecord | None
|
|
190
|
+
source_kind: Literal["sequence", "record"]
|
|
191
|
+
source_sha256: str
|
|
192
|
+
normalized_parts: tuple[str | Gap, ...]
|
|
193
|
+
changes: tuple[GapNormalizationChange, ...]
|
|
194
|
+
preserved_gap_count: int
|
|
195
|
+
parameters: FrozenDict
|
|
196
|
+
algorithm_version: str
|
|
197
|
+
provenance: Provenance
|
|
198
|
+
issues: tuple[Issue, ...]
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def was_modified(self) -> bool:
|
|
202
|
+
return bool(self.changes)
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def output(self) -> DNASequence | DNARecord:
|
|
206
|
+
return self.record if self.record is not None else self.sequence
|
|
207
|
+
|
|
208
|
+
def to_dict(self) -> dict[str, Any]:
|
|
209
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def normalize_gaps(
|
|
213
|
+
value: GapNormalizable,
|
|
214
|
+
*,
|
|
215
|
+
config: GapNormalizationConfig | None = None,
|
|
216
|
+
) -> GapNormalizationResult:
|
|
217
|
+
"""Convert qualifying N-runs to known gaps without mutating the source object.
|
|
218
|
+
|
|
219
|
+
Runs are evaluated independently inside each text part. Existing explicit
|
|
220
|
+
:class:`Gap` objects are retained by identity and are never merged with a
|
|
221
|
+
newly created gap. Circular origin-spanning runs are deliberately split at
|
|
222
|
+
the stored origin unless ``circular_boundary_policy='error'`` is selected.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
if not isinstance(value, (DNA, DNASequence, DNARecord)):
|
|
226
|
+
raise TypeError("value must be DNA, DNASequence, or DNARecord.")
|
|
227
|
+
resolved = GapNormalizationConfig() if config is None else config
|
|
228
|
+
if not isinstance(resolved, GapNormalizationConfig):
|
|
229
|
+
raise ConfigurationError(
|
|
230
|
+
"config must be GapNormalizationConfig or None.",
|
|
231
|
+
code="INVALID_GAP_NORMALIZATION_CONFIG",
|
|
232
|
+
)
|
|
233
|
+
source_record: DNARecord | None
|
|
234
|
+
source_sequence: DNASequence
|
|
235
|
+
if isinstance(value, DNA):
|
|
236
|
+
source_record = value.record
|
|
237
|
+
source_sequence = value.sequence
|
|
238
|
+
elif isinstance(value, DNARecord):
|
|
239
|
+
source_record = value
|
|
240
|
+
source_sequence = value.sequence
|
|
241
|
+
else:
|
|
242
|
+
source_record = None
|
|
243
|
+
source_sequence = value
|
|
244
|
+
if source_sequence.symbol_length > resolved.max_input_symbols:
|
|
245
|
+
raise SequenceError(
|
|
246
|
+
"Sequence exceeds max_input_symbols.",
|
|
247
|
+
code="GAP_NORMALIZATION_INPUT_LIMIT_EXCEEDED",
|
|
248
|
+
context={
|
|
249
|
+
"symbol_length": source_sequence.symbol_length,
|
|
250
|
+
"max_input_symbols": resolved.max_input_symbols,
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
candidate_runs = _candidate_n_runs(source_sequence, resolved)
|
|
255
|
+
if candidate_runs and source_record is not None and source_record.letter_annotations:
|
|
256
|
+
raise UnsupportedGapOperationError(
|
|
257
|
+
"Converting N-runs removes symbol positions used by letter annotations.",
|
|
258
|
+
code="GAP_NORMALIZATION_LETTER_ANNOTATIONS_UNSUPPORTED",
|
|
259
|
+
context={
|
|
260
|
+
"record_id": source_record.id,
|
|
261
|
+
"annotation_names": tuple(source_record.letter_annotations),
|
|
262
|
+
},
|
|
263
|
+
hint="Remove or remap per-symbol annotations explicitly before converting runs.",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
issues = _circular_boundary_issues(source_sequence, resolved)
|
|
267
|
+
output_parts: list[str | Gap] = []
|
|
268
|
+
changes: list[GapNormalizationChange] = []
|
|
269
|
+
symbol_cursor = 0
|
|
270
|
+
coordinate_cursor: int | None = 0
|
|
271
|
+
preserved_gap_count = 0
|
|
272
|
+
|
|
273
|
+
for part_index, part in enumerate(source_sequence.parts):
|
|
274
|
+
if isinstance(part, Gap):
|
|
275
|
+
output_parts.append(part)
|
|
276
|
+
_check_output_part_limit(output_parts, resolved.max_output_parts)
|
|
277
|
+
preserved_gap_count += 1
|
|
278
|
+
if coordinate_cursor is not None:
|
|
279
|
+
coordinate_cursor = None if part.length is None else coordinate_cursor + part.length
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
part_cursor = 0
|
|
283
|
+
for match in _N_RUN.finditer(part):
|
|
284
|
+
start, end = match.span()
|
|
285
|
+
run_length = end - start
|
|
286
|
+
if run_length < resolved.min_run_length:
|
|
287
|
+
continue
|
|
288
|
+
if run_length > resolved.max_n_run_length:
|
|
289
|
+
raise SequenceError(
|
|
290
|
+
"An N-run exceeds max_n_run_length.",
|
|
291
|
+
code="GAP_NORMALIZATION_RUN_LIMIT_EXCEEDED",
|
|
292
|
+
context={
|
|
293
|
+
"source_part_index": part_index,
|
|
294
|
+
"part_start": start,
|
|
295
|
+
"run_length": run_length,
|
|
296
|
+
"max_n_run_length": resolved.max_n_run_length,
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
if len(changes) >= resolved.max_converted_runs:
|
|
300
|
+
raise SequenceError(
|
|
301
|
+
"N-run count exceeds max_converted_runs.",
|
|
302
|
+
code="GAP_NORMALIZATION_CHANGE_LIMIT_EXCEEDED",
|
|
303
|
+
context={"max_converted_runs": resolved.max_converted_runs},
|
|
304
|
+
)
|
|
305
|
+
if start > part_cursor:
|
|
306
|
+
output_parts.append(part[part_cursor:start])
|
|
307
|
+
_check_output_part_limit(output_parts, resolved.max_output_parts)
|
|
308
|
+
coordinate_interval = (
|
|
309
|
+
None
|
|
310
|
+
if coordinate_cursor is None
|
|
311
|
+
else Interval(coordinate_cursor + start, coordinate_cursor + end)
|
|
312
|
+
)
|
|
313
|
+
audit = {
|
|
314
|
+
"source": "N-run",
|
|
315
|
+
"original_symbol": "N",
|
|
316
|
+
"original_length": run_length,
|
|
317
|
+
"source_part_index": part_index,
|
|
318
|
+
"part_interval": {"start": start, "end": end},
|
|
319
|
+
"symbol_interval": {
|
|
320
|
+
"start": symbol_cursor + start,
|
|
321
|
+
"end": symbol_cursor + end,
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
gap_metadata = dict(resolved.metadata)
|
|
325
|
+
gap_metadata[_GAP_AUDIT_KEY] = audit
|
|
326
|
+
replacement = Gap(
|
|
327
|
+
run_length,
|
|
328
|
+
kind=resolved.kind,
|
|
329
|
+
crossable=resolved.crossable,
|
|
330
|
+
evidence=resolved.evidence,
|
|
331
|
+
metadata=gap_metadata,
|
|
332
|
+
)
|
|
333
|
+
output_parts.append(replacement)
|
|
334
|
+
_check_output_part_limit(output_parts, resolved.max_output_parts)
|
|
335
|
+
changes.append(
|
|
336
|
+
GapNormalizationChange(
|
|
337
|
+
source_part_index=part_index,
|
|
338
|
+
part_interval=Interval(start, end),
|
|
339
|
+
symbol_interval=Interval(symbol_cursor + start, symbol_cursor + end),
|
|
340
|
+
coordinate_interval=coordinate_interval,
|
|
341
|
+
original_symbol="N",
|
|
342
|
+
original_length=run_length,
|
|
343
|
+
original_sha256=hashlib.sha256(match.group().encode("ascii")).hexdigest(),
|
|
344
|
+
replacement=replacement,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
part_cursor = end
|
|
348
|
+
if part_cursor < len(part):
|
|
349
|
+
output_parts.append(part[part_cursor:])
|
|
350
|
+
_check_output_part_limit(output_parts, resolved.max_output_parts)
|
|
351
|
+
symbol_cursor += len(part)
|
|
352
|
+
if coordinate_cursor is not None:
|
|
353
|
+
coordinate_cursor += len(part)
|
|
354
|
+
|
|
355
|
+
if not changes:
|
|
356
|
+
output_sequence = source_sequence
|
|
357
|
+
output_record = source_record
|
|
358
|
+
normalized_parts = source_sequence.parts
|
|
359
|
+
else:
|
|
360
|
+
remaining_symbols = sum(len(part) for part in output_parts if isinstance(part, str))
|
|
361
|
+
if source_sequence.topology is Topology.CIRCULAR and remaining_symbols == 0:
|
|
362
|
+
raise UnsupportedGapOperationError(
|
|
363
|
+
"Gap normalization would leave a circular sequence with no nucleotide symbols.",
|
|
364
|
+
code="GAP_NORMALIZATION_EMPTY_CIRCULAR_OUTPUT",
|
|
365
|
+
)
|
|
366
|
+
output_sequence = DNASequence(
|
|
367
|
+
output_parts,
|
|
368
|
+
alphabet=source_sequence.alphabet,
|
|
369
|
+
topology=source_sequence.topology,
|
|
370
|
+
strandedness=source_sequence.strandedness,
|
|
371
|
+
)
|
|
372
|
+
normalized_parts = output_sequence.parts
|
|
373
|
+
output_record = (
|
|
374
|
+
None
|
|
375
|
+
if source_record is None
|
|
376
|
+
else DNARecord(
|
|
377
|
+
output_sequence,
|
|
378
|
+
source_record.id,
|
|
379
|
+
description=source_record.description,
|
|
380
|
+
features=source_record.features,
|
|
381
|
+
metadata=source_record.metadata,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return GapNormalizationResult(
|
|
386
|
+
sequence=output_sequence,
|
|
387
|
+
record=output_record,
|
|
388
|
+
source_kind="record" if source_record is not None else "sequence",
|
|
389
|
+
source_sha256=_sequence_sha256(source_sequence),
|
|
390
|
+
normalized_parts=normalized_parts,
|
|
391
|
+
changes=tuple(changes),
|
|
392
|
+
preserved_gap_count=preserved_gap_count,
|
|
393
|
+
parameters=freeze_mapping(
|
|
394
|
+
{
|
|
395
|
+
"min_run_length": resolved.min_run_length,
|
|
396
|
+
"kind": cast(GapKind, resolved.kind).value,
|
|
397
|
+
"crossable": resolved.crossable,
|
|
398
|
+
"evidence": resolved.evidence,
|
|
399
|
+
"metadata": resolved.metadata,
|
|
400
|
+
"circular_boundary_policy": resolved.circular_boundary_policy,
|
|
401
|
+
"max_input_symbols": resolved.max_input_symbols,
|
|
402
|
+
"max_n_run_length": resolved.max_n_run_length,
|
|
403
|
+
"max_converted_runs": resolved.max_converted_runs,
|
|
404
|
+
"max_output_parts": resolved.max_output_parts,
|
|
405
|
+
"coordinate_system": "0-based-half-open",
|
|
406
|
+
}
|
|
407
|
+
),
|
|
408
|
+
algorithm_version="dnakit-gap-normalization-v1",
|
|
409
|
+
provenance=_native_provenance(),
|
|
410
|
+
issues=tuple(issues),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _check_output_part_limit(parts: list[str | Gap], max_output_parts: int) -> None:
|
|
415
|
+
if len(parts) > max_output_parts:
|
|
416
|
+
raise SequenceError(
|
|
417
|
+
"Normalized sequence exceeds max_output_parts.",
|
|
418
|
+
code="GAP_NORMALIZATION_OUTPUT_LIMIT_EXCEEDED",
|
|
419
|
+
context={"max_output_parts": max_output_parts},
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _sequence_sha256(sequence: DNASequence) -> str:
|
|
424
|
+
payload = json.dumps(
|
|
425
|
+
to_json_compatible(sequence),
|
|
426
|
+
ensure_ascii=False,
|
|
427
|
+
sort_keys=True,
|
|
428
|
+
separators=(",", ":"),
|
|
429
|
+
).encode("utf-8")
|
|
430
|
+
return hashlib.sha256(payload).hexdigest()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _candidate_n_runs(sequence: DNASequence, config: GapNormalizationConfig) -> int:
|
|
434
|
+
count = 0
|
|
435
|
+
for part in sequence.parts:
|
|
436
|
+
if not isinstance(part, str):
|
|
437
|
+
continue
|
|
438
|
+
for match in _N_RUN.finditer(part):
|
|
439
|
+
run_length = len(match.group())
|
|
440
|
+
if run_length < config.min_run_length:
|
|
441
|
+
continue
|
|
442
|
+
if run_length > config.max_n_run_length:
|
|
443
|
+
raise SequenceError(
|
|
444
|
+
"An N-run exceeds max_n_run_length.",
|
|
445
|
+
code="GAP_NORMALIZATION_RUN_LIMIT_EXCEEDED",
|
|
446
|
+
context={
|
|
447
|
+
"run_length": run_length,
|
|
448
|
+
"max_n_run_length": config.max_n_run_length,
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
count += 1
|
|
452
|
+
if count > config.max_converted_runs:
|
|
453
|
+
raise SequenceError(
|
|
454
|
+
"N-run count exceeds max_converted_runs.",
|
|
455
|
+
code="GAP_NORMALIZATION_CHANGE_LIMIT_EXCEEDED",
|
|
456
|
+
context={"max_converted_runs": config.max_converted_runs},
|
|
457
|
+
)
|
|
458
|
+
return count
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _circular_boundary_issues(
|
|
462
|
+
sequence: DNASequence,
|
|
463
|
+
config: GapNormalizationConfig,
|
|
464
|
+
) -> list[Issue]:
|
|
465
|
+
if sequence.topology is not Topology.CIRCULAR or len(sequence.parts) == 0:
|
|
466
|
+
return []
|
|
467
|
+
first = sequence.parts[0]
|
|
468
|
+
last = sequence.parts[-1]
|
|
469
|
+
if not isinstance(first, str) or not isinstance(last, str):
|
|
470
|
+
return []
|
|
471
|
+
leading = len(first) - len(first.lstrip("N"))
|
|
472
|
+
trailing = len(last) - len(last.rstrip("N"))
|
|
473
|
+
if leading == 0 or trailing == 0 or leading + trailing < config.min_run_length:
|
|
474
|
+
return []
|
|
475
|
+
if config.circular_boundary_policy == "error":
|
|
476
|
+
raise UnsupportedGapOperationError(
|
|
477
|
+
"A qualifying N-run spans the stored origin of a circular sequence.",
|
|
478
|
+
code="GAP_NORMALIZATION_CIRCULAR_BOUNDARY_UNRESOLVED",
|
|
479
|
+
context={"leading_n": leading, "trailing_n": trailing},
|
|
480
|
+
hint="Rotate the sequence origin or use circular_boundary_policy='separate'.",
|
|
481
|
+
)
|
|
482
|
+
return [
|
|
483
|
+
Issue(
|
|
484
|
+
"STD_CIRCULAR_N_RUN_SPLIT_AT_ORIGIN",
|
|
485
|
+
IssueSeverity.INFO,
|
|
486
|
+
"A circular origin-spanning N-run was evaluated as two stored linear runs.",
|
|
487
|
+
details={
|
|
488
|
+
"leading_n": leading,
|
|
489
|
+
"trailing_n": trailing,
|
|
490
|
+
"policy": "separate",
|
|
491
|
+
},
|
|
492
|
+
)
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass(frozen=True, slots=True)
|
|
497
|
+
class AGPAssemblyConfig:
|
|
498
|
+
"""Strict output choices and resource limits for :func:`sequence_from_agp`."""
|
|
499
|
+
|
|
500
|
+
output_alphabet: DNAAlphabet | str | None = None
|
|
501
|
+
output_strandedness: Strandedness | str = Strandedness.SINGLE
|
|
502
|
+
record_description: str = ""
|
|
503
|
+
record_metadata: Mapping[str, object] = field(default_factory=dict)
|
|
504
|
+
max_entries: int = 1_000_000
|
|
505
|
+
max_components: int = 1_000_000
|
|
506
|
+
max_component_symbols: int = 100_000_000
|
|
507
|
+
max_output_symbols: int = 1_000_000_000
|
|
508
|
+
max_output_span: int = 1_000_000_000
|
|
509
|
+
max_header_lines: int = 10_000
|
|
510
|
+
max_header_length: int = 1_000_000
|
|
511
|
+
|
|
512
|
+
def __post_init__(self) -> None:
|
|
513
|
+
for name in (
|
|
514
|
+
"max_entries",
|
|
515
|
+
"max_components",
|
|
516
|
+
"max_component_symbols",
|
|
517
|
+
"max_output_symbols",
|
|
518
|
+
"max_output_span",
|
|
519
|
+
"max_header_lines",
|
|
520
|
+
"max_header_length",
|
|
521
|
+
):
|
|
522
|
+
_positive_integer(getattr(self, name), name, "INVALID_AGP_ASSEMBLY_LIMIT")
|
|
523
|
+
try:
|
|
524
|
+
alphabet = (
|
|
525
|
+
None
|
|
526
|
+
if self.output_alphabet is None
|
|
527
|
+
else self.output_alphabet
|
|
528
|
+
if isinstance(self.output_alphabet, DNAAlphabet)
|
|
529
|
+
else DNAAlphabet(self.output_alphabet)
|
|
530
|
+
)
|
|
531
|
+
strandedness = (
|
|
532
|
+
self.output_strandedness
|
|
533
|
+
if isinstance(self.output_strandedness, Strandedness)
|
|
534
|
+
else Strandedness(self.output_strandedness)
|
|
535
|
+
)
|
|
536
|
+
except (TypeError, ValueError) as exc:
|
|
537
|
+
raise ConfigurationError(
|
|
538
|
+
"Invalid AGP output alphabet or strandedness.",
|
|
539
|
+
code="INVALID_AGP_ASSEMBLY_CONFIG",
|
|
540
|
+
) from exc
|
|
541
|
+
if not isinstance(self.record_description, str):
|
|
542
|
+
raise ConfigurationError(
|
|
543
|
+
"record_description must be a string.",
|
|
544
|
+
code="INVALID_AGP_ASSEMBLY_CONFIG",
|
|
545
|
+
)
|
|
546
|
+
try:
|
|
547
|
+
metadata = freeze_mapping(self.record_metadata)
|
|
548
|
+
except (AttributeError, ConfigurationError, TypeError, ValueError) as exc:
|
|
549
|
+
raise ConfigurationError(
|
|
550
|
+
"record_metadata must be a JSON-compatible mapping.",
|
|
551
|
+
code="INVALID_AGP_ASSEMBLY_METADATA",
|
|
552
|
+
) from exc
|
|
553
|
+
if _AGP_AUDIT_KEY in metadata:
|
|
554
|
+
raise ConfigurationError(
|
|
555
|
+
f"record_metadata key {_AGP_AUDIT_KEY!r} is reserved.",
|
|
556
|
+
code="RESERVED_AGP_ASSEMBLY_METADATA",
|
|
557
|
+
)
|
|
558
|
+
object.__setattr__(self, "output_alphabet", alphabet)
|
|
559
|
+
object.__setattr__(self, "output_strandedness", strandedness)
|
|
560
|
+
object.__setattr__(self, "record_metadata", metadata)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@dataclass(frozen=True, slots=True)
|
|
564
|
+
class AGPAssemblySegment:
|
|
565
|
+
"""Auditable source coordinates for one assembled AGP row."""
|
|
566
|
+
|
|
567
|
+
object_id: str
|
|
568
|
+
object_interval: Interval
|
|
569
|
+
part_number: int
|
|
570
|
+
segment_type: Literal["component", "known_gap", "unknown_gap"]
|
|
571
|
+
agp_component_type: str
|
|
572
|
+
component_id: str | None = None
|
|
573
|
+
component_interval: Interval | None = None
|
|
574
|
+
orientation: str | None = None
|
|
575
|
+
gap_type: str | None = None
|
|
576
|
+
linkage: bool | None = None
|
|
577
|
+
linkage_evidence: tuple[str, ...] = ()
|
|
578
|
+
gap: Gap | None = None
|
|
579
|
+
|
|
580
|
+
def to_dict(self) -> dict[str, Any]:
|
|
581
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@dataclass(frozen=True, slots=True)
|
|
585
|
+
class AGPAssemblyResult:
|
|
586
|
+
"""A strict AGP assembly record with retained source-row coordinates."""
|
|
587
|
+
|
|
588
|
+
record: DNARecord
|
|
589
|
+
object_id: str
|
|
590
|
+
headers: tuple[str, ...]
|
|
591
|
+
segments: tuple[AGPAssemblySegment, ...]
|
|
592
|
+
used_components: tuple[str, ...]
|
|
593
|
+
parameters: FrozenDict
|
|
594
|
+
algorithm_version: str
|
|
595
|
+
provenance: Provenance
|
|
596
|
+
issues: tuple[Issue, ...]
|
|
597
|
+
|
|
598
|
+
@property
|
|
599
|
+
def sequence(self) -> DNASequence:
|
|
600
|
+
return self.record.sequence
|
|
601
|
+
|
|
602
|
+
@property
|
|
603
|
+
def coordinate_span_unresolved(self) -> bool:
|
|
604
|
+
return self.sequence.coordinate_span is None
|
|
605
|
+
|
|
606
|
+
def to_dict(self) -> dict[str, Any]:
|
|
607
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def sequence_from_agp(
|
|
611
|
+
document: AGPDocument | Iterable[AGPEntry],
|
|
612
|
+
components: Mapping[str, AGPComponentValue],
|
|
613
|
+
*,
|
|
614
|
+
object_id: str | None = None,
|
|
615
|
+
record_id: str | None = None,
|
|
616
|
+
config: AGPAssemblyConfig | None = None,
|
|
617
|
+
) -> AGPAssemblyResult:
|
|
618
|
+
"""Build one linear record from AGP rows without inferring missing semantics.
|
|
619
|
+
|
|
620
|
+
Only the selected object's component values are accessed. Consequently a
|
|
621
|
+
lazy mapping may resolve component sequences on demand without materializing
|
|
622
|
+
an entire assembly database.
|
|
623
|
+
"""
|
|
624
|
+
|
|
625
|
+
resolved = AGPAssemblyConfig() if config is None else config
|
|
626
|
+
if not isinstance(resolved, AGPAssemblyConfig):
|
|
627
|
+
raise ConfigurationError(
|
|
628
|
+
"config must be AGPAssemblyConfig or None.",
|
|
629
|
+
code="INVALID_AGP_ASSEMBLY_CONFIG",
|
|
630
|
+
)
|
|
631
|
+
selected_object_id = _non_empty_optional_text(object_id, "object_id", "INVALID_AGP_OBJECT_ID")
|
|
632
|
+
selected_record_id = _non_empty_optional_text(record_id, "record_id", "INVALID_AGP_RECORD_ID")
|
|
633
|
+
if not isinstance(components, Mapping):
|
|
634
|
+
raise ConfigurationError(
|
|
635
|
+
"components must map component IDs to DNASequence or DNARecord objects.",
|
|
636
|
+
code="INVALID_AGP_COMPONENT_MAPPING",
|
|
637
|
+
)
|
|
638
|
+
entries, headers = _materialize_agp_entries(document, resolved)
|
|
639
|
+
object_ids = _agp_object_blocks(entries)
|
|
640
|
+
if selected_object_id is None:
|
|
641
|
+
if len(object_ids) != 1:
|
|
642
|
+
raise ConfigurationError(
|
|
643
|
+
"object_id is required when AGP entries contain multiple objects.",
|
|
644
|
+
code="AGP_OBJECT_ID_REQUIRED",
|
|
645
|
+
context={"object_ids": object_ids},
|
|
646
|
+
)
|
|
647
|
+
selected_object_id = object_ids[0]
|
|
648
|
+
selected = tuple(entry for entry in entries if entry.object_id == selected_object_id)
|
|
649
|
+
if not selected:
|
|
650
|
+
raise SequenceError(
|
|
651
|
+
"The requested AGP object does not exist.",
|
|
652
|
+
code="AGP_OBJECT_NOT_FOUND",
|
|
653
|
+
context={"object_id": selected_object_id},
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
_validate_agp_continuity(selected, resolved.max_output_span)
|
|
657
|
+
output_parts: list[str | Gap] = []
|
|
658
|
+
segments: list[AGPAssemblySegment] = []
|
|
659
|
+
used_components: list[str] = []
|
|
660
|
+
seen_components: set[str] = set()
|
|
661
|
+
inferred_alphabet = DNAAlphabet.STRICT
|
|
662
|
+
output_symbol_count = 0
|
|
663
|
+
component_count = 0
|
|
664
|
+
unknown_gaps: list[AGPGap] = []
|
|
665
|
+
|
|
666
|
+
for entry in selected:
|
|
667
|
+
if isinstance(entry, AGPGap):
|
|
668
|
+
_validate_agp_gap(entry)
|
|
669
|
+
output_parts.append(entry.gap)
|
|
670
|
+
segment_type: Literal["known_gap", "unknown_gap"] = (
|
|
671
|
+
"known_gap" if entry.component_type == "N" else "unknown_gap"
|
|
672
|
+
)
|
|
673
|
+
if entry.component_type == "U":
|
|
674
|
+
unknown_gaps.append(entry)
|
|
675
|
+
segments.append(
|
|
676
|
+
AGPAssemblySegment(
|
|
677
|
+
entry.object_id,
|
|
678
|
+
entry.object_interval,
|
|
679
|
+
entry.part_number,
|
|
680
|
+
segment_type,
|
|
681
|
+
entry.component_type,
|
|
682
|
+
gap_type=entry.gap_type,
|
|
683
|
+
linkage=entry.linkage,
|
|
684
|
+
linkage_evidence=entry.linkage_evidence,
|
|
685
|
+
gap=entry.gap,
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
continue
|
|
689
|
+
|
|
690
|
+
component_count += 1
|
|
691
|
+
if component_count > resolved.max_components:
|
|
692
|
+
raise SequenceError(
|
|
693
|
+
"Selected AGP object exceeds max_components.",
|
|
694
|
+
code="AGP_COMPONENT_LIMIT_EXCEEDED",
|
|
695
|
+
context={"max_components": resolved.max_components},
|
|
696
|
+
)
|
|
697
|
+
remaining_output_symbols = resolved.max_output_symbols - output_symbol_count
|
|
698
|
+
if len(entry.component_interval) > remaining_output_symbols:
|
|
699
|
+
raise SequenceError(
|
|
700
|
+
"Selected AGP object exceeds max_output_symbols.",
|
|
701
|
+
code="AGP_OUTPUT_SYMBOL_LIMIT_EXCEEDED",
|
|
702
|
+
context={
|
|
703
|
+
"part_number": entry.part_number,
|
|
704
|
+
"requested_fragment_symbols": len(entry.component_interval),
|
|
705
|
+
"remaining_output_symbols": remaining_output_symbols,
|
|
706
|
+
"max_output_symbols": resolved.max_output_symbols,
|
|
707
|
+
},
|
|
708
|
+
)
|
|
709
|
+
fragment, component_alphabet = _resolve_agp_component(entry, components, resolved)
|
|
710
|
+
output_symbol_count += len(fragment)
|
|
711
|
+
if output_symbol_count > resolved.max_output_symbols:
|
|
712
|
+
raise SequenceError(
|
|
713
|
+
"Selected AGP object exceeds max_output_symbols.",
|
|
714
|
+
code="AGP_OUTPUT_SYMBOL_LIMIT_EXCEEDED",
|
|
715
|
+
context={"max_output_symbols": resolved.max_output_symbols},
|
|
716
|
+
)
|
|
717
|
+
output_parts.append(fragment)
|
|
718
|
+
if component_alphabet is DNAAlphabet.IUPAC:
|
|
719
|
+
inferred_alphabet = DNAAlphabet.IUPAC
|
|
720
|
+
if entry.component_id not in seen_components:
|
|
721
|
+
used_components.append(entry.component_id)
|
|
722
|
+
seen_components.add(entry.component_id)
|
|
723
|
+
segments.append(
|
|
724
|
+
AGPAssemblySegment(
|
|
725
|
+
entry.object_id,
|
|
726
|
+
entry.object_interval,
|
|
727
|
+
entry.part_number,
|
|
728
|
+
"component",
|
|
729
|
+
entry.component_type,
|
|
730
|
+
component_id=entry.component_id,
|
|
731
|
+
component_interval=entry.component_interval,
|
|
732
|
+
orientation=entry.orientation,
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
alphabet = cast(DNAAlphabet | None, resolved.output_alphabet) or inferred_alphabet
|
|
737
|
+
if alphabet is DNAAlphabet.STRICT and any(
|
|
738
|
+
isinstance(part, str) and set(part) - STRICT_SYMBOLS for part in output_parts
|
|
739
|
+
):
|
|
740
|
+
raise SequenceError(
|
|
741
|
+
"An assembled component fragment is incompatible with strict output alphabet.",
|
|
742
|
+
code="AGP_OUTPUT_ALPHABET_MISMATCH",
|
|
743
|
+
)
|
|
744
|
+
output_sequence = DNASequence(
|
|
745
|
+
output_parts,
|
|
746
|
+
alphabet=alphabet,
|
|
747
|
+
topology=Topology.LINEAR,
|
|
748
|
+
strandedness=cast(Strandedness, resolved.output_strandedness),
|
|
749
|
+
)
|
|
750
|
+
metadata = dict(resolved.record_metadata)
|
|
751
|
+
metadata[_AGP_AUDIT_KEY] = {
|
|
752
|
+
"object_id": selected_object_id,
|
|
753
|
+
"coordinate_system": "0-based-half-open",
|
|
754
|
+
"entry_count": len(selected),
|
|
755
|
+
"component_count": component_count,
|
|
756
|
+
"known_gap_count": sum(
|
|
757
|
+
isinstance(entry, AGPGap) and entry.component_type == "N" for entry in selected
|
|
758
|
+
),
|
|
759
|
+
"unknown_gap_count": len(unknown_gaps),
|
|
760
|
+
}
|
|
761
|
+
output_record = DNARecord(
|
|
762
|
+
output_sequence,
|
|
763
|
+
selected_record_id or selected_object_id,
|
|
764
|
+
description=resolved.record_description,
|
|
765
|
+
metadata=metadata,
|
|
766
|
+
)
|
|
767
|
+
issues: list[Issue] = []
|
|
768
|
+
if unknown_gaps:
|
|
769
|
+
issues.append(
|
|
770
|
+
Issue(
|
|
771
|
+
"STD_AGP_COORDINATE_SPAN_UNRESOLVED",
|
|
772
|
+
IssueSeverity.INFO,
|
|
773
|
+
"AGP U rows retain Gap(length=None); core coordinate_span is therefore unresolved.",
|
|
774
|
+
details={
|
|
775
|
+
"unknown_gap_count": len(unknown_gaps),
|
|
776
|
+
"agp_placeholder_intervals": [
|
|
777
|
+
{
|
|
778
|
+
"start": entry.object_interval.start,
|
|
779
|
+
"end": entry.object_interval.end,
|
|
780
|
+
}
|
|
781
|
+
for entry in unknown_gaps
|
|
782
|
+
],
|
|
783
|
+
},
|
|
784
|
+
)
|
|
785
|
+
)
|
|
786
|
+
return AGPAssemblyResult(
|
|
787
|
+
record=output_record,
|
|
788
|
+
object_id=selected_object_id,
|
|
789
|
+
headers=headers,
|
|
790
|
+
segments=tuple(segments),
|
|
791
|
+
used_components=tuple(used_components),
|
|
792
|
+
parameters=freeze_mapping(
|
|
793
|
+
{
|
|
794
|
+
"object_id": selected_object_id,
|
|
795
|
+
"record_id": selected_record_id or selected_object_id,
|
|
796
|
+
"output_alphabet": alphabet.value,
|
|
797
|
+
"output_strandedness": cast(Strandedness, resolved.output_strandedness).value,
|
|
798
|
+
"output_topology": Topology.LINEAR.value,
|
|
799
|
+
"coordinate_system": "0-based-half-open",
|
|
800
|
+
"record_description": resolved.record_description,
|
|
801
|
+
"record_metadata": resolved.record_metadata,
|
|
802
|
+
"max_entries": resolved.max_entries,
|
|
803
|
+
"max_components": resolved.max_components,
|
|
804
|
+
"max_component_symbols": resolved.max_component_symbols,
|
|
805
|
+
"max_output_symbols": resolved.max_output_symbols,
|
|
806
|
+
"max_output_span": resolved.max_output_span,
|
|
807
|
+
"max_header_lines": resolved.max_header_lines,
|
|
808
|
+
"max_header_length": resolved.max_header_length,
|
|
809
|
+
}
|
|
810
|
+
),
|
|
811
|
+
algorithm_version="dnakit-agp-sequence-assembly-v1",
|
|
812
|
+
provenance=Provenance(
|
|
813
|
+
implementation=ImplementationInfo(
|
|
814
|
+
label=ImplementationLabel.REIMPLEMENTATION,
|
|
815
|
+
execution_mode=ExecutionMode.INTERNAL,
|
|
816
|
+
origin_class=OriginClass.STANDARD,
|
|
817
|
+
)
|
|
818
|
+
),
|
|
819
|
+
issues=tuple(issues),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _materialize_agp_entries(
|
|
824
|
+
document: AGPDocument | Iterable[AGPEntry], config: AGPAssemblyConfig
|
|
825
|
+
) -> tuple[tuple[AGPEntry, ...], tuple[str, ...]]:
|
|
826
|
+
if isinstance(document, AGPDocument):
|
|
827
|
+
if len(document.entries) > config.max_entries:
|
|
828
|
+
raise SequenceError(
|
|
829
|
+
"AGP document exceeds max_entries.",
|
|
830
|
+
code="AGP_ENTRY_LIMIT_EXCEEDED",
|
|
831
|
+
context={"max_entries": config.max_entries},
|
|
832
|
+
)
|
|
833
|
+
if len(document.headers) > config.max_header_lines:
|
|
834
|
+
raise SequenceError(
|
|
835
|
+
"AGP document exceeds max_header_lines.",
|
|
836
|
+
code="AGP_HEADER_LIMIT_EXCEEDED",
|
|
837
|
+
context={"max_header_lines": config.max_header_lines},
|
|
838
|
+
)
|
|
839
|
+
oversized_header = next(
|
|
840
|
+
(
|
|
841
|
+
index
|
|
842
|
+
for index, header in enumerate(document.headers)
|
|
843
|
+
if len(header) > config.max_header_length
|
|
844
|
+
),
|
|
845
|
+
None,
|
|
846
|
+
)
|
|
847
|
+
if oversized_header is not None:
|
|
848
|
+
raise SequenceError(
|
|
849
|
+
"An AGP header exceeds max_header_length.",
|
|
850
|
+
code="AGP_HEADER_LENGTH_LIMIT_EXCEEDED",
|
|
851
|
+
context={
|
|
852
|
+
"header_index": oversized_header,
|
|
853
|
+
"max_header_length": config.max_header_length,
|
|
854
|
+
},
|
|
855
|
+
)
|
|
856
|
+
entries = document.entries
|
|
857
|
+
headers = document.headers
|
|
858
|
+
else:
|
|
859
|
+
raw_document: object = document
|
|
860
|
+
if isinstance(raw_document, (str, bytes)) or not isinstance(raw_document, Iterable):
|
|
861
|
+
raise TypeError("document must be AGPDocument or an iterable of AGP entries.")
|
|
862
|
+
materialized = tuple(
|
|
863
|
+
itertools.islice(iter(cast(Iterable[object], raw_document)), config.max_entries + 1)
|
|
864
|
+
)
|
|
865
|
+
if len(materialized) > config.max_entries:
|
|
866
|
+
raise SequenceError(
|
|
867
|
+
"AGP entry iterable exceeds max_entries.",
|
|
868
|
+
code="AGP_ENTRY_LIMIT_EXCEEDED",
|
|
869
|
+
context={"max_entries": config.max_entries},
|
|
870
|
+
)
|
|
871
|
+
validated_entries: list[AGPEntry] = []
|
|
872
|
+
for index, entry in enumerate(materialized):
|
|
873
|
+
if not isinstance(entry, (AGPComponent, AGPGap)):
|
|
874
|
+
raise SequenceError(
|
|
875
|
+
"AGP iterable contains an unsupported entry.",
|
|
876
|
+
code="INVALID_AGP_ASSEMBLY_ENTRY",
|
|
877
|
+
context={"entry_index": index, "entry_type": type(entry).__name__},
|
|
878
|
+
)
|
|
879
|
+
validated_entries.append(entry)
|
|
880
|
+
entries = tuple(validated_entries)
|
|
881
|
+
headers = ()
|
|
882
|
+
if not entries:
|
|
883
|
+
raise SequenceError("AGP input is empty.", code="EMPTY_AGP_ASSEMBLY")
|
|
884
|
+
for index, entry in enumerate(entries):
|
|
885
|
+
if not isinstance(entry, (AGPComponent, AGPGap)):
|
|
886
|
+
raise SequenceError(
|
|
887
|
+
"AGP iterable contains an unsupported entry.",
|
|
888
|
+
code="INVALID_AGP_ASSEMBLY_ENTRY",
|
|
889
|
+
context={"entry_index": index, "entry_type": type(entry).__name__},
|
|
890
|
+
)
|
|
891
|
+
return entries, headers
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _agp_object_blocks(entries: tuple[AGPEntry, ...]) -> tuple[str, ...]:
|
|
895
|
+
object_ids: list[str] = []
|
|
896
|
+
completed: set[str] = set()
|
|
897
|
+
active: str | None = None
|
|
898
|
+
for entry in entries:
|
|
899
|
+
if entry.object_id == active:
|
|
900
|
+
continue
|
|
901
|
+
if active is not None:
|
|
902
|
+
completed.add(active)
|
|
903
|
+
if entry.object_id in completed:
|
|
904
|
+
raise SequenceError(
|
|
905
|
+
"AGP object rows must form one contiguous block.",
|
|
906
|
+
code="AGP_OBJECT_BLOCK_DISCONTIGUOUS",
|
|
907
|
+
context={"object_id": entry.object_id},
|
|
908
|
+
)
|
|
909
|
+
active = entry.object_id
|
|
910
|
+
object_ids.append(active)
|
|
911
|
+
return tuple(object_ids)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _validate_agp_continuity(entries: tuple[AGPEntry, ...], max_output_span: int) -> None:
|
|
915
|
+
expected_start = 0
|
|
916
|
+
expected_part = 1
|
|
917
|
+
for entry in entries:
|
|
918
|
+
if entry.object_interval.start != expected_start or entry.part_number != expected_part:
|
|
919
|
+
raise SequenceError(
|
|
920
|
+
"AGP object coordinates and part numbers must be contiguous from zero/one.",
|
|
921
|
+
code="INVALID_AGP_ASSEMBLY_CONTINUITY",
|
|
922
|
+
context={
|
|
923
|
+
"part_number": entry.part_number,
|
|
924
|
+
"object_start": entry.object_interval.start,
|
|
925
|
+
"expected_part_number": expected_part,
|
|
926
|
+
"expected_object_start": expected_start,
|
|
927
|
+
},
|
|
928
|
+
)
|
|
929
|
+
if entry.object_interval.end > max_output_span:
|
|
930
|
+
raise SequenceError(
|
|
931
|
+
"AGP object exceeds max_output_span.",
|
|
932
|
+
code="AGP_OUTPUT_SPAN_LIMIT_EXCEEDED",
|
|
933
|
+
context={"max_output_span": max_output_span},
|
|
934
|
+
)
|
|
935
|
+
expected_start = entry.object_interval.end
|
|
936
|
+
expected_part += 1
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _validate_agp_gap(entry: AGPGap) -> None:
|
|
940
|
+
span = len(entry.object_interval)
|
|
941
|
+
expected_kind = _AGP_KIND_BY_TYPE.get(entry.gap_type)
|
|
942
|
+
if expected_kind is None or entry.gap.kind is not expected_kind:
|
|
943
|
+
raise SequenceError(
|
|
944
|
+
"AGP gap_type and Gap.kind do not agree.",
|
|
945
|
+
code="INVALID_AGP_ASSEMBLY_GAP_KIND",
|
|
946
|
+
context={"gap_type": entry.gap_type, "kind": entry.gap.kind.value},
|
|
947
|
+
)
|
|
948
|
+
if entry.gap.crossable is not entry.linkage or entry.gap.evidence != entry.linkage_evidence:
|
|
949
|
+
raise SequenceError(
|
|
950
|
+
"AGP linkage fields and embedded Gap do not agree.",
|
|
951
|
+
code="INVALID_AGP_ASSEMBLY_GAP_LINKAGE",
|
|
952
|
+
context={"part_number": entry.part_number},
|
|
953
|
+
)
|
|
954
|
+
metadata_type = entry.gap.metadata.get("agp_gap_type")
|
|
955
|
+
if metadata_type is not None and metadata_type != entry.gap_type:
|
|
956
|
+
raise SequenceError(
|
|
957
|
+
"AGP gap metadata conflicts with gap_type.",
|
|
958
|
+
code="INVALID_AGP_ASSEMBLY_GAP_METADATA",
|
|
959
|
+
context={"part_number": entry.part_number},
|
|
960
|
+
)
|
|
961
|
+
valid_length = (entry.component_type == "N" and entry.gap.length == span) or (
|
|
962
|
+
entry.component_type == "U" and entry.gap.length is None and span == 100
|
|
963
|
+
)
|
|
964
|
+
if not valid_length:
|
|
965
|
+
raise SequenceError(
|
|
966
|
+
"AGP known/unknown gap representation does not match its object span.",
|
|
967
|
+
code="INVALID_AGP_ASSEMBLY_GAP_LENGTH",
|
|
968
|
+
context={"part_number": entry.part_number, "component_type": entry.component_type},
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _resolve_agp_component(
|
|
973
|
+
entry: AGPComponent,
|
|
974
|
+
components: Mapping[str, AGPComponentValue],
|
|
975
|
+
config: AGPAssemblyConfig,
|
|
976
|
+
) -> tuple[str, DNAAlphabet]:
|
|
977
|
+
if len(entry.component_interval) != len(entry.object_interval):
|
|
978
|
+
raise SequenceError(
|
|
979
|
+
"AGP component and object interval lengths do not match.",
|
|
980
|
+
code="INVALID_AGP_ASSEMBLY_COMPONENT_SPAN",
|
|
981
|
+
context={"part_number": entry.part_number},
|
|
982
|
+
)
|
|
983
|
+
try:
|
|
984
|
+
value = components[entry.component_id]
|
|
985
|
+
except KeyError as exc:
|
|
986
|
+
raise SequenceError(
|
|
987
|
+
"AGP component sequence is missing.",
|
|
988
|
+
code="AGP_COMPONENT_NOT_FOUND",
|
|
989
|
+
context={"component_id": entry.component_id},
|
|
990
|
+
) from exc
|
|
991
|
+
if not isinstance(value, (DNA, DNASequence, DNARecord)):
|
|
992
|
+
raise SequenceError(
|
|
993
|
+
"AGP component mapping value must be DNA, DNASequence, or DNARecord.",
|
|
994
|
+
code="INVALID_AGP_COMPONENT_VALUE",
|
|
995
|
+
context={"component_id": entry.component_id, "value_type": type(value).__name__},
|
|
996
|
+
)
|
|
997
|
+
sequence = value.sequence if isinstance(value, (DNA, DNARecord)) else value
|
|
998
|
+
if sequence.topology is Topology.CIRCULAR:
|
|
999
|
+
raise UnsupportedGapOperationError(
|
|
1000
|
+
"AGP component extraction from a circular sequence requires an explicit origin policy.",
|
|
1001
|
+
code="AGP_CIRCULAR_COMPONENT_UNSUPPORTED",
|
|
1002
|
+
context={"component_id": entry.component_id},
|
|
1003
|
+
)
|
|
1004
|
+
if sequence.is_gapped:
|
|
1005
|
+
raise UnsupportedGapOperationError(
|
|
1006
|
+
"AGP component coordinates cannot be applied to an already-gapped sequence.",
|
|
1007
|
+
code="AGP_GAPPED_COMPONENT_UNSUPPORTED",
|
|
1008
|
+
context={"component_id": entry.component_id},
|
|
1009
|
+
)
|
|
1010
|
+
if sequence.symbol_length > config.max_component_symbols:
|
|
1011
|
+
raise SequenceError(
|
|
1012
|
+
"AGP component exceeds max_component_symbols.",
|
|
1013
|
+
code="AGP_COMPONENT_SYMBOL_LIMIT_EXCEEDED",
|
|
1014
|
+
context={
|
|
1015
|
+
"component_id": entry.component_id,
|
|
1016
|
+
"symbol_length": sequence.symbol_length,
|
|
1017
|
+
"max_component_symbols": config.max_component_symbols,
|
|
1018
|
+
},
|
|
1019
|
+
)
|
|
1020
|
+
if entry.component_interval.end > sequence.symbol_length:
|
|
1021
|
+
raise SequenceError(
|
|
1022
|
+
"AGP component interval exceeds its sequence.",
|
|
1023
|
+
code="AGP_COMPONENT_INTERVAL_OUT_OF_BOUNDS",
|
|
1024
|
+
context={
|
|
1025
|
+
"component_id": entry.component_id,
|
|
1026
|
+
"component_end": entry.component_interval.end,
|
|
1027
|
+
"symbol_length": sequence.symbol_length,
|
|
1028
|
+
},
|
|
1029
|
+
)
|
|
1030
|
+
if entry.orientation not in {"+", "-"}:
|
|
1031
|
+
raise UnsupportedGapOperationError(
|
|
1032
|
+
"AGP component orientation is unresolved; DNAKit will not guess it.",
|
|
1033
|
+
code="AGP_COMPONENT_ORIENTATION_UNRESOLVED",
|
|
1034
|
+
context={"component_id": entry.component_id, "orientation": entry.orientation},
|
|
1035
|
+
)
|
|
1036
|
+
fragment = sequence.symbols[entry.component_interval.start : entry.component_interval.end]
|
|
1037
|
+
if len(fragment) != len(entry.object_interval):
|
|
1038
|
+
raise SequenceError(
|
|
1039
|
+
"Extracted AGP component length does not equal its object span.",
|
|
1040
|
+
code="INVALID_AGP_ASSEMBLY_COMPONENT_SPAN",
|
|
1041
|
+
context={"component_id": entry.component_id},
|
|
1042
|
+
)
|
|
1043
|
+
if entry.orientation == "-":
|
|
1044
|
+
fragment = DNASequence(fragment, alphabet=sequence.alphabet).reverse_complement().symbols
|
|
1045
|
+
return fragment, sequence.alphabet
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
__all__ = [
|
|
1049
|
+
"AGPAssemblyConfig",
|
|
1050
|
+
"AGPAssemblyResult",
|
|
1051
|
+
"AGPAssemblySegment",
|
|
1052
|
+
"AGPComponentValue",
|
|
1053
|
+
"CircularBoundaryPolicy",
|
|
1054
|
+
"GapNormalizable",
|
|
1055
|
+
"GapNormalizationChange",
|
|
1056
|
+
"GapNormalizationConfig",
|
|
1057
|
+
"GapNormalizationResult",
|
|
1058
|
+
"normalize_gaps",
|
|
1059
|
+
"sequence_from_agp",
|
|
1060
|
+
]
|