Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@
## [Unreleased]

**Breaking**
- `varcode.Outcome` renamed to `varcode.EffectCandidate`. The helper
`outcomes_from_candidates` renamed to `candidates_from_effects`.
The module `varcode.outcomes` renamed to
`varcode.effect_candidates`. The test file `tests/test_outcomes.py`
renamed to `tests/test_effect_candidates.py`. The `description`
field on the wrapper class is removed — use
`candidate.effect.short_description` (it was always a passthrough).
Also removed from `make_rna_outcome(description=...)`. No
deprecation alias; update imports. The class ships with a module
docstring explaining *why* the wrapper exists: the same
`MutationEffect` instance can appear in multiple multi-outcome
contexts with different per-context provenance (e.g. a splice
candidate re-surfaced by the SV annotator with a different
`source` tag and `sv_type` in evidence); putting metadata on the
wrapper instead of the Effect lets the Effect stay shared while
the labels diverge. `MultiOutcomeEffect` retains two accessors:
`.candidates` (raw `tuple[MutationEffect, ...]`) and `.outcomes`
(wrapped `tuple[EffectCandidate, ...]`); the back-compat framing
on `.candidates` is dropped — both are first-class. Aspirational
`"isovar"` / `"exacto"` / `"longread_assembly"` example tags
scrubbed from varcode docstrings.
- 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
Expand Down
4 changes: 2 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ Auto-generated from in-source docstrings via

::: varcode.EffectCollection

### `varcode.Outcome`
### `varcode.EffectCandidate`

::: varcode.Outcome
::: varcode.EffectCandidate

### Priority ordering

Expand Down
2 changes: 1 addition & 1 deletion docs/germline.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ effects = somatic.effects(

Order: germline modifies the transcript first, phase collapses the
candidate set next, RNA collapses further (or appends observed-only
outcomes). Cross-axis key is `Outcome.evidence["haplotype"]`, so an
outcomes). Cross-axis key is `EffectCandidate.evidence["haplotype"]`, so an
RNA observation tagged with the same haplotype tag aligns with the
right germline-aware outcome.

Expand Down
2 changes: 1 addition & 1 deletion docs/transforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ genome shape was passed.

### Behavior

| Variant kind | Outcome |
| Variant kind | EffectCandidate |
|---|---|
| Pure SNV / MNV / complex (`ATG→GCC`) | Pass-through, `source_variants=()` |
| Already-canonical indel (no equivalent leftward position) | Pass-through, `source_variants=()` |
Expand Down
8 changes: 4 additions & 4 deletions tests/test_cryptic_exons.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import pytest

from varcode import (
Outcome,
EffectCandidate,
StructuralVariant,
enumerate_cryptic_exon_candidates,
score_acceptor,
Expand Down Expand Up @@ -153,7 +153,7 @@ def custom_scorer(window: str, kind: str) -> float:

def test_cryptic_exon_candidate_wraps_as_outcome():
"""A :class:`CrypticExonCandidate` can be wrapped in an
:class:`Outcome` for the harmonized multi-outcome interface.
:class:`EffectCandidate` for the harmonized multi-outcome interface.
This is how an SV annotator attaches a cryptic-exon outcome
alongside its varcode-nominated `LargeDeletion` / `GeneFusion`
outcome."""
Expand All @@ -164,7 +164,7 @@ def test_cryptic_exon_candidate_wraps_as_outcome():
interval_end=1_000_120,
donor_score=0.85,
acceptor_score=0.90)
outcome = Outcome(
outcome = EffectCandidate(
effect=cand,
probability=0.87,
source="varcode-motif",
Expand All @@ -173,7 +173,7 @@ def test_cryptic_exon_candidate_wraps_as_outcome():
assert outcome.probability == 0.87
# An external rescore (SpliceAI-style) wraps the same candidate
# with its own probability — downstream consumers filter by source.
rescored = Outcome(
rescored = EffectCandidate(
effect=cand,
probability=0.98,
source="spliceai",
Expand Down
74 changes: 33 additions & 41 deletions tests/test_outcomes.py → tests/test_effect_candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tests for the unified :class:`Outcome` type (openvax/varcode#299).
"""Tests for the unified :class:`EffectCandidate` type (openvax/varcode#299).

The type is deliberately minimal (dataclass + one helper), so tests
focus on (a) the contract — probability bounds, defaults, frozen
Expand All @@ -21,7 +21,7 @@
import pytest
from pyensembl import cached_release

from varcode import Outcome, Variant, outcomes_from_candidates
from varcode import EffectCandidate, Variant, candidates_from_effects
from varcode.effects.effect_classes import (
ExonicSpliceSite,
MultiOutcomeEffect,
Expand All @@ -30,57 +30,51 @@
ensembl_grch38 = cached_release(81)


def test_outcome_defaults():
o = Outcome(effect=object())
def test_effect_candidate_defaults():
o = EffectCandidate(effect=object())
assert o.probability is None
assert o.source == "varcode"
assert o.evidence == {}
assert o.description is None


def test_outcome_description_field():
"""``Outcome`` carries a first-class human description distinct
from ``effect.short_description`` — the latter is the effect's
HGVS-style label, the former is an outcome-specific narrative
(#339)."""
class _FakeEffect:
short_description = "exon-loss"
o = Outcome(
effect=_FakeEffect(),
description="Exon 7 is skipped (in-frame, 15 aa removed).")
assert o.description.startswith("Exon 7")
assert o.short_description == "exon-loss"
def test_effect_candidate_rejects_removed_description_kwarg():
"""The pre-rename ``Outcome`` carried a ``description`` field that
was a pure passthrough to ``effect.short_description``. The rename
dropped it; lock that in by pinning the TypeError for any caller
still using the old kwarg."""
with pytest.raises(TypeError):
EffectCandidate(effect=object(), description="old kwarg")


def test_outcome_probability_bounds():
Outcome(effect=object(), probability=0.0)
Outcome(effect=object(), probability=1.0)
Outcome(effect=object(), probability=None)
def test_effect_candidate_probability_bounds():
EffectCandidate(effect=object(), probability=0.0)
EffectCandidate(effect=object(), probability=1.0)
EffectCandidate(effect=object(), probability=None)
with pytest.raises(ValueError):
Outcome(effect=object(), probability=-0.1)
EffectCandidate(effect=object(), probability=-0.1)
with pytest.raises(ValueError):
Outcome(effect=object(), probability=1.5)
EffectCandidate(effect=object(), probability=1.5)


def test_outcome_is_frozen():
o = Outcome(effect=object(), source="test")
def test_effect_candidate_is_frozen():
o = EffectCandidate(effect=object(), source="test")
with pytest.raises(Exception):
# dataclasses.FrozenInstanceError is a subclass of AttributeError
# in some Python versions — catch the broad shape.
o.source = "mutated"


def test_outcome_short_description_passthrough():
def test_effect_candidate_short_description_passthrough():
class _FakeEffect:
short_description = "p.L101del"
o = Outcome(effect=_FakeEffect())
o = EffectCandidate(effect=_FakeEffect())
assert o.short_description == "p.L101del"


def test_outcomes_from_candidates_tags_source():
def test_candidates_from_effects_tags_source():
class _C:
short_description = "c1"
outcomes = outcomes_from_candidates((_C(), _C()), source="test_source")
outcomes = candidates_from_effects((_C(), _C()), source="test_source")
assert len(outcomes) == 2
assert all(o.source == "test_source" for o in outcomes)
assert all(o.probability is None for o in outcomes)
Expand All @@ -89,7 +83,7 @@ class _C:
def test_exonic_splice_site_exposes_outcomes():
"""Real integration: an SNV at the last base of an exon yields
``ExonicSpliceSite``, which is a ``MultiOutcomeEffect``. Its
``.outcomes`` should return two :class:`Outcome` entries (the
``.outcomes`` should return two :class:`EffectCandidate` entries (the
splice-disruption outcome and the coding-change alternate)."""
# CFTR exon 3 ends at 117531114 (last exon base).
variant = Variant("7", 117531114, "G", "A", ensembl_grch38)
Expand All @@ -100,7 +94,7 @@ def test_exonic_splice_site_exposes_outcomes():

outcomes = effect.outcomes
assert len(outcomes) == 2
assert all(isinstance(o, Outcome) for o in outcomes)
assert all(isinstance(o, EffectCandidate) for o in outcomes)
# First outcome: the splice-disruption classification (the
# ExonicSpliceSite itself).
assert outcomes[0].effect is effect
Expand Down Expand Up @@ -163,37 +157,35 @@ def test_uniform_iteration_sv_and_splice_outcomes():
_ = o.effect.mutant_protein_sequence


def test_outcome_round_trips_via_json():
"""``Outcome`` now inherits :class:`DataclassSerializable`, so
def test_effect_candidate_round_trips_via_json():
"""``EffectCandidate`` now inherits :class:`DataclassSerializable`, so
``to_json`` / ``from_json`` round-trip the full outcome — including
a polymorphic :class:`MutationEffect` ``effect`` field — without
any custom serialization code (#343)."""
variant = Variant("7", 117531114, "G", "A", ensembl_grch38)
transcript = ensembl_grch38.transcript_by_id("ENST00000003084")
real_effect = variant.effect_on_transcript(transcript)
o = Outcome(
o = EffectCandidate(
effect=real_effect,
probability=0.75,
source="spliceai",
evidence={"ds_ag": 0.12},
description="fake")
rt = Outcome.from_json(o.to_json())
evidence={"ds_ag": 0.12})
rt = EffectCandidate.from_json(o.to_json())
assert rt.probability == 0.75
assert rt.source == "spliceai"
assert rt.evidence == {"ds_ag": 0.12}
assert rt.description == "fake"
# effect round-trips polymorphically — same class as the original.
assert type(rt.effect) is type(real_effect)


def test_outcome_accepts_external_scorer_shape():
def test_effect_candidate_accepts_external_scorer_shape():
"""An external predictor (SpliceAI-style) can construct an
``Outcome`` with its own probability and evidence dict. This
``EffectCandidate`` with its own probability and evidence dict. This
pins the interchange contract — varcode doesn't ship a scorer,
but the type stays usable by one."""
class _FakeEffect:
short_description = "splice-donor"
scored = Outcome(
scored = EffectCandidate(
effect=_FakeEffect(),
probability=0.87,
source="spliceai",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_germline_effects.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ def test_phase_ambiguous_outcomes_carry_alignable_haplotype_keys(self):
"""The germline-aware Outcomes carry ``haplotype`` keys with
opaque tags ("A", "B"); the RNA-evidence Outcomes from #259
use the same key. Cross-axis matching is just a dict lookup
— pin that the keys are present and match the Outcome
— pin that the keys are present and match the EffectCandidate
evidence-dict convention."""
ann = get_default_annotator()
ctx = GermlineContext.from_variants(
Expand Down
7 changes: 3 additions & 4 deletions tests/test_rna_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
StructuralVariantEffect,
Substitution,
)
from varcode.outcomes import Outcome
from varcode.effect_candidates import EffectCandidate


ensembl_grch38 = cached_release(81)
Expand Down Expand Up @@ -199,8 +199,7 @@ def test_observed_outcomes_appended_to_outcomes_view(self):
source="isovar",
transcript_model_id="ISOFORM_A",
read_count=42,
probability=0.78,
description="In-frame loss of CFTR exons 2-4 observed in tumor RNA")
probability=0.78)
apply_rna_evidence_to_effects(
[effect], _StubResolver([observed]))

Expand Down Expand Up @@ -364,7 +363,7 @@ def test_variant_effects_with_rna_resolver_attaches(self):


# --------------------------------------------------------------------
# Outcome ordering: DNA-predicted preserved at the front
# EffectCandidate ordering: DNA-predicted preserved at the front
# --------------------------------------------------------------------


Expand Down
11 changes: 4 additions & 7 deletions tests/test_splice_outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ def test_multi_outcome_effect_exported_at_package_level():


# --------------------------------------------------------------------
# Unified Outcome.effect contract (#339).
# Unified EffectCandidate.effect contract (#339).
#
# After #339, ``outcome.effect`` is always a MutationEffect — never a
# SpliceCandidate — and consumers can read
Expand All @@ -407,7 +407,7 @@ def test_outcomes_effect_is_always_a_mutation_effect():
target = next(e for e in effects if e.transcript is transcript)
for outcome in target.outcomes:
assert isinstance(outcome.effect, MutationEffect), (
"Outcome.effect must be MutationEffect, got %s"
"EffectCandidate.effect must be MutationEffect, got %s"
% type(outcome.effect).__name__)
# short_description is always present and a str.
assert isinstance(outcome.effect.short_description, str)
Expand All @@ -426,18 +426,15 @@ def test_outcomes_carry_splice_outcome_in_evidence():
assert SpliceOutcome.INTRON_RETENTION in tags


def test_outcomes_carry_plausibility_and_description():
def test_outcomes_carry_plausibility():
variant = Variant("7", 117531115, "G", "A", ensembl_grch38)
transcript = ensembl_grch38.transcript_by_id(CFTR_TRANSCRIPT_ID)
effects = variant.effects(splice_outcomes=True)
target = next(e for e in effects if e.transcript is transcript)
for outcome in target.outcomes:
# plausibility moved from SpliceCandidate to Outcome.probability.
# plausibility moved from SpliceCandidate to EffectCandidate.probability.
assert outcome.probability is not None
assert 0.0 <= outcome.probability <= 1.0
# Description carries the human sentence.
assert outcome.description is None or isinstance(
outcome.description, str)


def test_intron_retention_outcome_effect_is_placeholder_class():
Expand Down
Loading