diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ff975..98f5ea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api.md b/docs/api.md index 9f7545e..2f5b5ee 100644 --- a/docs/api.md +++ b/docs/api.md @@ -53,9 +53,9 @@ Auto-generated from in-source docstrings via ::: varcode.EffectCollection -### `varcode.Outcome` +### `varcode.EffectCandidate` -::: varcode.Outcome +::: varcode.EffectCandidate ### Priority ordering diff --git a/docs/germline.md b/docs/germline.md index 8f2dc25..3d118e3 100644 --- a/docs/germline.md +++ b/docs/germline.md @@ -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. diff --git a/docs/transforms.md b/docs/transforms.md index 1c8b7a7..2807b02 100644 --- a/docs/transforms.md +++ b/docs/transforms.md @@ -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=()` | diff --git a/tests/test_cryptic_exons.py b/tests/test_cryptic_exons.py index 17f5505..444b422 100644 --- a/tests/test_cryptic_exons.py +++ b/tests/test_cryptic_exons.py @@ -22,7 +22,7 @@ import pytest from varcode import ( - Outcome, + EffectCandidate, StructuralVariant, enumerate_cryptic_exon_candidates, score_acceptor, @@ -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.""" @@ -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", @@ -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", diff --git a/tests/test_outcomes.py b/tests/test_effect_candidates.py similarity index 76% rename from tests/test_outcomes.py rename to tests/test_effect_candidates.py index fa7239b..fcfcbc7 100644 --- a/tests/test_outcomes.py +++ b/tests/test_effect_candidates.py @@ -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 @@ -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, @@ -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) @@ -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) @@ -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 @@ -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", diff --git a/tests/test_germline_effects.py b/tests/test_germline_effects.py index 74128b8..3cde9bf 100644 --- a/tests/test_germline_effects.py +++ b/tests/test_germline_effects.py @@ -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( diff --git a/tests/test_rna_evidence.py b/tests/test_rna_evidence.py index 43599e9..5aa2961 100644 --- a/tests/test_rna_evidence.py +++ b/tests/test_rna_evidence.py @@ -43,7 +43,7 @@ StructuralVariantEffect, Substitution, ) -from varcode.outcomes import Outcome +from varcode.effect_candidates import EffectCandidate ensembl_grch38 = cached_release(81) @@ -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])) @@ -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 # -------------------------------------------------------------------- diff --git a/tests/test_splice_outcomes.py b/tests/test_splice_outcomes.py index cbf3864..45f94a2 100644 --- a/tests/test_splice_outcomes.py +++ b/tests/test_splice_outcomes.py @@ -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 @@ -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) @@ -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(): diff --git a/tests/test_structural_variant_annotator.py b/tests/test_structural_variant_annotator.py index f39aa23..274eaf5 100644 --- a/tests/test_structural_variant_annotator.py +++ b/tests/test_structural_variant_annotator.py @@ -15,7 +15,7 @@ These cover the minimal dispatch shape — SV type + overlap pattern routed to the right effect class — and the ``MultiOutcomeEffect`` harmonized interface (``effect.outcomes`` tuple, each carrying -``Outcome`` with ``source="varcode"``). +``EffectCandidate`` with ``source="varcode"``). Downstream integrations (external splice predictors, RNA evidence, long-read assembly) layer additional outcomes on top; those aren't @@ -27,7 +27,7 @@ from pyensembl import cached_release from varcode import ( - Outcome, + EffectCandidate, StructuralVariant, StructuralVariantAnnotator, get_annotator, @@ -98,7 +98,7 @@ def test_sv_large_deletion_spans_cftr_exons(): def test_sv_large_deletion_outcomes_shape(): """The harmonized interface: ``effect.outcomes`` is a tuple of - :class:`Outcome` entries with ``source="varcode"``.""" + :class:`EffectCandidate` entries with ``source="varcode"``.""" transcript = _cftr() sv = StructuralVariant( contig="7", @@ -112,7 +112,7 @@ def test_sv_large_deletion_outcomes_shape(): assert isinstance(outcomes, tuple) assert len(outcomes) >= 1 for o in outcomes: - assert isinstance(o, Outcome) + assert isinstance(o, EffectCandidate) assert o.source == "varcode" assert o.probability is None # varcode doesn't assign; integrations do @@ -232,14 +232,14 @@ def test_sv_breakend_mate_intergenic_is_translocation(): # -------------------------------------------------------------------- -# Outcome extensibility +# EffectCandidate extensibility # -------------------------------------------------------------------- def test_external_integrations_can_attach_scored_outcomes(): """Smoke test: an external tool wraps a varcode-nominated - effect in an Outcome with source/probability/evidence, and - that Outcome works as the interchange format PR 7 defined.""" + effect in an EffectCandidate with source/probability/evidence, and + that EffectCandidate works as the interchange format PR 7 defined.""" transcript = _cftr() sv = StructuralVariant( contig="7", @@ -252,7 +252,7 @@ def test_external_integrations_can_attach_scored_outcomes(): # An RNA-evidence tool (Isovar-style) would score the varcode # outcome with junction read support. - rna_scored = Outcome( + rna_scored = EffectCandidate( effect=effect, probability=0.94, source="isovar", @@ -262,7 +262,7 @@ def test_external_integrations_can_attach_scored_outcomes(): assert rna_scored.evidence["junction_reads"] == 87 # A long-read assembly tool would attach its own resolution. - lr_scored = Outcome( + lr_scored = EffectCandidate( effect=effect, probability=0.99, source="longread_assembly", diff --git a/varcode/__init__.py b/varcode/__init__.py index e44b175..a1ef351 100644 --- a/varcode/__init__.py +++ b/varcode/__init__.py @@ -52,7 +52,7 @@ score_acceptor, score_donor, ) -from .outcomes import Outcome, outcomes_from_candidates +from .effect_candidates import EffectCandidate, candidates_from_effects from .phasing import ( MutantTranscriptSource, ReadPhaseResolver, @@ -113,8 +113,8 @@ "apply_variants_to_transcript", # Unified multi-outcome type (openvax/varcode#299) - "Outcome", - "outcomes_from_candidates", + "EffectCandidate", + "candidates_from_effects", # Structural variants (openvax/varcode#252 / #264) "StructuralVariant", diff --git a/varcode/annotators/structural_variant.py b/varcode/annotators/structural_variant.py index cd1196a..974a07f 100644 --- a/varcode/annotators/structural_variant.py +++ b/varcode/annotators/structural_variant.py @@ -21,7 +21,7 @@ ``LargeDuplication``, ``Inversion``, ``GeneFusion``, ``TranslocationToIntergenic``), each of which is a :class:`~varcode.effects.MultiOutcomeEffect` exposing -:attr:`outcomes` — a tuple of :class:`~varcode.outcomes.Outcome` +:attr:`outcomes` — a tuple of :class:`~varcode.effect_candidates.EffectCandidate` entries each carrying an effect + probability + source + evidence. Scope @@ -36,11 +36,11 @@ whether a cryptic exon will be retained, or rank outcomes by likelihood. Those require external tools (SpliceAI, RNA evidence via Isovar, or long-read assembly) that attach additional - :class:`Outcome` entries with their own ``source`` and ``evidence``. + :class:`EffectCandidate` entries with their own ``source`` and ``evidence``. The point of shipping this annotator now, even with thin logic, is to make the *shape* of SV output available to the rest of the -ecosystem: the ``MultiOutcomeEffect`` + ``Outcome`` contract is +ecosystem: the ``MultiOutcomeEffect`` + ``EffectCandidate`` contract is what lets RNA and assembly tools integrate cleanly. Integration hooks @@ -53,12 +53,12 @@ sequences have a concrete assembled cDNA to read. * **External splice predictor**: call the annotator, then wrap each returned effect in a new :class:`MultiOutcomeEffect` whose - ``outcomes`` tuple includes a fresh ``Outcome(effect=cryptic, + ``outcomes`` tuple includes a fresh ``EffectCandidate(effect=cryptic, source="spliceai", probability=...)`` entry. * **Short-read RNA evidence**: same pattern. Attach an - ``Outcome(effect=existing, source="isovar", evidence={ - "junction_reads": N})`` entry alongside the varcode-nominated - outcome. + ``EffectCandidate`` carrying the read-evidence tool's ``source`` + and a ``junction_reads`` field in ``evidence`` alongside the + varcode-nominated outcome. """ from ..effects.effect_classes import ( @@ -680,7 +680,7 @@ def _enumerate_and_attach_splice_outcomes(self, variant, transcript, effect): window. """ from ..effects.effect_classes import StructuralVariantEffect - from ..outcomes import Outcome + from ..effect_candidates import EffectCandidate from ..splice_outcomes import SpliceOutcome, enumerate_splice_outcomes if not isinstance(effect, StructuralVariantEffect): return @@ -722,11 +722,10 @@ def _enumerate_and_attach_splice_outcomes(self, variant, transcript, effect): # Primary SV classification already covers the # "splicing proceeds normally" interpretation. continue - attached.append(Outcome( + attached.append(EffectCandidate( effect=outcome.effect, probability=outcome.probability, source="varcode_splice", - description=outcome.description, evidence={**dict(outcome.evidence), **sv_evidence})) if attached: effect._attach_splice_outcomes(attached) diff --git a/varcode/cryptic_exons.py b/varcode/cryptic_exons.py index 489e7af..b69c659 100644 --- a/varcode/cryptic_exons.py +++ b/varcode/cryptic_exons.py @@ -41,16 +41,17 @@ / SQUIRLS outputs can bypass the motif scorer entirely. Pass a custom ``score_fn`` to :func:`enumerate_candidates`, or take the output of :func:`enumerate_candidates` and re-score each -:class:`CrypticExonCandidate` — the :class:`~varcode.Outcome` +:class:`CrypticExonCandidate` — the :class:`~varcode.EffectCandidate` shape from #299 carries both the effect and the external ``probability`` / ``evidence`` without needing varcode to understand the scorer. **RNA evidence** (optional): if the pipeline has RNA-seq, the correct discriminator is "are there split reads that span the -candidate junction." Isovar-style tools add -``Outcome(source="isovar", evidence={"junction_reads": N})`` -on top of the varcode-nominated candidate. +candidate junction." Read-evidence tools attach an +``EffectCandidate`` carrying their own ``source`` tag and a +``junction_reads`` field in ``evidence`` on top of the varcode- +nominated candidate. **Long-read assembly** (optional): a resolved rearranged allele assembled from HiFi / ONT reads can be passed as the ``sequence`` diff --git a/varcode/effect_candidates.py b/varcode/effect_candidates.py new file mode 100644 index 0000000..fd9832d --- /dev/null +++ b/varcode/effect_candidates.py @@ -0,0 +1,135 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""``EffectCandidate``: one plausible effect with per-context provenance. + +An :class:`EffectCandidate` pairs a :class:`MutationEffect` with a +``source`` / ``probability`` / ``evidence`` triple describing **how +this particular candidate came to be**. The wrapper exists because +the same :class:`MutationEffect` instance can appear in multiple +candidate sets with different provenance per context — the wrapper +carries the context-specific labels without forcing a copy of the +Effect itself. + +Concrete example: a splice candidate produced by varcode's splice +classifier surfaces inside its parent +:class:`SpliceOutcomeSet` tagged ``source="varcode"`` with a +``splice_outcome`` enum in evidence. When an SV breakpoint sits in +the same splice window, the SV annotator re-uses that same Effect +on the SV's own :class:`StructuralVariantEffect` — but tagged +``source="varcode_splice"`` with the SV's ``sv_type`` merged into +evidence. The Effect is shared; the metadata diverges. That's why +the wrapper exists. + +Without ``EffectCandidate``, the alternatives are: + +* Copy the Effect at every re-tag site (breaks identity, adds + overhead, has to be deep enough to detach evidence dicts). +* Put metadata on the Effect itself (forces all contexts to share + one labeling — wrong). +* Carry parallel sidecar dicts keyed by ``id(effect)`` (fragile, + awkward to serialize). + +The wrapper is the cheapest answer. + +Where ``EffectCandidate`` appears +--------------------------------- + +:attr:`MultiOutcomeEffect.outcomes` returns +``tuple[EffectCandidate, ...]``. :attr:`MultiOutcomeEffect.candidates` +returns the raw ``tuple[MutationEffect, ...]`` — both accessors are +first-class: + +* Use ``outcomes`` when you need per-candidate provenance (filter by + source, read evidence, score by probability). +* Use ``candidates`` when you only need the underlying Effects (e.g. + iterate to render short descriptions, dispatch on effect type). + +External integrations (splice predictors, RNA-evidence callers, +long-read assembly tools) construct :class:`EffectCandidate` +instances to surface scored or annotated effects through the same +surface as varcode's built-ins. +""" + +from dataclasses import dataclass, field +from typing import Any, Mapping, Optional, Tuple + +from serializable import DataclassSerializable + + +@dataclass(frozen=True) +class EffectCandidate(DataclassSerializable): + """One plausible effect for a variant, paired with provenance. + + Parameters + ---------- + effect : MutationEffect + The effect this candidate represents. Guaranteed to be a + :class:`~varcode.effects.MutationEffect` instance — producers + that can't compute a full coding effect use placeholder + subclasses (e.g. + :class:`~varcode.effects.effect_classes.PredictedIntronRetention`) + so consumers can read + ``candidate.effect.short_description`` and + ``candidate.effect.mutant_protein_sequence`` uniformly across + SV, splice, and point-variant candidates. + probability : float or None + Estimated likelihood this candidate actually happens, in + ``[0, 1]``. ``None`` means "not scored" — the candidate is in + the set but no tool has assigned a probability. Callers that + treat ``None`` as "unknown" rather than "zero" will be robust + to new candidates added over time. + source : str + Name of the tool or annotator that produced this candidate. + Defaults to ``"varcode"`` for built-in classifications. + External integrations set their own opaque string. Varcode + does not interpret this field beyond exact-string equality. + evidence : Mapping[str, Any] + Open-ended provenance dict. Shape is source-specific; the + convention is that keys match the source's native field names. + Consumers that need a particular shape should type-check at + the call site rather than rely on a rigid schema here. + """ + + effect: Any # MutationEffect — typed loosely to avoid import cycle + probability: Optional[float] = None + source: str = "varcode" + evidence: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self): + if self.probability is not None and not ( + 0.0 <= self.probability <= 1.0): + raise ValueError( + "probability must be in [0, 1] or None, got %r" + % (self.probability,)) + + @property + def short_description(self) -> str: + """Convenience passthrough to ``self.effect.short_description``. + Lets callers build tables without unpacking + ``candidate.effect.short_description`` everywhere.""" + return self.effect.short_description + + +def candidates_from_effects( + effects: Tuple[Any, ...], + source: str = "varcode") -> Tuple[EffectCandidate, ...]: + """Wrap a tuple of :class:`MutationEffect` instances as + :class:`EffectCandidate` objects with a shared ``source`` string. + + Used by the default :attr:`MultiOutcomeEffect.outcomes` + implementation to lift a raw ``candidates`` tuple into the wrapped + form without churning every subclass. No probabilities are + assigned — callers that want scored candidates construct + :class:`EffectCandidate` directly. + """ + return tuple(EffectCandidate(effect=c, source=source) for c in effects) diff --git a/varcode/effects/effect_classes.py b/varcode/effects/effect_classes.py index 23df10f..cc2e0c2 100644 --- a/varcode/effects/effect_classes.py +++ b/varcode/effects/effect_classes.py @@ -20,7 +20,7 @@ def _cryptic_probability(candidate): """Average of a cryptic-exon candidate's donor and acceptor motif scores. Used by :attr:`StructuralVariantEffect.outcomes` to - populate ``Outcome.probability`` when no external scorer is + populate ``EffectCandidate.probability`` when no external scorer is attached (#337). Both scores are match-ratios in ``[0, 1]`` so their mean is already in range. """ @@ -143,7 +143,7 @@ class MultiOutcomeEffect(MutationEffect): **Harmonized interface (#299):** new code should read :attr:`outcomes` instead of ``candidates``. Each entry is an - :class:`~varcode.outcomes.Outcome` carrying the effect plus + :class:`~varcode.effect_candidates.EffectCandidate` carrying the effect plus provenance (probability, source, evidence dict). The default implementation wraps ``candidates`` with ``source="varcode"`` and no probability — external scorers (SpliceAI, Pangolin), @@ -158,7 +158,7 @@ class MultiOutcomeEffect(MutationEffect): @property def outcomes(self): - """Tuple of :class:`~varcode.outcomes.Outcome` objects, + """Tuple of :class:`~varcode.effect_candidates.EffectCandidate` objects, most-plausible-first. Default implementation wraps :attr:`candidates` under ``source="varcode"``; subclasses (or external integrations) override to attach probabilities @@ -169,9 +169,9 @@ def outcomes(self): subclasses overriding this property must call that helper on their derived tuple so the plug-in path remains uniform. """ - from ..outcomes import outcomes_from_candidates + from ..effect_candidates import candidates_from_effects return self._with_extra_outcomes( - outcomes_from_candidates(self.candidates)) + candidates_from_effects(self.candidates)) def _with_extra_outcomes(self, base_outcomes): """Append externally-attached outcomes (#259) to a derived @@ -313,7 +313,7 @@ class SpliceAcceptor(IntronicSpliceSite): # Predicted-but-uncomputed placeholder effects (#339). # # Used by :class:`~varcode.splice_outcomes.SpliceOutcomeSet` and, later, -# :class:`StructuralVariantEffect` to fill :attr:`Outcome.effect` with a +# :class:`StructuralVariantEffect` to fill :attr:`EffectCandidate.effect` with a # real :class:`MutationEffect` when the protein-level outcome cannot be # computed from cached transcript cDNA alone (intron retention requires # intron sequence; cryptic splice requires flanking genomic sequence). @@ -328,7 +328,7 @@ class PredictedIntronRetention(TranscriptMutationEffect): """Placeholder effect: intron retention predicted, exact protein not computable from cached transcript cDNA. - Emitted as the :attr:`Outcome.effect` of a SpliceOutcomeSet's + Emitted as the :attr:`EffectCandidate.effect` of a SpliceOutcomeSet's ``INTRON_RETENTION`` outcome. The biologically expected outcome is a premature stop codon inside the retained intron; consumers that need the exact protein sequence require intron genomic sequence @@ -344,7 +344,7 @@ class PredictedCrypticSpliceSite(TranscriptMutationEffect): ``direction`` is ``"donor"`` or ``"acceptor"``. Exact protein consequence requires flanking genomic sequence; emitted as the - :attr:`Outcome.effect` of a SpliceOutcomeSet's ``CRYPTIC_DONOR`` / + :attr:`EffectCandidate.effect` of a SpliceOutcomeSet's ``CRYPTIC_DONOR`` / ``CRYPTIC_ACCEPTOR`` outcome. """ @@ -964,7 +964,7 @@ def short_description(self): # subclass exposing an ``outcomes`` tuple per #299. # # The classes are deliberately thin wrappers — the important interface -# is ``outcomes``, which carries the ``Outcome`` objects with +# is ``outcomes``, which carries the ``EffectCandidate`` objects with # probability / source / evidence so external tools (RNA evidence, # SpliceAI, long-read assembly) can score them without subclassing. # ===================================================================== @@ -979,7 +979,7 @@ class StructuralVariantEffect(TranscriptMutationEffect, MultiOutcomeEffect): ``(self,)`` when no specific alternates have been nominated, or a richer tuple when the subclass carries explicit alternate effects. ``MultiOutcomeEffect.outcomes`` lifts that to the - unified :class:`Outcome` shape automatically. + unified :class:`EffectCandidate` shape automatically. """ def __init__( @@ -996,7 +996,7 @@ def __init__( self._cryptic_candidates = () # Splice-outcome candidates attached by the SV annotator when # an SV breakpoint lands in a canonical splice window (#341). - # Pre-constructed :class:`~varcode.Outcome` tuples; the + # Pre-constructed :class:`~varcode.EffectCandidate` tuples; the # annotator re-sources them as ``"varcode_splice"`` and # enriches evidence with ``sv_type`` before attaching. self._splice_candidates = () @@ -1012,7 +1012,7 @@ def most_likely(self): def _attach_cryptic_candidates(self, cryptic_candidates): """Attach cryptic-exon candidates (#337). Called by the SV annotator after effect construction so the candidates appear - as additional :class:`Outcome` entries on :attr:`outcomes` + as additional :class:`EffectCandidate` entries on :attr:`outcomes` without polluting :attr:`candidates` (which stays the primary classification tuple for back-compat). """ @@ -1022,7 +1022,7 @@ def _attach_splice_outcomes(self, splice_outcomes): """Attach splice-outcome candidates that the SV annotator generated by feeding a synthesized splice-disrupting effect into :func:`enumerate_splice_outcomes` (#341). Each entry is - a pre-constructed :class:`~varcode.Outcome`; the annotator + a pre-constructed :class:`~varcode.EffectCandidate`; the annotator has already re-sourced them as ``"varcode_splice"`` and enriched evidence with the SV type. """ @@ -1030,7 +1030,7 @@ def _attach_splice_outcomes(self, splice_outcomes): @property def outcomes(self): - """Unified :class:`~varcode.Outcome` view over + """Unified :class:`~varcode.EffectCandidate` view over :attr:`candidates`, attached cryptic candidates, and any splice-outcome candidates (#339, #337, #341). @@ -1040,22 +1040,20 @@ def outcomes(self): marks provenance so external scorers (SpliceAI, Pangolin, RNA evidence) can filter before rescoring. """ - from ..outcomes import Outcome + from ..effect_candidates import EffectCandidate sv_type = getattr(self.variant, "sv_type", None) base_evidence = {"sv_type": sv_type} if sv_type is not None else {} primary = tuple( - Outcome( + EffectCandidate( effect=candidate, source="varcode", - description=getattr(candidate, "short_description", None), evidence=base_evidence) for candidate in self._candidates) cryptic = tuple( - Outcome( + EffectCandidate( effect=c, source="varcode_motif", probability=_cryptic_probability(c), - description=c.short_description, evidence={ **base_evidence, "donor_score": c.donor_score, @@ -1131,8 +1129,8 @@ class GeneFusion(StructuralVariantEffect): allele. Predicting the exact fused-protein sequence requires knowing which exons are retained, which typically needs RNA evidence — outcomes beyond "this is a plausible fusion" are - left to downstream tools that attach :class:`Outcome` objects - with ``source="isovar"`` / ``"longread_assembly"`` / etc. + left to downstream tools that attach :class:`EffectCandidate` + objects with their own producer ``source`` tag. """ short_description = "sv-gene-fusion" @@ -1160,7 +1158,7 @@ class CrypticExonCandidate(MutationEffect): """A region where an SV has brought novel sequence into range of the transcript, and motif scoring flags a plausible new splice acceptor / donor pair. Produced by PR 11's cryptic-exon - enumerator; attached as additional :class:`Outcome` entries on + enumerator; attached as additional :class:`EffectCandidate` entries on SV effects rather than standalone. Not a :class:`TranscriptMutationEffect` because the candidate @@ -1168,7 +1166,7 @@ class CrypticExonCandidate(MutationEffect): exon hypothesis. Carries the contig / interval and the motif scores as plain fields; external predictors (SpliceAI, Pangolin) attach their own scores via the enclosing - :class:`Outcome.evidence` dict. + :class:`EffectCandidate.evidence` dict. """ short_description = "sv-cryptic-exon-candidate" @@ -1230,10 +1228,11 @@ def __init__( TranscriptMutationEffect.__init__(self, variants[0], transcript) self.variants = tuple(variants) self.mutant_transcript = mutant_transcript - # Which resolver produced the phase grouping (e.g. "vcf_ps", - # "isovar"). Useful for downstream filtering and for auditing - # whether the cis call came from DNA-only phasing or - # RNA-assembly evidence. + # Which resolver produced the phase grouping (e.g. "vcf_ps" + # for DNA-PS-tag phasing, "read_phasing" for an RNA / long- + # read source). Useful for downstream filtering and for + # auditing whether the cis call came from DNA-only phasing + # or RNA-assembly evidence. self.phase_source = phase_source # Single-outcome wrapping: the haplotype IS the outcome. # Kept to honor the MultiOutcomeEffect contract without @@ -1350,7 +1349,7 @@ def most_likely(self): @property def outcomes(self): - """One :class:`Outcome` per hypothesis, carrying the phase + """One :class:`EffectCandidate` per hypothesis, carrying the phase metadata needed to align with RNA-evidence outcomes (#259). ``evidence`` keys: ``phase_state`` (``"phased"`` / @@ -1358,14 +1357,13 @@ def outcomes(self): ``haplotype`` (opaque tag), ``germline_variants`` (tuple of the cis germline variants on that hypothesis's haplotype). """ - from ..outcomes import Outcome + from ..effect_candidates import EffectCandidate outs = [] for candidate, hypothesis in zip( self._candidates_raw, self._hypotheses): - outs.append(Outcome( + outs.append(EffectCandidate( effect=candidate, source="varcode_germline", - description=getattr(candidate, "short_description", None), evidence={ "phase_state": hypothesis.phase_state, "haplotype": hypothesis.haplotype, diff --git a/varcode/outcomes.py b/varcode/outcomes.py deleted file mode 100644 index d082b6d..0000000 --- a/varcode/outcomes.py +++ /dev/null @@ -1,141 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unified ``Outcome`` type for multi-outcome effects (#299). - -An ``Outcome`` is "one possible consequence of a variant" with -provenance attached. A single variant can have multiple outcomes — -splice disruption vs normal splicing (SNV at exon boundary), fusion -vs exon loss vs readthrough (translocation), missense vs UTR (variant -that spans a stop codon). Each outcome carries: - -* the :class:`~varcode.effects.MutationEffect` it represents -* a probability / confidence score (or ``None`` if unscored) -* a string naming the source that produced the outcome -* an open-ended ``evidence`` dict for provenance - -This shape is deliberately minimal. It's the interchange format -between: - -* varcode's built-in classifiers (nominate candidates) -* external splice predictors (SpliceAI, Pangolin — score candidates) -* RNA-seq evidence callers (Isovar-style — upgrade / downgrade - candidates with read support) -* long-read / assembly-based tools (resolve breakpoint ambiguity) - -None of those integrations are wired up yet; the type exists so they -can plug in without churning the core classes. - -Harmonization across effect kinds ---------------------------------- - -Today varcode has three overlapping multi-outcome shapes: - -* :class:`~varcode.effects.effect_classes.ExonicSpliceSite` — - ``alternate_effect`` is a single fallback. -* :mod:`varcode.splice_outcomes` — ``SpliceOutcomeSet`` is a richer - list of splice-aware candidates. -* :class:`~varcode.effects.effect_classes.MultiOutcomeEffect` — - marker base class with a ``candidates`` tuple. - -All three converge on a single surface via the -:meth:`MultiOutcomeEffect.outcomes` property: ``tuple[Outcome, ...]``. -Existing ``candidates`` / ``alternate_effect`` accessors stay for -back-compat. New code should read ``outcomes``. -""" - -from dataclasses import dataclass, field -from typing import Any, Mapping, Optional, Tuple - -from serializable import DataclassSerializable - - -@dataclass(frozen=True) -class Outcome(DataclassSerializable): - """One plausible consequence of a variant. - - Parameters - ---------- - effect : MutationEffect - The effect this outcome represents. Guaranteed to be a - :class:`~varcode.effects.MutationEffect` instance — producers - that can't compute a full coding effect use placeholder - subclasses (e.g. - :class:`~varcode.effects.effect_classes.PredictedIntronRetention`) - so consumers can iterate ``outcome.effect.short_description`` - and ``outcome.effect.mutant_protein_sequence`` uniformly - across SV, splice, and point-variant outcomes (#339). - probability : float or None - Estimated likelihood this outcome actually happens, in - ``[0, 1]``. ``None`` means "not scored" — the outcome is in - the candidate set but no tool has assigned a probability. - Callers that treat ``None`` as "unknown" rather than "zero" - will be robust to new outcomes added over time. - source : str - Name of the tool or annotator that produced this outcome. - Defaults to ``"varcode"`` for built-in classifications. - 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 - (e.g. SpliceAI scores under ``{"ds_ag": 0.12, "ds_al": ...}``, - RNA read counts under ``{"junction_reads": 42}``). Consumers - that need a particular shape should type-check at the call - site rather than rely on a rigid schema here. - description : str or None - Optional human-readable sentence describing this specific - outcome ("Exon 7 is skipped (in-frame, 15 aa removed)"). - Distinct from ``effect.short_description``, which is the - effect's HGVS-style label. Producers that want a richer - narrative attach it here rather than nesting it in - ``evidence``. Declared last so positional calls of the form - ``Outcome(effect, probability, source, evidence)`` continue - to route the dict to ``evidence``. - """ - - effect: Any # MutationEffect — typed loosely to avoid import cycle - probability: Optional[float] = None - source: str = "varcode" - evidence: Mapping[str, Any] = field(default_factory=dict) - description: Optional[str] = None - - def __post_init__(self): - if self.probability is not None and not ( - 0.0 <= self.probability <= 1.0): - raise ValueError( - "probability must be in [0, 1] or None, got %r" - % (self.probability,)) - - @property - def short_description(self) -> str: - """Convenience passthrough to the wrapped effect's - ``short_description``. Lets callers build tables of outcomes - without unpacking ``outcome.effect.short_description`` - everywhere.""" - return self.effect.short_description - - -def outcomes_from_candidates( - candidates: Tuple[Any, ...], - source: str = "varcode") -> Tuple[Outcome, ...]: - """Wrap a tuple of :class:`MutationEffect` instances as - :class:`Outcome` objects with a shared ``source`` string. - - Used by the default ``MultiOutcomeEffect.outcomes`` implementation - to lift the existing ``candidates`` tuple into the new type without - churning every subclass. No probabilities are assigned — callers - that want scored outcomes construct :class:`Outcome` directly. - """ - return tuple(Outcome(effect=c, source=source) for c in candidates) diff --git a/varcode/phasing.py b/varcode/phasing.py index c4a5b78..e680d8e 100644 --- a/varcode/phasing.py +++ b/varcode/phasing.py @@ -94,7 +94,7 @@ class ReadPhaseResolver: #: 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 + #: :attr:`EffectCandidate.source` (an unrelated producer tag on RNA-evidence #: outcomes). phase_source = "read_phasing" diff --git a/varcode/rna_evidence.py b/varcode/rna_evidence.py index c3ed10b..679bf5e 100644 --- a/varcode/rna_evidence.py +++ b/varcode/rna_evidence.py @@ -18,7 +18,7 @@ the actual protein consequence depends on what's transcribed and spliced. This module defines the contract that lets RNA-aware tools (Isovar, Exacto, custom long-read pipelines) attach observed -:class:`~varcode.outcomes.Outcome` objects to existing effects +:class:`~varcode.effect_candidates.EffectCandidate` objects to existing effects without subclassing or churning the core classes. The shape mirrors :mod:`varcode.phasing`: a runtime-checkable @@ -47,7 +47,7 @@ from typing import Any, Iterable, Mapping, Optional, Protocol, Sequence, runtime_checkable -from .outcomes import Outcome +from .effect_candidates import EffectCandidate @runtime_checkable @@ -55,7 +55,7 @@ class RNAEvidenceResolver(Protocol): """Source of RNA-observed outcomes for a ``(variant, transcript)`` pair. - Implementers return zero or more :class:`~varcode.outcomes.Outcome` + Implementers return zero or more :class:`~varcode.effect_candidates.EffectCandidate` objects describing isoforms, fusions, or RNA-level events that were actually observed in reads. An empty sequence means "no evidence for this pair" — the existing DNA-predicted outcomes are left @@ -69,7 +69,7 @@ class RNAEvidenceResolver(Protocol): factory that fills the common fields. """ - def observed_outcomes(self, variant, transcript) -> Sequence[Outcome]: + def observed_outcomes(self, variant, transcript) -> Sequence[EffectCandidate]: """Return RNA-observed outcomes for ``variant`` on ``transcript``, or an empty sequence when no evidence is available. Must not raise on unknown ``(variant, transcript)`` @@ -84,7 +84,7 @@ class NullRNAEvidenceResolver: and as a baseline in tests. ``apply_rna_evidence_to_effects`` is safe to call with this resolver — it's a no-op walk.""" - def observed_outcomes(self, variant, transcript) -> Sequence[Outcome]: + def observed_outcomes(self, variant, transcript) -> Sequence[EffectCandidate]: return () @@ -95,10 +95,9 @@ def make_rna_outcome( source: str = "rna", transcript_model_id: Optional[str] = None, read_count: Optional[int] = None, - description: Optional[str] = None, - extra_evidence: Optional[Mapping[str, Any]] = None) -> Outcome: - """Construct an :class:`~varcode.outcomes.Outcome` carrying - RNA-derived provenance. + extra_evidence: Optional[Mapping[str, Any]] = None) -> EffectCandidate: + """Construct an :class:`~varcode.effect_candidates.EffectCandidate` + carrying RNA-derived provenance. Convenience factory for the common fields a reads-based or long-read assembly tool wants on each observed outcome — keeps @@ -121,9 +120,6 @@ def make_rna_outcome( Stored under ``evidence["transcript_model_id"]``. read_count : int or None Supporting read count. Stored under ``evidence["read_count"]``. - description : str or None - Human-readable label, passed through to - :attr:`Outcome.description`. extra_evidence : Mapping or None Producer-specific extra fields, merged into the evidence dict on top of the well-known keys above. Allows tool-native fields @@ -137,12 +133,11 @@ def make_rna_outcome( evidence["read_count"] = read_count if extra_evidence: evidence.update(extra_evidence) - return Outcome( + return EffectCandidate( effect=effect, probability=probability, source=source, evidence=evidence, - description=description, ) @@ -181,7 +176,7 @@ def apply_rna_evidence_to_effects(effects: Iterable, resolver) -> Iterable: return effects # Lazy import to avoid an import cycle (effect_classes imports - # from varcode.outcomes, which sits below us). + # from varcode.effect_candidates, which sits below us). from .effects.effect_classes import MultiOutcomeEffect for effect in effects: diff --git a/varcode/splice_outcomes.py b/varcode/splice_outcomes.py index f37a702..482b5cc 100644 --- a/varcode/splice_outcomes.py +++ b/varcode/splice_outcomes.py @@ -67,7 +67,7 @@ SpliceDonor, ) from .mutant_transcript import MutantTranscript, TranscriptEdit -from .outcomes import Outcome +from .effect_candidates import EffectCandidate class SpliceOutcome(Enum): @@ -238,7 +238,7 @@ def most_likely(self) -> SpliceCandidate: @property def outcomes(self): - """Unified :class:`~varcode.Outcome` view over :attr:`candidates` + """Unified :class:`~varcode.EffectCandidate` view over :attr:`candidates` (#339). Each outcome's ``effect`` is guaranteed to be a real @@ -256,11 +256,10 @@ def outcomes(self): """ return self._with_extra_outcomes( tuple( - Outcome( + EffectCandidate( effect=self._outcome_effect(candidate), probability=candidate.plausibility, source="varcode", - description=candidate.description, evidence={"splice_outcome": candidate.outcome}) for candidate in self.candidates)) @@ -462,7 +461,7 @@ def _plausibility_table_for(splice_effect): # --------------------------------------------------------------------- -# Outcome construction +# EffectCandidate construction # --------------------------------------------------------------------- diff --git a/varcode/structural_variant.py b/varcode/structural_variant.py index fe5cdd5..09751df 100644 --- a/varcode/structural_variant.py +++ b/varcode/structural_variant.py @@ -47,7 +47,7 @@ class is deliberately minimal — it describes *what the VCF said*, not * **Short-read RNA evidence** — evidence from RNA-seq (Isovar-style junction reads, exon-skipping counts) attaches through the - :class:`~varcode.outcomes.Outcome` ``evidence`` dict rather than + :class:`~varcode.effect_candidates.EffectCandidate` ``evidence`` dict rather than fields on the variant itself. This keeps variants immutable and lets multiple pieces of evidence accumulate per outcome.