varcode 7.1.2__tar.gz → 7.2.0__tar.gz

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 (142) hide show
  1. {varcode-7.1.2/varcode.egg-info → varcode-7.2.0}/PKG-INFO +1 -1
  2. varcode-7.2.0/tests/test_effect_hypotheses.py +183 -0
  3. varcode-7.2.0/tests/test_genomic_layout.py +160 -0
  4. varcode-7.2.0/varcode/effect_hypotheses.py +319 -0
  5. varcode-7.2.0/varcode/genomic_layout.py +554 -0
  6. varcode-7.2.0/varcode/version.py +1 -0
  7. {varcode-7.1.2 → varcode-7.2.0/varcode.egg-info}/PKG-INFO +1 -1
  8. {varcode-7.1.2 → varcode-7.2.0}/varcode.egg-info/SOURCES.txt +4 -0
  9. varcode-7.1.2/varcode/version.py +0 -1
  10. {varcode-7.1.2 → varcode-7.2.0}/LICENSE +0 -0
  11. {varcode-7.1.2 → varcode-7.2.0}/MANIFEST.in +0 -0
  12. {varcode-7.1.2 → varcode-7.2.0}/README.md +0 -0
  13. {varcode-7.1.2 → varcode-7.2.0}/pyproject.toml +0 -0
  14. {varcode-7.1.2 → varcode-7.2.0}/requirements.txt +0 -0
  15. {varcode-7.1.2 → varcode-7.2.0}/setup.cfg +0 -0
  16. {varcode-7.1.2 → varcode-7.2.0}/tests/test_annotator_divergence_scenarios.py +0 -0
  17. {varcode-7.1.2 → varcode-7.2.0}/tests/test_annotator_parity_adversarial.py +0 -0
  18. {varcode-7.1.2 → varcode-7.2.0}/tests/test_annotators.py +0 -0
  19. {varcode-7.1.2 → varcode-7.2.0}/tests/test_cli_effects.py +0 -0
  20. {varcode-7.1.2 → varcode-7.2.0}/tests/test_cli_genes.py +0 -0
  21. {varcode-7.1.2 → varcode-7.2.0}/tests/test_collection_filtering.py +0 -0
  22. {varcode-7.1.2 → varcode-7.2.0}/tests/test_collection_variants_attr_consistency.py +0 -0
  23. {varcode-7.1.2 → varcode-7.2.0}/tests/test_common.py +0 -0
  24. {varcode-7.1.2 → varcode-7.2.0}/tests/test_cosmic_mutations.py +0 -0
  25. {varcode-7.1.2 → varcode-7.2.0}/tests/test_cryptic_exons.py +0 -0
  26. {varcode-7.1.2 → varcode-7.2.0}/tests/test_csv_roundtrip.py +0 -0
  27. {varcode-7.1.2 → varcode-7.2.0}/tests/test_dbnsfp_validation.py +0 -0
  28. {varcode-7.1.2 → varcode-7.2.0}/tests/test_effect_annotation_errors.py +0 -0
  29. {varcode-7.1.2 → varcode-7.2.0}/tests/test_effect_candidates.py +0 -0
  30. {varcode-7.1.2 → varcode-7.2.0}/tests/test_effect_classes.py +0 -0
  31. {varcode-7.1.2 → varcode-7.2.0}/tests/test_effect_collection.py +0 -0
  32. {varcode-7.1.2 → varcode-7.2.0}/tests/test_effect_collection_serialization.py +0 -0
  33. {varcode-7.1.2 → varcode-7.2.0}/tests/test_effect_collection_sort_order.py +0 -0
  34. {varcode-7.1.2 → varcode-7.2.0}/tests/test_effects_from_mutagenix_variants.py +0 -0
  35. {varcode-7.1.2 → varcode-7.2.0}/tests/test_exonic_splice_site.py +0 -0
  36. {varcode-7.1.2 → varcode-7.2.0}/tests/test_fast_path.py +0 -0
  37. {varcode-7.1.2 → varcode-7.2.0}/tests/test_frameshift_cterminus_regression.py +0 -0
  38. {varcode-7.1.2 → varcode-7.2.0}/tests/test_frameshift_helpers.py +0 -0
  39. {varcode-7.1.2 → varcode-7.2.0}/tests/test_genome_sequence.py +0 -0
  40. {varcode-7.1.2 → varcode-7.2.0}/tests/test_genotype.py +0 -0
  41. {varcode-7.1.2 → varcode-7.2.0}/tests/test_genotype_from_vcf.py +0 -0
  42. {varcode-7.1.2 → varcode-7.2.0}/tests/test_germline_context.py +0 -0
  43. {varcode-7.1.2 → varcode-7.2.0}/tests/test_germline_effects.py +0 -0
  44. {varcode-7.1.2 → varcode-7.2.0}/tests/test_haplotype_effect.py +0 -0
  45. {varcode-7.1.2 → varcode-7.2.0}/tests/test_maf.py +0 -0
  46. {varcode-7.1.2 → varcode-7.2.0}/tests/test_mitochondrial.py +0 -0
  47. {varcode-7.1.2 → varcode-7.2.0}/tests/test_mm10_klf6_frameshift.py +0 -0
  48. {varcode-7.1.2 → varcode-7.2.0}/tests/test_mouse.py +0 -0
  49. {varcode-7.1.2 → varcode-7.2.0}/tests/test_mutant_transcript.py +0 -0
  50. {varcode-7.1.2 → varcode-7.2.0}/tests/test_mutate.py +0 -0
  51. {varcode-7.1.2 → varcode-7.2.0}/tests/test_no_duplicate_variants.py +0 -0
  52. {varcode-7.1.2 → varcode-7.2.0}/tests/test_osteosarc_fusions.py +0 -0
  53. {varcode-7.1.2 → varcode-7.2.0}/tests/test_premature_stop_short_description.py +0 -0
  54. {varcode-7.1.2 → varcode-7.2.0}/tests/test_problematic_variants.py +0 -0
  55. {varcode-7.1.2 → varcode-7.2.0}/tests/test_protein_diff_parity.py +0 -0
  56. {varcode-7.1.2 → varcode-7.2.0}/tests/test_read_phase_resolver.py +0 -0
  57. {varcode-7.1.2 → varcode-7.2.0}/tests/test_real_world_callers.py +0 -0
  58. {varcode-7.1.2 → varcode-7.2.0}/tests/test_reference.py +0 -0
  59. {varcode-7.1.2 → varcode-7.2.0}/tests/test_reference_mismatch_error.py +0 -0
  60. {varcode-7.1.2 → varcode-7.2.0}/tests/test_reference_segments.py +0 -0
  61. {varcode-7.1.2 → varcode-7.2.0}/tests/test_rna_evidence.py +0 -0
  62. {varcode-7.1.2 → varcode-7.2.0}/tests/test_rna_read_phasing_source.py +0 -0
  63. {varcode-7.1.2 → varcode-7.2.0}/tests/test_selenocysteine.py +0 -0
  64. {varcode-7.1.2 → varcode-7.2.0}/tests/test_silent_aa_pos.py +0 -0
  65. {varcode-7.1.2 → varcode-7.2.0}/tests/test_silent_hgvs_description.py +0 -0
  66. {varcode-7.1.2 → varcode-7.2.0}/tests/test_splice_outcomes.py +0 -0
  67. {varcode-7.1.2 → varcode-7.2.0}/tests/test_splice_site_effects.py +0 -0
  68. {varcode-7.1.2 → varcode-7.2.0}/tests/test_stop_codon_classification_bugs.py +0 -0
  69. {varcode-7.1.2 → varcode-7.2.0}/tests/test_string_helpers.py +0 -0
  70. {varcode-7.1.2 → varcode-7.2.0}/tests/test_structural_variant_annotator.py +0 -0
  71. {varcode-7.1.2 → varcode-7.2.0}/tests/test_structural_variants.py +0 -0
  72. {varcode-7.1.2 → varcode-7.2.0}/tests/test_symbolic_alleles.py +0 -0
  73. {varcode-7.1.2 → varcode-7.2.0}/tests/test_timings.py +0 -0
  74. {varcode-7.1.2 → varcode-7.2.0}/tests/test_transforms_left_align.py +0 -0
  75. {varcode-7.1.2 → varcode-7.2.0}/tests/test_transforms_pair_breakends.py +0 -0
  76. {varcode-7.1.2 → varcode-7.2.0}/tests/test_variant.py +0 -0
  77. {varcode-7.1.2 → varcode-7.2.0}/tests/test_variant_collection.py +0 -0
  78. {varcode-7.1.2 → varcode-7.2.0}/tests/test_vcf.py +0 -0
  79. {varcode-7.1.2 → varcode-7.2.0}/tests/test_vcf_output.py +0 -0
  80. {varcode-7.1.2 → varcode-7.2.0}/tests/test_vcf_parsing.py +0 -0
  81. {varcode-7.1.2 → varcode-7.2.0}/tests/test_vcf_phase_resolver.py +0 -0
  82. {varcode-7.1.2 → varcode-7.2.0}/tests/test_vcf_spec_examples.py +0 -0
  83. {varcode-7.1.2 → varcode-7.2.0}/varcode/__init__.py +0 -0
  84. {varcode-7.1.2 → varcode-7.2.0}/varcode/annotators/__init__.py +0 -0
  85. {varcode-7.1.2 → varcode-7.2.0}/varcode/annotators/fast.py +0 -0
  86. {varcode-7.1.2 → varcode-7.2.0}/varcode/annotators/protein_diff.py +0 -0
  87. {varcode-7.1.2 → varcode-7.2.0}/varcode/annotators/registry.py +0 -0
  88. {varcode-7.1.2 → varcode-7.2.0}/varcode/annotators/structural_variant.py +0 -0
  89. {varcode-7.1.2 → varcode-7.2.0}/varcode/cli/__init__.py +0 -0
  90. {varcode-7.1.2 → varcode-7.2.0}/varcode/cli/effects_script.py +0 -0
  91. {varcode-7.1.2 → varcode-7.2.0}/varcode/cli/genes_script.py +0 -0
  92. {varcode-7.1.2 → varcode-7.2.0}/varcode/cli/logging.conf +0 -0
  93. {varcode-7.1.2 → varcode-7.2.0}/varcode/cli/variant_args.py +0 -0
  94. {varcode-7.1.2 → varcode-7.2.0}/varcode/cli/version_info.py +0 -0
  95. {varcode-7.1.2 → varcode-7.2.0}/varcode/common.py +0 -0
  96. {varcode-7.1.2 → varcode-7.2.0}/varcode/cryptic_exons.py +0 -0
  97. {varcode-7.1.2 → varcode-7.2.0}/varcode/csv_helpers.py +0 -0
  98. {varcode-7.1.2 → varcode-7.2.0}/varcode/effect_candidates.py +0 -0
  99. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/__init__.py +0 -0
  100. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/classify.py +0 -0
  101. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/codon_tables.py +0 -0
  102. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/common.py +0 -0
  103. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/effect_classes.py +0 -0
  104. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/effect_collection.py +0 -0
  105. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/effect_helpers.py +0 -0
  106. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/effect_ordering.py +0 -0
  107. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/effect_prediction.py +0 -0
  108. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/effect_prediction_coding.py +0 -0
  109. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/effect_prediction_coding_frameshift.py +0 -0
  110. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/effect_prediction_coding_in_frame.py +0 -0
  111. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/fast_path.py +0 -0
  112. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/mutate.py +0 -0
  113. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/transcript_helpers.py +0 -0
  114. {varcode-7.1.2 → varcode-7.2.0}/varcode/effects/translate.py +0 -0
  115. {varcode-7.1.2 → varcode-7.2.0}/varcode/errors.py +0 -0
  116. {varcode-7.1.2 → varcode-7.2.0}/varcode/genome.py +0 -0
  117. {varcode-7.1.2 → varcode-7.2.0}/varcode/genome_sequence.py +0 -0
  118. {varcode-7.1.2 → varcode-7.2.0}/varcode/genotype.py +0 -0
  119. {varcode-7.1.2 → varcode-7.2.0}/varcode/germline.py +0 -0
  120. {varcode-7.1.2 → varcode-7.2.0}/varcode/maf.py +0 -0
  121. {varcode-7.1.2 → varcode-7.2.0}/varcode/mutant_transcript.py +0 -0
  122. {varcode-7.1.2 → varcode-7.2.0}/varcode/nucleotides.py +0 -0
  123. {varcode-7.1.2 → varcode-7.2.0}/varcode/phasing.py +0 -0
  124. {varcode-7.1.2 → varcode-7.2.0}/varcode/reference.py +0 -0
  125. {varcode-7.1.2 → varcode-7.2.0}/varcode/rna_evidence.py +0 -0
  126. {varcode-7.1.2 → varcode-7.2.0}/varcode/rna_read_phasing.py +0 -0
  127. {varcode-7.1.2 → varcode-7.2.0}/varcode/splice_outcomes.py +0 -0
  128. {varcode-7.1.2 → varcode-7.2.0}/varcode/string_helpers.py +0 -0
  129. {varcode-7.1.2 → varcode-7.2.0}/varcode/structural_variant.py +0 -0
  130. {varcode-7.1.2 → varcode-7.2.0}/varcode/sv_allele_parser.py +0 -0
  131. {varcode-7.1.2 → varcode-7.2.0}/varcode/transforms/__init__.py +0 -0
  132. {varcode-7.1.2 → varcode-7.2.0}/varcode/ucsc_reference_names.py +0 -0
  133. {varcode-7.1.2 → varcode-7.2.0}/varcode/util.py +0 -0
  134. {varcode-7.1.2 → varcode-7.2.0}/varcode/variant.py +0 -0
  135. {varcode-7.1.2 → varcode-7.2.0}/varcode/variant_collection.py +0 -0
  136. {varcode-7.1.2 → varcode-7.2.0}/varcode/vcf.py +0 -0
  137. {varcode-7.1.2 → varcode-7.2.0}/varcode/vcf_output.py +0 -0
  138. {varcode-7.1.2 → varcode-7.2.0}/varcode/vcf_parsing.py +0 -0
  139. {varcode-7.1.2 → varcode-7.2.0}/varcode.egg-info/dependency_links.txt +0 -0
  140. {varcode-7.1.2 → varcode-7.2.0}/varcode.egg-info/entry_points.txt +0 -0
  141. {varcode-7.1.2 → varcode-7.2.0}/varcode.egg-info/requires.txt +0 -0
  142. {varcode-7.1.2 → varcode-7.2.0}/varcode.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: varcode
3
- Version: 7.1.2
3
+ Version: 7.2.0
4
4
  Summary: Variant annotation in Python
5
5
  Author-email: Alex Rubinsteyn <alex.rubinsteyn@unc.edu>
6
6
  Project-URL: Homepage, https://github.com/openvax/varcode
@@ -0,0 +1,183 @@
1
+ """Tests for graph-valid hypotheses and baseline-aware merging."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+
7
+ from varcode.effect_hypotheses import (
8
+ ClassifiedOutcome,
9
+ EffectHypothesis,
10
+ RealizedTranscriptProduct,
11
+ SpliceAxis,
12
+ SpliceOption,
13
+ SpliceSiteKey,
14
+ enumerate_splice_plans,
15
+ merge_classified_outcomes,
16
+ order_realized_candidates,
17
+ )
18
+
19
+
20
+ def effect(name="Deletion", priority=5):
21
+ cls = type(name, (), {})
22
+ value = cls()
23
+ value.aa_mutation_start_offset = 3
24
+ value.aa_ref = "D"
25
+ value.aa_alt = ""
26
+ value.priority = priority
27
+ return value
28
+
29
+
30
+ def product(protein, cdna="AAACCC", junctions=()):
31
+ return RealizedTranscriptProduct(
32
+ transcript=None,
33
+ cdna_sequence=cdna,
34
+ protein_sequence=protein,
35
+ junction_signature=junctions)
36
+
37
+
38
+ def test_splice_enumeration_omits_site_removed_by_another_choice():
39
+ exon4 = (4, 1)
40
+ acceptor = SpliceAxis(
41
+ key=SpliceSiteKey("acceptor", 4),
42
+ anchor_runs=(exon4,),
43
+ options=(
44
+ SpliceOption("normal", 0, required_runs=(exon4,)),
45
+ SpliceOption("exon_skip", 1, skipped_runs=(exon4,)),
46
+ ))
47
+ donor = SpliceAxis(
48
+ key=SpliceSiteKey("donor", 4),
49
+ anchor_runs=(exon4,),
50
+ options=(
51
+ SpliceOption("normal", 0, required_runs=(exon4,)),
52
+ SpliceOption("exon_skip", 1, skipped_runs=(exon4,)),
53
+ ))
54
+
55
+ plans = enumerate_splice_plans((acceptor, donor), (exon4,))
56
+ mechanisms = [
57
+ tuple(option.mechanism for _, option in plan.choices)
58
+ for plan in plans]
59
+
60
+ assert mechanisms == [("normal", "normal"), ("exon_skip",),
61
+ ("exon_skip",)]
62
+ assert all(len(plan.choices) != 2 or plan.kept_runs for plan in plans)
63
+
64
+
65
+ def test_splice_rule_rank_is_not_reported_as_probability():
66
+ axis = SpliceAxis(
67
+ key=SpliceSiteKey("donor", 4),
68
+ anchor_runs=(4,),
69
+ options=(SpliceOption("exon_skip", 0, skipped_runs=(4,)),))
70
+
71
+ (plan,) = enumerate_splice_plans((axis,), (4,))
72
+
73
+ assert plan.ordinal_key == (0,)
74
+ assert plan.probability is None
75
+
76
+
77
+ def test_calibrated_splice_probabilities_are_multiplied():
78
+ axes = tuple(
79
+ SpliceAxis(
80
+ key=SpliceSiteKey("donor", exon),
81
+ anchor_runs=(exon,),
82
+ options=(SpliceOption(
83
+ "normal", 0, required_runs=(exon,), probability=p),))
84
+ for exon, p in ((4, 0.8), (5, 0.5)))
85
+
86
+ (plan,) = enumerate_splice_plans(axes, (4, 5))
87
+
88
+ assert plan.probability == pytest.approx(0.4)
89
+
90
+
91
+ def test_same_mutant_different_patient_baseline_does_not_merge():
92
+ mutant = product("ABCDE")
93
+ outcome_a = ClassifiedOutcome(
94
+ hypothesis=EffectHypothesis(phase=(("g", "trans"),)),
95
+ effect=effect(),
96
+ baseline=product("ABCDEFG"),
97
+ mutant=mutant,
98
+ change_aa_offset=3)
99
+ outcome_b = ClassifiedOutcome(
100
+ hypothesis=EffectHypothesis(phase=(("g", "cis"),)),
101
+ effect=effect(),
102
+ baseline=product("ABCXEFG"),
103
+ mutant=mutant,
104
+ change_aa_offset=3)
105
+
106
+ candidates = merge_classified_outcomes((outcome_a, outcome_b))
107
+
108
+ assert len(candidates) == 2
109
+
110
+
111
+ def test_identical_comparisons_merge_and_preserve_hypotheses():
112
+ baseline = product("ABCDEFG")
113
+ mutant = product("ABCDE")
114
+ outcomes = tuple(
115
+ ClassifiedOutcome(
116
+ hypothesis=EffectHypothesis(
117
+ phase=(("g", phase),), phase_probability=0.5,
118
+ enumeration_index=index),
119
+ effect=effect(),
120
+ baseline=baseline,
121
+ mutant=mutant,
122
+ change_aa_offset=3)
123
+ for index, phase in enumerate(("trans", "cis")))
124
+
125
+ (candidate,) = merge_classified_outcomes(outcomes)
126
+
127
+ assert len(candidate.hypotheses) == 2
128
+ assert candidate.phase_mass == 1.0
129
+
130
+
131
+ def test_candidate_probability_combines_calibrated_splice_and_phase():
132
+ baseline = product("ABCDEFG")
133
+ mutant = product("ABCDE")
134
+ plan = SimpleNamespace(ordinal_key=(0,), probability=0.4)
135
+ outcomes = tuple(
136
+ ClassifiedOutcome(
137
+ EffectHypothesis(
138
+ phase=(("g", phase),), phase_probability=0.5,
139
+ splice_plan=plan),
140
+ effect(), baseline, mutant, 3)
141
+ for phase in ("trans", "cis"))
142
+
143
+ (candidate,) = merge_classified_outcomes(outcomes)
144
+
145
+ assert candidate.probability == pytest.approx(0.4)
146
+
147
+
148
+ def test_junction_signature_is_part_of_product_identity():
149
+ baseline = product("ABCDEFG")
150
+ outcomes = (
151
+ ClassifiedOutcome(
152
+ EffectHypothesis(), effect(), baseline,
153
+ product("ABCDE", junctions=((4, 6),)), 3),
154
+ ClassifiedOutcome(
155
+ EffectHypothesis(), effect(), baseline,
156
+ product("ABCDE", junctions=((4, 7),)), 3),
157
+ )
158
+
159
+ assert len(merge_classified_outcomes(outcomes)) == 2
160
+
161
+
162
+ def test_ordering_uses_ordinal_rank_when_scores_are_uncalibrated():
163
+ baseline = product("ABCDEFG")
164
+ mutant = product("ABCDE")
165
+ low_priority = effect("Deletion", priority=5)
166
+ high_priority = effect("FrameShift", priority=10)
167
+ plans = [
168
+ SimpleNamespace(ordinal_key=(0,), probability=None),
169
+ SimpleNamespace(ordinal_key=(1,), probability=None),
170
+ ]
171
+ outcomes = tuple(
172
+ ClassifiedOutcome(
173
+ EffectHypothesis(splice_plan=plan, enumeration_index=index),
174
+ current_effect, baseline, mutant, 3)
175
+ for index, (plan, current_effect) in enumerate(
176
+ zip(plans, (low_priority, high_priority))))
177
+ candidates = merge_classified_outcomes(outcomes)
178
+
179
+ ordered = order_realized_candidates(
180
+ candidates, lambda value: value.priority)
181
+
182
+ assert ordered[0].effect is low_priority
183
+ assert ordered[0].probability is None
@@ -0,0 +1,160 @@
1
+ """Tests for the coordinate-aware realized-genome layout."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+
7
+ from varcode.genomic_layout import (
8
+ GenomicLayout,
9
+ LayoutSegment,
10
+ SequenceUnavailable,
11
+ join_breakends,
12
+ project_intervals,
13
+ )
14
+ from varcode.structural_variant import Breakend
15
+
16
+
17
+ SEQUENCES = {
18
+ "a": "AACCGGTTAACC",
19
+ "b": "TTGGCCAATTGG",
20
+ }
21
+
22
+
23
+ def provider(contig, start, end):
24
+ return SEQUENCES[contig][start - 1:end]
25
+
26
+
27
+ def point(contig, start, ref, alt):
28
+ return SimpleNamespace(
29
+ contig=contig,
30
+ trimmed_base1_start=start,
31
+ trimmed_base1_end=start + len(ref) - 1 if ref else start,
32
+ trimmed_ref=ref,
33
+ trimmed_alt=alt,
34
+ is_structural=False)
35
+
36
+
37
+ def structural(kind, start, end, affected_start=None, affected_end=None,
38
+ alt_assembly=None):
39
+ return SimpleNamespace(
40
+ contig="a",
41
+ start=start,
42
+ end=end,
43
+ affected_start=(start if affected_start is None else affected_start),
44
+ affected_end=(end if affected_end is None else affected_end),
45
+ sv_type=kind,
46
+ alt_assembly=alt_assembly,
47
+ is_structural=True)
48
+
49
+
50
+ def test_layout_is_lazy_until_materialized():
51
+ layout = GenomicLayout.from_interval("a", 1, 12)
52
+ assert layout.length == 12
53
+ with pytest.raises(SequenceUnavailable):
54
+ layout.materialize()
55
+
56
+
57
+ def test_point_variants_preserve_origins_on_both_strands():
58
+ forward = GenomicLayout.from_interval(
59
+ "a", 1, 12, sequence_provider=provider)
60
+ reverse = GenomicLayout.from_interval(
61
+ "a", 1, 12, strand="-", sequence_provider=provider)
62
+ variant = point("a", 3, "C", "T")
63
+
64
+ forward_mutant = forward.apply_point_variant(variant)
65
+ reverse_mutant = reverse.apply_point_variant(variant)
66
+
67
+ assert forward_mutant.materialize() == "AATCGGTTAACC"
68
+ assert reverse_mutant.materialize() == "GGTTAACCGATT"
69
+ assert forward_mutant.origins()[2] == ("a", 3, "alternate")
70
+ assert reverse_mutant.origins()[-3] == ("a", 3, "alternate")
71
+
72
+
73
+ def test_indels_have_explicit_inserted_and_deleted_origins():
74
+ layout = GenomicLayout.from_interval(
75
+ "a", 1, 12, sequence_provider=provider)
76
+ inserted = layout.apply_point_variant(point("a", 4, "", "TA"))
77
+ deleted = layout.apply_point_variant(point("a", 3, "CC", ""))
78
+
79
+ assert inserted.materialize() == "AACCTA GGTTAACC".replace(" ", "")
80
+ assert [origin[1] for origin in inserted.origins()][4:6] == [None, None]
81
+ assert deleted.materialize() == "AAGGTTAACC"
82
+ assert 3 not in [origin[1] for origin in deleted.origins()]
83
+ assert 4 not in [origin[1] for origin in deleted.origins()]
84
+
85
+
86
+ def test_typed_deletion_does_not_remove_retained_padding_anchor():
87
+ layout = GenomicLayout.from_interval(
88
+ "a", 1, 12, sequence_provider=provider)
89
+ deletion = structural(
90
+ "DEL", start=2, end=6, affected_start=3, affected_end=6)
91
+
92
+ mutant = layout.apply_structural_variant(deletion)
93
+
94
+ assert mutant.materialize() == "AATTAACC"
95
+ assert [origin[1] for origin in mutant.origins()][:3] == [1, 2, 7]
96
+
97
+
98
+ def test_duplication_and_inversion_work_in_transcript_order():
99
+ forward = GenomicLayout.from_interval(
100
+ "a", 1, 12, sequence_provider=provider)
101
+ reverse = GenomicLayout.from_interval(
102
+ "a", 1, 12, strand="-", sequence_provider=provider)
103
+
104
+ forward_dup = forward.apply_structural_variant(structural("DUP", 3, 6))
105
+ reverse_dup = reverse.apply_structural_variant(structural("DUP", 3, 6))
106
+ inverted = forward.apply_structural_variant(structural("INV", 2, 5))
107
+
108
+ assert forward_dup.materialize() == "AACCGGCCGGTTAACC"
109
+ assert reverse_dup.materialize() == "GGTTAACCGGCCGGTT"
110
+ assert inverted.materialize() == "ACGGTGTTAACC"
111
+ assert {kind for _, _, kind in inverted.origins()[1:5]} == {"inverted"}
112
+
113
+
114
+ def test_apply_variants_deletes_an_earlier_point_edit_on_same_haplotype():
115
+ layout = GenomicLayout.from_interval(
116
+ "a", 1, 12, sequence_provider=provider)
117
+ variants = [
118
+ structural("DEL", 2, 6, affected_start=3, affected_end=6),
119
+ point("a", 4, "C", "T"),
120
+ ]
121
+
122
+ mutant = layout.apply_variants(variants)
123
+
124
+ assert mutant.materialize() == "AATTAACC"
125
+ assert all(kind != "alternate" for _, _, kind in mutant.origins())
126
+
127
+
128
+ def test_cross_contig_breakend_join_orients_retained_sides():
129
+ left = GenomicLayout.from_interval(
130
+ "a", 1, 12, sequence_provider=provider)
131
+ right = GenomicLayout.from_interval(
132
+ "b", 1, 12, strand="-", sequence_provider=provider)
133
+
134
+ fusion = join_breakends(
135
+ left,
136
+ Breakend("a", 3, "left"),
137
+ right,
138
+ Breakend("b", 8, "right"))
139
+
140
+ assert fusion.materialize() == "AACATTGG"
141
+ assert fusion.endpoint_origin(first=False) == ("b", 12)
142
+ assert fusion.origins()[2][:2] == ("a", 3)
143
+ assert fusion.origins()[3][:2] == ("b", 8)
144
+
145
+
146
+ def test_project_intervals_keeps_duplicate_exon_occurrences():
147
+ layout = GenomicLayout.from_interval(
148
+ "a", 1, 12, sequence_provider=provider)
149
+ duplicated = layout.apply_structural_variant(structural("DUP", 3, 6))
150
+
151
+ runs = project_intervals(duplicated, (("a", 3, 6),))
152
+
153
+ assert [run.materialize() for run in runs] == ["CCGG", "CCGG"]
154
+ assert runs[0].segments[0].origin_kind == "reference"
155
+ assert runs[1].segments[0].origin_kind == "duplicated"
156
+
157
+
158
+ def test_inserted_segment_requires_sequence():
159
+ with pytest.raises(ValueError):
160
+ LayoutSegment(None, None, None)
@@ -0,0 +1,319 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ """Composable hypothesis and realized-product data types.
14
+
15
+ These types deliberately keep three concepts separate:
16
+
17
+ * a hypothesis records uncertain biological choices;
18
+ * a transcript product records what sequence those choices produce;
19
+ * a classified outcome compares a mutant product with the patient's own
20
+ baseline product.
21
+
22
+ Keeping the baseline in the merge identity is load-bearing. Two phase
23
+ hypotheses can produce the same mutant protein while deleting different
24
+ patient-specific residues; those are different effects even though the
25
+ mutant molecule alone is identical.
26
+ """
27
+
28
+ import itertools
29
+ from dataclasses import dataclass, field
30
+ from typing import Any, Mapping, Optional, Tuple
31
+
32
+
33
+ @dataclass(frozen=True, order=True)
34
+ class SpliceSiteKey:
35
+ """Stable identity of a splice site in a rearranged layout."""
36
+
37
+ side: str
38
+ exon_number: int
39
+ occurrence: int = 1
40
+
41
+ def __post_init__(self):
42
+ if self.side not in ("donor", "acceptor"):
43
+ raise ValueError("side must be 'donor' or 'acceptor'")
44
+ if self.exon_number < 1 or self.occurrence < 1:
45
+ raise ValueError("exon_number and occurrence must be positive")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SpliceOption:
50
+ """One graph rewrite offered at a disrupted splice site.
51
+
52
+ ``ordinal_rank`` expresses preference only. It is not a probability.
53
+ A calibrated scorer can independently populate ``probability``.
54
+ """
55
+
56
+ mechanism: str
57
+ ordinal_rank: int
58
+ skipped_runs: Tuple[Any, ...] = field(default_factory=tuple)
59
+ required_runs: Tuple[Any, ...] = field(default_factory=tuple)
60
+ probability: Optional[float] = None
61
+ evidence: Mapping[str, Any] = field(default_factory=dict)
62
+
63
+ def __post_init__(self):
64
+ if self.ordinal_rank < 0:
65
+ raise ValueError("ordinal_rank must be non-negative")
66
+ if self.probability is not None and not 0.0 <= self.probability <= 1.0:
67
+ raise ValueError("probability must be between zero and one")
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class SpliceAxis:
72
+ """Choices attached to one splice-graph site."""
73
+
74
+ key: SpliceSiteKey
75
+ anchor_runs: Tuple[Any, ...]
76
+ options: Tuple[SpliceOption, ...]
77
+
78
+ def __post_init__(self):
79
+ if not self.anchor_runs:
80
+ raise ValueError("A splice axis requires at least one anchor run")
81
+ if not self.options:
82
+ raise ValueError("A splice axis requires at least one option")
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class SplicePlan:
87
+ """A graph-valid set of splice choices."""
88
+
89
+ choices: Tuple[Tuple[SpliceSiteKey, SpliceOption], ...]
90
+ kept_runs: Tuple[Any, ...]
91
+
92
+ @property
93
+ def ordinal_key(self):
94
+ """Lexicographic mechanism preference; lower is preferred."""
95
+ return tuple(option.ordinal_rank for _, option in self.choices)
96
+
97
+ @property
98
+ def probability(self):
99
+ """Joint probability only when every selected option is calibrated."""
100
+ probabilities = [option.probability for _, option in self.choices]
101
+ if any(value is None for value in probabilities):
102
+ return None
103
+ result = 1.0
104
+ for value in probabilities:
105
+ result *= value
106
+ return result
107
+
108
+
109
+ def enumerate_splice_plans(axes, run_keys, max_plans=64):
110
+ """Enumerate graph-valid plans without multiplying dead splice sites.
111
+
112
+ A Cartesian product treats every initially disrupted site as independent.
113
+ That produces impossible combinations when one choice skips the exon that
114
+ another choice needs. Here every axis also has an implicit inactive
115
+ choice. It may be inactive only when another selected rewrite removes
116
+ all of its anchor runs. Conversely, a selected option is rejected when
117
+ another rewrite removes one of its required runs.
118
+ """
119
+ axes = tuple(sorted(axes, key=lambda axis: axis.key))
120
+ run_keys = tuple(run_keys)
121
+ plans = []
122
+ # Put real choices before the inactive sentinel so enumeration order
123
+ # follows each axis's declared biological preference.
124
+ selections = [(axis.options + (None,)) for axis in axes]
125
+ for combination in itertools.product(*selections):
126
+ skipped_by = {}
127
+ for axis, option in zip(axes, combination):
128
+ if option is None:
129
+ continue
130
+ for run in option.skipped_runs:
131
+ skipped_by.setdefault(run, set()).add(axis.key)
132
+ kept = tuple(run for run in run_keys if run not in skipped_by)
133
+ kept_set = set(kept)
134
+ valid = True
135
+ choices = []
136
+ for axis, option in zip(axes, combination):
137
+ removed_by_other = any(
138
+ any(owner != axis.key for owner in skipped_by.get(run, ()))
139
+ for run in axis.anchor_runs)
140
+ if option is None:
141
+ if not all(run not in kept_set for run in axis.anchor_runs):
142
+ valid = False
143
+ break
144
+ continue
145
+ if removed_by_other:
146
+ valid = False
147
+ break
148
+ if any(run not in kept_set for run in option.required_runs):
149
+ valid = False
150
+ break
151
+ choices.append((axis.key, option))
152
+ if not valid:
153
+ continue
154
+ plans.append(SplicePlan(tuple(choices), kept))
155
+ if len(plans) > max_plans:
156
+ raise ValueError(
157
+ "Splice hypothesis count exceeds max_plans=%d" % max_plans)
158
+ return tuple(plans)
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class EffectHypothesis:
163
+ """One assignment of phase, splicing and structural resolution."""
164
+
165
+ phase: Tuple[Tuple[Any, str], ...] = field(default_factory=tuple)
166
+ splice_plan: Optional[SplicePlan] = None
167
+ phase_probability: Optional[float] = 1.0
168
+ evidence: Mapping[str, Any] = field(default_factory=dict)
169
+ enumeration_index: int = 0
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class RealizedTranscriptProduct:
174
+ """A materialized mRNA/protein plus its non-reference junctions."""
175
+
176
+ transcript: Optional[Any]
177
+ cdna_sequence: Optional[str]
178
+ protein_sequence: Optional[str]
179
+ junction_signature: Tuple[Tuple[Any, ...], ...] = field(
180
+ default_factory=tuple)
181
+ start_codon_present: Optional[bool] = None
182
+ evidence: Mapping[str, Any] = field(default_factory=dict)
183
+
184
+ def local_protein(self, offset, flank=30):
185
+ """Protein window around a change, or the full product if unknown."""
186
+ if self.protein_sequence is None:
187
+ return None
188
+ if offset is None:
189
+ return self.protein_sequence
190
+ start = max(0, offset - flank)
191
+ end = min(len(self.protein_sequence), offset + flank + 1)
192
+ return self.protein_sequence[start:end]
193
+
194
+ def local_cdna(self, offset, flank=90):
195
+ """cDNA window for products with no translated difference."""
196
+ if self.cdna_sequence is None:
197
+ return None
198
+ if offset is None:
199
+ return self.cdna_sequence
200
+ start = max(0, offset - flank)
201
+ end = min(len(self.cdna_sequence), offset + flank + 1)
202
+ return self.cdna_sequence[start:end]
203
+
204
+
205
+ def _effect_identity(effect):
206
+ """Stable semantic fields used in candidate merge identity."""
207
+ return (
208
+ type(effect).__name__,
209
+ getattr(effect, "aa_mutation_start_offset", None),
210
+ getattr(effect, "aa_ref", None),
211
+ getattr(effect, "aa_alt", None),
212
+ getattr(effect, "splice_signal", None),
213
+ )
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class ClassifiedOutcome:
218
+ """An effect classified between a baseline and mutant product."""
219
+
220
+ hypothesis: EffectHypothesis
221
+ effect: Any
222
+ baseline: RealizedTranscriptProduct
223
+ mutant: RealizedTranscriptProduct
224
+ change_aa_offset: Optional[int] = None
225
+ change_cdna_offset: Optional[int] = None
226
+
227
+ def merge_key(self, protein_flank=30, cdna_flank=90):
228
+ """Identity of the comparison, not merely the mutant product."""
229
+ return (
230
+ _effect_identity(self.effect),
231
+ self.baseline.local_protein(self.change_aa_offset, protein_flank),
232
+ self.mutant.local_protein(self.change_aa_offset, protein_flank),
233
+ self.baseline.local_cdna(self.change_cdna_offset, cdna_flank),
234
+ self.mutant.local_cdna(self.change_cdna_offset, cdna_flank),
235
+ self.baseline.junction_signature,
236
+ self.mutant.junction_signature,
237
+ )
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class RealizedEffectCandidate:
242
+ """Merged equivalent outcomes with all hypothesis provenance retained."""
243
+
244
+ effect: Any
245
+ outcomes: Tuple[ClassifiedOutcome, ...]
246
+ ordinal_key: Tuple[int, ...]
247
+ probability: Optional[float]
248
+ phase_mass: Optional[float]
249
+ first_enumeration_index: int
250
+
251
+ @property
252
+ def hypotheses(self):
253
+ return tuple(outcome.hypothesis for outcome in self.outcomes)
254
+
255
+
256
+ def merge_classified_outcomes(outcomes, protein_flank=30, cdna_flank=90):
257
+ """Merge only outcomes with identical baseline→mutant comparisons."""
258
+ groups = {}
259
+ order = []
260
+ for outcome in outcomes:
261
+ key = outcome.merge_key(protein_flank, cdna_flank)
262
+ if key not in groups:
263
+ groups[key] = []
264
+ order.append(key)
265
+ groups[key].append(outcome)
266
+
267
+ candidates = []
268
+ for key in order:
269
+ group = tuple(groups[key])
270
+ plans = [outcome.hypothesis.splice_plan for outcome in group]
271
+ ordinal_keys = [
272
+ plan.ordinal_key if plan is not None else () for plan in plans]
273
+ probabilities = []
274
+ for outcome, plan in zip(group, plans):
275
+ splice_probability = (
276
+ plan.probability if plan is not None else 1.0)
277
+ phase_probability = outcome.hypothesis.phase_probability
278
+ probabilities.append(
279
+ splice_probability * phase_probability
280
+ if splice_probability is not None
281
+ and phase_probability is not None
282
+ else None)
283
+ probability = (
284
+ sum(probabilities)
285
+ if all(value is not None for value in probabilities) else None)
286
+ phase_probabilities = [
287
+ outcome.hypothesis.phase_probability for outcome in group]
288
+ phase_mass = (
289
+ sum(phase_probabilities)
290
+ if all(value is not None for value in phase_probabilities)
291
+ else None)
292
+ candidates.append(RealizedEffectCandidate(
293
+ effect=group[0].effect,
294
+ outcomes=group,
295
+ ordinal_key=min(ordinal_keys),
296
+ probability=probability,
297
+ phase_mass=phase_mass,
298
+ first_enumeration_index=min(
299
+ outcome.hypothesis.enumeration_index for outcome in group)))
300
+ return tuple(candidates)
301
+
302
+
303
+ def order_realized_candidates(candidates, effect_priority_fn):
304
+ """Order candidates without pretending ordinal rules are probabilities."""
305
+ candidates = tuple(candidates)
306
+ all_calibrated = bool(candidates) and all(
307
+ candidate.probability is not None for candidate in candidates)
308
+
309
+ def key(candidate):
310
+ probability_key = (
311
+ -candidate.probability if all_calibrated else 0.0)
312
+ return (
313
+ probability_key,
314
+ candidate.ordinal_key,
315
+ -effect_priority_fn(candidate.effect),
316
+ candidate.first_enumeration_index,
317
+ )
318
+
319
+ return tuple(sorted(candidates, key=key))