diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b59997..e9ff975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ ## [Unreleased] **Breaking** +- Phasing API generalized; Isovar-named identifiers removed from the + varcode public surface ([#378](https://github.com/openvax/varcode/issues/378)). + Varcode no longer imports or names any upstream tool — implementations + of the new generic Protocols live in their respective packages + (e.g. `isovar.IsovarReadPhasing`, openvax/isovar#183). + - `IsovarAssemblyProvider` (Protocol) **removed**, split into: + - `ReadPhasingSource` with `has_evidence(variant) -> bool` and + `partners_in_cis(variant) -> Sequence[Variant]`. + - `MutantTranscriptSource` with + `mutant_transcript(variant, transcript) -> Optional[MutantTranscript]`. + - `IsovarPhaseResolver` renamed to `ReadPhaseResolver`. Constructor + accepts any `ReadPhasingSource`; routes `mutant_transcript(...)` + to the wrapped source when it also satisfies `MutantTranscriptSource`. + Returns `None` otherwise instead of raising. + - Resolver `source` tag changed from `"isovar"` to `"read_phasing"` + on `ReadPhaseResolver`. Consumers filtering effects by phase + source need to update their filter values. - `varcode.effects.effect_classes.PhaseAmbiguousEffect` renamed to `PhaseCandidateSet`. No deprecation alias — update imports. Public surface (`.candidates`, `.outcomes`, `.most_likely`, diff --git a/docs/api.md b/docs/api.md index 6a9f3fb..9f7545e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -109,9 +109,17 @@ Auto-generated from in-source docstrings via ## Phasing -### `varcode.IsovarPhaseResolver` +### `varcode.ReadPhasingSource` -::: varcode.IsovarPhaseResolver +::: varcode.ReadPhasingSource + +### `varcode.MutantTranscriptSource` + +::: varcode.MutantTranscriptSource + +### `varcode.ReadPhaseResolver` + +::: varcode.ReadPhaseResolver ### `varcode.VCFPhaseResolver` diff --git a/docs/germline.md b/docs/germline.md index 8dd43f5..8f2dc25 100644 --- a/docs/germline.md +++ b/docs/germline.md @@ -21,8 +21,9 @@ possibility set** — one classified effect per haplotype hypothesis - `VCFPhaseResolver(merged_phased.vcf)` — reads `PS` tags from a WhatsHap- or HapCUT2-phased merged VCF. -- `IsovarPhaseResolver(provider)` — checks which haplotype the - somatic was observed on in RNA reads. +- `ReadPhaseResolver(source)` — wraps any RNA-phasing source + (typically an Isovar adapter shipped by `openvax/isovar`) to check + which haplotype the somatic was observed on in RNA reads. - Anything implementing `in_cis(v1, v2, transcript) -> bool | None`. Phase known → single `MutationEffect`. Phase unknown → @@ -109,7 +110,7 @@ print("trans ->", type(eff_trans).__name__, eff_trans.short_description) ``` In real code use `VCFPhaseResolver(merged_phased.vcf)` or -`IsovarPhaseResolver(provider)` — same `phase_resolver=` slot. +`ReadPhaseResolver(source)` — same `phase_resolver=` slot. ## When does this matter? diff --git a/tests/test_haplotype_effect.py b/tests/test_haplotype_effect.py index 3ecf6b2..7cfbcf0 100644 --- a/tests/test_haplotype_effect.py +++ b/tests/test_haplotype_effect.py @@ -16,7 +16,7 @@ variants are in cis according to a phase resolver, varcode builds a single joint :class:`MutantTranscript` rather than computing per-variant effects independently. Tests exercise both the -:class:`IsovarPhaseResolver` and :class:`VCFPhaseResolver` backends. +:class:`ReadPhaseResolver` and :class:`VCFPhaseResolver` backends. """ import os @@ -25,8 +25,8 @@ import pytest from varcode import ( - IsovarPhaseResolver, MutantTranscript, + ReadPhaseResolver, VCFPhaseResolver, Variant, VariantCollection, @@ -86,29 +86,28 @@ def test_apply_variants_order_independent(): # ----------------------------------------------------------------- -# Isovar-resolver-driven joint effect +# Read-phasing-resolver-driven joint effect # ----------------------------------------------------------------- -class _StubAssemblyProvider: - def __init__(self, contigs): - self._contigs = contigs +class _StubReadPhasingSource: + def __init__(self, phasing=None, mutant_transcripts=None): + self._phasing = dict(phasing) if phasing else {} + self._mts = dict(mutant_transcripts) if mutant_transcripts else {} - def has_contig(self, variant, transcript): - return (variant, transcript.id) in self._contigs + def has_evidence(self, variant): + return variant in self._phasing - def variants_in_contig(self, variant, transcript): - entry = self._contigs.get((variant, transcript.id)) - return entry[0] if entry is not None else () + def partners_in_cis(self, variant): + return self._phasing.get(variant, ()) def mutant_transcript(self, variant, transcript): - entry = self._contigs.get((variant, transcript.id)) - return entry[1] if entry is not None else None + return self._mts.get((variant, transcript.id)) -def test_isovar_resolver_produces_haplotype_effect(): - """Two cis variants via Isovar stub → collection contains a - HaplotypeEffect with both variants and the contig-derived +def test_read_resolver_produces_haplotype_effect(): + """Two cis variants via read-phasing stub → collection contains a + HaplotypeEffect with both variants and the resolver-provided MutantTranscript (not DNA inference).""" from pyensembl import cached_release g = cached_release(81) @@ -118,14 +117,17 @@ def test_isovar_resolver_produces_haplotype_effect(): stub_mt = MutantTranscript( reference_transcript=transcript, cdna_sequence="ACGT" * 50, - mutant_protein_sequence="MISOVAR" + "A" * 100, - annotator_name="isovar", + mutant_protein_sequence="MOBSERVED" + "A" * 100, + annotator_name="upstream_rna_tool", ) - contigs = { - (v1, transcript.id): ((v1, v2), stub_mt), - (v2, transcript.id): ((v1, v2), stub_mt), - } - resolver = IsovarPhaseResolver(_StubAssemblyProvider(contigs)) + source = _StubReadPhasingSource( + phasing={v1: (v1, v2), v2: (v1, v2)}, + mutant_transcripts={ + (v1, transcript.id): stub_mt, + (v2, transcript.id): stub_mt, + }, + ) + resolver = ReadPhaseResolver(source) collection = VariantCollection([v1, v2]) effects = collection.effects(phase_resolver=resolver) haplotype_effects = [e for e in effects if isinstance(e, HaplotypeEffect)] @@ -133,14 +135,13 @@ def test_isovar_resolver_produces_haplotype_effect(): he = haplotype_effects[0] assert set(he.variants) == {v1, v2} assert he.transcript is transcript - assert he.phase_source == "isovar" - # Prefers the Isovar contig over DNA inference. + assert he.phase_source == "read_phasing" + # Prefers the resolver-provided contig over DNA inference. assert he.mutant_transcript is stub_mt - # Protein passes through. - assert he.mutant_protein_sequence.startswith("MISOVAR") + assert he.mutant_protein_sequence.startswith("MOBSERVED") -def test_isovar_resolver_haplotype_and_per_variant_coexist(): +def test_read_resolver_haplotype_and_per_variant_coexist(): """Per-variant effects stay on the collection alongside the HaplotypeEffect — additive, not replacement.""" from pyensembl import cached_release @@ -152,13 +153,15 @@ def test_isovar_resolver_haplotype_and_per_variant_coexist(): reference_transcript=transcript, cdna_sequence="ACGT" * 50, mutant_protein_sequence="M" + "A" * 99, - annotator_name="isovar", + annotator_name="upstream_rna_tool", + ) + source = _StubReadPhasingSource( + phasing={v1: (v1, v2)}, + mutant_transcripts={(v1, transcript.id): stub_mt}, ) - contigs = {(v1, transcript.id): ((v1, v2), stub_mt)} - resolver = IsovarPhaseResolver(_StubAssemblyProvider(contigs)) + resolver = ReadPhaseResolver(source) collection = VariantCollection([v1, v2]) effects = collection.effects(phase_resolver=resolver) - # Per-variant Substitution for v1 AND v2 are still present. per_variant_for_v1 = [ e for e in effects if e.variant == v1 and not isinstance(e, HaplotypeEffect)] @@ -169,9 +172,7 @@ def test_isovar_resolver_haplotype_and_per_variant_coexist(): assert per_variant_for_v2 -def test_isovar_resolver_no_haplotype_when_only_one_variant(): - """Single variant with a contig → no HaplotypeEffect (minimum - group size is 2).""" +def test_read_resolver_no_haplotype_when_only_one_variant(): from pyensembl import cached_release g = cached_release(81) transcript = g.transcript_by_id("ENST00000003084") @@ -180,16 +181,18 @@ def test_isovar_resolver_no_haplotype_when_only_one_variant(): reference_transcript=transcript, cdna_sequence="ACGT" * 50, mutant_protein_sequence="M" + "A" * 99, - annotator_name="isovar", + annotator_name="upstream_rna_tool", ) - contigs = {(v1, transcript.id): ((v1,), stub_mt)} - resolver = IsovarPhaseResolver(_StubAssemblyProvider(contigs)) + source = _StubReadPhasingSource( + phasing={v1: (v1,)}, + mutant_transcripts={(v1, transcript.id): stub_mt}, + ) + resolver = ReadPhaseResolver(source) effects = VariantCollection([v1]).effects(phase_resolver=resolver) assert not any(isinstance(e, HaplotypeEffect) for e in effects) -def test_isovar_resolver_no_haplotype_when_variants_trans(): - """Two variants with separate contigs → no HaplotypeEffect.""" +def test_read_resolver_no_haplotype_when_variants_trans(): from pyensembl import cached_release g = cached_release(81) transcript = g.transcript_by_id("ENST00000003084") @@ -198,17 +201,20 @@ def test_isovar_resolver_no_haplotype_when_variants_trans(): mt1 = MutantTranscript( reference_transcript=transcript, cdna_sequence="ACGT" * 50, - annotator_name="isovar") + annotator_name="upstream_rna_tool") mt2 = MutantTranscript( reference_transcript=transcript, cdna_sequence="TGCA" * 50, - annotator_name="isovar") - # Each variant is on its own contig — they're trans. - contigs = { - (v1, transcript.id): ((v1,), mt1), - (v2, transcript.id): ((v2,), mt2), - } - resolver = IsovarPhaseResolver(_StubAssemblyProvider(contigs)) + annotator_name="upstream_rna_tool") + # Each variant has evidence only of itself — they're trans. + source = _StubReadPhasingSource( + phasing={v1: (v1,), v2: (v2,)}, + mutant_transcripts={ + (v1, transcript.id): mt1, + (v2, transcript.id): mt2, + }, + ) + resolver = ReadPhaseResolver(source) effects = VariantCollection([v1, v2]).effects(phase_resolver=resolver) assert not any(isinstance(e, HaplotypeEffect) for e in effects) diff --git a/tests/test_isovar_phase_resolver.py b/tests/test_read_phase_resolver.py similarity index 52% rename from tests/test_isovar_phase_resolver.py rename to tests/test_read_phase_resolver.py index b905ae3..95571ae 100644 --- a/tests/test_isovar_phase_resolver.py +++ b/tests/test_read_phase_resolver.py @@ -10,21 +10,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for the Isovar phase-resolver integration (#269). +"""Tests for ReadPhaseResolver and the read-phasing protocols (#269). -These don't import Isovar. A tiny in-memory ``StubAssemblyProvider`` -implements :class:`IsovarAssemblyProvider`'s three methods and stands -in for the real thing — verifying the plumbing without tying the test -suite to Isovar's release cadence. +These don't import any upstream tool. A tiny in-memory +``StubReadPhasingSource`` implements the two narrow Protocols and +stands in for the real thing (e.g. an Isovar adapter shipped by +openvax/isovar) — verifying the plumbing without tying the test suite +to any one upstream's release cadence. """ -import pytest from pyensembl import cached_release from varcode import ( - IsovarAssemblyProvider, - IsovarPhaseResolver, MutantTranscript, + MutantTranscriptSource, + ReadPhaseResolver, + ReadPhasingSource, Variant, VariantCollection, apply_phase_resolver_to_effects, @@ -33,30 +34,25 @@ ensembl_grch38 = cached_release(81) CFTR_ID = "ENST00000003084" -BRCA1_ID = "ENST00000357654" -class StubAssemblyProvider: - """In-memory provider for tests. Maps - ``(variant, transcript.id) -> (variants_on_contig, mutant_transcript)``. +class StubReadPhasingSource: + """In-memory source for tests. Variant-keyed phasing channel + + optional ``(variant, transcript)``-keyed mutant-transcript channel. """ - def __init__(self, contigs): - self._contigs = { - (v, tid): (partners, mt) - for (v, tid), (partners, mt) in contigs.items() - } + def __init__(self, phasing=None, mutant_transcripts=None): + self._phasing = dict(phasing) if phasing else {} + self._mts = dict(mutant_transcripts) if mutant_transcripts else {} - def has_contig(self, variant, transcript): - return (variant, transcript.id) in self._contigs + def has_evidence(self, variant): + return variant in self._phasing - def variants_in_contig(self, variant, transcript): - entry = self._contigs.get((variant, transcript.id)) - return entry[0] if entry is not None else () + def partners_in_cis(self, variant): + return self._phasing.get(variant, ()) def mutant_transcript(self, variant, transcript): - entry = self._contigs.get((variant, transcript.id)) - return entry[1] if entry is not None else None + return self._mts.get((variant, transcript.id)) # -------------------------------------------------------------------- @@ -64,15 +60,18 @@ def mutant_transcript(self, variant, transcript): # -------------------------------------------------------------------- -def test_stub_provider_matches_protocol(): - """Sanity check: the stub used throughout these tests satisfies - the :class:`IsovarAssemblyProvider` runtime-checkable Protocol.""" - provider = StubAssemblyProvider({}) - assert isinstance(provider, IsovarAssemblyProvider) +def test_stub_satisfies_read_phasing_protocol(): + source = StubReadPhasingSource() + assert isinstance(source, ReadPhasingSource) + + +def test_stub_satisfies_mutant_transcript_protocol(): + source = StubReadPhasingSource() + assert isinstance(source, MutantTranscriptSource) # -------------------------------------------------------------------- -# IsovarPhaseResolver basics +# ReadPhaseResolver basics # -------------------------------------------------------------------- @@ -80,34 +79,26 @@ def _cftr(): return ensembl_grch38.transcript_by_id(CFTR_ID) -def test_resolver_source_is_isovar(): - resolver = IsovarPhaseResolver(StubAssemblyProvider({})) - assert resolver.source == "isovar" +def test_resolver_default_phase_source_tag(): + resolver = ReadPhaseResolver(StubReadPhasingSource()) + assert resolver.phase_source == "read_phasing" -def test_in_cis_true_when_both_on_same_contig(): +def test_in_cis_true_when_both_observed_together(): transcript = _cftr() v1 = Variant("7", 117531100, "T", "A", ensembl_grch38) v2 = Variant("7", 117531114, "G", "T", ensembl_grch38) - contigs = { - (v1, transcript.id): ((v1, v2), None), - (v2, transcript.id): ((v1, v2), None), - } - resolver = IsovarPhaseResolver(StubAssemblyProvider(contigs)) + source = StubReadPhasingSource(phasing={v1: (v1, v2), v2: (v1, v2)}) + resolver = ReadPhaseResolver(source) assert resolver.in_cis(v1, v2, transcript=transcript) is True -def test_in_cis_false_when_on_different_contigs(): +def test_in_cis_false_when_observed_on_different_molecules(): transcript = _cftr() v1 = Variant("7", 117531100, "T", "A", ensembl_grch38) v2 = Variant("7", 117531114, "G", "T", ensembl_grch38) - # v1 is on a contig, v2 is on a *different* contig. Each contig - # only contains its own variant. - contigs = { - (v1, transcript.id): ((v1,), None), - (v2, transcript.id): ((v2,), None), - } - resolver = IsovarPhaseResolver(StubAssemblyProvider(contigs)) + source = StubReadPhasingSource(phasing={v1: (v1,), v2: (v2,)}) + resolver = ReadPhaseResolver(source) assert resolver.in_cis(v1, v2, transcript=transcript) is False @@ -115,23 +106,19 @@ def test_in_cis_none_when_no_evidence(): transcript = _cftr() v1 = Variant("7", 117531100, "T", "A", ensembl_grch38) v2 = Variant("7", 117531114, "G", "T", ensembl_grch38) - resolver = IsovarPhaseResolver(StubAssemblyProvider({})) + resolver = ReadPhaseResolver(StubReadPhasingSource()) assert resolver.in_cis(v1, v2, transcript=transcript) is None def test_in_cis_handles_one_sided_evidence(): - """v1 has no contig but appears in v2's contig → cis by virtue - of being on the same physical molecule as v2.""" + """v1 has no direct evidence but appears in v2's partner set → + cis. Matches the case where an upstream tool builds one entry per + somatic variant and germline partners appear only inside it.""" transcript = _cftr() v1 = Variant("7", 117531100, "T", "A", ensembl_grch38) v2 = Variant("7", 117531114, "G", "T", ensembl_grch38) - contigs = { - (v2, transcript.id): ((v1, v2), None), - # Deliberately no entry keyed on v1 — a real Isovar result - # might only build one assembly per somatic variant, and - # germline variants only show up inside it. - } - resolver = IsovarPhaseResolver(StubAssemblyProvider(contigs)) + source = StubReadPhasingSource(phasing={v2: (v1, v2)}) + resolver = ReadPhaseResolver(source) assert resolver.in_cis(v1, v2, transcript=transcript) is True @@ -139,52 +126,69 @@ def test_phased_partners_returns_cis_set(): transcript = _cftr() v1 = Variant("7", 117531100, "T", "A", ensembl_grch38) v2 = Variant("7", 117531114, "G", "T", ensembl_grch38) - contigs = {(v1, transcript.id): ((v1, v2), None)} - resolver = IsovarPhaseResolver(StubAssemblyProvider(contigs)) + source = StubReadPhasingSource(phasing={v1: (v1, v2)}) + resolver = ReadPhaseResolver(source) partners = resolver.phased_partners(v1, transcript) assert v2 in partners +def test_phased_partners_empty_without_evidence(): + transcript = _cftr() + v1 = Variant("7", 117531100, "T", "A", ensembl_grch38) + resolver = ReadPhaseResolver(StubReadPhasingSource()) + assert resolver.phased_partners(v1, transcript) == () + + # -------------------------------------------------------------------- -# Effect-attachment flow — the headline integration +# MutantTranscript channel — optional; routed through wrapped source # -------------------------------------------------------------------- def _stub_mutant_transcript(transcript): - """Build a MutantTranscript that's clearly distinguishable from - anything the DNA-only annotator would construct: tag the - annotator_name so tests can tell the Isovar payload landed.""" return MutantTranscript( reference_transcript=transcript, cdna_sequence="ACGT" * 50, mutant_protein_sequence="M" + "A" * 99, - annotator_name="isovar", + annotator_name="upstream_rna_tool", ) -def test_effect_gets_isovar_mutant_transcript_attached(): - """End-to-end: pass a ``phase_resolver`` into - ``Variant.effects()`` and verify the returned effect's - ``mutant_transcript`` is the Isovar-provided one, not None.""" +def test_mutant_transcript_returns_none_when_source_lacks_method(): + """A ReadPhasingSource that doesn't implement MutantTranscriptSource + causes the resolver's mutant_transcript() to return None silently + rather than raise.""" + + class PhasingOnly: + def has_evidence(self, variant): + return False + + def partners_in_cis(self, variant): + return () + + transcript = _cftr() + v = Variant("7", 117531100, "T", "A", ensembl_grch38) + resolver = ReadPhaseResolver(PhasingOnly()) + assert resolver.mutant_transcript(v, transcript) is None + + +def test_effect_gets_observed_mutant_transcript_attached(): transcript = _cftr() variant = Variant("7", 117531100, "T", "A", ensembl_grch38) stub_mt = _stub_mutant_transcript(transcript) - contigs = {(variant, transcript.id): ((variant,), stub_mt)} - resolver = IsovarPhaseResolver(StubAssemblyProvider(contigs)) + source = StubReadPhasingSource( + phasing={variant: (variant,)}, + mutant_transcripts={(variant, transcript.id): stub_mt}, + ) + resolver = ReadPhaseResolver(source) effects = variant.effects(phase_resolver=resolver) target = next(e for e in effects if getattr(e, "transcript", None) is transcript) assert target.mutant_transcript is stub_mt - # The assembled protein is what's attached — not varcode's - # reference-diff inference. - assert target.mutant_transcript.annotator_name == "isovar" + assert target.mutant_transcript.annotator_name == "upstream_rna_tool" def test_effects_without_resolver_have_no_mutant_transcript_attached(): - """Back-compat: without a resolver, point-variant effects carry - the class-default ``mutant_transcript = None`` — nothing has - been populated.""" transcript = _cftr() variant = Variant("7", 117531100, "T", "A", ensembl_grch38) effects = variant.effects() @@ -194,14 +198,15 @@ def test_effects_without_resolver_have_no_mutant_transcript_attached(): def test_variant_collection_effects_accepts_phase_resolver(): - """Same plumbing on the collection. Returns an EffectCollection - whose covered effects carry the Isovar MutantTranscript.""" transcript = _cftr() v1 = Variant("7", 117531100, "T", "A", ensembl_grch38) v2 = Variant("7", 117531114, "G", "T", ensembl_grch38) mt1 = _stub_mutant_transcript(transcript) - contigs = {(v1, transcript.id): ((v1, v2), mt1)} - resolver = IsovarPhaseResolver(StubAssemblyProvider(contigs)) + source = StubReadPhasingSource( + phasing={v1: (v1, v2)}, + mutant_transcripts={(v1, transcript.id): mt1}, + ) + resolver = ReadPhaseResolver(source) collection = VariantCollection([v1, v2]) effects = collection.effects(phase_resolver=resolver) @@ -210,19 +215,16 @@ def test_variant_collection_effects_accepts_phase_resolver(): and getattr(e, "transcript", None) is transcript] assert v1_effects assert v1_effects[0].mutant_transcript is mt1 - # v2 has no contig of its own — mutant_transcript stays None. v2_effects = [e for e in effects if e.variant == v2 and getattr(e, "transcript", None) is transcript] for e in v2_effects: assert e.mutant_transcript is None -def test_resolver_without_contig_leaves_effects_alone(): - """Resolver present but no contig for this (variant, transcript) → - effect's ``mutant_transcript`` stays None.""" +def test_resolver_without_evidence_leaves_effects_alone(): transcript = _cftr() variant = Variant("7", 117531100, "T", "A", ensembl_grch38) - resolver = IsovarPhaseResolver(StubAssemblyProvider({})) + resolver = ReadPhaseResolver(StubReadPhasingSource()) effects = variant.effects(phase_resolver=resolver) target = next(e for e in effects if getattr(e, "transcript", None) is transcript) @@ -230,13 +232,14 @@ def test_resolver_without_contig_leaves_effects_alone(): def test_apply_phase_resolver_helper_is_idempotent(): - """Calling apply_phase_resolver_to_effects twice with the same - resolver leaves the same objects in place.""" transcript = _cftr() variant = Variant("7", 117531100, "T", "A", ensembl_grch38) mt = _stub_mutant_transcript(transcript) - contigs = {(variant, transcript.id): ((variant,), mt)} - resolver = IsovarPhaseResolver(StubAssemblyProvider(contigs)) + source = StubReadPhasingSource( + phasing={variant: (variant,)}, + mutant_transcripts={(variant, transcript.id): mt}, + ) + resolver = ReadPhaseResolver(source) effects = variant.effects() apply_phase_resolver_to_effects(effects, resolver) apply_phase_resolver_to_effects(effects, resolver) @@ -246,16 +249,14 @@ def test_apply_phase_resolver_helper_is_idempotent(): def test_user_example_shape_works(): - """The canonical example from #269 works as-written: build a - resolver, pass it to effects(), iterate and read - effect.mutant_transcript.mutant_protein_sequence.""" transcript = _cftr() variant = Variant("7", 117531100, "T", "A", ensembl_grch38) stub_mt = _stub_mutant_transcript(transcript) - contigs = {(variant, transcript.id): ((variant,), stub_mt)} - - isovar_results = StubAssemblyProvider(contigs) - resolver = IsovarPhaseResolver(isovar_results) + source = StubReadPhasingSource( + phasing={variant: (variant,)}, + mutant_transcripts={(variant, transcript.id): stub_mt}, + ) + resolver = ReadPhaseResolver(source) variants = VariantCollection([variant]) effects = variants.effects(phase_resolver=resolver) @@ -263,7 +264,7 @@ def test_user_example_shape_works(): found_protein = None for e in effects: t = getattr(e, "transcript", None) - if t is transcript and isovar_results.has_contig(variant, t): + if t is transcript and source.has_evidence(variant): found_protein = e.mutant_transcript.mutant_protein_sequence break assert found_protein == stub_mt.mutant_protein_sequence diff --git a/tests/test_vcf_phase_resolver.py b/tests/test_vcf_phase_resolver.py index eeabb83..cfd76cc 100644 --- a/tests/test_vcf_phase_resolver.py +++ b/tests/test_vcf_phase_resolver.py @@ -179,24 +179,24 @@ def test_phased_partners_returns_cis_variants_only(phased_vcf): # ------------------------------------------------------------------ -def test_vcf_resolver_source_tag(): +def test_vcf_resolver_phase_source_tag(): from varcode import VariantCollection resolver = VCFPhaseResolver(VariantCollection([]), sample="x") - assert resolver.source == "vcf_ps" + assert resolver.phase_source == "vcf_ps" # ------------------------------------------------------------------ -# Interface symmetry with IsovarPhaseResolver — same method set +# Interface symmetry with ReadPhaseResolver — same method set # ------------------------------------------------------------------ -def test_vcf_resolver_has_same_phase_api_as_isovar_resolver(): +def test_vcf_resolver_has_same_phase_api_as_read_resolver(): from varcode import VariantCollection - from varcode.phasing import IsovarPhaseResolver - required = {"in_cis", "phased_partners", "source"} + from varcode.phasing import ReadPhaseResolver + required = {"in_cis", "phased_partners", "phase_source"} vcf_r = VCFPhaseResolver(VariantCollection([]), sample="x") - # Can't instantiate IsovarPhaseResolver without a provider; check + # Can't instantiate ReadPhaseResolver without a source; check # class attrs instead. for attr in required: assert hasattr(vcf_r, attr) - assert hasattr(IsovarPhaseResolver, attr) + assert hasattr(ReadPhaseResolver, attr) diff --git a/varcode/__init__.py b/varcode/__init__.py index f8d3db0..e44b175 100644 --- a/varcode/__init__.py +++ b/varcode/__init__.py @@ -54,8 +54,9 @@ ) from .outcomes import Outcome, outcomes_from_candidates from .phasing import ( - IsovarAssemblyProvider, - IsovarPhaseResolver, + MutantTranscriptSource, + ReadPhaseResolver, + ReadPhasingSource, VCFPhaseResolver, apply_phase_resolver_to_effects, ) @@ -127,8 +128,9 @@ "score_acceptor", # Phase resolvers (openvax/varcode#269) - "IsovarAssemblyProvider", - "IsovarPhaseResolver", + "MutantTranscriptSource", + "ReadPhaseResolver", + "ReadPhasingSource", "VCFPhaseResolver", "apply_phase_resolver_to_effects", diff --git a/varcode/outcomes.py b/varcode/outcomes.py index 234f48e..d082b6d 100644 --- a/varcode/outcomes.py +++ b/varcode/outcomes.py @@ -83,9 +83,10 @@ class Outcome(DataclassSerializable): source : str Name of the tool or annotator that produced this outcome. Defaults to ``"varcode"`` for built-in classifications. - External integrations set their own (``"spliceai"``, - ``"isovar"``, ``"longread_assembly"``, etc.) so downstream - callers can filter by source. + External integrations set their own (a splice predictor, an + RNA assembler, a long-read caller, ...) so downstream callers + can filter by source. The string is opaque to varcode; pick + whatever your pipeline prefers. evidence : Mapping[str, Any] Open-ended provenance dict. Shape is source-specific; the convention is that keys match the source's native field names diff --git a/varcode/phasing.py b/varcode/phasing.py index a0f6730..c4a5b78 100644 --- a/varcode/phasing.py +++ b/varcode/phasing.py @@ -13,136 +13,134 @@ """Phase-resolver interfaces for cis/trans-aware effect prediction (openvax/varcode#269). -A :class:`PhaseResolver` answers "are these two variants on the same +A *phase resolver* answers "are these two variants on the same haplotype?" from some evidence source — DNA ``PS`` tags, RNA -read co-occurrence, or an assembled-contig tool like -`Isovar `_. +read co-occurrence, an assembled contig from a long-read pipeline, +or anything else that can express variant co-occurrence. -Varcode defines the protocol here; the resolvers plug into +Varcode defines two narrow Protocols here and plugs the resolvers into :meth:`Variant.effects` / :meth:`VariantCollection.effects` via the -``phase_resolver`` kwarg. Concrete DNA-based and read-based resolvers -can live here too or be shipped by downstream tools. - -This module deliberately imports nothing from Isovar. An -:class:`IsovarAssemblyProvider` is a duck-typed interface — Isovar -(or any contig assembly tool) implements it; varcode consumes it. +``phase_resolver`` kwarg. Varcode does **not** import or name any +specific upstream tool — implementations live in whatever package +produces the evidence (Isovar, long-read callers, custom assemblers, +test stubs). """ from typing import Optional, Protocol, Sequence, runtime_checkable @runtime_checkable -class IsovarAssemblyProvider(Protocol): - """Minimal interface for a source of per-locus RNA assemblies - with phased variants (#269, #259). - - An "assembly" is a consensus contig built from RNA reads spanning - a somatic variant. Variants co-occurring on the same contig are - in cis — they came from the same physical molecule, so - co-occurrence is direct evidence, not inference. The contig's - translated protein IS the observed mutant product, not something - inferred from reference + edits. - - Isovar is the reference implementation, but varcode intentionally - does not import it. Any tool exposing this shape plugs in — a - long-read haplotype caller, a custom assembler, or a mock in - tests. - - Implementers need to be consistent about ``transcript`` — if the - tool returns different contigs per isoform, the methods must key - on ``(variant, transcript)``. If the tool is isoform-agnostic, - all ``transcript`` arguments may be ignored (the provider just - returns the same contig for any). +class ReadPhasingSource(Protocol): + """Reports per-variant read-level evidence and co-occurring partners. + + The minimum interface needed to answer ``in_cis(v1, v2)`` from + read-level data — co-observation on the same supporting reads, + same long-read fragment, same assembled contig, etc. Implementations + decide how strong the evidence is; consumers only see a boolean + membership question. """ - def has_contig(self, variant, transcript) -> bool: - """True if this provider has an assembled contig covering - ``variant`` on ``transcript``.""" + def has_evidence(self, variant) -> bool: + """True if this source has any alt-supporting evidence for + ``variant``.""" ... - def variants_in_contig(self, variant, transcript) -> Sequence: - """Variants observed on the same contig as ``variant``. - - May include germline SNPs, nearby somatic variants, or - ``variant`` itself (implementations pick their convention). - Empty sequence when no contig covers ``variant``. - """ + def partners_in_cis(self, variant) -> Sequence: + """Variants observed in cis with ``variant`` — i.e. on the + same supporting reads / fragment / contig. May include + germline SNPs, nearby somatic variants, or ``variant`` itself + (implementations pick their convention). Empty sequence when + no evidence covers ``variant``.""" ... + +@runtime_checkable +class MutantTranscriptSource(Protocol): + """Reports an observed mutant transcript for ``(variant, transcript)``. + + Independent of phasing. A source can implement just + :class:`ReadPhasingSource`, just :class:`MutantTranscriptSource`, + or both. Consumers iterating effects use this channel to substitute + the observed mutant protein (from RNA assembly, long-read calling, + etc.) for the reference-inferred one. + """ + def mutant_transcript(self, variant, transcript): - """The :class:`~varcode.MutantTranscript` assembled from the - contig. ``None`` when no contig covers - ``(variant, transcript)``. - """ + """The :class:`~varcode.MutantTranscript` for + ``(variant, transcript)``, or ``None`` when this source has + no observed transcript for that pair.""" ... -class IsovarPhaseResolver: - """Phase resolver backed by an :class:`IsovarAssemblyProvider` - (#269, #259). +class ReadPhaseResolver: + """Phase resolver backed by a :class:`ReadPhasingSource` (#269, #259). - Two variants are cis if they appear on the same assembled contig. + Two variants are cis if the source reports them as co-observed. That's direct molecular evidence — not a probabilistic call. Usage:: - isovar_results = run_isovar(bam, vcf) - resolver = IsovarPhaseResolver(isovar_results) + # Any object satisfying ReadPhasingSource works. Common + # implementation: an Isovar adapter shipped by openvax/isovar. + resolver = ReadPhaseResolver(source) effects = variants.effects(phase_resolver=resolver) - Any effect whose ``(variant, transcript)`` is covered by an - assembled contig gets its :attr:`~MutationEffect.mutant_transcript` - populated with the contig-derived :class:`MutantTranscript` — - the protein attached to the effect is the protein actually - observed in RNA, not one inferred from the reference. + If ``source`` also satisfies :class:`MutantTranscriptSource`, the + resolver routes :meth:`mutant_transcript` calls to it — so any + effect whose ``(variant, transcript)`` is covered by the source + gets its :attr:`~MutationEffect.mutant_transcript` populated with + the observed mutant transcript. """ - #: Provenance string for downstream consumers that filter by - #: phase source. Matches the pattern established by the - #: :class:`Outcome.source` field. - source = "isovar" + #: Provenance tag flowing into :attr:`HaplotypeEffect.phase_source` + #: when this resolver produced the cis grouping. Distinct from + #: :attr:`Outcome.source` (an unrelated producer tag on RNA-evidence + #: outcomes). + phase_source = "read_phasing" - def __init__(self, provider: IsovarAssemblyProvider): - self.provider = provider + def __init__(self, phasing_source: ReadPhasingSource): + self.phasing_source = phasing_source - def has_contig(self, variant, transcript) -> bool: - """Convenience passthrough to the provider.""" - return self.provider.has_contig(variant, transcript) + def has_evidence(self, variant) -> bool: + """Convenience passthrough to the wrapped source.""" + return self.phasing_source.has_evidence(variant) def mutant_transcript(self, variant, transcript): - """Return the assembled :class:`MutantTranscript`, or ``None`` - when this provider has no contig for ``(variant, transcript)``. - """ - if not self.provider.has_contig(variant, transcript): + """Return the observed :class:`MutantTranscript` for + ``(variant, transcript)``, or ``None`` when the wrapped source + doesn't implement :class:`MutantTranscriptSource` or has no + transcript for that pair.""" + get = getattr(self.phasing_source, "mutant_transcript", None) + if get is None: return None - return self.provider.mutant_transcript(variant, transcript) + return get(variant, transcript) def in_cis(self, v1, v2, transcript=None) -> Optional[bool]: - """Return ``True`` if ``v1`` and ``v2`` appear on the same - Isovar contig, ``False`` if they're each on a different - contig (distinct physical molecules — trans), ``None`` when - neither variant has a contig (no evidence). - - ``transcript`` is required because assemblies may be - isoform-specific; pass ``None`` only if the provider is - isoform-agnostic (the protocol allows this). + """Return ``True`` if ``v1`` and ``v2`` are co-observed by the + wrapped source, ``False`` if exactly one has evidence (so they + are on distinct physical molecules — trans), ``None`` when + neither has evidence. + + ``transcript`` is accepted for interface symmetry with + :class:`VCFPhaseResolver.in_cis` but isn't consulted at the + Protocol layer — isoform-specific sources are not yet a + first-class concern. Reintroducible later as an optional + Protocol extension. """ - if transcript is None: - return None - v1_has = self.provider.has_contig(v1, transcript) - v2_has = self.provider.has_contig(v2, transcript) + v1_has = self.phasing_source.has_evidence(v1) + v2_has = self.phasing_source.has_evidence(v2) if not v1_has and not v2_has: return None if v1_has: - return v2 in self.provider.variants_in_contig(v1, transcript) - return v1 in self.provider.variants_in_contig(v2, transcript) + return v2 in self.phasing_source.partners_in_cis(v1) + return v1 in self.phasing_source.partners_in_cis(v2) - def phased_partners(self, variant, transcript) -> Sequence: - """Variants observed on the same contig as ``variant`` on - ``transcript`` — i.e. the cis set. Empty if no contig.""" - if not self.provider.has_contig(variant, transcript): + def phased_partners(self, variant, transcript=None) -> Sequence: + """Variants co-observed with ``variant`` — i.e. the cis set. + Empty when the source has no evidence for ``variant``.""" + if not self.phasing_source.has_evidence(variant): return () - return tuple(self.provider.variants_in_contig(variant, transcript)) + return tuple(self.phasing_source.partners_in_cis(variant)) class VCFPhaseResolver: @@ -187,8 +185,8 @@ class VCFPhaseResolver: the grouping. """ - #: Provenance tag, matching :attr:`IsovarPhaseResolver.source`. - source = "vcf_ps" + #: Provenance tag, matching :attr:`ReadPhaseResolver.phase_source`. + phase_source = "vcf_ps" def __init__(self, variant_collection, sample): self._collection = variant_collection @@ -240,7 +238,7 @@ def in_cis(self, v1, v2, transcript=None) -> Optional[bool]: different phase sets, uncalled alleles). ``transcript`` is accepted for interface symmetry with - :class:`IsovarPhaseResolver.in_cis` but isn't consulted — + :class:`ReadPhaseResolver.in_cis` but isn't consulted — DNA-level phase is isoform-agnostic. """ g1 = self._genotype(v1) @@ -290,14 +288,14 @@ def phased_partners(self, variant, transcript=None): def apply_phase_resolver_to_effects(effects, phase_resolver): """Post-process an :class:`EffectCollection` (or any iterable of - :class:`MutationEffect`) to attach contig-derived + :class:`MutationEffect`) to attach observed :class:`MutantTranscript` objects when the resolver has evidence. Mutates each effect in place by setting ``effect.mutant_transcript``. Effects whose transcript isn't - resolvable or whose ``(variant, transcript)`` has no contig are - left untouched — so this is safe to call on a mixed collection - where only some variants have RNA evidence. + resolvable or whose ``(variant, transcript)`` has no observed + transcript are left untouched — so this is safe to call on a mixed + collection where only some variants have RNA evidence. """ if phase_resolver is None: return effects @@ -312,8 +310,9 @@ def apply_phase_resolver_to_effects(effects, phase_resolver): if mt is not None: # Intentional mutation: the effect's mutant_transcript # slot was either None (point variants, cryptic stubs, - # etc.) or populated from DNA-only inference. Isovar's - # assembly is higher-confidence evidence, so it wins. + # etc.) or populated from DNA-only inference. An observed + # mutant transcript is higher-confidence evidence, so it + # wins. e.mutant_transcript = mt return effects @@ -396,8 +395,8 @@ def union(i, j): for members in groups.values(): if len(members) < 2: continue - # Prefer a resolver-provided MutantTranscript (Isovar / - # long-read assembly) when available — it's the actual + # Prefer a resolver-provided MutantTranscript (RNA assembly, + # long-read, etc.) when available — it's the actual # observed molecule, not inferred from reference + edits. # Members of a cis group share a contig by definition, so # any member's contig covers the whole group. @@ -415,6 +414,6 @@ def union(i, j): variants=members, transcript=transcript, mutant_transcript=mt, - phase_source=getattr(phase_resolver, "source", None), + phase_source=getattr(phase_resolver, "phase_source", None), )) return haplotype_effects diff --git a/varcode/rna_evidence.py b/varcode/rna_evidence.py index c13a5d1..c3ed10b 100644 --- a/varcode/rna_evidence.py +++ b/varcode/rna_evidence.py @@ -62,11 +62,11 @@ class RNAEvidenceResolver(Protocol): alone. Returned outcomes should set ``source`` to a producer-specific - string (``"isovar"``, ``"exacto"``, ``"longread_assembly"``, ...) - and populate ``evidence`` with whatever shape that producer - natively emits (transcript model IDs, junction read counts, etc.). - See :func:`make_rna_outcome` for a convenience factory that fills - the common fields. + string (the name of the RNA assembler, long-read caller, fusion + detector, etc.) and populate ``evidence`` with whatever shape that + producer natively emits (transcript model IDs, junction read + counts, etc.). See :func:`make_rna_outcome` for a convenience + factory that fills the common fields. """ def observed_outcomes(self, variant, transcript) -> Sequence[Outcome]: @@ -113,8 +113,9 @@ def make_rna_outcome( Estimated frequency of this isoform (e.g. expression-supported fraction). ``None`` means "not scored". source : str - Producer name; defaults to ``"rna"``. Set to ``"isovar"`` / - ``"exacto"`` / etc. for tool-specific filtering. + Producer name; defaults to ``"rna"``. Set to a tool-specific + string (RNA assembler, long-read caller, etc.) for downstream + filtering. Opaque to varcode. transcript_model_id : str or None Stable ID of the observed transcript model from the producer. Stored under ``evidence["transcript_model_id"]``. diff --git a/varcode/variant.py b/varcode/variant.py index f231480..0a512eb 100644 --- a/varcode/variant.py +++ b/varcode/variant.py @@ -479,14 +479,15 @@ def effects( against the registry. See openvax/varcode#271. phase_resolver : PhaseResolver or None - Optional phase-evidence source (typically an - :class:`~varcode.phasing.IsovarPhaseResolver`). When - provided and the resolver has an assembled contig for + Optional phase-evidence source (typically a + :class:`~varcode.phasing.ReadPhaseResolver` wrapping an + upstream RNA-phasing tool, or + :class:`~varcode.phasing.VCFPhaseResolver`). When provided + and the resolver has an observed mutant transcript for ``(self, transcript)``, the returned effect's - ``mutant_transcript`` is populated with the - contig-derived :class:`~varcode.MutantTranscript` — the - protein is the protein actually observed in RNA rather - than one inferred from the reference. See + ``mutant_transcript`` is populated with that observed + transcript — the protein is the protein actually observed + in RNA rather than one inferred from the reference. See openvax/varcode#269. rna_resolver : RNAEvidenceResolver or None diff --git a/varcode/variant_collection.py b/varcode/variant_collection.py index ddc3b63..438d324 100644 --- a/varcode/variant_collection.py +++ b/varcode/variant_collection.py @@ -138,12 +138,13 @@ def effects( openvax/varcode#271. phase_resolver : PhaseResolver or None - Optional phase-evidence source (e.g. - :class:`~varcode.phasing.IsovarPhaseResolver`). When - provided, any effect whose ``(variant, transcript)`` is - covered by an assembled contig has its - ``mutant_transcript`` populated with the contig-derived - :class:`~varcode.MutantTranscript`. See + Optional phase-evidence source (e.g. a + :class:`~varcode.phasing.ReadPhaseResolver` wrapping an + upstream RNA-phasing tool, or + :class:`~varcode.phasing.VCFPhaseResolver`). When provided, + any effect whose ``(variant, transcript)`` is covered by an + observed mutant transcript has its ``mutant_transcript`` + populated with that observed transcript. See openvax/varcode#269. rna_resolver : RNAEvidenceResolver or None diff --git a/varcode/version.py b/varcode/version.py index f106f53..8f71965 100644 --- a/varcode/version.py +++ b/varcode/version.py @@ -1 +1 @@ -__version__ = "4.23.0" +__version__ = "4.24.0"