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,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
+ ]