fusion-function 0.2.1__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.
@@ -0,0 +1,1024 @@
1
+ from __future__ import annotations
2
+
3
+ from itertools import product
4
+ from typing import Literal, NotRequired, TypedDict, cast
5
+
6
+ from pathlib import Path
7
+
8
+ from .reference import ReferenceDatabase, get_reference
9
+
10
+ from .ensembl import (
11
+ EnsemblError,
12
+ ProteinFeature,
13
+ FeatureEvidence,
14
+ feature_identity,
15
+ Strand,
16
+ TranscriptProteinFeatureResult,
17
+ get_protein_domains,
18
+ )
19
+
20
+
21
+ FusionSideName = Literal["transcript1", "transcript2"]
22
+ FusionTerminus = Literal["N", "C"]
23
+ SpliceSiteType = Literal["donor", "acceptor"]
24
+ BreakpointBasedStatus = Literal["included", "disrupted", "excluded"]
25
+ PostSplicingStatus = Literal["preserved", "lost"]
26
+ PostTranslationStatus = Literal[
27
+ "preserved",
28
+ "frame_disrupted",
29
+ "translation_start_disrupted",
30
+ "translation_start_excluded",
31
+ "premature_termination_disrupted",
32
+ "premature_termination_excluded",
33
+ ]
34
+ FrameStatus = Literal["in_frame", "out_of_frame"]
35
+ FusionFrameStatus = Literal["in_frame", "out_of_frame", "in_frame/out_of_frame"]
36
+
37
+
38
+ TranslationStartStatus = Literal[
39
+ "native_start_retained",
40
+ "alternative_start_found",
41
+ "alternative_start_not_found",
42
+ "native_start_lost",
43
+ ]
44
+ TRANSLATION_START_STATUS_ORDER = (
45
+ "native_start_retained",
46
+ "alternative_start_found",
47
+ "alternative_start_not_found",
48
+ "native_start_lost",
49
+ )
50
+
51
+
52
+ class CodingFragment(TypedDict):
53
+ side: FusionSideName
54
+ transcript_id: str
55
+ source_cds_start: int
56
+ source_cds_end: int
57
+ product_start: int
58
+ product_end: int
59
+
60
+
61
+ class SpliceProduct(TypedDict):
62
+ translation_start: int | None
63
+ stop_position: int | None
64
+ frame_status: FrameStatus
65
+ coding_fragments: list[CodingFragment]
66
+ translation_start_source: NotRequired[Literal["alternative", "not_found"]]
67
+ start_status: NotRequired[TranslationStartStatus]
68
+ start_transcript_id: NotRequired[str | None]
69
+
70
+
71
+ class FusionCodingSegment(TypedDict):
72
+ side: FusionSideName
73
+ transcript_id: str
74
+ fusion_terminus: FusionTerminus
75
+ local_start: int
76
+ local_end: int
77
+ source_cds_start: int
78
+ source_cds_end: int
79
+
80
+
81
+ class FusionSpliceSite(TypedDict):
82
+ type: SpliceSiteType
83
+ side: FusionSideName
84
+ transcript_id: str
85
+ exon_number: int
86
+ genomic_position: int
87
+ premrna_position: int
88
+ local_position: int
89
+ intact: bool
90
+
91
+
92
+ class FusionSide(TypedDict):
93
+ side: FusionSideName
94
+ transcript_id: str
95
+ breakpoint: str
96
+ fusion_terminus: FusionTerminus
97
+ strand: Strand
98
+ inferred_breakpoint_orientation: Literal["L", "R"]
99
+ length: int
100
+ sequence: str
101
+ splice_sites: list[FusionSpliceSite]
102
+ coding_segments: list[FusionCodingSegment]
103
+ protein_features: list[ProteinFeature]
104
+
105
+
106
+ class LayoutSpliceSite(FusionSpliceSite):
107
+ fusion_position: int
108
+
109
+
110
+ class LayoutCodingSegment(FusionCodingSegment):
111
+ fusion_start: int
112
+ fusion_end: int
113
+
114
+
115
+ class FusionLayout(TypedDict):
116
+ n_terminal_side: FusionSideName
117
+ total_length: int
118
+ sequence: str
119
+ splice_sites: list[LayoutSpliceSite]
120
+ coding_segments: list[LayoutCodingSegment]
121
+
122
+
123
+ class FeatureProductPrediction(TypedDict):
124
+ post_splicing_status: PostSplicingStatus
125
+ post_translation_status: PostTranslationStatus | None
126
+
127
+
128
+ class AnnotatedProteinFeature(ProteinFeature):
129
+ transcript_id: str
130
+ breakpoint_based_status: BreakpointBasedStatus
131
+ breakpoint_retained_percent: float
132
+ post_splicing_status: str | None
133
+ post_translation_status: str | None
134
+
135
+
136
+ class ResolvedAnnotatedProteinFeature(AnnotatedProteinFeature):
137
+ resolved_interpro_id: str | None
138
+ member_name: str | None
139
+ member_entry_type: str | None
140
+
141
+
142
+ class AggregatedDomain(TypedDict):
143
+ transcript_id: str
144
+ interpro_id: str | None
145
+ name: str | None
146
+ specific_name: NotRequired[str | None]
147
+ domain_type: str | None
148
+ sources: list[str]
149
+ feature_ids: NotRequired[list[str]]
150
+ uniprot_accessions: NotRequired[list[str]]
151
+ uniprot_isoforms: NotRequired[list[str]]
152
+ evidence: NotRequired[list[FeatureEvidence]]
153
+ start: int
154
+ end: int
155
+ breakpoint_based_status: str
156
+ breakpoint_retained_percent: float
157
+ post_splicing_status: str | None
158
+ post_translation_status: str | None
159
+
160
+
161
+ class FunctionalDomain(TypedDict):
162
+ sources: list[str]
163
+ feature_ids: list[str]
164
+ uniprot_accessions: NotRequired[list[str]]
165
+ uniprot_isoforms: NotRequired[list[str]]
166
+ evidence: NotRequired[list[FeatureEvidence]]
167
+ transcript_id: str
168
+ interpro_id: str | None
169
+ name: str
170
+ domain_type: str
171
+ start: int
172
+ end: int
173
+ breakpoint_based_status: str
174
+ breakpoint_retained_percent: float
175
+ post_splicing_status: str | None
176
+ post_translation_status: str | None
177
+
178
+
179
+ class FusionDomainResult(TypedDict):
180
+ frame_status: FusionFrameStatus | None
181
+ domains: list[FunctionalDomain]
182
+ assumed_disruptive: NotRequired[bool]
183
+ translation_start: NotRequired[dict[str, str]]
184
+
185
+
186
+ STOP_CODONS = {"TAA", "TAG", "TGA"}
187
+ RELEVANT_ENTRY_TYPES = {
188
+ "family",
189
+ "domain",
190
+ "homologous_superfamily",
191
+ "binding_site",
192
+ "active_site",
193
+ "conserved_site",
194
+ "motif",
195
+ }
196
+
197
+
198
+ def _parse_breakpoint(breakpoint: str) -> tuple[str, int]:
199
+ """Parse an exact chromosome:position breakpoint."""
200
+ chromosome, position = breakpoint.split(":", 1)
201
+ if "-" in position:
202
+ raise NotImplementedError(f"Breakpoint intervals are not supported: {breakpoint}")
203
+ return chromosome, int(position)
204
+
205
+
206
+ def _infer_breakpoint_orientation(
207
+ strand: Strand, fusion_terminus: FusionTerminus
208
+ ) -> Literal["L", "R"]:
209
+ """Infer the retained genomic side from strand and fusion terminus."""
210
+ return "L" if (strand, fusion_terminus) in {(1, "N"), (-1, "C")} else "R"
211
+
212
+
213
+ def _genomic_to_premrna(position: int, transcript: TranscriptProteinFeatureResult) -> int:
214
+ """Convert a genomic position to transcript-oriented pre-mRNA coordinates."""
215
+ start = transcript["transcript_genomic_start"]
216
+ end = transcript["transcript_genomic_end"]
217
+ if not start <= position <= end:
218
+ raise ValueError(f"Position {position} lies outside transcript genomic span {start}-{end}")
219
+ return position - start + 1 if transcript["strand"] == 1 else end - position + 1
220
+
221
+
222
+ def _build_fusion_side(
223
+ side: FusionSideName,
224
+ transcript_id: str,
225
+ breakpoint: str,
226
+ fusion_terminus: FusionTerminus,
227
+ transcript: TranscriptProteinFeatureResult,
228
+ ) -> FusionSide:
229
+ """Build the retained pre-mRNA portion of one fusion partner."""
230
+ chromosome, breakpoint_position = _parse_breakpoint(breakpoint)
231
+ if str(transcript["chromosome"]).removeprefix("chr") != chromosome.removeprefix("chr"):
232
+ raise ValueError(
233
+ f"Breakpoint chromosome {chromosome} does not match "
234
+ f"transcript chromosome {transcript['chromosome']}"
235
+ )
236
+ breakpoint_premrna = _genomic_to_premrna(breakpoint_position, transcript)
237
+ if fusion_terminus == "N":
238
+ retained_start, retained_end = 1, breakpoint_premrna
239
+ else:
240
+ retained_start, retained_end = breakpoint_premrna, transcript["premrna_length"]
241
+ splice_sites: list[FusionSpliceSite] = []
242
+ for site in transcript["splice_sites"]:
243
+ premrna_position = site["premrna_position"]
244
+ if retained_start <= premrna_position <= retained_end:
245
+ splice_sites.append(
246
+ {
247
+ "type": site["type"],
248
+ "side": side,
249
+ "transcript_id": transcript_id,
250
+ "exon_number": site["exon_number"],
251
+ "genomic_position": site["genomic_position"],
252
+ "premrna_position": premrna_position,
253
+ "local_position": premrna_position - retained_start + 1,
254
+ "intact": not site["disruption_start"]
255
+ <= breakpoint_position
256
+ <= site["disruption_end"],
257
+ }
258
+ )
259
+ coding_segments: list[FusionCodingSegment] = []
260
+ for block in transcript["cds_blocks"]:
261
+ start = max(block["premrna_start"], retained_start)
262
+ end = min(block["premrna_end"], retained_end)
263
+ if start > end:
264
+ continue
265
+ coding_segments.append(
266
+ {
267
+ "side": side,
268
+ "transcript_id": transcript_id,
269
+ "fusion_terminus": fusion_terminus,
270
+ "local_start": start - retained_start + 1,
271
+ "local_end": end - retained_start + 1,
272
+ "source_cds_start": block["cds_start"] + start - block["premrna_start"],
273
+ "source_cds_end": block["cds_start"] + end - block["premrna_start"],
274
+ }
275
+ )
276
+ return {
277
+ "side": side,
278
+ "transcript_id": transcript_id,
279
+ "breakpoint": f"{chromosome}:{breakpoint_position}",
280
+ "fusion_terminus": fusion_terminus,
281
+ "strand": transcript["strand"],
282
+ "inferred_breakpoint_orientation": _infer_breakpoint_orientation(
283
+ transcript["strand"], fusion_terminus
284
+ ),
285
+ "length": retained_end - retained_start + 1,
286
+ "sequence": transcript["premrna_sequence"][retained_start - 1 : retained_end],
287
+ "splice_sites": splice_sites,
288
+ "coding_segments": coding_segments,
289
+ "protein_features": transcript["protein_features"],
290
+ }
291
+
292
+
293
+ def _build_fusion_layout(
294
+ side1: FusionSide, side2: FusionSide, inserted_sequence: str = ""
295
+ ) -> FusionLayout:
296
+ """Arrange retained partners N-to-C and build the fusion pre-mRNA."""
297
+ if {side1["fusion_terminus"], side2["fusion_terminus"]} != {"N", "C"}:
298
+ raise ValueError("Fusion prediction requires one N-terminal and one C-terminal partner")
299
+ n_side, c_side = (side1, side2) if side1["fusion_terminus"] == "N" else (side2, side1)
300
+ inserted_sequence = inserted_sequence.upper()
301
+ c_offset = n_side["length"] + len(inserted_sequence)
302
+ splice_sites: list[LayoutSpliceSite] = [
303
+ {
304
+ **site,
305
+ "fusion_position": site["local_position"]
306
+ + (0 if side["fusion_terminus"] == "N" else c_offset),
307
+ }
308
+ for side in (n_side, c_side)
309
+ for site in side["splice_sites"]
310
+ ]
311
+ coding_segments: list[LayoutCodingSegment] = [
312
+ {
313
+ **segment,
314
+ "fusion_start": segment["local_start"]
315
+ + (0 if side["fusion_terminus"] == "N" else c_offset),
316
+ "fusion_end": segment["local_end"]
317
+ + (0 if side["fusion_terminus"] == "N" else c_offset),
318
+ }
319
+ for side in (n_side, c_side)
320
+ for segment in side["coding_segments"]
321
+ ]
322
+ return {
323
+ "n_terminal_side": n_side["side"],
324
+ "total_length": n_side["length"] + len(inserted_sequence) + c_side["length"],
325
+ "sequence": n_side["sequence"] + inserted_sequence + c_side["sequence"],
326
+ "splice_sites": sorted(splice_sites, key=lambda x: x["fusion_position"]),
327
+ "coding_segments": sorted(coding_segments, key=lambda x: x["fusion_start"]),
328
+ }
329
+
330
+
331
+ def _build_retained_side_layout(side: FusionSide) -> FusionLayout:
332
+ """Model the known retained sequence without inventing the unknown partner."""
333
+ return {
334
+ "n_terminal_side": side["side"],
335
+ "total_length": side["length"],
336
+ "sequence": side["sequence"],
337
+ "splice_sites": [
338
+ {**site, "fusion_position": site["local_position"]} for site in side["splice_sites"]
339
+ ],
340
+ "coding_segments": [
341
+ {**segment, "fusion_start": segment["local_start"], "fusion_end": segment["local_end"]}
342
+ for segment in side["coding_segments"]
343
+ ],
344
+ }
345
+
346
+
347
+ def _generate_splice_patterns(splice_sites: list[LayoutSpliceSite]) -> list[list[LayoutSpliceSite]]:
348
+ """Generate splice patterns from intact donor/acceptor alternatives."""
349
+ groups: list[list[LayoutSpliceSite]] = []
350
+ for site in sorted(
351
+ (site for site in splice_sites if site["intact"]), key=lambda x: x["fusion_position"]
352
+ ):
353
+ if groups and groups[-1][0]["type"] == site["type"]:
354
+ groups[-1].append(site)
355
+ else:
356
+ groups.append([site])
357
+ if groups and groups[0][0]["type"] == "acceptor":
358
+ groups.pop(0)
359
+ if groups and groups[-1][0]["type"] == "donor":
360
+ groups.pop()
361
+ return [list(pattern) for pattern in product(*groups)] if groups else [[]]
362
+
363
+
364
+ def _subtract_intervals(
365
+ start: int, end: int, intervals: list[tuple[int, int]]
366
+ ) -> list[tuple[int, int]]:
367
+ """Subtract inclusive intervals from another inclusive interval."""
368
+ segments = [(start, end)]
369
+ for remove_start, remove_end in intervals:
370
+ updated = []
371
+ for segment_start, segment_end in segments:
372
+ if remove_end < segment_start or remove_start > segment_end:
373
+ updated.append((segment_start, segment_end))
374
+ continue
375
+ if segment_start < remove_start:
376
+ updated.append((segment_start, remove_start - 1))
377
+ if segment_end > remove_end:
378
+ updated.append((remove_end + 1, segment_end))
379
+ segments = updated
380
+ return segments
381
+
382
+
383
+ def _get_product_frame_status(
384
+ translation_start: int | None, coding_fragments: list[CodingFragment]
385
+ ) -> FrameStatus:
386
+ """Determine whether retained CDS fragments preserve source codon phase."""
387
+ if translation_start is None:
388
+ return "out_of_frame"
389
+ translated_fragments = [
390
+ fragment for fragment in coding_fragments if fragment["product_end"] >= translation_start
391
+ ]
392
+ if not translated_fragments:
393
+ return "out_of_frame"
394
+ return (
395
+ "in_frame"
396
+ if all(
397
+ (fragment["product_start"] - translation_start) % 3
398
+ == (fragment["source_cds_start"] - 1) % 3
399
+ for fragment in translated_fragments
400
+ )
401
+ else "out_of_frame"
402
+ )
403
+
404
+
405
+ def _find_stop_position(sequence: str, translation_start: int | None) -> int | None:
406
+ """Return the first base of the first in-frame stop codon."""
407
+ if translation_start is None:
408
+ return None
409
+ for position in range(translation_start - 1, len(sequence) - 2, 3):
410
+ if sequence[position : position + 3] in STOP_CODONS:
411
+ return position + 1
412
+ return None
413
+
414
+
415
+ def _build_splice_product(
416
+ layout: FusionLayout, splice_pattern: list[LayoutSpliceSite]
417
+ ) -> SpliceProduct:
418
+ """Construct one spliced fusion transcript and determine frame and stop."""
419
+ if len(splice_pattern) % 2:
420
+ raise ValueError("Splice pattern must contain donor/acceptor pairs")
421
+ removed: list[tuple[int, int]] = []
422
+ for donor, acceptor in zip(splice_pattern[::2], splice_pattern[1::2]):
423
+ if donor["type"] != "donor" or acceptor["type"] != "acceptor":
424
+ raise ValueError("Splice pattern must alternate donor and acceptor sites")
425
+ if donor["fusion_position"] >= acceptor["fusion_position"]:
426
+ raise ValueError("Donor must precede acceptor")
427
+ if donor["fusion_position"] + 1 <= acceptor["fusion_position"] - 1:
428
+ removed.append((donor["fusion_position"] + 1, acceptor["fusion_position"] - 1))
429
+ retained: list[tuple[int, int, int, int]] = []
430
+ product_position = 1
431
+ for fusion_start, fusion_end in _subtract_intervals(1, layout["total_length"], removed):
432
+ length = fusion_end - fusion_start + 1
433
+ retained.append((fusion_start, fusion_end, product_position, product_position + length - 1))
434
+ product_position += length
435
+ sequence = "".join(
436
+ layout["sequence"][fusion_start - 1 : fusion_end]
437
+ for fusion_start, fusion_end, _, _ in retained
438
+ )
439
+ coding_fragments: list[CodingFragment] = []
440
+ for segment in layout["coding_segments"]:
441
+ for fusion_start, fusion_end, product_start, _ in retained:
442
+ start = max(segment["fusion_start"], fusion_start)
443
+ end = min(segment["fusion_end"], fusion_end)
444
+ if start > end:
445
+ continue
446
+ coding_fragments.append(
447
+ {
448
+ "side": segment["side"],
449
+ "transcript_id": segment["transcript_id"],
450
+ "source_cds_start": segment["source_cds_start"]
451
+ + start
452
+ - segment["fusion_start"],
453
+ "source_cds_end": segment["source_cds_start"] + end - segment["fusion_start"],
454
+ "product_start": product_start + start - fusion_start,
455
+ "product_end": product_start + end - fusion_start,
456
+ }
457
+ )
458
+ coding_fragments.sort(key=lambda x: x["product_start"])
459
+ # Select one native initiation site in the complete spliced product. A
460
+ # retained C-terminal start can supply initiation if the N portion is UTR-only.
461
+ native_candidates = [
462
+ (fragment["product_start"] + 1 - fragment["source_cds_start"], fragment["transcript_id"])
463
+ for fragment in coding_fragments
464
+ if fragment["source_cds_start"] <= 1 <= fragment["source_cds_end"]
465
+ ]
466
+ native_candidates = [
467
+ (position, transcript_id)
468
+ for position, transcript_id in native_candidates
469
+ if sequence[position - 1 : position + 2] == "ATG"
470
+ ]
471
+ translation_start, start_transcript_id = next(iter(native_candidates), (None, None))
472
+ start_source: Literal["alternative", "not_found"] | None = None
473
+ native_start_retained = translation_start is not None
474
+ if translation_start is None and coding_fragments:
475
+ # Search after splicing, in any phase. Choosing the earliest retained ATG
476
+ # is a heuristic; compatibility with the original protein is checked
477
+ # separately. With no retained CDS there are no original domains to translate.
478
+ start_index = sequence.find("ATG")
479
+ translation_start = start_index + 1 if start_index >= 0 else None
480
+ start_source = "alternative" if translation_start is not None else "not_found"
481
+ result: SpliceProduct = {
482
+ "translation_start": translation_start,
483
+ "stop_position": _find_stop_position(sequence, translation_start),
484
+ "frame_status": _get_product_frame_status(translation_start, coding_fragments),
485
+ "coding_fragments": coding_fragments,
486
+ }
487
+ if start_source is not None:
488
+ result["translation_start_source"] = start_source
489
+ # This describes initiation of this product once, rather than separate
490
+ # outcomes for each occurrence of the same source transcript.
491
+ result["start_status"] = (
492
+ "native_start_retained"
493
+ if native_start_retained
494
+ else "native_start_lost"
495
+ if not coding_fragments
496
+ else "alternative_start_found"
497
+ if translation_start is not None
498
+ else "alternative_start_not_found"
499
+ )
500
+ result["start_transcript_id"] = start_transcript_id
501
+ return result
502
+
503
+
504
+ def _covered_length(start: int, end: int, intervals: list[tuple[int, int]]) -> int:
505
+ """Return the number of bases covered within an inclusive interval."""
506
+ intervals = sorted(
507
+ (max(start, a), min(end, b)) for a, b in intervals if b >= start and a <= end
508
+ )
509
+ if not intervals:
510
+ return 0
511
+ covered = 0
512
+ current_start, current_end = intervals[0]
513
+ for interval_start, interval_end in intervals[1:]:
514
+ if interval_start <= current_end + 1:
515
+ current_end = max(current_end, interval_end)
516
+ else:
517
+ covered += current_end - current_start + 1
518
+ current_start, current_end = interval_start, interval_end
519
+ return covered + current_end - current_start + 1
520
+
521
+
522
+ def _classify_feature(
523
+ feature: ProteinFeature, side: FusionSideName, splice_product: SpliceProduct
524
+ ) -> FeatureProductPrediction:
525
+ """Check the feature portion retained by the breakpoint in one splice product.
526
+
527
+ The caller clips CDS bounds to the retained portion. This lets a disrupted
528
+ domain undergo the same downstream checks as a fully included domain.
529
+ """
530
+ fragments: list[tuple[int, int, int, int]] = []
531
+ for fragment in splice_product["coding_fragments"]:
532
+ if (
533
+ fragment["side"] != side
534
+ or fragment["source_cds_end"] < feature["cds_start"]
535
+ or fragment["source_cds_start"] > feature["cds_end"]
536
+ ):
537
+ continue
538
+ source_start = max(feature["cds_start"], fragment["source_cds_start"])
539
+ source_end = min(feature["cds_end"], fragment["source_cds_end"])
540
+ fragments.append(
541
+ (
542
+ source_start,
543
+ source_end,
544
+ fragment["product_start"] + source_start - fragment["source_cds_start"],
545
+ fragment["product_start"] + source_end - fragment["source_cds_start"],
546
+ )
547
+ )
548
+ fragments.sort()
549
+ if (
550
+ _covered_length(
551
+ feature["cds_start"],
552
+ feature["cds_end"],
553
+ [(start, end) for start, end, _, _ in fragments],
554
+ )
555
+ < feature["cds_end"] - feature["cds_start"] + 1
556
+ ):
557
+ return {"post_splicing_status": "lost", "post_translation_status": None}
558
+ translation_start = splice_product["translation_start"]
559
+ if translation_start is None:
560
+ status: PostTranslationStatus = (
561
+ "translation_start_excluded"
562
+ if splice_product.get("translation_start_source") == "not_found"
563
+ else "frame_disrupted"
564
+ )
565
+ elif translation_start > fragments[-1][3]:
566
+ status = "translation_start_excluded"
567
+ else:
568
+ # An alternative ATG can occur inside a retained feature. Compare phase
569
+ # and continuity only for bases translated from that start onward.
570
+ translated = [
571
+ (
572
+ start + max(0, translation_start - product_start),
573
+ end,
574
+ max(product_start, translation_start),
575
+ product_end,
576
+ )
577
+ for start, end, product_start, product_end in fragments
578
+ if product_end >= translation_start
579
+ ]
580
+ if (
581
+ any(
582
+ current[0] != previous[1] + 1 or current[2] != previous[3] + 1
583
+ for previous, current in zip(translated, translated[1:])
584
+ )
585
+ or (translated[0][2] - translation_start) % 3 != (translated[0][0] - 1) % 3
586
+ ):
587
+ status = "frame_disrupted"
588
+ elif (
589
+ splice_product["stop_position"] is not None
590
+ and splice_product["stop_position"] <= translated[0][2]
591
+ ):
592
+ status = "premature_termination_excluded"
593
+ elif (
594
+ splice_product["stop_position"] is not None
595
+ and splice_product["stop_position"] <= translated[-1][3]
596
+ ):
597
+ status = "premature_termination_disrupted"
598
+ elif translation_start > fragments[0][2]:
599
+ status = "translation_start_disrupted"
600
+ else:
601
+ status = "preserved"
602
+ return {"post_splicing_status": "preserved", "post_translation_status": status}
603
+
604
+
605
+ def _combine_statuses(statuses: list[str | None], order: tuple[str, ...]) -> str | None:
606
+ """Combine statuses into a stable slash-delimited summary."""
607
+ observed = {status for value in statuses if value is not None for status in value.split("/")}
608
+ return "/".join(status for status in order if status in observed) or None
609
+
610
+
611
+ def _annotate_feature_statuses(
612
+ side: FusionSide, splice_products: list[SpliceProduct]
613
+ ) -> list[AnnotatedProteinFeature]:
614
+ """Annotate each protein feature independently across predicted products."""
615
+ retained_cds = [
616
+ (segment["source_cds_start"], segment["source_cds_end"])
617
+ for segment in side["coding_segments"]
618
+ ]
619
+ annotated: list[AnnotatedProteinFeature] = []
620
+ for feature in side["protein_features"]:
621
+ feature_length = feature["cds_end"] - feature["cds_start"] + 1
622
+ covered = _covered_length(feature["cds_start"], feature["cds_end"], retained_cds)
623
+ breakpoint_status: BreakpointBasedStatus = (
624
+ "excluded" if covered == 0 else "disrupted" if covered < feature_length else "included"
625
+ )
626
+ if covered > 0:
627
+ # An N- or C-terminal breakpoint retains a continuous source CDS
628
+ # interval, even when its genomic sequence spans several exons.
629
+ # Check that interval after splicing without treating bases already
630
+ # removed by the breakpoint as an additional splicing loss.
631
+ retained_feature: ProteinFeature = {
632
+ **feature,
633
+ "cds_start": max(feature["cds_start"], min(start for start, _ in retained_cds)),
634
+ "cds_end": min(feature["cds_end"], max(end for _, end in retained_cds)),
635
+ }
636
+ predictions = [
637
+ _classify_feature(retained_feature, side["side"], splice_product)
638
+ for splice_product in splice_products
639
+ ]
640
+ splicing_status = _combine_statuses(
641
+ [x["post_splicing_status"] for x in predictions], ("preserved", "lost")
642
+ )
643
+ sequence_status = _combine_statuses(
644
+ [x["post_translation_status"] for x in predictions],
645
+ (
646
+ "preserved",
647
+ "frame_disrupted",
648
+ "translation_start_disrupted",
649
+ "translation_start_excluded",
650
+ "premature_termination_disrupted",
651
+ "premature_termination_excluded",
652
+ ),
653
+ )
654
+ else:
655
+ splicing_status = None
656
+ sequence_status = None
657
+ annotated.append(
658
+ {
659
+ **feature,
660
+ "transcript_id": side["transcript_id"],
661
+ "breakpoint_based_status": breakpoint_status,
662
+ "breakpoint_retained_percent": round(100 * covered / feature_length, 1),
663
+ "post_splicing_status": splicing_status,
664
+ "post_translation_status": sequence_status,
665
+ }
666
+ )
667
+ return annotated
668
+
669
+
670
+ def _resolve_feature_metadata(
671
+ features: list[AnnotatedProteinFeature],
672
+ ) -> list[ResolvedAnnotatedProteinFeature]:
673
+ """Use metadata already resolved during preprocessing."""
674
+ resolved: list[ResolvedAnnotatedProteinFeature] = []
675
+ for feature in features:
676
+ entry_type = feature.get("feature_type") or feature.get("interpro_entry_type")
677
+ if feature["interpro_id"] and entry_type is None:
678
+ raise ValueError(
679
+ f"Missing InterPro entry type for {feature['interpro_id']}. "
680
+ "Reprocess the database with 'fusion-function prepare-data --preprocess-only DB'. "
681
+ "Metadata is fetched automatically; --interpro-entries FILE supplies a local list."
682
+ )
683
+ resolved.append(
684
+ {
685
+ **feature,
686
+ "resolved_interpro_id": feature["interpro_id"],
687
+ "member_name": feature.get("interpro_name"),
688
+ "member_entry_type": entry_type,
689
+ }
690
+ )
691
+ return resolved
692
+
693
+
694
+ def _aggregate_feature_group(features: list[ResolvedAnnotatedProteinFeature]) -> AggregatedDomain:
695
+ """Combine identical intervals; keep alternative-product statuses together."""
696
+ interpro_id = next(
697
+ (
698
+ feature["resolved_interpro_id"]
699
+ for feature in features
700
+ if feature["resolved_interpro_id"] is not None
701
+ ),
702
+ None,
703
+ )
704
+ result: AggregatedDomain = {
705
+ "transcript_id": features[0]["transcript_id"],
706
+ "interpro_id": interpro_id,
707
+ "name": next(
708
+ (feature["member_name"] for feature in features if feature["member_name"]),
709
+ next((feature["description"] for feature in features if feature["description"]), None),
710
+ ),
711
+ "specific_name": next(
712
+ (
713
+ feature.get("panther_subfamily_description")
714
+ for feature in features
715
+ if feature.get("panther_subfamily_description")
716
+ ),
717
+ None,
718
+ ),
719
+ "domain_type": next(
720
+ (feature["member_entry_type"] for feature in features if feature["member_entry_type"]),
721
+ None,
722
+ ),
723
+ "sources": sorted(
724
+ {feature["source"] for feature in features if feature["source"] is not None}
725
+ ),
726
+ "feature_ids": sorted(
727
+ {
728
+ cast(str, feature.get("panther_subfamily_id") or feature["feature_id"])
729
+ for feature in features
730
+ if feature.get("panther_subfamily_id") or feature["feature_id"]
731
+ }
732
+ ),
733
+ "start": features[0]["start"],
734
+ "end": features[0]["end"],
735
+ "breakpoint_based_status": cast(
736
+ str,
737
+ _combine_statuses(
738
+ [feature["breakpoint_based_status"] for feature in features],
739
+ ("included", "disrupted", "excluded"),
740
+ ),
741
+ ),
742
+ "breakpoint_retained_percent": max(
743
+ feature["breakpoint_retained_percent"] for feature in features
744
+ ),
745
+ "post_splicing_status": _combine_statuses(
746
+ [feature["post_splicing_status"] for feature in features], ("preserved", "lost")
747
+ ),
748
+ "post_translation_status": _combine_statuses(
749
+ [feature["post_translation_status"] for feature in features],
750
+ (
751
+ "preserved",
752
+ "frame_disrupted",
753
+ "translation_start_disrupted",
754
+ "translation_start_excluded",
755
+ "premature_termination_disrupted",
756
+ "premature_termination_excluded",
757
+ ),
758
+ ),
759
+ }
760
+
761
+ accessions = sorted(
762
+ {feature["uniprot_accession"] for feature in features if "uniprot_accession" in feature}
763
+ )
764
+ isoforms = sorted(
765
+ {feature["uniprot_isoform"] for feature in features if "uniprot_isoform" in feature}
766
+ )
767
+ if accessions:
768
+ result["uniprot_accessions"] = accessions
769
+ if isoforms:
770
+ result["uniprot_isoforms"] = isoforms
771
+ evidence = {
772
+ tuple(sorted(item.items())) for feature in features for item in feature.get("evidence", [])
773
+ }
774
+ if any("evidence" in feature for feature in features):
775
+ result["evidence"] = [cast(FeatureEvidence, dict(item)) for item in sorted(evidence)]
776
+ return result
777
+
778
+
779
+ def _finalize_domains(domains: list[AggregatedDomain]) -> list[FunctionalDomain]:
780
+ """Expose functional features with their true types, bounds and provenance.
781
+
782
+ Exact reference duplicates are grouped before this step. Overlap alone does
783
+ not imply equivalence: signatures can disagree on boundaries, and distinct
784
+ active/binding sites can occupy the same residues.
785
+ """
786
+ unique: dict[tuple[object, ...], AggregatedDomain] = {}
787
+ for feature in domains:
788
+ identity = feature["interpro_id"] or (
789
+ tuple(feature["sources"]),
790
+ tuple(feature.get("feature_ids", [])),
791
+ feature["name"],
792
+ )
793
+ key = (
794
+ feature["transcript_id"],
795
+ identity,
796
+ feature["domain_type"],
797
+ feature["start"],
798
+ feature["end"],
799
+ )
800
+ if key not in unique:
801
+ unique[key] = cast(AggregatedDomain, dict(feature))
802
+ continue
803
+ previous = unique[key]
804
+ previous["sources"] = sorted(set(previous["sources"] + feature["sources"]))
805
+ previous["feature_ids"] = sorted(
806
+ set(previous.get("feature_ids", []) + feature.get("feature_ids", []))
807
+ )
808
+ previous["breakpoint_based_status"] = cast(
809
+ str,
810
+ _combine_statuses(
811
+ [previous["breakpoint_based_status"], feature["breakpoint_based_status"]],
812
+ ("included", "disrupted", "excluded"),
813
+ ),
814
+ )
815
+ previous["post_splicing_status"] = _combine_statuses(
816
+ [previous["post_splicing_status"], feature["post_splicing_status"]],
817
+ ("preserved", "lost"),
818
+ )
819
+ previous["post_translation_status"] = _combine_statuses(
820
+ [previous["post_translation_status"], feature["post_translation_status"]],
821
+ (
822
+ "preserved",
823
+ "frame_disrupted",
824
+ "translation_start_disrupted",
825
+ "translation_start_excluded",
826
+ "premature_termination_disrupted",
827
+ "premature_termination_excluded",
828
+ ),
829
+ )
830
+ final: list[FunctionalDomain] = []
831
+ for feature in unique.values():
832
+ if feature["domain_type"] not in RELEVANT_ENTRY_TYPES:
833
+ continue
834
+ name = feature["name"] or feature["interpro_id"] or "Unnamed functional feature"
835
+ if feature["domain_type"] == "family" and feature.get("specific_name"):
836
+ name = cast(str, feature["specific_name"])
837
+ public: FunctionalDomain = {
838
+ "transcript_id": feature["transcript_id"],
839
+ "interpro_id": feature["interpro_id"],
840
+ "name": name,
841
+ "domain_type": cast(str, feature["domain_type"]),
842
+ "start": feature["start"],
843
+ "end": feature["end"],
844
+ "sources": feature["sources"],
845
+ "feature_ids": feature.get("feature_ids", []),
846
+ "breakpoint_based_status": feature["breakpoint_based_status"],
847
+ "breakpoint_retained_percent": feature["breakpoint_retained_percent"],
848
+ "post_splicing_status": feature["post_splicing_status"],
849
+ "post_translation_status": feature["post_translation_status"],
850
+ }
851
+ if "uniprot_accessions" in feature:
852
+ public["uniprot_accessions"] = feature["uniprot_accessions"]
853
+ if "uniprot_isoforms" in feature:
854
+ public["uniprot_isoforms"] = feature["uniprot_isoforms"]
855
+ if "evidence" in feature:
856
+ public["evidence"] = feature["evidence"]
857
+ final.append(public)
858
+ return sorted(
859
+ final,
860
+ key=lambda item: (
861
+ item["transcript_id"],
862
+ item["start"],
863
+ item["end"],
864
+ item["domain_type"],
865
+ item["name"],
866
+ item["sources"],
867
+ item["feature_ids"],
868
+ ),
869
+ )
870
+
871
+
872
+ def _summarize_frame_status(splice_products: list[SpliceProduct]) -> FusionFrameStatus:
873
+ """Summarize frame status across predicted splice products."""
874
+ statuses = {product["frame_status"] for product in splice_products}
875
+ if statuses == {"in_frame"}:
876
+ return "in_frame"
877
+ if statuses == {"out_of_frame"}:
878
+ return "out_of_frame"
879
+ return "in_frame/out_of_frame"
880
+
881
+
882
+ def _summarize_translation_start(products: list[SpliceProduct]) -> str:
883
+ """Combine distinct product initiation outcomes in a stable slash order."""
884
+ return cast(
885
+ str,
886
+ _combine_statuses(
887
+ [product["start_status"] for product in products], TRANSLATION_START_STATUS_ORDER
888
+ ),
889
+ )
890
+
891
+
892
+ def annotate_fusion_domains(
893
+ transcript1_id: str | None = None,
894
+ transcript2_id: str | None = None,
895
+ breakpoint1: str | None = None,
896
+ breakpoint2: str | None = None,
897
+ gene1_terminus: FusionTerminus | None = None,
898
+ gene2_terminus: FusionTerminus | None = None,
899
+ inserted_sequence: str = "",
900
+ *,
901
+ reference: ReferenceDatabase | None = None,
902
+ database: str | Path | None = None,
903
+ release: int | None = None,
904
+ ) -> FusionDomainResult | EnsemblError:
905
+ """Annotate retained features and reconstruct a fusion when both ends permit it.
906
+
907
+ A single known partner is assumed disruptive and its retained portion is
908
+ spliced and checked. Every product lacking a native start but retaining CDS
909
+ uses its first ATG as a heuristic start. N/N and C/C pairs are unsupported.
910
+ """
911
+ partners = [
912
+ ("transcript1", transcript1_id, breakpoint1, gene1_terminus),
913
+ ("transcript2", transcript2_id, breakpoint2, gene2_terminus),
914
+ ]
915
+ if (
916
+ transcript1_id
917
+ and transcript2_id
918
+ and gene1_terminus == gene2_terminus
919
+ and gene1_terminus in ("N", "C")
920
+ ):
921
+ raise NotImplementedError(
922
+ "N/N and C/C fusions are unsupported; two known partners require one N and one C terminus"
923
+ )
924
+ known_partners: list[tuple[FusionSideName, str, str, FusionTerminus]] = []
925
+ for side_name, transcript_id, breakpoint, terminus in partners:
926
+ if not transcript_id:
927
+ continue
928
+ if not breakpoint or terminus not in ("N", "C"):
929
+ return {
930
+ "error": f"{side_name} ({transcript_id}) requires a breakpoint and "
931
+ f"an N or C terminus; received breakpoint={breakpoint!r}, terminus={terminus!r}"
932
+ }
933
+ known_partners.append(
934
+ (
935
+ cast(FusionSideName, side_name),
936
+ transcript_id,
937
+ breakpoint,
938
+ cast(FusionTerminus, terminus),
939
+ )
940
+ )
941
+ if not known_partners:
942
+ return {"error": "At least one known transcript is required for feature annotation"}
943
+
944
+ reader = get_reference(reference=reference, database=database, release=release)
945
+ transcripts: dict[str, TranscriptProteinFeatureResult] = {}
946
+ for transcript_id in {partner[1] for partner in known_partners}:
947
+ transcript_result = get_protein_domains(transcript_id, reference=reader)
948
+ if "error" in transcript_result:
949
+ return cast(EnsemblError, transcript_result)
950
+ transcripts[transcript_id] = cast(TranscriptProteinFeatureResult, transcript_result)
951
+ sides: list[FusionSide] = []
952
+ for side_name, transcript_id, breakpoint, terminus in known_partners:
953
+ try:
954
+ sides.append(
955
+ _build_fusion_side(
956
+ side_name, transcript_id, breakpoint, terminus, transcripts[transcript_id]
957
+ )
958
+ )
959
+ except (ValueError, NotImplementedError) as error:
960
+ return {"error": f"{side_name} ({transcript_id}): {error}"}
961
+
962
+ can_reconstruct = len(sides) == 2 and {side["fusion_terminus"] for side in sides} == {"N", "C"}
963
+ splice_products: list[SpliceProduct] = []
964
+ products_by_side: dict[FusionSideName, list[SpliceProduct]] = {}
965
+ if can_reconstruct:
966
+ layout = _build_fusion_layout(sides[0], sides[1], inserted_sequence)
967
+ splice_products = [
968
+ _build_splice_product(layout, pattern)
969
+ for pattern in _generate_splice_patterns(layout["splice_sites"])
970
+ ]
971
+ products_by_side = {side["side"]: splice_products for side in sides}
972
+ else:
973
+ for side in sides:
974
+ layout = _build_retained_side_layout(side)
975
+ products = [
976
+ _build_splice_product(layout, pattern)
977
+ for pattern in _generate_splice_patterns(layout["splice_sites"])
978
+ ]
979
+ products_by_side[side["side"]] = products
980
+ splice_products.extend(products)
981
+ # Combine identical source intervals; different boundaries remain separate.
982
+ grouped: dict[tuple[object, ...], list[ResolvedAnnotatedProteinFeature]] = {}
983
+ for side in sides:
984
+ features = _resolve_feature_metadata(
985
+ _annotate_feature_statuses(side, products_by_side[side["side"]])
986
+ )
987
+ for feature in features:
988
+ key = (side["transcript_id"], *feature_identity(feature))
989
+ grouped.setdefault(key, []).append(feature)
990
+ domains = [_aggregate_feature_group(features) for features in grouped.values()]
991
+ result: FusionDomainResult = {
992
+ "frame_status": (
993
+ _summarize_frame_status(splice_products)
994
+ if can_reconstruct
995
+ or any(
996
+ product["translation_start"] is not None
997
+ and any(
998
+ fragment["product_end"] >= product["translation_start"]
999
+ for fragment in product["coding_fragments"]
1000
+ )
1001
+ for product in splice_products
1002
+ )
1003
+ else None
1004
+ ),
1005
+ "domains": _finalize_domains(domains),
1006
+ }
1007
+ # Each complete product has one initiation prediction. Key it by the
1008
+ # transcript supplying the selected native start, or the intended initiating
1009
+ # transcript when that start is lost or an alternative start is used.
1010
+ default_start_transcript = next(
1011
+ (side["transcript_id"] for side in sides if side["fusion_terminus"] == "N"),
1012
+ sides[0]["transcript_id"],
1013
+ )
1014
+ start_products: dict[str, list[SpliceProduct]] = {}
1015
+ for splice_product in splice_products:
1016
+ transcript_id = splice_product["start_transcript_id"] or default_start_transcript
1017
+ start_products.setdefault(transcript_id, []).append(splice_product)
1018
+ result["translation_start"] = {
1019
+ transcript_id: _summarize_translation_start(products)
1020
+ for transcript_id, products in start_products.items()
1021
+ }
1022
+ if not can_reconstruct:
1023
+ result["assumed_disruptive"] = True
1024
+ return result